Skip to main content

xmtp_mls_common/app_data/
component_registry.rs

1use crate::tls_map::TlsMap;
2use prost::Message;
3use tls_codec::{Deserialize, Serialize, VLBytes};
4use xmtp_proto::xmtp::mls::message_contents::{
5    ComponentMetadata, ComponentPermissions, ComponentType, MetadataPolicy as MetadataPolicyProto,
6    metadata_policy::{Kind as MetadataPolicyKind, MetadataBasePolicy},
7};
8
9use super::component_id::ComponentId;
10
11/// The operation being performed on a component.
12///
13/// Each variant maps to one of the policy fields in
14/// [`ComponentPermissions`](xmtp_proto::xmtp::mls::message_contents::ComponentPermissions):
15/// `Insert` → `insert_policy`, `Update` → `update_policy`, `Delete` → `delete_policy`.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ComponentOp {
18    /// Creating a new value (component does not yet exist).
19    Insert,
20    /// Modifying an existing value.
21    Update,
22    /// Removing a value.
23    Delete,
24}
25
26impl std::fmt::Display for ComponentOp {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        match self {
29            ComponentOp::Insert => write!(f, "insert"),
30            ComponentOp::Update => write!(f, "update"),
31            ComponentOp::Delete => write!(f, "delete"),
32        }
33    }
34}
35
36/// Helper to construct a [`ComponentMetadata`] from permissions and type.
37pub fn new_component_metadata(
38    permissions: ComponentPermissions,
39    component_type: ComponentType,
40) -> ComponentMetadata {
41    ComponentMetadata {
42        permissions: Some(permissions),
43        component_type: component_type as i32,
44        external_committer_permissions: None,
45    }
46}
47
48#[derive(Debug, thiserror::Error)]
49pub enum ComponentRegistryError {
50    #[error("component ID {0} is not in the component ID space")]
51    InvalidComponentId(ComponentId),
52    #[error("component ID {0} is in the reserved range")]
53    ReservedRange(ComponentId),
54    #[error("immutable component {0} cannot be modified after initial insert")]
55    ImmutableComponent(ComponentId),
56    #[error("hardcoded component {0} cannot be removed")]
57    HardcodedComponent(ComponentId),
58    #[error("component {0} not found")]
59    NotFound(ComponentId),
60    #[error("component {0} metadata is missing the permissions field")]
61    MissingPermissions(ComponentId),
62    #[error("component {0} metadata is missing the {1} policy field")]
63    MissingPolicyField(ComponentId, ComponentOp),
64    #[error("decode error for component {component_id}: {source}")]
65    DecodeError {
66        component_id: ComponentId,
67        #[source]
68        source: prost::DecodeError,
69    },
70    #[error("tls codec error: {0}")]
71    TlsCodecError(#[from] tls_codec::Error),
72    #[error("constrained component {0} requires AllowIfAdmin or AllowIfSuperAdmin policies")]
73    ConstrainedPolicyViolation(ComponentId),
74}
75
76/// A component registry stored as a `TlsMap<ComponentId, VLBytes>` where
77/// each value is a protobuf-encoded [`ComponentMetadata`] describing the
78/// component's data type and permission policies.
79///
80/// The registry provides deterministic TLS serialization (sorted by
81/// ComponentId) and enforces that hardcoded and reserved component IDs
82/// cannot be modified through this map (their permissions are enforced in
83/// code).
84///
85/// Stored at well-known component ID `0x8000` (`ComponentId::COMPONENT_REGISTRY`).
86///
87/// ## One raw map, a validated view
88///
89/// The map holds every entry exactly as it was read, byte for byte —
90/// including entries this build cannot validate. Validation is applied
91/// lazily on read: [`get`](Self::get), [`iter`](Self::iter),
92/// [`contains`](Self::contains), and [`len`](Self::len) present only
93/// *recognized* entries (those that pass
94/// [`validate_entry`](Self::validate_entry)). An entry that fails
95/// validation is invisible to all of them, so a write against it falls to
96/// the deny-by-default `NoRegistryEntry` policy verdict. The raw bytes
97/// still surface through [`to_bytes`](Self::to_bytes) (verbatim) and
98/// [`unrecognized_ids`](Self::unrecognized_ids) (a diagnostic).
99///
100/// Two invariants motivate carrying bytes we can't read:
101///
102/// - **Never fork by dropping bytes.** The registry lives inside the
103///   `app_data_dictionary` group-context extension; every member must
104///   compute byte-identical dict bytes or the group splits. A committer
105///   reads the dict, changes one component, and re-emits the whole thing,
106///   so it must round-trip untouched entries exactly — and prost does not
107///   preserve unknown proto fields across a decode/re-encode, which is why
108///   we keep raw `VLBytes` and never re-serialize an entry we didn't
109///   author.
110/// - **Never brick on state we didn't write.** A single entry left by a
111///   newer protocol version, or by a historically buggy writer (a poisoned
112///   group), must degrade to "that one component is unwritable here," never
113///   to "no commit on this group validates again." Rejecting the whole
114///   snapshot would do the latter, because the registry is decoded on the
115///   validation path of *every* commit.
116///
117/// Tolerance is a read-side backstop, not an enforcement hole: new entries
118/// still cannot *enter* a group's registry unvalidated (the steady-state
119/// wire path validates per-mutation and the bootstrap validator validates
120/// each entry of the initial delta).
121#[derive(Debug, Clone, PartialEq)]
122pub struct ComponentRegistry {
123    inner: TlsMap<ComponentId, VLBytes>,
124}
125
126impl ComponentRegistry {
127    pub fn new() -> Self {
128        Self {
129            inner: TlsMap::new(),
130        }
131    }
132
133    /// Get the metadata for a *recognized* component.
134    ///
135    /// Returns `Ok(None)` both when no entry exists and when an entry exists
136    /// but fails [`validate_entry`](Self::validate_entry) — an unreadable
137    /// entry is deny-by-default, indistinguishable from absent, so callers
138    /// never have to tell the two apart. The `Result` is retained for API
139    /// stability; this method does not currently produce an error.
140    pub fn get(
141        &self,
142        id: &ComponentId,
143    ) -> Result<Option<ComponentMetadata>, ComponentRegistryError> {
144        Ok(self
145            .inner
146            .get(id)
147            .and_then(|raw| Self::decode_recognized(*id, raw.as_slice()).ok()))
148    }
149
150    /// Register or update a component's metadata.
151    ///
152    /// For mutable components in the registry, this silently overwrites any
153    /// existing entry — there is no audit log here because the audit trail
154    /// lives in the MLS commit history that produced the change.
155    ///
156    /// A valid write to an id that currently holds an unrecognized entry
157    /// repairs it — the raw bytes are overwritten, so `to_bytes` serializes
158    /// the repaired value rather than resurrecting the broken bytes.
159    ///
160    /// Rejects invalid IDs, IDs in the reserved range, hardcoded components
161    /// (whose permissions are enforced in code, not metadata), immutable
162    /// components that already hold a recognized entry (write-once
163    /// semantics), metadata missing required fields, and constrained
164    /// components with invalid policy values.
165    pub fn set(
166        &mut self,
167        id: ComponentId,
168        meta: ComponentMetadata,
169    ) -> Result<(), ComponentRegistryError> {
170        self.validate_modifiable(&id)?;
171        Self::validate_metadata(&id, &meta)?;
172        let bytes = VLBytes::new(meta.encode_to_vec());
173        self.inner.set(id, bytes);
174        Ok(())
175    }
176
177    /// Remove a component from the registry.
178    ///
179    /// Rejects everything [`set`](Self::set) rejects (invalid IDs, reserved,
180    /// hardcoded, and overwrite of a write-once-immutable entry). Removing a
181    /// modifiable id drops its bytes whether the entry was recognized or an
182    /// unrecognized one preserved by [`from_bytes`](Self::from_bytes)
183    /// (repair-by-delete).
184    pub fn remove(&mut self, id: &ComponentId) -> Result<(), ComponentRegistryError> {
185        self.validate_modifiable(id)?;
186        self.inner
187            .remove(id)
188            .map_err(|_| ComponentRegistryError::NotFound(*id))?;
189        Ok(())
190    }
191
192    /// Returns true if the registry contains a *recognized* entry for the
193    /// given component. An unrecognized (preserved-but-invalid) entry
194    /// reports `false`.
195    pub fn contains(&self, id: &ComponentId) -> bool {
196        self.inner
197            .get(id)
198            .is_some_and(|raw| Self::decode_recognized(*id, raw.as_slice()).is_ok())
199    }
200
201    /// Returns the number of *recognized* entries. Unrecognized entries
202    /// preserved by [`from_bytes`](Self::from_bytes) are not counted.
203    pub fn len(&self) -> usize {
204        self.iter().count()
205    }
206
207    /// Returns true if the registry has no recognized entries. A registry
208    /// carrying only unrecognized entries still reports empty.
209    pub fn is_empty(&self) -> bool {
210        self.iter().next().is_none()
211    }
212
213    /// Iterate over the *recognized* component IDs and their decoded
214    /// metadata, in ComponentId order. Unrecognized entries preserved by
215    /// [`from_bytes`](Self::from_bytes) are skipped — writes against them
216    /// fall to deny-by-default — and are surfaced via
217    /// [`unrecognized_ids`](Self::unrecognized_ids) instead.
218    ///
219    /// Each item is a `Result` for API stability; a recognized entry always
220    /// decodes, so this only ever yields `Ok`.
221    pub fn iter(
222        &self,
223    ) -> impl Iterator<Item = Result<(ComponentId, ComponentMetadata), ComponentRegistryError>> + '_
224    {
225        self.inner.iter().filter_map(|(&id, raw)| {
226            Self::decode_recognized(id, raw.as_slice())
227                .ok()
228                .map(|meta| Ok((id, meta)))
229        })
230    }
231
232    /// Serialize the registry as the **dict-storage format**: the raw
233    /// `TlsMap<ComponentId, VLBytes>` snapshot, verbatim. Because the map is
234    /// stored exactly as read (recognized and unrecognized entries alike), a
235    /// load → store round-trip is byte-identical and never drops data
236    /// another (possibly newer) client wrote.
237    ///
238    /// Wire-format encoding (a `TlsMapDelta` describing changes) is done
239    /// piecemeal at the call site — there is no whole-registry wire encoder,
240    /// because every steady-state update emits only the few entries it
241    /// touches, and the bootstrap encoder builds its `TlsMapDelta`-from-empty
242    /// inline at the synthesis site (see
243    /// `xmtp_mls::groups::app_data::migration::synthesize_initial_component_values`).
244    pub fn to_bytes(&self) -> Result<Vec<u8>, ComponentRegistryError> {
245        Ok(self.inner.tls_serialize_detached()?)
246    }
247
248    /// Deserialize a component registry from its **dict-storage format**: a
249    /// raw `TlsMap<ComponentId, VLBytes>` snapshot.
250    ///
251    /// Fails only when the outer `TlsMap` doesn't decode (truncated /
252    /// non-canonical bytes). Individual entries are *not* validated here —
253    /// they're kept raw and validated lazily on read, so an entry from a
254    /// newer protocol version (or a historical invalid entry) is preserved
255    /// rather than making every future commit on the group unvalidatable.
256    /// Callers that care can inspect
257    /// [`unrecognized_ids`](Self::unrecognized_ids) and log.
258    ///
259    /// New entries cannot *enter* a group's registry unvalidated: the
260    /// steady-state wire path validates per-mutation
261    /// (`ComponentRegistryComponent::apply_update_payload` /
262    /// `expand_to_changes`) and the bootstrap validator validates each entry
263    /// of the initial delta. Tolerance here is the read-side backstop, not
264    /// the enforcement point.
265    pub fn from_bytes(bytes: &[u8]) -> Result<Self, ComponentRegistryError> {
266        let inner = TlsMap::<ComponentId, VLBytes>::tls_deserialize_exact(bytes)?;
267        Ok(Self { inner })
268    }
269
270    /// Component ids of entries [`from_bytes`](Self::from_bytes) preserved
271    /// but that could not validate. Empty in the overwhelmingly common case;
272    /// non-empty means the dict was written by a newer protocol version (or
273    /// carries a historical invalid entry) and is worth a log line at the
274    /// load site.
275    pub fn unrecognized_ids(&self) -> impl Iterator<Item = ComponentId> + '_ {
276        self.inner.iter().filter_map(|(&id, raw)| {
277            Self::decode_recognized(id, raw.as_slice())
278                .is_err()
279                .then_some(id)
280        })
281    }
282
283    /// Validate one `(ComponentId, raw bytes)` entry against the registry's
284    /// invariants — the single definition of "recognized." Used by the
285    /// wire-decode path in the bootstrap validator (which walks a
286    /// `TlsMapDelta`'s mutations directly so it can surface the `Insert`-only
287    /// check before per-entry validation) and, internally, by every read
288    /// method.
289    pub fn validate_entry(id: ComponentId, raw: &[u8]) -> Result<(), ComponentRegistryError> {
290        Self::decode_recognized(id, raw).map(|_| ())
291    }
292
293    /// Decode and fully validate a stored entry, returning its metadata iff
294    /// the entry is recognized. This is the one place that decides
295    /// visibility: [`get`](Self::get), [`iter`](Self::iter),
296    /// [`contains`](Self::contains), [`len`](Self::len), and
297    /// [`unrecognized_ids`](Self::unrecognized_ids) are all defined in terms
298    /// of whether this returns `Ok`.
299    ///
300    /// Rejects ids outside the component space, reserved ids, hardcoded ids
301    /// (their permissions are enforced in code), values that don't decode as
302    /// `ComponentMetadata`, and metadata that is structurally incomplete or
303    /// violates a constrained component's policy allowlist.
304    fn decode_recognized(
305        id: ComponentId,
306        raw: &[u8],
307    ) -> Result<ComponentMetadata, ComponentRegistryError> {
308        if !id.is_in_component_space() {
309            return Err(ComponentRegistryError::InvalidComponentId(id));
310        }
311        if id.is_reserved() {
312            return Err(ComponentRegistryError::ReservedRange(id));
313        }
314        if id.is_hardcoded() {
315            return Err(ComponentRegistryError::HardcodedComponent(id));
316        }
317        let meta = ComponentMetadata::decode(raw).map_err(|source| {
318            ComponentRegistryError::DecodeError {
319                component_id: id,
320                source,
321            }
322        })?;
323        Self::validate_metadata(&id, &meta)?;
324        Ok(meta)
325    }
326
327    /// Validate that the registry entry for `id` can be inserted, updated,
328    /// or removed.
329    ///
330    /// Rejects:
331    /// - IDs outside the component ID space
332    /// - IDs in the reserved range
333    /// - Hardcoded IDs (their permissions are enforced in code; allowing
334    ///   metadata entries here would create a silent disagreement between
335    ///   what the registry says and what `validate_component_write` actually
336    ///   enforces)
337    /// - Immutable IDs that already hold a *recognized* entry (write-once).
338    ///   An immutable id holding only an unrecognized entry is repairable,
339    ///   since write-once protects an established value, not broken bytes.
340    fn validate_modifiable(&self, id: &ComponentId) -> Result<(), ComponentRegistryError> {
341        if !id.is_in_component_space() {
342            return Err(ComponentRegistryError::InvalidComponentId(*id));
343        }
344        if id.is_reserved() {
345            return Err(ComponentRegistryError::ReservedRange(*id));
346        }
347        if id.is_hardcoded() {
348            return Err(ComponentRegistryError::HardcodedComponent(*id));
349        }
350        if id.is_immutable() && self.contains(id) {
351            return Err(ComponentRegistryError::ImmutableComponent(*id));
352        }
353        Ok(())
354    }
355
356    /// Validate that the metadata for a component is structurally complete
357    /// and (for constrained components) uses allowed policy values.
358    ///
359    /// Every component must have:
360    /// - `permissions` set to `Some`
361    /// - All three policy fields (`insert_policy`, `update_policy`,
362    ///   `delete_policy`) set to `Some`
363    ///
364    /// Constrained components (e.g. `ADMIN_LIST`) additionally require each
365    /// policy to be the base policy `AllowIfAdmin` (admin or super admin) or
366    /// `AllowIfSuperAdmin` (super admin only). Combinator policies
367    /// (`AndCondition` / `AnyCondition`) are rejected for constrained
368    /// components.
369    fn validate_metadata(
370        id: &ComponentId,
371        meta: &ComponentMetadata,
372    ) -> Result<(), ComponentRegistryError> {
373        let perms = meta
374            .permissions
375            .as_ref()
376            .ok_or(ComponentRegistryError::MissingPermissions(*id))?;
377
378        for (op, policy) in [
379            (ComponentOp::Insert, &perms.insert_policy),
380            (ComponentOp::Update, &perms.update_policy),
381            (ComponentOp::Delete, &perms.delete_policy),
382        ] {
383            let p = policy
384                .as_ref()
385                .ok_or(ComponentRegistryError::MissingPolicyField(*id, op))?;
386
387            if id.is_constrained() && !Self::is_admin_or_super_admin_policy(p) {
388                return Err(ComponentRegistryError::ConstrainedPolicyViolation(*id));
389            }
390        }
391        Ok(())
392    }
393
394    /// Returns true if the policy is the base policy `AllowIfAdmin`
395    /// (admin or super admin) or `AllowIfSuperAdmin` (super admin only).
396    ///
397    /// All variants are matched explicitly with no catch-all so that adding a
398    /// new `MetadataPolicyKind` variant in the proto forces a compile error
399    /// here, requiring an explicit decision about how it interacts with
400    /// constrained components.
401    fn is_admin_or_super_admin_policy(policy: &MetadataPolicyProto) -> bool {
402        match &policy.kind {
403            Some(MetadataPolicyKind::Base(base)) => {
404                *base == MetadataBasePolicy::AllowIfAdmin as i32
405                    || *base == MetadataBasePolicy::AllowIfSuperAdmin as i32
406            }
407            // Combinator policies are explicitly rejected for constrained
408            // components — even if every leaf is admin-only, the combinator
409            // wrapper itself is not on the constrained-component allowlist.
410            Some(MetadataPolicyKind::AndCondition(_)) => false,
411            Some(MetadataPolicyKind::AnyCondition(_)) => false,
412            None => false,
413        }
414    }
415}
416
417impl Default for ComponentRegistry {
418    fn default() -> Self {
419        Self::new()
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use crate::app_data::component_permissions::component_permissions;
427    use xmtp_proto::xmtp::mls::message_contents::metadata_policy::{AndCondition, AnyCondition};
428
429    fn allow() -> MetadataPolicyProto {
430        MetadataPolicyProto {
431            kind: Some(MetadataPolicyKind::Base(MetadataBasePolicy::Allow as i32)),
432        }
433    }
434
435    fn deny() -> MetadataPolicyProto {
436        MetadataPolicyProto {
437            kind: Some(MetadataPolicyKind::Base(MetadataBasePolicy::Deny as i32)),
438        }
439    }
440
441    fn admin_only() -> MetadataPolicyProto {
442        MetadataPolicyProto {
443            kind: Some(MetadataPolicyKind::Base(
444                MetadataBasePolicy::AllowIfAdmin as i32,
445            )),
446        }
447    }
448
449    /// An `AndCondition` whose leaves are all `AllowIfAdmin`. The combinator
450    /// wrapper itself must still be rejected for constrained components,
451    /// regardless of how admin-y its contents are.
452    fn and_condition_admin_only() -> MetadataPolicyProto {
453        MetadataPolicyProto {
454            kind: Some(MetadataPolicyKind::AndCondition(AndCondition {
455                policies: vec![admin_only(), admin_only()],
456            })),
457        }
458    }
459
460    /// An `AnyCondition` whose leaves are all `AllowIfAdmin`. Same rationale
461    /// as `and_condition_admin_only`.
462    fn any_condition_admin_only() -> MetadataPolicyProto {
463        MetadataPolicyProto {
464            kind: Some(MetadataPolicyKind::AnyCondition(AnyCondition {
465                policies: vec![admin_only(), admin_only()],
466            })),
467        }
468    }
469
470    fn sample_meta() -> ComponentMetadata {
471        new_component_metadata(
472            component_permissions()
473                .insert(allow())
474                .update(admin_only())
475                .delete(deny())
476                .call(),
477            ComponentType::Bytes,
478        )
479    }
480
481    #[xmtp_common::test]
482    fn test_set_and_get() {
483        let mut reg = ComponentRegistry::new();
484        let id = ComponentId::GROUP_NAME;
485        reg.set(id, sample_meta()).unwrap();
486        let meta = reg.get(&id).unwrap().unwrap();
487        assert_eq!(meta, sample_meta());
488    }
489
490    #[xmtp_common::test]
491    fn test_get_missing_returns_none() {
492        let reg = ComponentRegistry::new();
493        assert!(reg.get(&ComponentId::GROUP_NAME).unwrap().is_none());
494    }
495
496    #[xmtp_common::test]
497    fn test_set_overwrites() {
498        let mut reg = ComponentRegistry::new();
499        let id = ComponentId::GROUP_NAME;
500        reg.set(id, sample_meta()).unwrap();
501
502        let new_meta = new_component_metadata(
503            component_permissions()
504                .insert(deny())
505                .update(deny())
506                .delete(deny())
507                .call(),
508            ComponentType::TlsMapBytesBytes,
509        );
510        reg.set(id, new_meta.clone()).unwrap();
511
512        let got = reg.get(&id).unwrap().unwrap();
513        assert_eq!(got, new_meta);
514        assert_eq!(reg.len(), 1);
515    }
516
517    #[xmtp_common::test]
518    fn test_remove() {
519        let mut reg = ComponentRegistry::new();
520        let id = ComponentId::GROUP_NAME;
521        reg.set(id, sample_meta()).unwrap();
522        reg.remove(&id).unwrap();
523        assert!(!reg.contains(&id));
524    }
525
526    #[xmtp_common::test]
527    fn test_remove_missing_returns_error() {
528        let mut reg = ComponentRegistry::new();
529        let result = reg.remove(&ComponentId::GROUP_NAME);
530        assert!(result.is_err());
531    }
532
533    #[xmtp_common::test]
534    fn test_reject_hardcoded_set() {
535        // Hardcoded components must NEVER have a registry entry. Their
536        // permissions are enforced in code by `validate_component_write`,
537        // and a stored entry would create a silent disagreement between the
538        // registry and the actual enforcement path.
539        let mut reg = ComponentRegistry::new();
540        assert!(matches!(
541            reg.set(ComponentId::COMPONENT_REGISTRY, sample_meta()),
542            Err(ComponentRegistryError::HardcodedComponent(_))
543        ));
544        assert!(matches!(
545            reg.set(ComponentId::SUPER_ADMIN_LIST, sample_meta()),
546            Err(ComponentRegistryError::HardcodedComponent(_))
547        ));
548        assert!(reg.is_empty());
549    }
550
551    #[xmtp_common::test]
552    fn test_reject_hardcoded_remove() {
553        // Even though hardcoded entries should never be in the registry, the
554        // remove path still rejects them for defense in depth.
555        let mut reg = ComponentRegistry::new();
556        assert!(matches!(
557            reg.remove(&ComponentId::COMPONENT_REGISTRY),
558            Err(ComponentRegistryError::HardcodedComponent(_))
559        ));
560        assert!(matches!(
561            reg.remove(&ComponentId::SUPER_ADMIN_LIST),
562            Err(ComponentRegistryError::HardcodedComponent(_))
563        ));
564    }
565
566    #[xmtp_common::test]
567    fn test_admin_list_accepts_admin_or_super_admin_policy() {
568        let mut reg = ComponentRegistry::new();
569        let meta = new_component_metadata(
570            component_permissions()
571                .insert(admin_only())
572                .update(admin_only())
573                .delete(admin_only())
574                .call(),
575            ComponentType::Bytes,
576        );
577        assert!(reg.set(ComponentId::ADMIN_LIST, meta).is_ok());
578    }
579
580    #[xmtp_common::test]
581    fn test_admin_list_accepts_super_admin_only_policy() {
582        let mut reg = ComponentRegistry::new();
583        let super_admin_only = MetadataPolicyProto {
584            kind: Some(MetadataPolicyKind::Base(
585                MetadataBasePolicy::AllowIfSuperAdmin as i32,
586            )),
587        };
588        let meta = new_component_metadata(
589            component_permissions()
590                .insert(super_admin_only.clone())
591                .update(super_admin_only.clone())
592                .delete(super_admin_only)
593                .call(),
594            ComponentType::Bytes,
595        );
596        assert!(reg.set(ComponentId::ADMIN_LIST, meta).is_ok());
597    }
598
599    #[xmtp_common::test]
600    fn test_admin_list_rejects_allow_policy() {
601        let mut reg = ComponentRegistry::new();
602        let meta = new_component_metadata(
603            component_permissions()
604                .insert(allow())
605                .update(allow())
606                .delete(allow())
607                .call(),
608            ComponentType::Bytes,
609        );
610        assert!(matches!(
611            reg.set(ComponentId::ADMIN_LIST, meta),
612            Err(ComponentRegistryError::ConstrainedPolicyViolation(_))
613        ));
614    }
615
616    #[xmtp_common::test]
617    fn test_admin_list_rejects_deny_policy() {
618        let mut reg = ComponentRegistry::new();
619        let meta = new_component_metadata(
620            component_permissions()
621                .insert(admin_only())
622                .update(deny())
623                .delete(admin_only())
624                .call(),
625            ComponentType::Bytes,
626        );
627        assert!(matches!(
628            reg.set(ComponentId::ADMIN_LIST, meta),
629            Err(ComponentRegistryError::ConstrainedPolicyViolation(_))
630        ));
631    }
632
633    #[xmtp_common::test]
634    fn test_admin_list_rejects_mixed_invalid_policy() {
635        let mut reg = ComponentRegistry::new();
636        let meta = new_component_metadata(
637            component_permissions()
638                .insert(admin_only())
639                .update(allow())
640                .delete(admin_only())
641                .call(),
642            ComponentType::Bytes,
643        );
644        assert!(matches!(
645            reg.set(ComponentId::ADMIN_LIST, meta),
646            Err(ComponentRegistryError::ConstrainedPolicyViolation(_))
647        ));
648    }
649
650    #[xmtp_common::test]
651    fn test_admin_list_rejects_and_condition_policy() {
652        // Combinator policies must be rejected for constrained components
653        // even when every leaf is admin-only — it's the wrapper itself that's
654        // disallowed, not the contents. Locks in the explicit rejection in
655        // `is_admin_or_super_admin_policy` so a future refactor of that match
656        // can't silently let combinators through.
657        let mut reg = ComponentRegistry::new();
658        let meta = new_component_metadata(
659            component_permissions()
660                .insert(and_condition_admin_only())
661                .update(admin_only())
662                .delete(admin_only())
663                .call(),
664            ComponentType::Bytes,
665        );
666        assert!(matches!(
667            reg.set(ComponentId::ADMIN_LIST, meta),
668            Err(ComponentRegistryError::ConstrainedPolicyViolation(_))
669        ));
670    }
671
672    #[xmtp_common::test]
673    fn test_admin_list_rejects_any_condition_policy() {
674        // Same rationale as `test_admin_list_rejects_and_condition_policy`,
675        // but exercising the `AnyCondition` branch on a different policy slot
676        // so all three slots and both combinator variants are covered across
677        // the constrained-component test cluster.
678        let mut reg = ComponentRegistry::new();
679        let meta = new_component_metadata(
680            component_permissions()
681                .insert(admin_only())
682                .update(any_condition_admin_only())
683                .delete(admin_only())
684                .call(),
685            ComponentType::Bytes,
686        );
687        assert!(matches!(
688            reg.set(ComponentId::ADMIN_LIST, meta),
689            Err(ComponentRegistryError::ConstrainedPolicyViolation(_))
690        ));
691    }
692
693    #[xmtp_common::test]
694    fn test_rejects_missing_permissions() {
695        let mut reg = ComponentRegistry::new();
696        // Construct ComponentMetadata directly (bypassing new_component_metadata)
697        // because the helper doesn't allow `permissions: None` — that's exactly
698        // the negative case we're testing here.
699        let meta = ComponentMetadata {
700            permissions: None,
701            external_committer_permissions: None,
702            component_type: ComponentType::Bytes as i32,
703        };
704        // Applies to ALL components, not just constrained ones.
705        assert!(matches!(
706            reg.set(ComponentId::GROUP_NAME, meta.clone()),
707            Err(ComponentRegistryError::MissingPermissions(_))
708        ));
709        assert!(matches!(
710            reg.set(ComponentId::ADMIN_LIST, meta),
711            Err(ComponentRegistryError::MissingPermissions(_))
712        ));
713    }
714
715    #[xmtp_common::test]
716    fn test_rejects_missing_policy_field() {
717        let mut reg = ComponentRegistry::new();
718        let meta = new_component_metadata(
719            ComponentPermissions {
720                insert_policy: Some(allow()),
721                update_policy: None, // missing
722                delete_policy: Some(allow()),
723            },
724            ComponentType::Bytes,
725        );
726        // Applies to ALL components, not just constrained ones.
727        assert!(matches!(
728            reg.set(ComponentId::GROUP_NAME, meta),
729            Err(ComponentRegistryError::MissingPolicyField(
730                _,
731                ComponentOp::Update
732            ))
733        ));
734    }
735
736    #[xmtp_common::test]
737    fn test_reject_reserved_set() {
738        let mut reg = ComponentRegistry::new();
739        assert!(matches!(
740            reg.set(ComponentId::new(0xFF00), sample_meta()),
741            Err(ComponentRegistryError::ReservedRange(_))
742        ));
743    }
744
745    #[xmtp_common::test]
746    fn test_reject_invalid_id() {
747        let mut reg = ComponentRegistry::new();
748        assert!(matches!(
749            reg.set(ComponentId::new(0x0001), sample_meta()),
750            Err(ComponentRegistryError::InvalidComponentId(_))
751        ));
752    }
753
754    #[xmtp_common::test]
755    fn test_app_range_allowed() {
756        let mut reg = ComponentRegistry::new();
757        let id = ComponentId::new(0xC000);
758        reg.set(id, sample_meta()).unwrap();
759        assert!(reg.contains(&id));
760    }
761
762    #[xmtp_common::test]
763    fn test_immutable_first_insert_allowed() {
764        // Immutable components can be inserted once.
765        let mut reg = ComponentRegistry::new();
766        let id = ComponentId::new(0xBE00);
767        reg.set(id, sample_meta()).unwrap();
768        assert!(reg.contains(&id));
769    }
770
771    #[xmtp_common::test]
772    fn test_immutable_subsequent_set_rejected() {
773        let mut reg = ComponentRegistry::new();
774        let id = ComponentId::new(0xBE00);
775        reg.set(id, sample_meta()).unwrap();
776        // Second set on the same immutable id is rejected.
777        assert!(matches!(
778            reg.set(id, sample_meta()),
779            Err(ComponentRegistryError::ImmutableComponent(_))
780        ));
781    }
782
783    #[xmtp_common::test]
784    fn test_immutable_remove_rejected() {
785        let mut reg = ComponentRegistry::new();
786        let id = ComponentId::new(0xBE00);
787        reg.set(id, sample_meta()).unwrap();
788        assert!(matches!(
789            reg.remove(&id),
790            Err(ComponentRegistryError::ImmutableComponent(_))
791        ));
792        assert!(reg.contains(&id));
793    }
794
795    #[xmtp_common::test]
796    fn test_tls_round_trip() {
797        let mut reg = ComponentRegistry::new();
798        reg.set(ComponentId::GROUP_NAME, sample_meta()).unwrap();
799        reg.set(
800            ComponentId::GROUP_DESCRIPTION,
801            new_component_metadata(
802                component_permissions()
803                    .insert(deny())
804                    .update(deny())
805                    .delete(deny())
806                    .call(),
807                ComponentType::Bytes,
808            ),
809        )
810        .unwrap();
811
812        let bytes = reg.to_bytes().unwrap();
813        let restored = ComponentRegistry::from_bytes(&bytes).unwrap();
814        assert_eq!(reg, restored);
815    }
816
817    #[xmtp_common::test]
818    fn test_iter() {
819        let mut reg = ComponentRegistry::new();
820        reg.set(ComponentId::GROUP_NAME, sample_meta()).unwrap();
821        reg.set(ComponentId::GROUP_DESCRIPTION, sample_meta())
822            .unwrap();
823
824        let entries: Vec<_> = reg.iter().collect::<Result<Vec<_>, _>>().unwrap();
825        assert_eq!(entries.len(), 2);
826        assert!(entries[0].0 < entries[1].0);
827    }
828
829    #[xmtp_common::test]
830    fn test_empty_registry_round_trip() {
831        let reg = ComponentRegistry::new();
832        let bytes = reg.to_bytes().unwrap();
833        let restored = ComponentRegistry::from_bytes(&bytes).unwrap();
834        assert_eq!(reg, restored);
835        assert!(restored.is_empty());
836    }
837
838    /// Assert the tolerance contract for one invalid entry: the load
839    /// succeeds, the entry is invisible to every read, it is reported
840    /// via `unrecognized_ids`, and `to_bytes` preserves it verbatim.
841    fn assert_tolerated(bytes: &[u8], id: ComponentId) {
842        let reg = ComponentRegistry::from_bytes(bytes).unwrap();
843        assert!(reg.get(&id).unwrap().is_none());
844        assert!(!reg.contains(&id));
845        assert_eq!(reg.len(), 0);
846        assert!(reg.iter().next().is_none());
847        assert_eq!(reg.unrecognized_ids().collect::<Vec<_>>(), vec![id]);
848        // Round-trip preserves the raw entry byte-for-byte.
849        assert_eq!(reg.to_bytes().unwrap(), bytes);
850    }
851
852    #[xmtp_common::test]
853    fn test_from_bytes_tolerates_out_of_space_id() {
854        // An out-of-space key (0x0001) is preserved-but-invisible: it
855        // must not poison the load, because the registry is what makes
856        // every other commit on the group validatable.
857        let id = ComponentId::new(0x0001);
858        let bytes = raw_bytes_with_entry(id, sample_meta().encode_to_vec());
859        assert_tolerated(&bytes, id);
860    }
861
862    #[xmtp_common::test]
863    fn test_from_bytes_tolerates_reserved_id() {
864        // Reserved-range ids are future protocol slots — a newer
865        // version allocating one must degrade to "unknown entry" on
866        // this version, not "registry unloadable".
867        let id = ComponentId::new(0xFF50);
868        let bytes = raw_bytes_with_entry(id, sample_meta().encode_to_vec());
869        assert_tolerated(&bytes, id);
870    }
871
872    /// Build a TLS-encoded `TlsMap<ComponentId, VLBytes>` snapshot
873    /// containing a single entry. Bypasses `ComponentRegistry::set` so
874    /// we can construct payloads that the public API would refuse to
875    /// produce.
876    fn raw_bytes_with_entry(id: ComponentId, value: Vec<u8>) -> Vec<u8> {
877        let mut map: TlsMap<ComponentId, VLBytes> = TlsMap::new();
878        map.insert(id, VLBytes::new(value)).unwrap();
879        map.tls_serialize_detached().unwrap()
880    }
881
882    #[xmtp_common::test]
883    fn test_from_bytes_tolerates_hardcoded_id() {
884        // A smuggled shadow entry for a hardcoded component stays
885        // invisible — its permissions are enforced in code, so hiding
886        // the entry preserves exactly the enforcement that matters.
887        let bytes = raw_bytes_with_entry(
888            ComponentId::COMPONENT_REGISTRY,
889            sample_meta().encode_to_vec(),
890        );
891        assert_tolerated(&bytes, ComponentId::COMPONENT_REGISTRY);
892
893        let bytes =
894            raw_bytes_with_entry(ComponentId::SUPER_ADMIN_LIST, sample_meta().encode_to_vec());
895        assert_tolerated(&bytes, ComponentId::SUPER_ADMIN_LIST);
896    }
897
898    #[xmtp_common::test]
899    fn test_from_bytes_tolerates_missing_permissions() {
900        // A metadata entry whose permissions field is None is invisible
901        // rather than fatal: writes to that component then fail the
902        // deny-by-default `NoRegistryEntry` policy check, which is the
903        // safe direction.
904        let meta = ComponentMetadata {
905            permissions: None,
906            external_committer_permissions: None,
907            component_type: ComponentType::Bytes as i32,
908        };
909        let bytes = raw_bytes_with_entry(ComponentId::GROUP_NAME, meta.encode_to_vec());
910        assert_tolerated(&bytes, ComponentId::GROUP_NAME);
911    }
912
913    #[xmtp_common::test]
914    fn test_from_bytes_tolerates_missing_policy_field() {
915        // Permissions present, but one of the three policy fields is missing.
916        let meta = new_component_metadata(
917            ComponentPermissions {
918                insert_policy: Some(allow()),
919                update_policy: None,
920                delete_policy: Some(allow()),
921            },
922            ComponentType::Bytes,
923        );
924        let bytes = raw_bytes_with_entry(ComponentId::GROUP_NAME, meta.encode_to_vec());
925        assert_tolerated(&bytes, ComponentId::GROUP_NAME);
926    }
927
928    #[xmtp_common::test]
929    fn test_from_bytes_tolerates_constrained_violation() {
930        // An ADMIN_LIST entry with an Allow policy violates the
931        // constrained-component invariant; hiding it means admin-list
932        // writes deny until a super admin repairs the entry — instead
933        // of every commit on the group failing to validate.
934        let meta = new_component_metadata(
935            component_permissions()
936                .insert(allow())
937                .update(allow())
938                .delete(allow())
939                .call(),
940            ComponentType::Bytes,
941        );
942        let bytes = raw_bytes_with_entry(ComponentId::ADMIN_LIST, meta.encode_to_vec());
943        assert_tolerated(&bytes, ComponentId::ADMIN_LIST);
944    }
945
946    #[xmtp_common::test]
947    fn test_reads_skip_entry_with_undecodable_value() {
948        // A snapshot with one good entry and one whose value at a valid id
949        // isn't decodable `ComponentMetadata`. The good entry stays fully
950        // visible; the corrupt one is skipped by `iter`, invisible to `get`
951        // (deny-by-default rather than a surfaced error), reported by
952        // `unrecognized_ids`, and preserved verbatim by `to_bytes`.
953        let mut map: TlsMap<ComponentId, VLBytes> = TlsMap::new();
954        map.insert(
955            ComponentId::GROUP_NAME,
956            VLBytes::new(sample_meta().encode_to_vec()),
957        )
958        .unwrap();
959        map.insert(
960            ComponentId::GROUP_DESCRIPTION,
961            VLBytes::new(vec![0xFF, 0xFF, 0xFF, 0xFF]),
962        )
963        .unwrap();
964        let bytes = map.tls_serialize_detached().unwrap();
965
966        let reg = ComponentRegistry::from_bytes(&bytes).unwrap();
967
968        let entries: Vec<_> = reg.iter().collect::<Result<Vec<_>, _>>().unwrap();
969        assert_eq!(entries.len(), 1);
970        assert_eq!(entries[0].0, ComponentId::GROUP_NAME);
971        assert_eq!(
972            reg.get(&ComponentId::GROUP_NAME).unwrap(),
973            Some(sample_meta())
974        );
975        assert!(reg.get(&ComponentId::GROUP_DESCRIPTION).unwrap().is_none());
976        assert_eq!(
977            reg.unrecognized_ids().collect::<Vec<_>>(),
978            vec![ComponentId::GROUP_DESCRIPTION]
979        );
980        assert_eq!(reg.to_bytes().unwrap(), bytes);
981    }
982
983    #[xmtp_common::test]
984    fn test_from_bytes_tolerates_malformed_protobuf_value() {
985        // The key is valid but the value bytes are not a parseable
986        // ComponentMetadata (e.g. a future breaking re-encoding).
987        // Preserved-but-invisible, like every other invalid entry.
988        let bytes = raw_bytes_with_entry(ComponentId::GROUP_NAME, vec![0xFF, 0xFF, 0xFF, 0xFF]);
989        assert_tolerated(&bytes, ComponentId::GROUP_NAME);
990    }
991
992    #[xmtp_common::test]
993    fn test_from_bytes_rejects_undecodable_outer_map() {
994        // Entry-level tolerance never extends to the outer container:
995        // truncated or non-canonical snapshot bytes are a hard error.
996        let mut bytes =
997            raw_bytes_with_entry(ComponentId::GROUP_NAME, sample_meta().encode_to_vec());
998        bytes.truncate(bytes.len() - 1);
999        assert!(matches!(
1000            ComponentRegistry::from_bytes(&bytes),
1001            Err(ComponentRegistryError::TlsCodecError(_))
1002        ));
1003    }
1004
1005    #[xmtp_common::test]
1006    fn test_from_bytes_mixed_valid_and_invalid_entries() {
1007        // One valid entry, one reserved-range entry: the valid one is
1008        // fully readable, the invalid one is preserved-but-invisible,
1009        // and the round-trip loses neither.
1010        let reserved = ComponentId::new(0xFF00);
1011        let mut map: TlsMap<ComponentId, VLBytes> = TlsMap::new();
1012        map.insert(
1013            ComponentId::GROUP_NAME,
1014            VLBytes::new(sample_meta().encode_to_vec()),
1015        )
1016        .unwrap();
1017        map.insert(reserved, VLBytes::new(b"future-slot".to_vec()))
1018            .unwrap();
1019        let bytes = map.tls_serialize_detached().unwrap();
1020
1021        let reg = ComponentRegistry::from_bytes(&bytes).unwrap();
1022        assert_eq!(reg.len(), 1);
1023        assert_eq!(
1024            reg.get(&ComponentId::GROUP_NAME).unwrap(),
1025            Some(sample_meta())
1026        );
1027        assert!(!reg.contains(&reserved));
1028        assert_eq!(reg.unrecognized_ids().collect::<Vec<_>>(), vec![reserved]);
1029        assert_eq!(reg.to_bytes().unwrap(), bytes);
1030    }
1031
1032    /// The headline tolerance property end-to-end at the unit level: a
1033    /// blob carrying one valid entry and one poisoned entry must load,
1034    /// keep the healthy component fully usable — including the
1035    /// per-element policy gate commit validation runs
1036    /// ([`validate_component_write`]) — deny writes against the
1037    /// poisoned component, and round-trip byte-identically so the
1038    /// poison is never dropped.
1039    ///
1040    /// The load/lookup/round-trip half deliberately overlaps
1041    /// `test_from_bytes_mixed_valid_and_invalid_entries`; this test
1042    /// adds the validation-gate half.
1043    // TODO: the fuller end-to-end version — a poisoned
1044    // COMPONENT_REGISTRY dict entry on a real group still validating
1045    // unrelated commits through `ValidatedCommit::from_staged_commit`
1046    // — belongs in xmtp_mls's group tests, where the commit pipeline
1047    // exists.
1048    #[xmtp_common::test(unwrap_try = true)]
1049    fn test_poisoned_registry_still_validates_unrelated_writes() {
1050        use crate::app_data::validation::{
1051            ActorAuthority, ComponentChange, ComponentPermissionError, validate_component_write,
1052        };
1053
1054        // One valid GROUP_NAME entry plus one reserved-range entry
1055        // whose bytes aren't even valid ComponentMetadata — the shape
1056        // a newer protocol version (or a historical bad write) would
1057        // leave behind.
1058        let poisoned = ComponentId::new(0xFF20);
1059        let mut map: TlsMap<ComponentId, VLBytes> = TlsMap::new();
1060        map.insert(
1061            ComponentId::GROUP_NAME,
1062            VLBytes::new(sample_meta().encode_to_vec()),
1063        )?;
1064        map.insert(poisoned, VLBytes::new(vec![0xDE, 0xAD, 0xBE, 0xEF]))?;
1065        let bytes = map.tls_serialize_detached()?;
1066
1067        // The load tolerates the poison...
1068        let reg = ComponentRegistry::from_bytes(&bytes)?;
1069        assert_eq!(reg.unrecognized_ids().collect::<Vec<_>>(), vec![poisoned]);
1070
1071        // ...and the healthy entry stays fully usable: readable, and
1072        // its raw bytes still pass entry validation.
1073        assert_eq!(reg.get(&ComponentId::GROUP_NAME)?, Some(sample_meta()));
1074        ComponentRegistry::validate_entry(ComponentId::GROUP_NAME, &sample_meta().encode_to_vec())?;
1075
1076        // The policy gate still allows a write to the healthy
1077        // component against the poisoned registry...
1078        let member = ActorAuthority {
1079            is_admin: false,
1080            is_super_admin: false,
1081        };
1082        let healthy_write = ComponentChange::builder()
1083            .component_id(ComponentId::GROUP_NAME)
1084            .op(ComponentOp::Insert)
1085            .actor(member)
1086            .build();
1087        validate_component_write(&healthy_write, &reg)?;
1088
1089        // ...while a write against the poisoned component falls to
1090        // deny-by-default (the entry is preserved but invisible).
1091        let poisoned_write = ComponentChange::builder()
1092            .component_id(poisoned)
1093            .op(ComponentOp::Insert)
1094            .actor(member)
1095            .build();
1096        assert!(matches!(
1097            validate_component_write(&poisoned_write, &reg),
1098            Err(ComponentPermissionError::NoRegistryEntry(id)) if id == poisoned
1099        ));
1100
1101        // And serialization round-trips byte-identically — the poison
1102        // is preserved, never dropped.
1103        assert_eq!(reg.to_bytes()?, bytes);
1104    }
1105
1106    #[xmtp_common::test]
1107    fn test_set_repairs_unrecognized_entry() {
1108        // A valid write to an id that currently holds an unrecognized
1109        // entry replaces it — `to_bytes` must serialize the repaired
1110        // value, not resurrect the broken bytes.
1111        let broken = ComponentMetadata {
1112            permissions: None,
1113            external_committer_permissions: None,
1114            component_type: ComponentType::Bytes as i32,
1115        };
1116        let bytes = raw_bytes_with_entry(ComponentId::GROUP_NAME, broken.encode_to_vec());
1117        let mut reg = ComponentRegistry::from_bytes(&bytes).unwrap();
1118        assert_eq!(reg.unrecognized_ids().count(), 1);
1119
1120        reg.set(ComponentId::GROUP_NAME, sample_meta()).unwrap();
1121        assert_eq!(reg.unrecognized_ids().count(), 0);
1122        assert_eq!(
1123            reg.get(&ComponentId::GROUP_NAME).unwrap(),
1124            Some(sample_meta())
1125        );
1126
1127        let round_tripped = ComponentRegistry::from_bytes(&reg.to_bytes().unwrap()).unwrap();
1128        assert_eq!(round_tripped.unrecognized_ids().count(), 0);
1129        assert_eq!(
1130            round_tripped.get(&ComponentId::GROUP_NAME).unwrap(),
1131            Some(sample_meta())
1132        );
1133    }
1134
1135    #[xmtp_common::test]
1136    fn test_remove_deletes_unrecognized_entry() {
1137        // Repair-by-delete: removing a modifiable id that only exists
1138        // as an unrecognized entry drops the preserved bytes.
1139        let bytes = raw_bytes_with_entry(ComponentId::GROUP_NAME, vec![0xFF, 0xFF]);
1140        let mut reg = ComponentRegistry::from_bytes(&bytes).unwrap();
1141        assert_eq!(reg.unrecognized_ids().count(), 1);
1142
1143        reg.remove(&ComponentId::GROUP_NAME).unwrap();
1144        assert_eq!(reg.unrecognized_ids().count(), 0);
1145        let empty = ComponentRegistry::new();
1146        assert_eq!(reg.to_bytes().unwrap(), empty.to_bytes().unwrap());
1147    }
1148
1149    #[xmtp_common::test]
1150    fn test_preserves_component_type() {
1151        let mut reg = ComponentRegistry::new();
1152        let meta = new_component_metadata(
1153            component_permissions()
1154                .insert(allow())
1155                .update(allow())
1156                .delete(deny())
1157                .call(),
1158            ComponentType::TlsMapBytesBytes,
1159        );
1160        reg.set(ComponentId::GROUP_MEMBERSHIP, meta).unwrap();
1161
1162        let got = reg.get(&ComponentId::GROUP_MEMBERSHIP).unwrap().unwrap();
1163        assert_eq!(got.component_type, ComponentType::TlsMapBytesBytes as i32);
1164    }
1165}