Skip to main content

choreo_content/
encode.rs

1//! Content protocol: protobuf `ItemMessage` encoding/decoding, mixin
2//! construction, deterministic item-id derivation, and CID <-> sha2-256-digest
3//! conversion.
4//!
5//! On-chain content is a protobuf `ItemMessage`: an ordered list of
6//! `MixinPayloadMessage { mixin_id, payload }`, each tagged with a 32-bit mixin
7//! ID. The semantic *type* of an item is implied by which mixins are present
8//! (a `feed`/`comment` type marker, a `profile` mixin, etc.), so the same
9//! encoding serves a decentralized GitHub, a decentralized Wiki, or arbitrary
10//! content — only the mixin set differs.
11//!
12//! The 32-byte `IpfsHash` stored on-chain is the sha2-256 digest of an item's
13//! bytes, extracted from an IPFS `CIDv0` multihash (`0x12 0x20 || digest`). This
14//! module converts between the CID (Base58, as IPFS returns) and the digest hex
15//! (`0x`-prefixed) that is what actually reaches the chain events.
16
17use prost::Message;
18use sp_crypto_hashing::blake2_256;
19
20use crate::config::ITEM_ID_NAMESPACE;
21
22// ── Mixin ID constants (pinned to the reference `acuity-dioxus` protocol) ────
23
24/// BCP-47 language tag.
25pub const LANGUAGE_MIXIN_ID: u32 = 0x9bc7_a0e6;
26/// Human-readable title.
27pub const TITLE_MIXIN_ID: u32 = 0x344f_4812;
28/// Body text (markdown/plain).
29pub const BODY_TEXT_MIXIN_ID: u32 = 0x2d38_2044;
30/// Image (full-res + mipmap levels).
31pub const IMAGE_MIXIN_ID: u32 = 0x045e_ee8c;
32/// Profile (account type + location), used alongside title/body/language.
33pub const PROFILE_MIXIN_ID: u32 = 0xbeef_2144;
34/// Feed-type marker (empty payload).
35pub const FEED_TYPE_MIXIN_ID: u32 = 0xbcec_8faa;
36/// Comment-type marker (empty payload).
37pub const COMMENT_TYPE_MIXIN_ID: u32 = 0x874a_ba65;
38
39/// Default BCP-47 language tag applied when the caller omits one.
40pub const DEFAULT_LANGUAGE_TAG: &str = "en";
41
42// ── Protobuf message types ──────────────────────────────────────────────────
43
44/// The top-level content envelope: an ordered list of mixins.
45#[derive(Clone, PartialEq, Message)]
46pub struct ItemMessage {
47    #[prost(message, repeated, tag = "1")]
48    pub mixin_payload: Vec<MixinPayloadMessage>,
49}
50
51/// A single tagged mixin: a 32-bit type discriminator plus its payload.
52#[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/// BCP-47 language tag.
61#[derive(Clone, PartialEq, Message)]
62pub struct LanguageMixinMessage {
63    #[prost(string, tag = "1")]
64    pub language_tag: String,
65}
66
67/// Human-readable title.
68#[derive(Clone, PartialEq, Message)]
69pub struct TitleMixinMessage {
70    #[prost(string, tag = "1")]
71    pub title: String,
72}
73
74/// Body text.
75#[derive(Clone, PartialEq, Message)]
76pub struct BodyTextMixinMessage {
77    #[prost(string, tag = "1")]
78    pub body_text: String,
79}
80
81/// An image reference plus its mipmap pyramid (each level a separate IPFS CID).
82#[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/// One mipmap level of an [`ImageMixinMessage`].
99#[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/// Account profile mixin: signed account type + free-text location.
108#[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/// The account-type taxonomy encoded in a [`ProfileMixinMessage`].
117#[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// ── Content type and structured item I/O ────────────────────────────────────
132
133/// Fixed, protocol-level content-type vocabulary. The type is *encoded* by
134/// which mixins an item carries; this enum is the closure over the known
135/// marker/profile mixins. `Document` is the default (title + body + language).
136#[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    /// Title + body + language — the default item shape.
150    #[default]
151    Document,
152    Feed,
153    Comment,
154    Profile,
155    Image,
156}
157
158/// A mipmap level of an image, as uploaded to IPFS (a pre-encoded CID per
159/// level).
160#[derive(
161    Clone, Debug, Default, PartialEq, Eq, schemars::JsonSchema, serde::Serialize, serde::Deserialize,
162)]
163pub struct MipmapLevel {
164    /// Size of the level's raw bytes.
165    pub filesize: u64,
166    /// IPFS CID (Base58) of this level.
167    pub cid: String,
168}
169
170/// Structured image reference carried in an item.
171#[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    /// sha2-256 digest hex (`0x`-prefixed) of the full-resolution image.
178    pub digest_hex: String,
179    pub width: u32,
180    pub height: u32,
181    pub mipmap_levels: Vec<MipmapLevel>,
182}
183
184/// Structured profile fields.
185#[derive(
186    Clone, Debug, Default, PartialEq, Eq, schemars::JsonSchema, serde::Serialize, serde::Deserialize,
187)]
188pub struct ProfileSpec {
189    /// Account type (0..=8, see [`AccountType`]).
190    pub account_type: i32,
191    pub location: String,
192}
193
194/// Caller-supplied image reference for a publish.
195///
196/// The easy path is `path`: the daemon reads the file, builds the JPEG mipmap
197/// pyramid, uploads every level to IPFS, and derives the full [`ImageSpec`]
198/// (see [`crate::image::build_image_spec`]) — the ported `acuity-dioxus`
199/// publishing pipeline. Callers that already have a complete spec (e.g. re-
200/// publishing a previously resolved image) can supply `spec` directly.
201#[derive(
202    Clone, Debug, Default, PartialEq, Eq, schemars::JsonSchema, serde::Serialize, serde::Deserialize,
203)]
204pub struct ImageInput {
205    /// Local file path of the image to read, process, and upload. Mutually
206    /// exclusive with `spec`.
207    pub path: Option<String>,
208    /// Stored filename override; defaults to the file name in `path`.
209    pub filename: Option<String>,
210    /// A fully-specified image reference, used verbatim. Mutually exclusive
211    /// with `path`.
212    pub spec: Option<ImageSpec>,
213}
214
215/// A [`ContentInput`] whose image reference has been resolved into a complete
216/// [`ImageSpec`] — the input [`encode_item`] actually encodes. Built via
217/// [`ContentInput::to_prepared`] (usually through the orchestration layer,
218/// which performs the IPFS work for `path`-based inputs).
219#[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    /// BCP-47 language tag; defaults to [`DEFAULT_LANGUAGE_TAG`].
225    pub language: Option<String>,
226    pub image: Option<ImageSpec>,
227    pub profile: Option<ProfileSpec>,
228}
229
230/// Input to a publish: everything a caller wants in a new item revision.
231///
232/// `image` is an [`ImageInput`] so callers can just point at a local file;
233/// it is resolved (reading + uploading as needed) into a [`PreparedContent`]
234/// before encoding.
235#[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    /// BCP-47 language tag; defaults to [`DEFAULT_LANGUAGE_TAG`].
243    pub language: Option<String>,
244    pub image: Option<ImageInput>,
245    pub profile: Option<ProfileSpec>,
246}
247
248impl ContentInput {
249    /// Merge this input with an already-resolved image spec (the result of
250    /// resolving [`ImageInput::path`], or `spec` copied verbatim) into the
251    /// fully-specified form [`encode_item`] consumes.
252    #[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/// The decoded, field-extracted view of an item (what a read tool returns).
266#[derive(
267    Clone, Debug, Default, PartialEq, Eq, schemars::JsonSchema, serde::Serialize, serde::Deserialize,
268)]
269pub struct DecodedItem {
270    /// The type inferred from the present marker/profile mixins.
271    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
279/// Encode a [`ContentInput`] into the protobuf `ItemMessage` bytes, tagging the
280/// right mixin set for the chosen [`ContentType`].
281///
282/// Returns an error when an embedded image's digest hex or mipmap CID is
283/// malformed (see [`encode_image_mixin`]); a well-formed item always succeeds.
284///
285/// # Errors
286///
287/// Fails with [`crate::ContentError::Cid`] when the embedded image's
288/// `digest_hex` or any mipmap CID is malformed (propagated from
289/// [`encode_image_mixin`]); otherwise never fails.
290pub 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    // Type markers come first (empty payloads for feed/comment).
299    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    // Ordinal content mixins.
306    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
355/// Decode `ItemMessage` bytes into an extracted [`DecodedItem`]. Unknown mixins
356/// are ignored (forward compatibility); a missing/ill-formed mixin degrades
357/// that field to `None` rather than failing the whole decode.
358///
359/// # Errors
360///
361/// Fails with [`crate::ContentError::Content`] only when the top-level
362/// `ItemMessage` protobuf cannot be decoded from `bytes`; ill-formed
363/// individual mixins degrade their field to `None` instead.
364pub 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/// Infer the [`ContentType`] of a decoded item from its marker/profile mixins.
410#[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
430/// Build an empty-payload marker mixin.
431fn marker(mixin_id: u32) -> MixinPayloadMessage {
432    MixinPayloadMessage {
433        mixin_id,
434        payload: Vec::new(),
435    }
436}
437
438/// Encode an [`ImageSpec`] into the `ImageMixinMessage` payload bytes.
439///
440/// Returns an error if the image digest hex or any mipmap CID is malformed,
441/// rather than silently encoding an empty `ipfs_hash`. A bad image reference
442/// must fail the whole publish so it can never land on-chain.
443///
444/// # Errors
445///
446/// Fails with [`crate::ContentError::Cid`] when the image's `digest_hex` is
447/// not 32 bytes of `0x` hex (via [`multihash_bytes`]) or any mipmap level's
448/// `cid` is not a sha2-256 `CIDv0` multihash (via [`cid_to_multihash_bytes`]).
449pub 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                // The reference client (`acuity-dioxus`) stores the FULL
457                // 34-byte multihash (`0x12 0x20 ‖ digest`) in these fields
458                // and turns them into CIDs with a bare `bs58::encode` — so
459                // the header must be present here or its `api/v0/cat` calls
460                // get a base58-of-raw-digest string that Kubo rejects (500).
461                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/// Decode an `ImageMixinMessage` payload into an [`ImageSpec`], converting each
478/// level's digest back to a CID for display.
479#[must_use]
480pub fn decode_image_mixin(msg: ImageMixinMessage) -> ImageSpec {
481    // Accept both the reference form (34-byte multihash with the `0x12 0x20`
482    // header) and the legacy form this crate briefly wrote (bare 32-byte
483    // digest) so older items keep decoding.
484    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
501/// Normalize a mixin hash field to the bare 32-byte digest: strip the sha2-256
502/// multihash header when present, pass raw 32-byte digests through unchanged.
503fn digest_from_bytes(bytes: &[u8]) -> Vec<u8> {
504    // The len check above guarantees indices 0..2 are in bounds.
505    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/// Extract a single mixin's payload by ID.
513#[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// ── Item-id derivation ──────────────────────────────────────────────────────
525
526/// Derive a deterministic item ID from the publishing account, a caller-
527/// supplied nonce, and the fixed namespace:
528///
529/// `blake2_256(SCALE(account_id) ++ SCALE(nonce) ++ SCALE(namespace=1000))`
530///
531/// The result matches what `pallet-content` computes, so a client can predict
532/// an item's ID before it exists and subscribe to it.
533#[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// ── Hex / CID helpers ──────────────────────────────────────────────────────
545
546/// `[u8; 32]` -> `0x`-prefixed hex string.
547#[must_use]
548pub fn bytes_to_hex(bytes: &[u8]) -> String {
549    format!("0x{}", hex::encode(bytes))
550}
551
552/// `0x`-prefixed hex (32 bytes) -> `[u8; 32]`.
553///
554/// # Errors
555///
556/// Fails with [`crate::ContentError::Cid`] when `hex_value` is not valid hex
557/// or does not decode to exactly 32 bytes.
558pub 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
565/// sha2-256 digest hex (`0x`) -> IPFS `CIDv0` (Base58), prefixing the
566/// `0x12 0x20` multihash header.
567///
568/// # Errors
569///
570/// Fails with [`crate::ContentError::Cid`] (via [`hex_to_bytes`]) when
571/// `hex_value` is not exactly 32 bytes of `0x`-prefixed hex.
572pub 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
581/// IPFS `CIDv0` (Base58 sha2-256) -> `0x`-prefixed digest hex.
582///
583/// # Errors
584///
585/// Fails with [`crate::ContentError::Cid`] when `cid` is not valid Base58,
586/// does not decode to a 34-byte multihash with the `0x12 0x20` sha2-256
587/// header, or the digest cannot round-trip through [`hex_to_bytes`].
588pub 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    // The != 34 check above guarantees the header indices are in bounds.
593    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
605/// `0x`-prefixed digest hex -> raw digest bytes, for the protobuf mixin.
606fn 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
615/// IPFS `CIDv0` -> full multihash bytes (with the `0x12 0x20` header), for the
616/// protobuf mixin — the reference wire form. Validates the multihash shape
617/// and the 32-byte digest length like [`cid_to_digest_bytes`].
618fn 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/// Raw 32-byte digest -> IPFS `CIDv0` (Base58).
628#[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
637/// IPFS `CIDv0` -> raw 32-byte digest, for the protobuf mixin. Performs both the
638/// multihash-form validation (in [`cid_to_digest_hex`]) and the 32-byte length
639/// check (in [`hex_to_bytes`]), returning an error instead of an empty payload
640/// on any malformed input.
641fn 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/// Abbreviate a long hex string to `first10...last8` for display.
646#[must_use]
647pub fn short_hex(value: &str) -> String {
648    if value.len() <= 18 {
649        value.to_string()
650    } else {
651        // Hex strings are ASCII, so these are always char boundaries; .get
652        // keeps the truncation total against any future non-hex input.
653        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        // Changing the account changes the id.
678        assert_ne!(
679            derive_item_id(account, nonce_a),
680            derive_item_id([8u8; 32], nonce_a)
681        );
682        // Non-zero.
683        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        // string_slice has no test-allowance config; the 0x prefix is ASCII
693        // so the .get fallback is unreachable.
694        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        // short_hex keeps the 0x prefix in its leading 10 chars ("0x" + 8 nibbles).
706        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(&not_sha256).is_err());
722    }
723
724    #[test]
725    fn image_mixin_stores_reference_multihash_form() {
726        // acuity-dioxus turns `mipmap_level.ipfs_hash` into a CID with a bare
727        // `bs58::encode`, so the bytes must be a full 34-byte sha2-256
728        // multihash — not the bare digest (which yields an unparseable CID
729        // and a Kubo 500). Regression for the interop break.
730        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        // Round-trips through decode (both spec fields come back as before).
741        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        // Items published before the multihash fix carried the raw 32-byte
749        // digest; they must still decode to the same spec fields.
750        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        // Default language applied.
809        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    /// A fully-specified image input built from the parts the tests vary.
858    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    /// Prepare a [`ContentInput`] for encoding the way the orchestration
884    /// layer does: copy a `spec`-based image through, drop `path`-based ones
885    /// (those need IPFS and are covered in `image.rs`'s tests instead).
886    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        // Round-trips the full-res digest and the mipmap CID.
904        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        // A valid title mixin alongside the unknown one.
922        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        // A malformed image digest must fail the whole encode rather than
937        // silently producing a payload with an empty `ipfs_hash`.
938        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        // A malformed mipmap CID must fail the whole encode too.
945        let input = spec_input("b.jpg", &format!("0x{}", "22".repeat(32)), "not-a-cid");
946        assert!(encode_item(&prepare(&input)).is_err());
947    }
948}