Skip to main content

wechat_protocol/
push.rs

1//! Persistent authenticated incoming notifications. The outer longlink command
2//! is distinct from the encrypted business CGI: heartbeat 238/518, sync 121/138.
3use crate::{
4    business::AuthenticatedPacket,
5    mmtls::{Longlink, Session},
6    wire, AccountState,
7};
8use anyhow::{ensure, Result};
9use std::collections::VecDeque;
10use std::time::{Duration, Instant};
11
12const REPLY_OFFSET: u32 = 1_000_000_000;
13const NOOP: u32 = 6;
14const NOOP_SEQUENCE: u32 = u32::MAX;
15const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30);
16const KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(15);
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub struct Notification {
20    pub command: u32,
21    pub selector: u32,
22}
23
24/// Unknown commands are retained as transport traffic, not interpreted as sync
25/// work. Command 24 has a four-byte big-endian selector, not protobuf data.
26fn notification(packet: &Longlink) -> Result<Option<Notification>> {
27    if packet.sequence != 0 {
28        return Ok(None);
29    }
30    if packet.command == 356 {
31        use crate::protobuf as p;
32        ensure!(
33            packet.payload.len() <= 128 * 1024,
34            "Contact hint exceeds limit"
35        );
36        let fields = p::decode(&packet.payload)?;
37        if p::scalar(&fields, 2)? != 11 {
38            return Ok(None);
39        }
40        let notice = p::decode(p::data(&fields, 3)?)?;
41        if p::scalar(&notice, 1)? != 2048 {
42            return Ok(None);
43        }
44        // Some namespace notices have no selector payload. They do not match
45        // the mapped contact hint and must not interrupt an in-flight reply.
46        let Some(raw_selectors) = p::optional_data(&notice, 2)? else {
47            return Ok(None);
48        };
49        let selectors = p::decode(raw_selectors)?;
50        if p::optional(&selectors, 1)?.is_none() {
51            return Ok(None);
52        }
53        let selector = u32::try_from(p::scalar(&selectors, 1)?)?;
54        return Ok(
55            (selector == crate::contact_sync::CONTACT_SELECTOR as u32).then_some(Notification {
56                command: 356,
57                selector,
58            }),
59        );
60    }
61    if packet.command != 24 {
62        return Ok(None);
63    }
64    ensure!(packet.payload.len() == 4, "Invalid native push selector");
65    let selector = u32::from_be_bytes(packet.payload.as_slice().try_into()?);
66    Ok((selector & 7 != 0).then_some(Notification {
67        command: 24,
68        selector,
69    }))
70}
71
72#[derive(Default)]
73struct Inbox {
74    pending: VecDeque<Notification>,
75    noop_sent: Option<Instant>,
76}
77impl Inbox {
78    fn observe(&mut self, packet: &Longlink) -> Result<()> {
79        if packet.command == NOOP + REPLY_OFFSET && packet.sequence == NOOP_SEQUENCE {
80            ensure!(packet.payload.is_empty(), "Invalid keepalive reply");
81            self.noop_sent = None;
82        } else if let Some(next) = notification(packet)? {
83            // Repeated notifications announce work; they are not message IDs.
84            // Coalesce them while a business response is in flight.
85            match self
86                .pending
87                .iter_mut()
88                .find(|prior| prior.command == next.command)
89            {
90                Some(prior) => prior.selector |= next.selector,
91                None => self.pending.push_back(next),
92            }
93        }
94        Ok(())
95    }
96}
97
98pub struct Connection {
99    session: Session,
100    timeout: Duration,
101    sequence: u32,
102    last_keepalive: Instant,
103    inbox: Inbox,
104}
105impl Connection {
106    pub(crate) fn open(
107        state: &AccountState,
108        timeout: Duration,
109        receive_budget: usize,
110    ) -> Result<Self> {
111        state.validate()?;
112        Ok(Self {
113            session: Session::connect(&state.long_host, timeout, receive_budget)?,
114            timeout,
115            sequence: 1,
116            last_keepalive: Instant::now(),
117            inbox: Inbox::default(),
118        })
119    }
120    fn keepalive(&mut self) -> Result<()> {
121        let now = Instant::now();
122        ensure!(
123            self.inbox
124                .noop_sent
125                .is_none_or(|sent| now.duration_since(sent) < KEEPALIVE_TIMEOUT),
126            "Native keepalive timed out"
127        );
128        if now.duration_since(self.last_keepalive) >= KEEPALIVE_INTERVAL
129            && self.inbox.noop_sent.is_none()
130        {
131            self.session.send_application(NOOP, NOOP_SEQUENCE, &[])?;
132            self.inbox.noop_sent = Some(now);
133            self.last_keepalive = now;
134        }
135        Ok(())
136    }
137    fn rpc(
138        &mut self,
139        state: &mut AccountState,
140        cgi: u16,
141        command: u32,
142        plain: &[u8],
143    ) -> Result<Vec<u8>> {
144        self.keepalive()?;
145        let sequence = self.sequence;
146        self.sequence = self
147            .sequence
148            .checked_add(1)
149            .filter(|next| *next != NOOP_SEQUENCE)
150            .ok_or_else(|| anyhow::anyhow!("Longlink sequence exhausted"))?;
151        let packet = AuthenticatedPacket::prepare(state, cgi, plain)?;
152        self.session
153            .send_application(command, sequence, &packet.packet)?;
154        let deadline = Instant::now() + self.timeout;
155        for _ in 0..64 {
156            let remaining = deadline.saturating_duration_since(Instant::now());
157            ensure!(!remaining.is_zero(), "Native longlink response timed out");
158            let reply = self
159                .session
160                .poll_application(remaining)?
161                .ok_or_else(|| anyhow::anyhow!("Native longlink response timed out"))?;
162            if reply.sequence != sequence {
163                self.inbox.observe(&reply)?;
164                continue;
165            }
166            ensure!(
167                reply.command == command + REPLY_OFFSET,
168                "Unexpected longlink response command"
169            );
170            let (plain, cookie) = packet.decode_verified_transport_reply(&reply.payload)?;
171            if !cookie.is_empty() {
172                state.cookie = cookie;
173            }
174            return Ok(plain);
175        }
176        anyhow::bail!("Native longlink response frame limit")
177    }
178    /// Also associates the authenticated account with this persistent socket.
179    pub fn heartbeat(
180        &mut self,
181        state: &mut AccountState,
182        cursor: &[u8],
183    ) -> Result<wire::Heartbeat> {
184        let request = wire::heartbeat_request(state, cursor)?;
185        wire::decode_heartbeat(&self.rpc(state, 518, 238, &request)?)
186    }
187    pub fn sync(
188        &mut self,
189        state: &mut AccountState,
190        cursor: &[u8],
191        scene: u32,
192    ) -> Result<wire::SyncBatch> {
193        ensure!([1, 6, 7].contains(&scene), "Unsupported sync scene");
194        let sent = if cursor.is_empty() { &[0][..] } else { cursor };
195        let request = wire::sync_request(sent, &state.device.device_type, scene)?;
196        wire::decode_sync(&self.rpc(state, 138, 121, &request)?, cursor)
197    }
198    /// Waiting owns no account mutation lock. The caller rechecks its credential
199    /// and logout fences under that lock before acting on a notification.
200    pub fn poll(&mut self, timeout: Duration) -> Result<Option<Notification>> {
201        self.keepalive()?;
202        if !self.inbox.pending.is_empty() {
203            return Ok(self.inbox.pending.pop_front());
204        }
205        let deadline = Instant::now() + timeout;
206        for _ in 0..64 {
207            let remaining = deadline.saturating_duration_since(Instant::now());
208            if remaining.is_zero() {
209                return Ok(None);
210            }
211            let Some(packet) = self.session.poll_application(remaining)? else {
212                return Ok(None);
213            };
214            self.inbox.observe(&packet)?;
215            if !self.inbox.pending.is_empty() {
216                return Ok(self.inbox.pending.pop_front());
217            }
218        }
219        anyhow::bail!("Native notification frame limit")
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    fn push(sequence: u32, selector: &[u8]) -> Longlink {
227        Longlink {
228            command: 24,
229            sequence,
230            payload: selector.to_vec(),
231        }
232    }
233    #[test]
234    fn selectors_are_strict_big_endian_work_hints() {
235        assert_eq!(
236            notification(&push(0, &6u32.to_be_bytes())).unwrap(),
237            Some(Notification {
238                command: 24,
239                selector: 6
240            })
241        );
242        assert!(notification(&push(0, &8u32.to_be_bytes()))
243            .unwrap()
244            .is_none());
245        assert!(notification(&push(12, &6u32.to_be_bytes()))
246            .unwrap()
247            .is_none());
248        for invalid in [&[][..], &[6][..], &[0, 0, 6][..], &[0; 5][..]] {
249            assert!(notification(&push(0, invalid)).is_err());
250        }
251    }
252    #[test]
253    fn unmapped_contact_notices_without_selectors_leave_the_inbox_intact() {
254        use crate::protobuf as p;
255        let mut inbox = Inbox::default();
256        inbox.observe(&push(0, &2u32.to_be_bytes())).unwrap();
257        for notice in [
258            p::uint(1, 2048),
259            p::message(&[p::uint(1, 2048), p::bytes(2, b"")]).unwrap(),
260        ] {
261            inbox
262                .observe(&Longlink {
263                    command: 356,
264                    sequence: 0,
265                    payload: p::message(&[p::uint(2, 11), p::bytes(3, &notice)]).unwrap(),
266                })
267                .unwrap();
268        }
269        assert_eq!(
270            inbox.pending.pop_front().unwrap(),
271            Notification {
272                command: 24,
273                selector: 2
274            }
275        );
276        assert!(inbox.pending.is_empty());
277    }
278    #[test]
279    fn contact_hint_coalesces_separately_from_message_work() {
280        use crate::protobuf as p;
281        let hint = Longlink {
282            command: 356,
283            sequence: 0,
284            payload: p::message(&[
285                p::bytes(1, b"opaque"),
286                p::uint(2, 11),
287                p::bytes(
288                    3,
289                    &p::message(&[
290                        p::uint(1, 2048),
291                        p::bytes(2, &p::uint(1, crate::contact_sync::CONTACT_SELECTOR)),
292                    ])
293                    .unwrap(),
294                ),
295            ])
296            .unwrap(),
297        };
298        let mut inbox = Inbox::default();
299        inbox.observe(&push(0, &2u32.to_be_bytes())).unwrap();
300        inbox.observe(&hint).unwrap();
301        inbox.observe(&push(0, &4u32.to_be_bytes())).unwrap();
302        inbox.observe(&hint).unwrap();
303        assert_eq!(inbox.pending.len(), 2);
304        assert_eq!(
305            inbox.pending.pop_front().unwrap(),
306            Notification {
307                command: 24,
308                selector: 6
309            }
310        );
311        assert_eq!(
312            inbox.pending.pop_front().unwrap(),
313            Notification {
314                command: 356,
315                selector: 0x20004
316            }
317        );
318        let mut malformed = hint;
319        malformed.payload = p::message(&[p::uint(2, 11), p::bytes(3, b"invalid")]).unwrap();
320        assert!(notification(&malformed).is_err());
321    }
322    #[test]
323    fn interleaved_pushes_coalesce_without_confusing_keepalive_or_unknown_commands() {
324        let mut inbox = Inbox {
325            noop_sent: Some(Instant::now()),
326            ..Default::default()
327        };
328        inbox.observe(&push(0, &2u32.to_be_bytes())).unwrap();
329        inbox
330            .observe(&Longlink {
331                command: REPLY_OFFSET + NOOP,
332                sequence: NOOP_SEQUENCE,
333                payload: vec![],
334            })
335            .unwrap();
336        inbox
337            .observe(&Longlink {
338                command: 912,
339                sequence: 0,
340                payload: vec![1, 2, 3],
341            })
342            .unwrap();
343        inbox.observe(&push(0, &4u32.to_be_bytes())).unwrap();
344        assert!(inbox.noop_sent.is_none());
345        assert_eq!(inbox.pending.pop_front().unwrap().selector, 6);
346        assert!(inbox.pending.is_empty());
347        assert!(inbox
348            .observe(&Longlink {
349                command: REPLY_OFFSET + NOOP,
350                sequence: NOOP_SEQUENCE,
351                payload: vec![1]
352            })
353            .is_err());
354    }
355}