Skip to main content

xmtp_mls_common/app_data/components/
metadata_attributes.rs

1//! [`Component`] impls for the eight `GroupMutableMetadata`-backed
2//! attribute components.
3//!
4//! Three flavours, distinguished by the typed `Value` they expose:
5//!
6//! - **String** (`GROUP_NAME`, `GROUP_DESCRIPTION`, `GROUP_IMAGE_URL`,
7//!   `APP_DATA`, `MIN_SUPPORTED_PROTOCOL_VERSION`): `Value = String`,
8//!   wire bytes are UTF-8.
9//! - **Big-endian `i64`** (`MESSAGE_DISAPPEAR_FROM_NS`,
10//!   `MESSAGE_DISAPPEAR_IN_NS`): `Value = i64`, wire bytes are exactly
11//!   8 bytes (`i64::to_be_bytes`). The legacy `GroupMutableMetadata`
12//!   path stringifies these as decimal; the AppData path uses the
13//!   binary representation directly so callers don't have to round-trip
14//!   through ASCII.
15//! - **Fixed-length signer key** (`COMMIT_LOG_SIGNER`):
16//!   `Value = xmtp_cryptography::Secret` (zeroized on drop) holding
17//!   exactly `ED25519_KEY_LENGTH` bytes — the raw Ed25519 private-key
18//!   material that the legacy path hex-encoded into a string. The
19//!   length is enforced at decode time; callers continue to handle a
20//!   `Secret` end-to-end without a hex round-trip.
21//!
22//! `AppDataUpdate::Update` payloads pass through verbatim after the
23//! component-specific length/UTF-8 validation, and there is no delta
24//! encoding.
25
26use openmls::messages::proposals::AppDataUpdateOperation;
27use xmtp_cryptography::{Secret, configuration::ED25519_KEY_LENGTH};
28use xmtp_proto::xmtp::mls::message_contents::ComponentType;
29
30use crate::app_data::{
31    component_id::ComponentId,
32    component_registry::ComponentOp,
33    typed::{Component, ComponentTypedError, ExpandedComponentChange},
34};
35
36/// Apply a passthrough Update payload — no delta math, the payload is
37/// the new full value bytes.
38pub(crate) fn apply_passthrough(payload: &[u8]) -> Result<Vec<u8>, ComponentTypedError> {
39    Ok(payload.to_vec())
40}
41
42/// Expand an `AppDataUpdate` proposal for a passthrough component:
43/// `Update` produces one `Update` change carrying the payload bytes;
44/// `Remove` produces one `Delete` change with no value.
45pub(crate) fn expand_passthrough(
46    op: &AppDataUpdateOperation,
47) -> Result<Vec<ExpandedComponentChange>, ComponentTypedError> {
48    match op {
49        AppDataUpdateOperation::Update(payload) => Ok(vec![ExpandedComponentChange {
50            op: ComponentOp::Update,
51            value: Some(payload.as_slice().to_vec()),
52        }]),
53        AppDataUpdateOperation::Remove => Ok(vec![ExpandedComponentChange {
54            op: ComponentOp::Delete,
55            value: None,
56        }]),
57    }
58}
59
60/// Decode UTF-8 bytes into a `String`, surfacing a structured
61/// [`ComponentTypedError::MalformedValue`] on invalid input rather than
62/// the bare `Utf8Error`.
63pub(crate) fn decode_utf8(
64    component_id: ComponentId,
65    bytes: &[u8],
66) -> Result<String, ComponentTypedError> {
67    std::str::from_utf8(bytes)
68        .map(str::to_owned)
69        .map_err(|err| ComponentTypedError::MalformedValue {
70            component_id,
71            reason: format!("not valid UTF-8: {err}"),
72        })
73}
74
75/// Decode an 8-byte big-endian `i64`, surfacing
76/// [`ComponentTypedError::MalformedValue`] on length mismatch.
77fn decode_be_i64(component_id: ComponentId, bytes: &[u8]) -> Result<i64, ComponentTypedError> {
78    let arr: [u8; 8] = bytes
79        .try_into()
80        .map_err(|_| ComponentTypedError::MalformedValue {
81            component_id,
82            reason: format!("expected 8 bytes (big-endian i64), got {}", bytes.len()),
83        })?;
84    Ok(i64::from_be_bytes(arr))
85}
86
87/// Validate a byte slice has the expected fixed length, surfacing
88/// [`ComponentTypedError::MalformedValue`] on mismatch.
89fn require_exact_len(
90    component_id: ComponentId,
91    bytes: &[u8],
92    expected: usize,
93) -> Result<(), ComponentTypedError> {
94    if bytes.len() != expected {
95        return Err(ComponentTypedError::MalformedValue {
96            component_id,
97            reason: format!("expected {} bytes, got {}", expected, bytes.len()),
98        });
99    }
100    Ok(())
101}
102
103/// Internal macro for declaring a passthrough metadata-attribute
104/// component impl.
105///
106/// Each invocation produces a unit struct + `Component` impl. The
107/// `decode_value` / `encode_value` / `encode_mutation` bodies vary
108/// between the `String` and `Bytes` shapes, so the macro takes them as
109/// arms.
110///
111/// The macro stays internal (`macro_rules!` with no `pub` attribute) —
112/// it's a within-this-file convenience, not a public API.
113macro_rules! passthrough_string_component {
114    ($struct_name:ident, $id:expr) => {
115        pub struct $struct_name;
116
117        impl Component for $struct_name {
118            const ID: ComponentId = $id;
119            const COMPONENT_TYPE: ComponentType = ComponentType::String;
120            type Value = String;
121            type Mutation = String;
122
123            fn decode_value(bytes: &[u8]) -> Result<Self::Value, ComponentTypedError> {
124                decode_utf8(Self::ID, bytes)
125            }
126
127            fn encode_value(value: &Self::Value) -> Result<Vec<u8>, ComponentTypedError> {
128                Ok(value.as_bytes().to_vec())
129            }
130
131            fn encode_mutation(mutation: &Self::Mutation) -> Result<Vec<u8>, ComponentTypedError> {
132                Ok(mutation.as_bytes().to_vec())
133            }
134
135            fn apply_update_payload(
136                payload: &[u8],
137                _prior: Option<&[u8]>,
138            ) -> Result<Vec<u8>, ComponentTypedError> {
139                // Validate UTF-8 before accepting the bytes into the
140                // dict. Without this, a peer could land a value every
141                // receiver ACCEPTS at commit time but none can READ
142                // back (`decode_value` requires UTF-8) — an
143                // accepted-but-unreadable component. Mirrors the
144                // `be_i64_component!` family's validate-then-passthrough
145                // shape, and matches the type-aware fallback for
146                // unknown String-typed ids, which already rejects
147                // non-UTF-8 — so known-impl and unknown-id receivers
148                // agree.
149                let _ = decode_utf8(Self::ID, payload)?;
150                apply_passthrough(payload)
151            }
152
153            fn expand_to_changes(
154                op: &AppDataUpdateOperation,
155                _prior: Option<&[u8]>,
156            ) -> Result<Vec<ExpandedComponentChange>, ComponentTypedError> {
157                // Keep the validator's verdict in lockstep with apply.
158                if let AppDataUpdateOperation::Update(payload) = op {
159                    let _ = decode_utf8(Self::ID, payload.as_slice())?;
160                }
161                expand_passthrough(op)
162            }
163        }
164    };
165}
166
167/// Internal macro for the two `i64` disappearance-window components.
168/// Both share the wire shape (8-byte BE) and only differ in their ID.
169macro_rules! be_i64_component {
170    ($struct_name:ident, $id:expr) => {
171        pub struct $struct_name;
172
173        impl Component for $struct_name {
174            const ID: ComponentId = $id;
175            const COMPONENT_TYPE: ComponentType = ComponentType::Bytes;
176            type Value = i64;
177            type Mutation = i64;
178
179            fn decode_value(bytes: &[u8]) -> Result<Self::Value, ComponentTypedError> {
180                decode_be_i64(Self::ID, bytes)
181            }
182
183            fn encode_value(value: &Self::Value) -> Result<Vec<u8>, ComponentTypedError> {
184                Ok(value.to_be_bytes().to_vec())
185            }
186
187            fn encode_mutation(mutation: &Self::Mutation) -> Result<Vec<u8>, ComponentTypedError> {
188                Ok(mutation.to_be_bytes().to_vec())
189            }
190
191            fn apply_update_payload(
192                payload: &[u8],
193                _prior: Option<&[u8]>,
194            ) -> Result<Vec<u8>, ComponentTypedError> {
195                // Validate length+shape and pass through.
196                let _ = decode_be_i64(Self::ID, payload)?;
197                Ok(payload.to_vec())
198            }
199
200            fn expand_to_changes(
201                op: &AppDataUpdateOperation,
202                _prior: Option<&[u8]>,
203            ) -> Result<Vec<ExpandedComponentChange>, ComponentTypedError> {
204                match op {
205                    AppDataUpdateOperation::Update(payload) => {
206                        let _ = decode_be_i64(Self::ID, payload.as_slice())?;
207                        Ok(vec![ExpandedComponentChange {
208                            op: ComponentOp::Update,
209                            value: Some(payload.as_slice().to_vec()),
210                        }])
211                    }
212                    AppDataUpdateOperation::Remove => Ok(vec![ExpandedComponentChange {
213                        op: ComponentOp::Delete,
214                        value: None,
215                    }]),
216                }
217            }
218        }
219    };
220}
221
222passthrough_string_component!(GroupNameComponent, ComponentId::GROUP_NAME);
223passthrough_string_component!(GroupDescriptionComponent, ComponentId::GROUP_DESCRIPTION);
224passthrough_string_component!(GroupImageUrlComponent, ComponentId::GROUP_IMAGE_URL);
225passthrough_string_component!(AppDataComponent, ComponentId::APP_DATA);
226passthrough_string_component!(
227    MinSupportedProtocolVersionComponent,
228    ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION
229);
230
231be_i64_component!(
232    MessageDisappearFromNsComponent,
233    ComponentId::MESSAGE_DISAPPEAR_FROM_NS
234);
235be_i64_component!(
236    MessageDisappearInNsComponent,
237    ComponentId::MESSAGE_DISAPPEAR_IN_NS
238);
239
240/// `Component` impl for the `COMMIT_LOG_SIGNER` component.
241///
242/// The decoded value is an [`xmtp_cryptography::Secret`] holding
243/// exactly `ED25519_KEY_LENGTH` bytes — the raw Ed25519 private-key
244/// material. The length is enforced at decode time so callers can
245/// always treat the resulting `Secret` as a 32-byte key.
246pub struct CommitLogSignerComponent;
247
248impl Component for CommitLogSignerComponent {
249    const ID: ComponentId = ComponentId::COMMIT_LOG_SIGNER;
250    const COMPONENT_TYPE: ComponentType = ComponentType::Bytes;
251    type Value = Secret;
252    type Mutation = Secret;
253
254    fn decode_value(bytes: &[u8]) -> Result<Self::Value, ComponentTypedError> {
255        require_exact_len(Self::ID, bytes, ED25519_KEY_LENGTH)?;
256        Ok(Secret::new(bytes.to_vec()))
257    }
258
259    fn encode_value(value: &Self::Value) -> Result<Vec<u8>, ComponentTypedError> {
260        require_exact_len(Self::ID, value.as_slice(), ED25519_KEY_LENGTH)?;
261        Ok(value.as_slice().to_vec())
262    }
263
264    fn encode_mutation(mutation: &Self::Mutation) -> Result<Vec<u8>, ComponentTypedError> {
265        require_exact_len(Self::ID, mutation.as_slice(), ED25519_KEY_LENGTH)?;
266        Ok(mutation.as_slice().to_vec())
267    }
268
269    fn apply_update_payload(
270        payload: &[u8],
271        _prior: Option<&[u8]>,
272    ) -> Result<Vec<u8>, ComponentTypedError> {
273        require_exact_len(Self::ID, payload, ED25519_KEY_LENGTH)?;
274        Ok(payload.to_vec())
275    }
276
277    fn expand_to_changes(
278        op: &AppDataUpdateOperation,
279        _prior: Option<&[u8]>,
280    ) -> Result<Vec<ExpandedComponentChange>, ComponentTypedError> {
281        match op {
282            AppDataUpdateOperation::Update(payload) => {
283                require_exact_len(Self::ID, payload.as_slice(), ED25519_KEY_LENGTH)?;
284                Ok(vec![ExpandedComponentChange {
285                    op: ComponentOp::Update,
286                    value: Some(payload.as_slice().to_vec()),
287                }])
288            }
289            AppDataUpdateOperation::Remove => Ok(vec![ExpandedComponentChange {
290                op: ComponentOp::Delete,
291                value: None,
292            }]),
293        }
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use openmls::messages::proposals::AppDataUpdateOperation;
301
302    #[xmtp_common::test(unwrap_try = true)]
303    fn string_component_round_trip() {
304        let name = String::from("Test Group 🚀");
305        let bytes = GroupNameComponent::encode_value(&name).unwrap();
306        let decoded = GroupNameComponent::decode_value(&bytes).unwrap();
307        assert_eq!(decoded, name);
308    }
309
310    #[xmtp_common::test(unwrap_try = true)]
311    fn string_component_decode_rejects_non_utf8() {
312        let bytes = vec![0xff, 0xfe, 0xfd];
313        let err = GroupDescriptionComponent::decode_value(&bytes).unwrap_err();
314        match err {
315            ComponentTypedError::MalformedValue { component_id, .. } => {
316                assert_eq!(component_id, ComponentId::GROUP_DESCRIPTION);
317            }
318            other => panic!("expected MalformedValue, got {other:?}"),
319        }
320    }
321
322    /// Apply/read symmetry: bytes the read path can't decode must be
323    /// rejected at APPLY time too. Pre-fix, `apply_update_payload` was
324    /// a raw passthrough — a peer could land a value every receiver
325    /// accepted at commit time but none could read back.
326    #[xmtp_common::test(unwrap_try = true)]
327    fn string_component_apply_rejects_non_utf8() {
328        let err = AppDataComponent::apply_update_payload(&[0xff, 0xfe, 0xfd], None).unwrap_err();
329        match err {
330            ComponentTypedError::MalformedValue { component_id, .. } => {
331                assert_eq!(component_id, ComponentId::APP_DATA);
332            }
333            other => panic!("expected MalformedValue, got {other:?}"),
334        }
335        // Valid UTF-8 still passes through byte-identical.
336        let ok = AppDataComponent::apply_update_payload("héllo".as_bytes(), None).unwrap();
337        assert_eq!(ok, "héllo".as_bytes());
338    }
339
340    /// Same symmetry on the validator's expansion path — apply and
341    /// expand must agree so every honest receiver returns the same
342    /// verdict regardless of which stage sees the payload first.
343    #[xmtp_common::test(unwrap_try = true)]
344    fn string_component_expand_rejects_non_utf8() {
345        let op = AppDataUpdateOperation::Update(vec![0xff, 0xfe, 0xfd].into());
346        let err = GroupNameComponent::expand_to_changes(&op, None).unwrap_err();
347        match err {
348            ComponentTypedError::MalformedValue { component_id, .. } => {
349                assert_eq!(component_id, ComponentId::GROUP_NAME);
350            }
351            other => panic!("expected MalformedValue, got {other:?}"),
352        }
353        // Remove never carries bytes and stays valid.
354        let changes =
355            GroupNameComponent::expand_to_changes(&AppDataUpdateOperation::Remove, None).unwrap();
356        assert_eq!(changes.len(), 1);
357        assert_eq!(changes[0].op, ComponentOp::Delete);
358    }
359
360    #[xmtp_common::test(unwrap_try = true)]
361    fn commit_log_signer_round_trip() {
362        let key = Secret::new(vec![0xAB; ED25519_KEY_LENGTH]);
363        let bytes = CommitLogSignerComponent::encode_value(&key).unwrap();
364        assert_eq!(bytes.len(), ED25519_KEY_LENGTH);
365        let decoded = CommitLogSignerComponent::decode_value(&bytes).unwrap();
366        assert_eq!(decoded.as_slice(), key.as_slice());
367    }
368
369    #[xmtp_common::test(unwrap_try = true)]
370    fn commit_log_signer_rejects_wrong_length() {
371        let too_short = vec![0u8; ED25519_KEY_LENGTH - 1];
372        let err = CommitLogSignerComponent::decode_value(&too_short).unwrap_err();
373        match err {
374            ComponentTypedError::MalformedValue { component_id, .. } => {
375                assert_eq!(component_id, ComponentId::COMMIT_LOG_SIGNER);
376            }
377            other => panic!("expected MalformedValue, got {other:?}"),
378        }
379        // Encoding a Secret that doesn't hold exactly 32 bytes also rejects.
380        let wrong = Secret::new(vec![0u8; ED25519_KEY_LENGTH + 1]);
381        let err = CommitLogSignerComponent::encode_value(&wrong).unwrap_err();
382        assert!(matches!(err, ComponentTypedError::MalformedValue { .. }));
383    }
384
385    #[xmtp_common::test(unwrap_try = true)]
386    fn message_disappear_round_trip() {
387        // Use a value that exercises sign-extension: large negative i64.
388        let v: i64 = -1_234_567_890_123;
389        let bytes = MessageDisappearFromNsComponent::encode_value(&v).unwrap();
390        assert_eq!(bytes.len(), 8);
391        let decoded = MessageDisappearFromNsComponent::decode_value(&bytes).unwrap();
392        assert_eq!(decoded, v);
393    }
394
395    #[xmtp_common::test(unwrap_try = true)]
396    fn message_disappear_rejects_wrong_length() {
397        // Catches a sender that emitted decimal-stringified bytes
398        // (e.g. b"3600000000000") under the legacy convention.
399        let ascii_decimal = b"3600000000000";
400        let err = MessageDisappearInNsComponent::decode_value(ascii_decimal).unwrap_err();
401        match err {
402            ComponentTypedError::MalformedValue { component_id, .. } => {
403                assert_eq!(component_id, ComponentId::MESSAGE_DISAPPEAR_IN_NS);
404            }
405            other => panic!("expected MalformedValue, got {other:?}"),
406        }
407    }
408
409    #[xmtp_common::test(unwrap_try = true)]
410    fn apply_update_passthrough() {
411        // String component still passes payloads through verbatim.
412        let payload = b"new value";
413        let new = GroupNameComponent::apply_update_payload(payload, Some(b"old")).unwrap();
414        assert_eq!(new, payload);
415
416        // i64 components validate the length-and-shape but pass the
417        // bytes through unchanged so receivers don't re-encode.
418        let valid = 42_i64.to_be_bytes();
419        let new = MessageDisappearInNsComponent::apply_update_payload(&valid, None).unwrap();
420        assert_eq!(new.as_slice(), valid.as_slice());
421
422        let err =
423            MessageDisappearInNsComponent::apply_update_payload(b"not 8 bytes", None).unwrap_err();
424        assert!(matches!(err, ComponentTypedError::MalformedValue { .. }));
425    }
426
427    #[xmtp_common::test(unwrap_try = true)]
428    fn expand_update_yields_one_change() {
429        let payload = b"hello".to_vec();
430        let op = AppDataUpdateOperation::Update(payload.clone().into());
431        let changes = GroupNameComponent::expand_to_changes(&op, None).unwrap();
432        assert_eq!(changes.len(), 1);
433        assert_eq!(changes[0].op, ComponentOp::Update);
434        assert_eq!(changes[0].value.as_deref(), Some(payload.as_slice()));
435    }
436
437    #[xmtp_common::test(unwrap_try = true)]
438    fn expand_remove_yields_delete_with_no_value() {
439        let op = AppDataUpdateOperation::Remove;
440        let changes = AppDataComponent::expand_to_changes(&op, None).unwrap();
441        assert_eq!(changes.len(), 1);
442        assert_eq!(changes[0].op, ComponentOp::Delete);
443        assert!(changes[0].value.is_none());
444    }
445
446    #[xmtp_common::test(unwrap_try = true)]
447    fn component_id_constants_match_static_id() {
448        // Sanity check that each component reports its own ID via the
449        // trait — a copy-paste error in the macro invocations would
450        // surface here.
451        assert_eq!(GroupNameComponent::ID, ComponentId::GROUP_NAME);
452        assert_eq!(
453            GroupDescriptionComponent::ID,
454            ComponentId::GROUP_DESCRIPTION
455        );
456        assert_eq!(GroupImageUrlComponent::ID, ComponentId::GROUP_IMAGE_URL);
457        assert_eq!(AppDataComponent::ID, ComponentId::APP_DATA);
458        assert_eq!(
459            MinSupportedProtocolVersionComponent::ID,
460            ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION
461        );
462        assert_eq!(
463            MessageDisappearFromNsComponent::ID,
464            ComponentId::MESSAGE_DISAPPEAR_FROM_NS
465        );
466        assert_eq!(
467            MessageDisappearInNsComponent::ID,
468            ComponentId::MESSAGE_DISAPPEAR_IN_NS
469        );
470        assert_eq!(CommitLogSignerComponent::ID, ComponentId::COMMIT_LOG_SIGNER);
471    }
472
473    #[xmtp_common::test(unwrap_try = true)]
474    fn component_types_are_correct() {
475        assert_eq!(GroupNameComponent::COMPONENT_TYPE, ComponentType::String);
476        assert_eq!(
477            CommitLogSignerComponent::COMPONENT_TYPE,
478            ComponentType::Bytes
479        );
480        assert_eq!(
481            MessageDisappearFromNsComponent::COMPONENT_TYPE,
482            ComponentType::Bytes
483        );
484        assert_eq!(
485            MinSupportedProtocolVersionComponent::COMPONENT_TYPE,
486            ComponentType::String
487        );
488    }
489}