1use crate::{protobuf as p, wire::base_request, AccountState};
4use anyhow::{ensure, Result};
5use serde::{Deserialize, Serialize};
6
7pub const MAX_QUOTE_CONTENT_BYTES: usize = 128 * 1024;
8pub const MAX_QUOTE_XML_BYTES: usize = 256 * 1024;
9
10#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct QuoteReference {
15 pub message_type: i32,
16 pub server_id: String,
17 pub from_user: String,
18 pub chat_user: String,
19 pub display_name: String,
20 pub content: String,
21 pub created_at: u32,
22 pub msg_source: Option<String>,
23}
24
25fn valid_text(value: &str, max: usize, empty: bool) -> bool {
26 (empty || !value.is_empty())
27 && value.len() <= max
28 && value.chars().all(|c| {
29 (!c.is_control() || matches!(c, '\t' | '\n' | '\r'))
30 && !matches!(c, '\u{fffe}' | '\u{ffff}')
31 })
32}
33
34fn identity(value: &str, empty: bool) -> bool {
35 valid_text(value, 256, empty)
36 && !value
37 .chars()
38 .any(|character| character.is_control() || character.is_whitespace())
39}
40
41impl QuoteReference {
42 pub fn validate(&self) -> Result<()> {
43 let server_id = self.server_id.parse::<u64>();
44 ensure!(
45 matches!(self.message_type, 1 | 3 | 34 | 42 | 43 | 47 | 48 | 49)
46 && server_id.is_ok_and(|value| value > 0 && value.to_string() == self.server_id)
47 && self.created_at > 0
48 && self.created_at <= i32::MAX as u32
49 && identity(&self.from_user, false)
50 && identity(&self.chat_user, true)
51 && valid_text(&self.display_name, 16 * 1024, true)
52 && valid_text(&self.content, MAX_QUOTE_CONTENT_BYTES, false)
53 && self.msg_source.as_deref().is_none_or(|value| valid_text(
54 value,
55 16 * 1024,
56 true
57 )),
58 "invalid_quote_reference"
59 );
60 Ok(())
61 }
62}
63
64fn tag(out: &mut String, name: &str, value: &str) {
65 out.push('<');
66 out.push_str(name);
67 out.push('>');
68 for character in value.chars() {
69 match character {
70 '&' => out.push_str("&"),
71 '<' => out.push_str("<"),
72 '>' => out.push_str(">"),
73 '"' => out.push_str("""),
74 '\'' => out.push_str("'"),
75 _ => out.push(character),
76 }
77 }
78 out.push_str("</");
79 out.push_str(name);
80 out.push('>');
81}
82
83pub fn quote_xml(sender: &str, text: &str, reference: &QuoteReference) -> Result<String> {
86 reference.validate()?;
87 ensure!(
88 identity(sender, false) && !text.trim().is_empty() && valid_text(text, 16 * 1024, false),
89 "invalid_quote_request"
90 );
91 let mut out = String::from("<appmsg appid=\"\" sdkver=\"0\">");
92 for (name, value) in [
93 ("title", text),
94 ("des", ""),
95 ("action", ""),
96 ("type", "57"),
97 ("showtype", "0"),
98 ("url", ""),
99 ("lowurl", ""),
100 ] {
101 tag(&mut out, name, value);
102 }
103 out.push_str("<refermsg>");
104 tag(&mut out, "type", &reference.message_type.to_string());
105 tag(&mut out, "svrid", &reference.server_id);
106 tag(&mut out, "fromusr", &reference.from_user);
107 tag(&mut out, "chatusr", &reference.chat_user);
108 tag(&mut out, "displayname", &reference.display_name);
109 tag(&mut out, "content", &reference.content);
110 tag(&mut out, "createtime", &reference.created_at.to_string());
111 if let Some(source) = reference
112 .msg_source
113 .as_deref()
114 .filter(|value| !value.is_empty())
115 {
116 tag(&mut out, "msgsource", source);
117 }
118 out.push_str("</refermsg></appmsg>");
119 tag(&mut out, "fromusername", sender);
120 ensure!(out.len() <= MAX_QUOTE_XML_BYTES, "invalid_quote_request");
121 Ok(out)
122}
123
124pub fn quote_send_request(
127 state: &AccountState,
128 destination: &str,
129 client_id: &str,
130 created_at: u32,
131 content: &str,
132) -> Result<Vec<u8>> {
133 quote_send_request_with_source(state, destination, client_id, created_at, content, None)
134}
135
136pub fn quote_send_request_with_source(
138 state: &AccountState,
139 destination: &str,
140 client_id: &str,
141 created_at: u32,
142 content: &str,
143 source: Option<&str>,
144) -> Result<Vec<u8>> {
145 ensure!(
146 identity(&state.own_username, false)
147 && identity(destination, false)
148 && valid_text(client_id, 1024, false)
149 && !client_id.chars().any(char::is_control)
150 && created_at > 0
151 && valid_text(content, MAX_QUOTE_XML_BYTES, false)
152 && source.is_none_or(|value| {
153 destination.ends_with("@chatroom") && valid_text(value, 64 * 1024, false)
154 }),
155 "invalid_quote_request"
156 );
157 let mut fields = vec![
158 p::bytes(1, state.own_username.as_bytes()),
159 p::bytes(2, &[]),
160 p::uint(3, 0),
161 p::bytes(4, destination.as_bytes()),
162 p::uint(5, 57),
163 p::bytes(6, content.as_bytes()),
164 p::uint(7, u64::from(created_at)),
165 p::bytes(8, client_id.as_bytes()),
166 p::int32(10, 1),
167 ];
168 if let Some(source) = source {
169 fields.push(p::bytes(12, source.as_bytes()));
170 }
171 fields.extend([p::bytes(13, &[]), p::bytes(14, &[]), p::bytes(15, &[])]);
172 let app = p::message(&fields)?;
173 p::message(&[
174 p::bytes(1, &base_request(&state.device, state.uin, 0)?),
175 p::bytes(2, &app),
176 p::bytes(5, &[]),
179 p::uint(9, 0),
180 p::uint(10, 0),
181 p::uint(11, 2),
182 ])
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 fn reference() -> QuoteReference {
190 QuoteReference {
191 message_type: 49,
192 server_id: u64::MAX.to_string(),
193 from_user: "example@chatroom".into(),
194 chat_user: "quoted-user".into(),
195 display_name: "Someone <A>".into(),
196 content: "<msg><appmsg><type>6</type><title>report & plan</title></appmsg></msg>"
197 .into(),
198 created_at: 1234566,
199 msg_source: Some("<msgsource><flag>1</flag></msgsource>".into()),
200 }
201 }
202
203 fn state() -> AccountState {
204 AccountState {
205 schema: 1,
206 device: crate::DeviceProfile {
207 device_id: vec![1; 16],
208 device_type: "UnifiedPCLinux synthetic x86_64".into(),
209 client_version: -227271398,
210 },
211 uin: 123,
212 own_username: "synthetic-user".into(),
213 cookie: vec![],
214 session_key: vec![1; 16],
215 request_key: vec![],
216 response_key: vec![],
217 auto_auth_key: vec![],
218 key_info: vec![],
219 database_password: vec![],
220 auth_snapshot: Default::default(),
221 long_host: "long.weixin.qq.com".into(),
222 short_host: "short.weixin.qq.com".into(),
223 }
224 }
225
226 #[test]
227 fn quote_fields_and_wire_match_independent_fixture() {
228 let fixture: serde_json::Value =
229 serde_json::from_str(include_str!("../fixtures/synthetic-quote-wire.json")).unwrap();
230 let xml = quote_xml("synthetic-user", "Reply <&> \"yes\"\n世界", &reference()).unwrap();
231 assert_eq!(xml, fixture["xml"]);
232 let request =
233 quote_send_request(&state(), "example@chatroom", "quote-client", 1234567, &xml)
234 .unwrap();
235 assert_eq!(hex::encode(&request), fixture["requestHex"]);
236 let outer = p::decode(&request).unwrap();
237 assert_eq!(
238 outer.iter().map(|field| field.number).collect::<Vec<_>>(),
239 [1, 2, 5, 9, 10, 11]
240 );
241 assert_eq!(p::data(&outer, 5).unwrap(), b"");
242 let app = p::decode(p::data(&outer, 2).unwrap()).unwrap();
243 assert_eq!(p::scalar(&app, 5).unwrap(), 57);
244 assert_eq!(p::data(&app, 6).unwrap(), xml.as_bytes());
245 assert!(p::optional_data(&app, 12).unwrap().is_none());
246 assert!(xml.contains("<msgsource><msgsource>"));
247 assert!(xml.contains("<refermsg><type>49</type>"));
248 assert!(!xml.contains("<refermsg><type>6</type>"));
249 }
250
251 #[test]
252 fn content_is_encoded_as_text_without_expanding_nested_markup() {
253 let mut selected = reference();
254 selected.message_type = 1;
255 selected.chat_user.clear();
256 selected.display_name.clear();
257 selected.msg_source = Some(String::new());
258 selected.content = "literal </content><type>10002</type> & ]]> ' \"".into();
259 let xml = quote_xml("synthetic-user", "comment", &selected).unwrap();
260 assert!(xml.contains("</content><type>10002</type>"));
261 assert!(xml.contains("&amp; ]]> ' ""));
262 assert!(!xml.contains("<type>10002</type>"));
263 assert!(!xml.contains("<msgsource>"));
264 assert!(xml.contains("<chatusr></chatusr><displayname></displayname>"));
265 }
266
267 #[test]
268 fn outgoing_mentions_use_app_source_independently_of_embedded_reference_source() {
269 let fixture: serde_json::Value = serde_json::from_str(include_str!(
270 "../fixtures/synthetic-quoted-mentions-wire.json"
271 ))
272 .unwrap();
273 let mut selected = reference();
274 selected.msg_source =
275 Some("<msgsource><atuserlist>old_member</atuserlist></msgsource>".into());
276 let xml = quote_xml("synthetic-user", "@Current\u{2005}Reply", &selected).unwrap();
277 assert_eq!(xml, fixture["xml"]);
278 let source = "<msgsource><atuserlist>current_member</atuserlist><alnode><fr>1</fr></alnode></msgsource>";
279 let request = quote_send_request_with_source(
280 &state(),
281 "example@chatroom",
282 "quote-client",
283 1234567,
284 &xml,
285 Some(source),
286 )
287 .unwrap();
288 assert_eq!(source, fixture["outgoingSource"]);
289 assert_eq!(hex::encode(&request), fixture["requestHex"]);
290 let outer = p::decode(&request).unwrap();
291 assert_eq!(
292 outer.iter().map(|field| field.number).collect::<Vec<_>>(),
293 [1, 2, 5, 9, 10, 11]
294 );
295 let app = p::decode(p::data(&outer, 2).unwrap()).unwrap();
296 assert_eq!(p::scalar(&app, 5).unwrap(), 57);
297 assert_eq!(p::data(&app, 12).unwrap(), source.as_bytes());
298 let body = std::str::from_utf8(p::data(&app, 6).unwrap()).unwrap();
299 assert!(body.contains("old_member"));
300 assert!(!body.contains("current_member"));
301 assert!(body.contains("<title>@Current\u{2005}Reply</title>"));
302 for invalid in ["", "bad\0source"] {
303 assert!(quote_send_request_with_source(
304 &state(),
305 "example@chatroom",
306 "client",
307 1,
308 &xml,
309 Some(invalid)
310 )
311 .is_err());
312 }
313 assert!(
314 quote_send_request_with_source(&state(), "peer", "client", 1, &xml, Some(source))
315 .is_err()
316 );
317 }
318
319 #[test]
320 fn reference_validation_rejects_lossy_or_unsupported_values() {
321 for id in ["0", "01", "+1", "18446744073709551616", "1\n"] {
322 let mut selected = reference();
323 selected.server_id = id.into();
324 assert!(selected.validate().is_err(), "{id:?}");
325 }
326 for kind in [0, -1, 2, 57, 10002] {
327 let mut selected = reference();
328 selected.message_type = kind;
329 assert!(selected.validate().is_err());
330 }
331 for control in ['\0', '\u{1}', '\u{fffe}', '\u{ffff}'] {
332 let mut selected = reference();
333 selected.content.push(control);
334 assert!(selected.validate().is_err());
335 assert!(quote_xml("synthetic-user", &control.to_string(), &reference()).is_err());
336 }
337 let mut selected = reference();
338 selected.created_at = 0;
339 assert!(selected.validate().is_err());
340 selected = reference();
341 selected.from_user.clear();
342 assert!(selected.validate().is_err());
343 for value in ["user name", "user\u{a0}name"] {
344 selected = reference();
345 selected.from_user = value.into();
346 assert!(selected.validate().is_err());
347 assert!(quote_xml(value, "comment", &reference()).is_err());
348 assert!(quote_send_request(&state(), value, "client", 1, "xml").is_err());
349 }
350 selected = reference();
351 selected.msg_source = Some("x".repeat(16 * 1024 + 1));
352 assert!(selected.validate().is_err());
353 }
354
355 #[test]
356 fn final_escaped_xml_and_request_size_remain_bounded() {
357 let mut selected = reference();
358 selected.content = "x".repeat(MAX_QUOTE_CONTENT_BYTES);
359 assert!(quote_xml("synthetic-user", "comment", &selected).is_ok());
360 selected.content.push('x');
361 assert!(selected.validate().is_err());
362 selected.content = "&".repeat(MAX_QUOTE_CONTENT_BYTES);
363 assert!(selected.validate().is_ok());
364 assert!(quote_xml("synthetic-user", "comment", &selected).is_err());
365 assert!(quote_xml("synthetic-user", " \n\t", &reference()).is_err());
366 assert!(quote_xml("synthetic-user", &"x".repeat(16 * 1024 + 1), &reference()).is_err());
367 assert!(quote_send_request(&state(), "dest\0", "client", 1, "xml").is_err());
368 assert!(quote_send_request(&state(), "dest", "client\n", 1, "xml").is_err());
369 assert!(quote_send_request(&state(), "dest", "client", 0, "xml").is_err());
370 assert!(quote_send_request(
371 &state(),
372 "dest",
373 "client",
374 1,
375 &"x".repeat(MAX_QUOTE_XML_BYTES + 1)
376 )
377 .is_err());
378 }
379}