Skip to main content

gtp/
client.rs

1//! Stateful GTP client.
2
3use crate::GtpMessage;
4use gbp::CodecError;
5use gbp_core::{BoundedSeen, GbpFlags, MemberId, PayloadCodec, StreamType};
6use gbp_node::{GroupNode, NodeError, OutboundFrame, Sealer};
7
8/// Errors returned by [`GtpClient`].
9#[derive(Debug, thiserror::Error)]
10pub enum GtpError {
11    /// Failed to decode the CBOR payload.
12    #[error("decode: {0}")]
13    Decode(#[from] CodecError),
14    /// Duplicate `(sender_id, message_id)` (idempotency).
15    #[error("duplicate (sender={sender_id}, mid=0x{message_id:X})")]
16    Duplicate {
17        /// Sender member id.
18        sender_id: MemberId,
19        /// Message id.
20        message_id: u64,
21    },
22    /// Underlying GBP node error during send.
23    #[error("node: {0}")]
24    Node(#[from] NodeError),
25}
26
27/// Outcome of accepting a GTP payload.
28#[derive(Debug)]
29pub enum GtpAccept {
30    /// First time `(sender_id, message_id)` is seen.
31    New(GtpMessage),
32    /// `(sender_id, message_id)` was already seen.
33    Duplicate(GtpMessage),
34}
35
36/// Per-epoch message dedup capacity (GTP §5).
37const GTP_SEEN_CAP: usize = 10_000;
38
39/// Stateful GTP client.
40///
41/// Tracks the set of already-seen `(sender_id, message_id)` pairs to enforce
42/// the idempotency contract of GTP §5. The seen-set is LRU-bounded at
43/// [`GTP_SEEN_CAP`] entries per epoch to prevent unbounded memory growth
44/// in long-lived groups.
45///
46/// The client observes the current group epoch on every [`GtpClient::send`]
47/// or [`GtpClient::accept`] call and automatically clears its idempotency
48/// set when the epoch advances. Callers may also drive a reset explicitly
49/// via [`GtpClient::reset`].
50pub struct GtpClient {
51    seen: BoundedSeen<(MemberId, u64)>,
52    current_epoch: Option<u64>,
53}
54
55impl Default for GtpClient {
56    fn default() -> Self {
57        Self::new()
58    }
59}
60
61impl GtpClient {
62    /// Creates an empty client.
63    pub fn new() -> Self {
64        Self {
65            seen: BoundedSeen::new(GTP_SEEN_CAP),
66            current_epoch: None,
67        }
68    }
69
70    /// Sends a text message via the given GBP node and AEAD sealer.
71    ///
72    /// Returns a wire-ready [`OutboundFrame`] that the caller MUST hand to the
73    /// transport. Uses the `O | R | A` profile from GTP §5.
74    /// `codec` controls how the [`GtpMessage`] payload is encoded inside the
75    /// GBP frame; use [`PayloadCodec::Cbor`] for maximum compatibility.
76    pub fn send<S: Sealer>(
77        &mut self,
78        node: &mut GroupNode,
79        seal: &mut S,
80        target: MemberId,
81        message_id: u64,
82        text: &str,
83        codec: PayloadCodec,
84    ) -> Result<OutboundFrame, GtpError> {
85        self.sync_epoch(node.current_epoch);
86        let msg = GtpMessage::plain(node.member_id, message_id, text);
87        let stream_id = node.member_stream_id(1);
88        let of = node.send_payload(
89            seal,
90            target,
91            StreamType::Text,
92            stream_id,
93            GbpFlags::ordered_reliable_ack(),
94            &msg.to_bytes(codec),
95            codec,
96        )?;
97        Ok(of)
98    }
99
100    /// Accepts a plaintext payload delivered by the GBP layer
101    /// (`Event::PayloadReceived`).
102    ///
103    /// `current_epoch` is the receiver node's current epoch — passing it lets
104    /// the client auto-reset its idempotency set when the epoch advances.
105    /// `codec` must match the value from [`DeliveredPayload::codec`].
106    /// Returns either [`GtpAccept::New`] or [`GtpAccept::Duplicate`], or
107    /// [`GtpError::Decode`] if the plaintext is not a valid GTP message.
108    pub fn accept(
109        &mut self,
110        plaintext: &[u8],
111        current_epoch: u64,
112        codec: PayloadCodec,
113    ) -> Result<GtpAccept, GtpError> {
114        self.sync_epoch(current_epoch);
115        let m = GtpMessage::from_bytes(plaintext, codec)?;
116        let key = (m.sender_id, m.message_id);
117        if !self.seen.insert(key) {
118            return Ok(GtpAccept::Duplicate(m));
119        }
120        Ok(GtpAccept::New(m))
121    }
122
123    /// Synchronises the client's view of the group epoch and resets
124    /// idempotency state when the epoch has advanced. Called automatically
125    /// by [`GtpClient::send`] and [`GtpClient::accept`].
126    pub fn sync_epoch(&mut self, epoch: u64) {
127        if Some(epoch) != self.current_epoch {
128            self.seen.clear();
129            self.current_epoch = Some(epoch);
130        }
131    }
132
133    /// Clears the idempotency set unconditionally.
134    pub fn reset(&mut self) {
135        self.seen.clear();
136        self.current_epoch = None;
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::GtpMessage;
144
145    fn encode_msg(sender_id: u32, message_id: u64) -> Vec<u8> {
146        GtpMessage::plain(sender_id, message_id, "hello").to_cbor()
147    }
148
149    #[test]
150    fn accept_new_message_returns_new() {
151        let mut client = GtpClient::new();
152        let payload = encode_msg(1, 100);
153        assert!(matches!(
154            client.accept(&payload, 0, PayloadCodec::Cbor).unwrap(),
155            GtpAccept::New(_)
156        ));
157    }
158
159    #[test]
160    fn accept_duplicate_returns_duplicate() {
161        let mut client = GtpClient::new();
162        let payload = encode_msg(1, 100);
163        client.accept(&payload, 0, PayloadCodec::Cbor).unwrap();
164        let result = client.accept(&payload, 0, PayloadCodec::Cbor).unwrap();
165        assert!(matches!(result, GtpAccept::Duplicate(_)));
166    }
167
168    #[test]
169    fn different_message_ids_both_new() {
170        let mut client = GtpClient::new();
171        let p1 = encode_msg(1, 1);
172        let p2 = encode_msg(1, 2);
173        assert!(matches!(
174            client.accept(&p1, 0, PayloadCodec::Cbor).unwrap(),
175            GtpAccept::New(_)
176        ));
177        assert!(matches!(
178            client.accept(&p2, 0, PayloadCodec::Cbor).unwrap(),
179            GtpAccept::New(_)
180        ));
181    }
182
183    #[test]
184    fn different_senders_same_message_id_both_new() {
185        let mut client = GtpClient::new();
186        let p1 = encode_msg(1, 42);
187        let p2 = encode_msg(2, 42);
188        assert!(matches!(
189            client.accept(&p1, 0, PayloadCodec::Cbor).unwrap(),
190            GtpAccept::New(_)
191        ));
192        assert!(matches!(
193            client.accept(&p2, 0, PayloadCodec::Cbor).unwrap(),
194            GtpAccept::New(_)
195        ));
196    }
197
198    #[test]
199    fn epoch_advance_clears_seen_set() {
200        let mut client = GtpClient::new();
201        let payload = encode_msg(1, 100);
202        client.accept(&payload, 0, PayloadCodec::Cbor).unwrap();
203        // same message, new epoch → New again
204        let result = client.accept(&payload, 1, PayloadCodec::Cbor).unwrap();
205        assert!(matches!(result, GtpAccept::New(_)));
206    }
207
208    #[test]
209    fn reset_clears_idempotency_state() {
210        let mut client = GtpClient::new();
211        let payload = encode_msg(7, 999);
212        client.accept(&payload, 5, PayloadCodec::Cbor).unwrap();
213        client.reset();
214        let result = client.accept(&payload, 5, PayloadCodec::Cbor).unwrap();
215        assert!(matches!(result, GtpAccept::New(_)));
216    }
217
218    #[test]
219    fn sync_epoch_same_value_keeps_state() {
220        let mut client = GtpClient::new();
221        let payload = encode_msg(1, 1);
222        client.accept(&payload, 3, PayloadCodec::Cbor).unwrap();
223        client.sync_epoch(3); // same epoch — does not clear
224        let result = client.accept(&payload, 3, PayloadCodec::Cbor).unwrap();
225        assert!(matches!(result, GtpAccept::Duplicate(_)));
226    }
227
228    #[test]
229    fn invalid_cbor_returns_decode_error() {
230        let mut client = GtpClient::new();
231        let result = client.accept(b"\xFF\xFF", 0, PayloadCodec::Cbor);
232        assert!(matches!(result, Err(GtpError::Decode(_))));
233    }
234}