1use prost::Message;
18use sp_crypto_hashing::blake2_256;
19
20use crate::config::ITEM_ID_NAMESPACE;
21
22pub const LANGUAGE_MIXIN_ID: u32 = 0x9bc7_a0e6;
26pub const TITLE_MIXIN_ID: u32 = 0x344f_4812;
28pub const BODY_TEXT_MIXIN_ID: u32 = 0x2d38_2044;
30pub const IMAGE_MIXIN_ID: u32 = 0x045e_ee8c;
32pub const PROFILE_MIXIN_ID: u32 = 0xbeef_2144;
34pub const FEED_TYPE_MIXIN_ID: u32 = 0xbcec_8faa;
36pub const COMMENT_TYPE_MIXIN_ID: u32 = 0x874a_ba65;
38
39pub const DEFAULT_LANGUAGE_TAG: &str = "en";
41
42#[derive(Clone, PartialEq, Message)]
46pub struct ItemMessage {
47 #[prost(message, repeated, tag = "1")]
48 pub mixin_payload: Vec<MixinPayloadMessage>,
49}
50
51#[derive(Clone, PartialEq, Message)]
53pub struct MixinPayloadMessage {
54 #[prost(fixed32, tag = "1")]
55 pub mixin_id: u32,
56 #[prost(bytes = "vec", tag = "2")]
57 pub payload: Vec<u8>,
58}
59
60#[derive(Clone, PartialEq, Message)]
62pub struct LanguageMixinMessage {
63 #[prost(string, tag = "1")]
64 pub language_tag: String,
65}
66
67#[derive(Clone, PartialEq, Message)]
69pub struct TitleMixinMessage {
70 #[prost(string, tag = "1")]
71 pub title: String,
72}
73
74#[derive(Clone, PartialEq, Message)]
76pub struct BodyTextMixinMessage {
77 #[prost(string, tag = "1")]
78 pub body_text: String,
79}
80
81#[derive(Clone, PartialEq, Message)]
83pub struct ImageMixinMessage {
84 #[prost(string, tag = "1")]
85 pub filename: String,
86 #[prost(uint64, tag = "2")]
87 pub filesize: u64,
88 #[prost(bytes = "vec", tag = "3")]
89 pub ipfs_hash: Vec<u8>,
90 #[prost(uint32, tag = "4")]
91 pub width: u32,
92 #[prost(uint32, tag = "5")]
93 pub height: u32,
94 #[prost(message, repeated, tag = "6")]
95 pub mipmap_level: Vec<MipmapLevelMessage>,
96}
97
98#[derive(Clone, PartialEq, Message)]
100pub struct MipmapLevelMessage {
101 #[prost(uint64, tag = "1")]
102 pub filesize: u64,
103 #[prost(bytes = "vec", tag = "2")]
104 pub ipfs_hash: Vec<u8>,
105}
106
107#[derive(Clone, PartialEq, Message)]
109pub struct ProfileMixinMessage {
110 #[prost(int32, tag = "1")]
111 pub account_type: i32,
112 #[prost(string, tag = "2")]
113 pub location: String,
114}
115
116#[derive(Clone, Copy, Debug, PartialEq, Eq, prost::Enumeration)]
118#[repr(i32)]
119pub enum AccountType {
120 Anon = 0,
121 Person = 1,
122 Project = 2,
123 Organization = 3,
124 Proxy = 4,
125 Parody = 5,
126 Bot = 6,
127 Shill = 7,
128 Test = 8,
129}
130
131#[derive(
137 Clone,
138 Copy,
139 Debug,
140 Default,
141 PartialEq,
142 Eq,
143 schemars::JsonSchema,
144 serde::Serialize,
145 serde::Deserialize,
146)]
147#[serde(rename_all = "lowercase")]
148pub enum ContentType {
149 #[default]
151 Document,
152 Feed,
153 Comment,
154 Profile,
155 Image,
156}
157
158#[derive(
161 Clone, Debug, Default, PartialEq, Eq, schemars::JsonSchema, serde::Serialize, serde::Deserialize,
162)]
163pub struct MipmapLevel {
164 pub filesize: u64,
166 pub cid: String,
168}
169
170#[derive(
172 Clone, Debug, Default, PartialEq, Eq, schemars::JsonSchema, serde::Serialize, serde::Deserialize,
173)]
174pub struct ImageSpec {
175 pub filename: String,
176 pub filesize: u64,
177 pub digest_hex: String,
179 pub width: u32,
180 pub height: u32,
181 pub mipmap_levels: Vec<MipmapLevel>,
182}
183
184#[derive(
186 Clone, Debug, Default, PartialEq, Eq, schemars::JsonSchema, serde::Serialize, serde::Deserialize,
187)]
188pub struct ProfileSpec {
189 pub account_type: i32,
191 pub location: String,
192}
193
194#[derive(
202 Clone, Debug, Default, PartialEq, Eq, schemars::JsonSchema, serde::Serialize, serde::Deserialize,
203)]
204pub struct ImageInput {
205 pub path: Option<String>,
208 pub filename: Option<String>,
210 pub spec: Option<ImageSpec>,
213}
214
215#[derive(Clone, Debug, Default, PartialEq, Eq)]
220pub struct PreparedContent {
221 pub content_type: ContentType,
222 pub title: Option<String>,
223 pub body: Option<String>,
224 pub language: Option<String>,
226 pub image: Option<ImageSpec>,
227 pub profile: Option<ProfileSpec>,
228}
229
230#[derive(
236 Clone, Debug, Default, PartialEq, Eq, schemars::JsonSchema, serde::Serialize, serde::Deserialize,
237)]
238pub struct ContentInput {
239 pub content_type: ContentType,
240 pub title: Option<String>,
241 pub body: Option<String>,
242 pub language: Option<String>,
244 pub image: Option<ImageInput>,
245 pub profile: Option<ProfileSpec>,
246}
247
248impl ContentInput {
249 #[must_use]
253 pub fn to_prepared(&self, image: Option<ImageSpec>) -> PreparedContent {
254 PreparedContent {
255 content_type: self.content_type,
256 title: self.title.clone(),
257 body: self.body.clone(),
258 language: self.language.clone(),
259 image,
260 profile: self.profile.clone(),
261 }
262 }
263}
264
265#[derive(
267 Clone, Debug, Default, PartialEq, Eq, schemars::JsonSchema, serde::Serialize, serde::Deserialize,
268)]
269pub struct DecodedItem {
270 pub content_type: ContentType,
272 pub title: Option<String>,
273 pub body: Option<String>,
274 pub language: Option<String>,
275 pub image: Option<ImageSpec>,
276 pub profile: Option<ProfileSpec>,
277}
278
279pub fn encode_item(input: &PreparedContent) -> Result<Vec<u8>, crate::ContentError> {
291 let language = input
292 .language
293 .clone()
294 .unwrap_or_else(|| DEFAULT_LANGUAGE_TAG.to_string());
295
296 let mut mixins: Vec<MixinPayloadMessage> = Vec::new();
297
298 match input.content_type {
300 ContentType::Feed => mixins.push(marker(FEED_TYPE_MIXIN_ID)),
301 ContentType::Comment => mixins.push(marker(COMMENT_TYPE_MIXIN_ID)),
302 _ => {}
303 }
304
305 mixins.push(MixinPayloadMessage {
307 mixin_id: LANGUAGE_MIXIN_ID,
308 payload: LanguageMixinMessage {
309 language_tag: language,
310 }
311 .encode_to_vec(),
312 });
313
314 if let Some(title) = &input.title {
315 mixins.push(MixinPayloadMessage {
316 mixin_id: TITLE_MIXIN_ID,
317 payload: TitleMixinMessage {
318 title: title.clone(),
319 }
320 .encode_to_vec(),
321 });
322 }
323 if let Some(body) = &input.body {
324 mixins.push(MixinPayloadMessage {
325 mixin_id: BODY_TEXT_MIXIN_ID,
326 payload: BodyTextMixinMessage {
327 body_text: body.clone(),
328 }
329 .encode_to_vec(),
330 });
331 }
332 if let Some(image) = &input.image {
333 mixins.push(MixinPayloadMessage {
334 mixin_id: IMAGE_MIXIN_ID,
335 payload: encode_image_mixin(image)?,
336 });
337 }
338 if let Some(profile) = &input.profile {
339 mixins.push(MixinPayloadMessage {
340 mixin_id: PROFILE_MIXIN_ID,
341 payload: ProfileMixinMessage {
342 account_type: profile.account_type,
343 location: profile.location.clone(),
344 }
345 .encode_to_vec(),
346 });
347 }
348
349 Ok(ItemMessage {
350 mixin_payload: mixins,
351 }
352 .encode_to_vec())
353}
354
355pub fn decode_item(bytes: &[u8]) -> Result<DecodedItem, crate::ContentError> {
365 let item = ItemMessage::decode(bytes)
366 .map_err(|e| crate::ContentError::Content(format!("failed to decode item payload: {e}")))?;
367
368 let mut out = DecodedItem {
369 content_type: infer_content_type(&item),
370 ..Default::default()
371 };
372
373 for mixin in &item.mixin_payload {
374 match mixin.mixin_id {
375 LANGUAGE_MIXIN_ID => {
376 out.language = LanguageMixinMessage::decode(mixin.payload.as_slice())
377 .ok()
378 .map(|m| m.language_tag);
379 }
380 TITLE_MIXIN_ID => {
381 out.title = TitleMixinMessage::decode(mixin.payload.as_slice())
382 .ok()
383 .map(|m| m.title);
384 }
385 BODY_TEXT_MIXIN_ID => {
386 out.body = BodyTextMixinMessage::decode(mixin.payload.as_slice())
387 .ok()
388 .map(|m| m.body_text);
389 }
390 IMAGE_MIXIN_ID => {
391 out.image = ImageMixinMessage::decode(mixin.payload.as_slice())
392 .ok()
393 .map(decode_image_mixin);
394 }
395 PROFILE_MIXIN_ID => {
396 out.profile = ProfileMixinMessage::decode(mixin.payload.as_slice())
397 .ok()
398 .map(|m| ProfileSpec {
399 account_type: m.account_type,
400 location: m.location,
401 });
402 }
403 _ => {}
404 }
405 }
406 Ok(out)
407}
408
409#[must_use]
411pub fn infer_content_type(item: &ItemMessage) -> ContentType {
412 for mixin in &item.mixin_payload {
413 match mixin.mixin_id {
414 FEED_TYPE_MIXIN_ID => return ContentType::Feed,
415 COMMENT_TYPE_MIXIN_ID => return ContentType::Comment,
416 PROFILE_MIXIN_ID => return ContentType::Profile,
417 _ => {}
418 }
419 }
420 if item
421 .mixin_payload
422 .iter()
423 .any(|m| m.mixin_id == IMAGE_MIXIN_ID)
424 {
425 return ContentType::Image;
426 }
427 ContentType::Document
428}
429
430fn marker(mixin_id: u32) -> MixinPayloadMessage {
432 MixinPayloadMessage {
433 mixin_id,
434 payload: Vec::new(),
435 }
436}
437
438pub fn encode_image_mixin(image: &ImageSpec) -> Result<Vec<u8>, crate::ContentError> {
450 let mipmap_levels = image
451 .mipmap_levels
452 .iter()
453 .map(|l| {
454 Ok(MipmapLevelMessage {
455 filesize: l.filesize,
456 ipfs_hash: cid_to_multihash_bytes(&l.cid)?,
462 })
463 })
464 .collect::<Result<Vec<_>, crate::ContentError>>()?;
465
466 let message = ImageMixinMessage {
467 filename: image.filename.clone(),
468 filesize: image.filesize,
469 ipfs_hash: multihash_bytes(&image.digest_hex)?,
470 width: image.width,
471 height: image.height,
472 mipmap_level: mipmap_levels,
473 };
474 Ok(message.encode_to_vec())
475}
476
477#[must_use]
480pub fn decode_image_mixin(msg: ImageMixinMessage) -> ImageSpec {
481 ImageSpec {
485 filename: msg.filename,
486 filesize: msg.filesize,
487 digest_hex: bytes_to_hex(&digest_from_bytes(&msg.ipfs_hash)),
488 width: msg.width,
489 height: msg.height,
490 mipmap_levels: msg
491 .mipmap_level
492 .into_iter()
493 .map(|l| MipmapLevel {
494 filesize: l.filesize,
495 cid: bytes_to_cid(&digest_from_bytes(&l.ipfs_hash)),
496 })
497 .collect(),
498 }
499}
500
501fn digest_from_bytes(bytes: &[u8]) -> Vec<u8> {
504 if bytes.len() == 34 && bytes.first() == Some(&0x12) && bytes.get(1) == Some(&0x20) {
506 bytes.get(2..).unwrap_or(bytes).to_vec()
507 } else {
508 bytes.to_vec()
509 }
510}
511
512#[must_use]
514pub fn decode_single_mixin<M>(item: &ItemMessage, mixin_id: u32) -> Option<M>
515where
516 M: Message + Default,
517{
518 item.mixin_payload
519 .iter()
520 .find(|m| m.mixin_id == mixin_id)
521 .and_then(|m| M::decode(m.payload.as_slice()).ok())
522}
523
524#[must_use]
534pub fn derive_item_id(account_id: [u8; 32], nonce: [u8; 32]) -> [u8; 32] {
535 let payload = [
536 parity_scale_codec::Encode::encode(&account_id),
537 parity_scale_codec::Encode::encode(&nonce),
538 parity_scale_codec::Encode::encode(&ITEM_ID_NAMESPACE),
539 ]
540 .concat();
541 blake2_256(&payload)
542}
543
544#[must_use]
548pub fn bytes_to_hex(bytes: &[u8]) -> String {
549 format!("0x{}", hex::encode(bytes))
550}
551
552pub fn hex_to_bytes(hex_value: &str) -> Result<[u8; 32], crate::ContentError> {
559 let raw = hex::decode(hex_value.trim_start_matches("0x"))
560 .map_err(|_| crate::ContentError::Cid(format!("invalid hex value {hex_value}")))?;
561 raw.try_into()
562 .map_err(|_| crate::ContentError::Cid(format!("expected 32 bytes for {hex_value}")))
563}
564
565pub fn digest_hex_to_cid(hex_value: &str) -> Result<String, crate::ContentError> {
573 let digest = hex_to_bytes(hex_value)?;
574 let mut multihash = Vec::with_capacity(34);
575 multihash.push(0x12);
576 multihash.push(0x20);
577 multihash.extend_from_slice(&digest);
578 Ok(bs58::encode(multihash).into_string())
579}
580
581pub fn cid_to_digest_hex(cid: &str) -> Result<String, crate::ContentError> {
589 let multihash = bs58::decode(cid)
590 .into_vec()
591 .map_err(|_| crate::ContentError::Cid(format!("failed to decode CID {cid}")))?;
592 if multihash.len() != 34 || multihash.first() != Some(&0x12) || multihash.get(1) != Some(&0x20)
594 {
595 return Err(crate::ContentError::Cid(format!(
596 "CID {cid} is not a sha2-256 CIDv0 multihash"
597 )));
598 }
599 Ok(format!(
600 "0x{}",
601 hex::encode(multihash.get(2..).unwrap_or(&multihash))
602 ))
603}
604
605fn multihash_bytes(hex_value: &str) -> Result<Vec<u8>, crate::ContentError> {
607 let digest = hex_to_bytes(hex_value)?;
608 let mut multihash = Vec::with_capacity(34);
609 multihash.push(0x12);
610 multihash.push(0x20);
611 multihash.extend_from_slice(&digest);
612 Ok(multihash)
613}
614
615fn cid_to_multihash_bytes(cid: &str) -> Result<Vec<u8>, crate::ContentError> {
619 let digest = cid_to_digest_bytes(cid)?;
620 let mut multihash = Vec::with_capacity(34);
621 multihash.push(0x12);
622 multihash.push(0x20);
623 multihash.extend_from_slice(&digest);
624 Ok(multihash)
625}
626
627#[must_use]
629pub fn bytes_to_cid(digest: &[u8]) -> String {
630 let mut multihash = Vec::with_capacity(34);
631 multihash.push(0x12);
632 multihash.push(0x20);
633 multihash.extend_from_slice(digest);
634 bs58::encode(multihash).into_string()
635}
636
637fn cid_to_digest_bytes(cid: &str) -> Result<Vec<u8>, crate::ContentError> {
642 Ok(hex_to_bytes(&cid_to_digest_hex(cid)?)?.to_vec())
643}
644
645#[must_use]
647pub fn short_hex(value: &str) -> String {
648 if value.len() <= 18 {
649 value.to_string()
650 } else {
651 let head = value.get(..10).unwrap_or("");
654 let tail = value.get(value.len().saturating_sub(8)..).unwrap_or("");
655 format!("{head}...{tail}")
656 }
657}
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662
663 #[test]
664 fn derive_item_id_is_deterministic_and_nonce_sensitive() {
665 let account = [7u8; 32];
666 let nonce_a = [1u8; 32];
667 let nonce_b = [2u8; 32];
668
669 assert_eq!(
670 derive_item_id(account, nonce_a),
671 derive_item_id(account, nonce_a)
672 );
673 assert_ne!(
674 derive_item_id(account, nonce_a),
675 derive_item_id(account, nonce_b)
676 );
677 assert_ne!(
679 derive_item_id(account, nonce_a),
680 derive_item_id([8u8; 32], nonce_a)
681 );
682 assert_ne!(derive_item_id(account, nonce_a), [0u8; 32]);
684 }
685
686 #[test]
687 fn hex_helpers_round_trip_and_validate() {
688 let bytes = [0xabu8; 32];
689 let encoded = bytes_to_hex(&bytes);
690 assert_eq!(encoded, format!("0x{}", "ab".repeat(32)));
691 assert_eq!(hex_to_bytes(&encoded).unwrap(), bytes);
692 assert_eq!(hex_to_bytes(encoded.get(2..).unwrap_or("")).unwrap(), bytes);
695
696 assert!(hex_to_bytes("0xzz").is_err());
697 assert!(hex_to_bytes("0x1234").is_err());
698 }
699
700 #[test]
701 fn cid_helpers_round_trip() {
702 let digest = format!("0x{}", "11".repeat(32));
703 let cid = digest_hex_to_cid(&digest).unwrap();
704 assert_eq!(cid_to_digest_hex(&cid).unwrap(), digest);
705 assert_eq!(
707 short_hex(&digest),
708 format!("0x{}...{}", "11111111", "11111111")
709 );
710 }
711
712 #[test]
713 fn cid_rejects_non_sha256_multihash() {
714 let not_sha256 = bs58::encode(
715 [0x13u8, 0x20]
716 .into_iter()
717 .chain([0u8; 32])
718 .collect::<Vec<_>>(),
719 )
720 .into_string();
721 assert!(cid_to_digest_hex(¬_sha256).is_err());
722 }
723
724 #[test]
725 fn image_mixin_stores_reference_multihash_form() {
726 let digest = format!("0x{}", "22".repeat(32));
731 let cid = digest_hex_to_cid(&digest).unwrap();
732 let input = spec_input("photo.jpg", &digest, &cid);
733 let payload = encode_image_mixin(prepare(&input).image.as_ref().unwrap()).unwrap();
734 let msg = ImageMixinMessage::decode(payload.as_slice()).unwrap();
735
736 let expected_multihash = [[0x12u8, 0x20].as_slice(), &[0x22u8; 32]].concat();
737 assert_eq!(msg.ipfs_hash, expected_multihash);
738 assert_eq!(msg.mipmap_level[0].ipfs_hash, expected_multihash);
739
740 let decoded = decode_image_mixin(msg);
742 assert_eq!(decoded.digest_hex, digest);
743 assert_eq!(decoded.mipmap_levels[0].cid, cid);
744 }
745
746 #[test]
747 fn decode_image_mixin_accepts_legacy_bare_digests() {
748 let msg = ImageMixinMessage {
751 filename: "legacy.jpg".into(),
752 filesize: 1,
753 ipfs_hash: vec![0x22; 32],
754 width: 10,
755 height: 10,
756 mipmap_level: vec![MipmapLevelMessage {
757 filesize: 1,
758 ipfs_hash: vec![0x22; 32],
759 }],
760 };
761 let decoded = decode_image_mixin(msg);
762 assert_eq!(decoded.digest_hex, format!("0x{}", "22".repeat(32)));
763 assert_eq!(
764 decoded.mipmap_levels[0].cid,
765 digest_hex_to_cid(&format!("0x{}", "22".repeat(32))).unwrap()
766 );
767 }
768
769 #[test]
770 fn encode_decode_document_round_trip() {
771 let input = ContentInput {
772 content_type: ContentType::Document,
773 title: Some("Hello".into()),
774 body: Some("World".into()),
775 language: Some("en".into()),
776 image: None,
777 profile: None,
778 };
779 let bytes = encode_item(&input.to_prepared(None)).unwrap();
780 let decoded = decode_item(&bytes).unwrap();
781
782 assert_eq!(decoded.content_type, ContentType::Document);
783 assert_eq!(decoded.title.as_deref(), Some("Hello"));
784 assert_eq!(decoded.body.as_deref(), Some("World"));
785 assert_eq!(decoded.language.as_deref(), Some("en"));
786 assert!(decoded.image.is_none());
787 assert!(decoded.profile.is_none());
788 }
789
790 #[test]
791 fn encode_decode_feed_includes_marker() {
792 let input = ContentInput {
793 content_type: ContentType::Feed,
794 title: Some("Feed".into()),
795 body: None,
796 language: None,
797 image: None,
798 profile: None,
799 };
800 let item =
801 ItemMessage::decode(encode_item(&input.to_prepared(None)).unwrap().as_slice()).unwrap();
802 assert_eq!(infer_content_type(&item), ContentType::Feed);
803 assert!(
804 item.mixin_payload
805 .iter()
806 .any(|m| m.mixin_id == FEED_TYPE_MIXIN_ID && m.payload.is_empty())
807 );
808 assert_eq!(
810 decode_single_mixin::<LanguageMixinMessage>(&item, LANGUAGE_MIXIN_ID)
811 .unwrap()
812 .language_tag,
813 DEFAULT_LANGUAGE_TAG
814 );
815 }
816
817 #[test]
818 fn encode_decode_comment_and_profile_types() {
819 let comment = ContentInput {
820 content_type: ContentType::Comment,
821 title: None,
822 body: Some("a comment".into()),
823 language: None,
824 image: None,
825 profile: None,
826 };
827 let c_item =
828 ItemMessage::decode(encode_item(&comment.to_prepared(None)).unwrap().as_slice())
829 .unwrap();
830 assert_eq!(infer_content_type(&c_item), ContentType::Comment);
831
832 let profile = ContentInput {
833 content_type: ContentType::Profile,
834 title: Some("Alice".into()),
835 body: Some("bio".into()),
836 language: None,
837 image: None,
838 profile: Some(ProfileSpec {
839 account_type: AccountType::Project as i32,
840 location: "Earth".into(),
841 }),
842 };
843 let p_item =
844 ItemMessage::decode(encode_item(&profile.to_prepared(None)).unwrap().as_slice())
845 .unwrap();
846 assert_eq!(infer_content_type(&p_item), ContentType::Profile);
847 let decoded = decode_item(&encode_item(&profile.to_prepared(None)).unwrap()).unwrap();
848 assert_eq!(decoded.content_type, ContentType::Profile);
849 assert_eq!(decoded.title.as_deref(), Some("Alice"));
850 assert_eq!(
851 decoded.profile.as_ref().map(|p| p.location.as_str()),
852 Some("Earth")
853 );
854 assert_eq!(decoded.profile.as_ref().map(|p| p.account_type), Some(2));
855 }
856
857 fn spec_input(filename: &str, digest_hex: &str, cid: &str) -> ContentInput {
859 ContentInput {
860 content_type: ContentType::Image,
861 title: None,
862 body: None,
863 language: None,
864 image: Some(ImageInput {
865 path: None,
866 filename: None,
867 spec: Some(ImageSpec {
868 filename: filename.into(),
869 filesize: 12345,
870 digest_hex: digest_hex.into(),
871 width: 800,
872 height: 600,
873 mipmap_levels: vec![MipmapLevel {
874 filesize: 100,
875 cid: cid.into(),
876 }],
877 }),
878 }),
879 profile: None,
880 }
881 }
882
883 fn prepare(input: &ContentInput) -> PreparedContent {
887 input.to_prepared(input.image.as_ref().and_then(|i| i.spec.clone()))
888 }
889
890 #[test]
891 fn encode_decode_image_round_trip() {
892 let digest = format!("0x{}", "22".repeat(32));
893 let input = spec_input("photo.jpg", &digest, &digest_hex_to_cid(&digest).unwrap());
894 let prepared = prepare(&input);
895 let item = ItemMessage::decode(encode_item(&prepared).unwrap().as_slice()).unwrap();
896 assert_eq!(infer_content_type(&item), ContentType::Image);
897 let decoded = decode_item(&encode_item(&prepared).unwrap()).unwrap();
898 let image = decoded.image.expect("image should decode");
899 assert_eq!(image.filename, "photo.jpg");
900 assert_eq!(image.filesize, 12345);
901 assert_eq!(image.width, 800);
902 assert_eq!(image.height, 600);
903 assert_eq!(image.digest_hex, digest);
905 assert_eq!(image.mipmap_levels.len(), 1);
906 assert_eq!(image.mipmap_levels[0].filesize, 100);
907 assert_eq!(
908 image.mipmap_levels[0].cid,
909 digest_hex_to_cid(&digest).unwrap()
910 );
911 }
912
913 #[test]
914 fn decode_unknown_mixins_are_ignored() {
915 let mut item = ItemMessage {
916 mixin_payload: vec![MixinPayloadMessage {
917 mixin_id: 0xdead_beef,
918 payload: vec![1, 2, 3],
919 }],
920 };
921 item.mixin_payload.push(MixinPayloadMessage {
923 mixin_id: TITLE_MIXIN_ID,
924 payload: TitleMixinMessage {
925 title: "kept".into(),
926 }
927 .encode_to_vec(),
928 });
929 let decoded = decode_item(&item.encode_to_vec()).unwrap();
930 assert_eq!(decoded.title.as_deref(), Some("kept"));
931 assert_eq!(decoded.content_type, ContentType::Document);
932 }
933
934 #[test]
935 fn encode_item_rejects_malformed_image_digest() {
936 let input = spec_input("bad.jpg", "0xnothex", "unused");
939 assert!(encode_item(&prepare(&input)).is_err());
940 }
941
942 #[test]
943 fn encode_item_rejects_malformed_mipmap_cid() {
944 let input = spec_input("b.jpg", &format!("0x{}", "22".repeat(32)), "not-a-cid");
946 assert!(encode_item(&prepare(&input)).is_err());
947 }
948}