Skip to main content

xmtp_mls_common/app_data/
protocol_floor.rs

1//! The committed `MIN_SUPPORTED_PROTOCOL_VERSION` floor check.
2
3use openmls::group::MlsGroup as OpenMlsGroup;
4
5use super::component_id::ComponentId;
6use crate::libxmtp_version::LibXMTPVersion;
7
8/// Returns the group's committed `MIN_SUPPORTED_PROTOCOL_VERSION` floor
9/// when it exceeds `own_version`, reading ONLY the pre-commit AppData
10/// dict — committed, already-validated state.
11///
12/// This is the shared trigger for the "pause, don't fork" guards on the
13/// `xmtp_mls` receive paths (`process_message_with_app_data` before
14/// dispatch; `ValidatedCommit::from_staged_commit` before interpreting
15/// migrated group state). It is deliberately blind to any floor bump
16/// carried by the commit currently being processed: that proposal has not passed
17/// the super-admin policy check yet, and a pause triggered by
18/// unvalidated input would let any member freeze the group permanently.
19/// The commit that *raises* the floor pauses below-floor receivers
20/// through the post-policy check at the end of commit validation
21/// instead. Consequence for protocol evolution: a release introducing
22/// a new wire format must land the group-floor bump in a *strictly
23/// earlier* commit than the first commit using that format.
24///
25/// Lenient on malformed state (non-UTF-8 floor bytes, unparseable
26/// semver ⇒ `None`), mirroring `enforce_min_version_monotonicity`'s
27/// treatment of malformed priors: garbage must never brick the group.
28pub fn committed_floor_exceeding(mls_group: &OpenMlsGroup, own: &LibXMTPVersion) -> Option<String> {
29    committed_floor_exceeding_in_extensions(mls_group.extensions(), own)
30}
31
32/// Extensions-only variant of [`committed_floor_exceeding`], split out so
33/// unit tests can exercise the parse-and-compare logic without
34/// materializing an `OpenMlsGroup`.
35pub fn committed_floor_exceeding_in_extensions(
36    extensions: &openmls::extensions::Extensions<openmls::group::GroupContext>,
37    own: &LibXMTPVersion,
38) -> Option<String> {
39    let bytes = extensions
40        .app_data_dictionary()?
41        .dictionary()
42        .get(&ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION.as_u16())?
43        .to_vec();
44    let floor = String::from_utf8(bytes).ok()?;
45    let floor_version = LibXMTPVersion::parse(&floor).ok()?;
46    (floor_version > *own).then_some(floor)
47}
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52    use openmls::extensions::{
53        AppDataDictionary, AppDataDictionaryExtension, Extension, Extensions,
54    };
55
56    fn extensions_with_dict(
57        entries: &[(u16, Vec<u8>)],
58    ) -> Extensions<openmls::group::GroupContext> {
59        let mut dict = AppDataDictionary::new();
60        for (id, bytes) in entries {
61            let _ = dict.insert(*id, bytes.clone());
62        }
63        Extensions::from_vec(vec![Extension::AppDataDictionary(
64            AppDataDictionaryExtension::new(dict),
65        )])
66        .expect("AppDataDictionary is a valid GroupContext extension")
67    }
68
69    fn empty_extensions() -> Extensions<openmls::group::GroupContext> {
70        Extensions::from_vec(vec![]).expect("empty extensions are always valid")
71    }
72
73    /// Parse a semver string the way the production caller does (once, from
74    /// the client's own `pkg_version`). Panics on invalid input — matching
75    /// `VersionInfo`, which asserts its own version is valid at construction.
76    fn ver(s: &str) -> LibXMTPVersion {
77        LibXMTPVersion::parse(s).unwrap()
78    }
79
80    // ========================================================================
81    // committed_floor_exceeding_in_extensions
82    // ========================================================================
83    //
84    // The shared trigger for the pause-before-parse guards. Two properties
85    // are load-bearing: (1) it fires strictly on floor > own — equal or
86    // lower floors must not pause; (2) it is lenient on garbage — malformed
87    // floor bytes must read as "no floor", never as an error that could
88    // wedge the group.
89
90    #[xmtp_common::test(unwrap_try = true)]
91    fn floor_above_own_version_fires() {
92        let exts = extensions_with_dict(&[(
93            ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION.as_u16(),
94            b"2.0.0".to_vec(),
95        )]);
96        assert_eq!(
97            committed_floor_exceeding_in_extensions(&exts, &ver("1.11.0")),
98            Some("2.0.0".to_string())
99        );
100        // Prerelease floors order correctly under semver: 1.11.0-dev
101        // exceeds 1.10.0 but not 1.11.0.
102        let exts = extensions_with_dict(&[(
103            ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION.as_u16(),
104            b"1.11.0-dev".to_vec(),
105        )]);
106        assert_eq!(
107            committed_floor_exceeding_in_extensions(&exts, &ver("1.10.0")),
108            Some("1.11.0-dev".to_string())
109        );
110        assert_eq!(
111            committed_floor_exceeding_in_extensions(&exts, &ver("1.11.0")),
112            None
113        );
114    }
115
116    #[xmtp_common::test(unwrap_try = true)]
117    fn floor_at_or_below_own_version_does_not_fire() {
118        let exts = extensions_with_dict(&[(
119            ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION.as_u16(),
120            b"1.11.0".to_vec(),
121        )]);
122        // Equal: not paused — the floor is inclusive.
123        assert_eq!(
124            committed_floor_exceeding_in_extensions(&exts, &ver("1.11.0")),
125            None
126        );
127        // Above: not paused.
128        assert_eq!(
129            committed_floor_exceeding_in_extensions(&exts, &ver("1.12.0")),
130            None
131        );
132    }
133
134    #[xmtp_common::test(unwrap_try = true)]
135    fn missing_floor_or_dict_does_not_fire() {
136        assert_eq!(
137            committed_floor_exceeding_in_extensions(&empty_extensions(), &ver("1.11.0")),
138            None
139        );
140        assert_eq!(
141            committed_floor_exceeding_in_extensions(&extensions_with_dict(&[]), &ver("1.11.0")),
142            None
143        );
144        // Dict present with other components but no floor entry.
145        let exts = extensions_with_dict(&[(ComponentId::GROUP_NAME.as_u16(), b"name".to_vec())]);
146        assert_eq!(
147            committed_floor_exceeding_in_extensions(&exts, &ver("1.11.0")),
148            None
149        );
150    }
151
152    #[xmtp_common::test(unwrap_try = true)]
153    fn malformed_floor_is_lenient() {
154        // Non-UTF-8 bytes → no floor, never an error.
155        let exts = extensions_with_dict(&[(
156            ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION.as_u16(),
157            vec![0xFF, 0xFE],
158        )]);
159        assert_eq!(
160            committed_floor_exceeding_in_extensions(&exts, &ver("1.11.0")),
161            None
162        );
163        // Unparseable floor semver → no floor.
164        let exts = extensions_with_dict(&[(
165            ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION.as_u16(),
166            b"not-a-version".to_vec(),
167        )]);
168        assert_eq!(
169            committed_floor_exceeding_in_extensions(&exts, &ver("1.11.0")),
170            None
171        );
172        // The client's own version can no longer be unparseable here: it is
173        // parsed once and asserted valid when `VersionInfo` is built, so this
174        // guard only ever compares against a valid `LibXMTPVersion`.
175    }
176}