Skip to main content

wechat_profile/
peer_metadata.rs

1//! Cached contact metadata from the same official database read transaction.
2use crate::{DatabaseState, ProfileError, ProfileReader, Result, SchemaReport};
3use rusqlite::{params_from_iter, types::ValueRef, Connection};
4use serde::Serialize;
5use std::collections::{BTreeMap, BTreeSet};
6
7const MAX_PEERS: usize = 200;
8const MAX_CATALOG_ROWS: usize = 4096;
9const MAX_METADATA_TEXT_BYTES: usize = 4096;
10
11#[derive(Clone, Debug, Serialize)]
12#[serde(rename_all = "camelCase")]
13pub struct PeerMetadata {
14    pub local_type: Option<i64>,
15    pub flag: Option<i64>,
16    pub delete_flag: Option<i64>,
17    pub verify_flag: Option<i64>,
18    /// Present field 13 of the ordinary contact extra message; absent is not zero.
19    pub contact_service_type: Option<i32>,
20    pub business: Option<PeerBusinessMetadata>,
21    pub openim: Option<PeerOpenImMetadata>,
22}
23
24#[derive(Clone, Debug, Serialize)]
25#[serde(rename_all = "camelCase")]
26pub struct PeerBusinessMetadata {
27    pub service_type: Option<i64>,
28    pub accept_type: Option<i64>,
29    pub child_type: Option<i64>,
30}
31
32#[derive(Clone, Debug, Serialize)]
33#[serde(rename_all = "camelCase")]
34pub struct PeerOpenImMetadata {
35    pub app_id: Option<String>,
36    pub wording_id: Option<String>,
37    pub wordings: Vec<PeerOpenImWording>,
38    pub applications: Vec<PeerOpenImApplication>,
39}
40
41#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
42#[serde(rename_all = "camelCase")]
43pub struct PeerOpenImWording {
44    pub language_id: i64,
45    pub wording: String,
46}
47
48#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
49#[serde(rename_all = "camelCase")]
50pub struct PeerOpenImApplication {
51    pub language_id: i64,
52    pub account_type_id: Option<String>,
53    pub account_type_cached: bool,
54}
55
56fn integer(row: &rusqlite::Row<'_>, index: usize) -> rusqlite::Result<Option<i64>> {
57    Ok(match row.get_ref(index)? {
58        ValueRef::Integer(value) => Some(value),
59        _ => None,
60    })
61}
62
63fn text(row: &rusqlite::Row<'_>, index: usize) -> rusqlite::Result<Option<String>> {
64    Ok(match row.get_ref(index)? {
65        ValueRef::Text(value) if value.len() <= MAX_METADATA_TEXT_BYTES => {
66            std::str::from_utf8(value).ok().map(str::to_owned)
67        }
68        _ => None,
69    })
70}
71
72fn has_table(schema: &SchemaReport, table: &str, columns: &[&str]) -> Result<bool> {
73    let Some(table) = schema.tables.iter().find(|item| item.name == table) else {
74        return Ok(false);
75    };
76    if columns
77        .iter()
78        .all(|name| table.columns.iter().any(|column| column.name == *name))
79    {
80        Ok(true)
81    } else {
82        Err(ProfileError::UnsupportedSchema(schema.database.clone()))
83    }
84}
85
86impl ProfileReader {
87    /// Read one page's saved metadata without refreshing contacts or catalogs.
88    /// Missing peers are omitted. A malformed extra buffer affects only its peer;
89    /// an incompatible database schema remains an error.
90    pub fn peer_metadata(&self, peers: &[String]) -> Result<BTreeMap<String, PeerMetadata>> {
91        if peers.len() > MAX_PEERS
92            || peers
93                .iter()
94                .any(|name| name.is_empty() || name.len() > 4096 || name.contains('\0'))
95        {
96            return Err(ProfileError::InvalidQuery);
97        }
98        if peers.is_empty() {
99            return Ok(BTreeMap::new());
100        }
101        let mut candidates = self
102            .snapshot
103            .inventory
104            .iter()
105            .filter(|item| item.relative_path.rsplit('/').next() == Some("contact.db"));
106        let database = candidates
107            .next()
108            .ok_or_else(|| ProfileError::UnsupportedSchema("contact.db".into()))?;
109        if candidates.next().is_some() {
110            return Err(ProfileError::UnsupportedSchema("contact.db".into()));
111        }
112        match database.state {
113            DatabaseState::KeyUnavailable => {
114                return Err(ProfileError::KeyUnavailable(database.relative_path.clone()))
115            }
116            DatabaseState::KeyMismatch => {
117                return Err(ProfileError::KeyMismatch(database.relative_path.clone()))
118            }
119            _ => {}
120        }
121        let schema = self
122            .schemas
123            .iter()
124            .find(|schema| schema.database == database.relative_path)
125            .ok_or_else(|| ProfileError::UnsupportedSchema(database.relative_path.clone()))?;
126        if !has_table(
127            schema,
128            "contact",
129            &[
130                "id",
131                "username",
132                "local_type",
133                "flag",
134                "delete_flag",
135                "verify_flag",
136                "extra_buffer",
137            ],
138        )? {
139            return Err(ProfileError::UnsupportedSchema(
140                database.relative_path.clone(),
141            ));
142        }
143        let connection = self.snapshot.open(&database.relative_path)?;
144        // Owned readers already begin a transaction. Copied snapshots are
145        // immutable to callers; begin here as well for all catalog joins.
146        let transaction = connection
147            .is_autocommit()
148            .then(|| connection.unchecked_transaction())
149            .transpose()?;
150        let result = read_metadata(
151            &connection,
152            schema,
153            peers,
154            self.snapshot.options.max_blob_bytes,
155        );
156        if let Some(transaction) = transaction {
157            transaction.rollback()?;
158        }
159        result
160    }
161}
162
163fn read_metadata(
164    connection: &Connection,
165    schema: &SchemaReport,
166    peers: &[String],
167    max_blob_bytes: usize,
168) -> Result<BTreeMap<String, PeerMetadata>> {
169    let placeholders = vec!["?"; peers.len()].join(",");
170    record_query();
171    let mut statement = connection.prepare(&format!(
172        "SELECT username,local_type,flag,delete_flag,verify_flag,CASE WHEN length(extra_buffer)<={max_blob_bytes} THEN extra_buffer END FROM contact WHERE id IN (SELECT min(id) FROM contact WHERE username IN ({placeholders}) GROUP BY username HAVING count(*)=1) LIMIT {}",
173        MAX_PEERS + 1
174    ))?;
175    let mut rows = statement.query(params_from_iter(peers))?;
176    let mut metadata = BTreeMap::new();
177    let mut count = 0;
178    while let Some(row) = rows.next()? {
179        count += 1;
180        if count > MAX_PEERS {
181            return Err(ProfileError::ResourceLimit);
182        }
183        let Some(name) = text(row, 0)? else {
184            continue;
185        };
186        let local_type = integer(row, 1)?;
187        let bytes = match row.get_ref(5)? {
188            ValueRef::Blob(value) | ValueRef::Text(value) => Some(value),
189            _ => None,
190        };
191        let (contact_service_type, openim) = match local_type {
192            Some(5..=7) => (None, bytes.and_then(|value| decode_openim(value).ok())),
193            Some(0..=4) => (
194                bytes
195                    .and_then(|value| decode_service_type(value).ok())
196                    .flatten(),
197                None,
198            ),
199            _ => (None, None),
200        };
201        let item = PeerMetadata {
202            local_type,
203            flag: integer(row, 2)?,
204            delete_flag: integer(row, 3)?,
205            verify_flag: integer(row, 4)?,
206            contact_service_type,
207            business: None,
208            openim,
209        };
210        metadata.insert(name, item);
211    }
212    drop(rows);
213    drop(statement);
214    if has_table(
215        schema,
216        "biz_info",
217        &["username", "type", "accept_type", "child_type"],
218    )? {
219        read_business(connection, &placeholders, peers, &mut metadata)?;
220    }
221    read_openim_catalogs(connection, schema, &mut metadata)?;
222    Ok(metadata)
223}
224
225fn read_business(
226    connection: &Connection,
227    placeholders: &str,
228    peers: &[String],
229    metadata: &mut BTreeMap<String, PeerMetadata>,
230) -> Result<()> {
231    record_query();
232    let mut statement = connection.prepare(&format!(
233        "SELECT username,type,accept_type,child_type FROM biz_info WHERE username IN ({placeholders}) GROUP BY username HAVING count(*)=1 LIMIT {}",
234        MAX_PEERS + 1
235    ))?;
236    let mut rows = statement.query(params_from_iter(peers))?;
237    let mut count = 0;
238    while let Some(row) = rows.next()? {
239        count += 1;
240        if count > MAX_PEERS {
241            return Err(ProfileError::ResourceLimit);
242        }
243        let Some(name) = text(row, 0)? else {
244            continue;
245        };
246        let Some(item) = metadata.get_mut(&name) else {
247            continue;
248        };
249        item.business = Some(PeerBusinessMetadata {
250            service_type: integer(row, 1)?,
251            accept_type: integer(row, 2)?,
252            child_type: integer(row, 3)?,
253        });
254    }
255    Ok(())
256}
257
258fn read_openim_catalogs(
259    connection: &Connection,
260    schema: &SchemaReport,
261    metadata: &mut BTreeMap<String, PeerMetadata>,
262) -> Result<()> {
263    let has_wordings = has_table(
264        schema,
265        "openim_wording",
266        &["app_id", "wording_id", "lang_id", "wording"],
267    )?;
268    let has_applications = has_table(
269        schema,
270        "openim_appid",
271        &["app_id", "lang_id", "acct_type_id"],
272    )?;
273    let has_account_types = has_table(schema, "openim_acct_type", &["acc_type_id", "lang_id"])?;
274    let pairs: BTreeSet<_> = metadata
275        .values()
276        .filter_map(|item| {
277            let item = item.openim.as_ref()?;
278            Some((
279                item.app_id.as_ref()?.clone(),
280                item.wording_id.as_ref()?.clone(),
281            ))
282        })
283        .collect();
284    let applications: BTreeSet<_> = metadata
285        .values()
286        .filter_map(|item| item.openim.as_ref()?.app_id.clone())
287        .collect();
288    let mut wordings = BTreeMap::<(String, String), Vec<PeerOpenImWording>>::new();
289    if has_wordings && !pairs.is_empty() {
290        let values = vec!["(?,?)"; pairs.len()].join(",");
291        let arguments: Vec<_> = pairs
292            .iter()
293            .flat_map(|(app, wording)| [app, wording])
294            .collect();
295        record_query();
296        let mut statement = connection.prepare(&format!(
297            "WITH requested(app,wording) AS (VALUES {values}) SELECT w.app_id,w.wording_id,w.lang_id,w.wording FROM requested r CROSS JOIN openim_wording w WHERE w.app_id=r.app AND w.wording_id=r.wording GROUP BY w.app_id,w.wording_id,w.lang_id HAVING count(*)=1 ORDER BY w.app_id,w.wording_id,w.lang_id LIMIT {}",
298            MAX_CATALOG_ROWS + 1
299        ))?;
300        let mut rows = statement.query(params_from_iter(arguments))?;
301        let mut count = 0;
302        while let Some(row) = rows.next()? {
303            count += 1;
304            if count > MAX_CATALOG_ROWS {
305                return Err(ProfileError::ResourceLimit);
306            }
307            if let (Some(app), Some(id), Some(language_id), Some(wording)) = (
308                text(row, 0)?,
309                text(row, 1)?,
310                integer(row, 2)?,
311                text(row, 3)?,
312            ) {
313                wordings
314                    .entry((app, id))
315                    .or_default()
316                    .push(PeerOpenImWording {
317                        language_id,
318                        wording,
319                    });
320            }
321        }
322    }
323    let mut app_records = BTreeMap::<String, Vec<PeerOpenImApplication>>::new();
324    if has_applications && !applications.is_empty() {
325        let values = vec!["(?)"; applications.len()].join(",");
326        let account_join = if has_account_types {
327            "LEFT JOIN openim_acct_type a ON a.acc_type_id=p.acct_type_id AND a.lang_id=p.lang_id"
328        } else {
329            ""
330        };
331        let cached = if has_account_types {
332            "a.acc_type_id IS NOT NULL"
333        } else {
334            "0"
335        };
336        record_query();
337        let mut statement = connection.prepare(&format!(
338            "WITH requested(app) AS (VALUES {values}) SELECT p.app_id,p.lang_id,p.acct_type_id,{cached} FROM requested r CROSS JOIN openim_appid p {account_join} WHERE p.app_id=r.app GROUP BY p.app_id,p.lang_id HAVING count(*)=1 ORDER BY p.app_id,p.lang_id LIMIT {}",
339            MAX_CATALOG_ROWS + 1
340        ))?;
341        let mut rows = statement.query(params_from_iter(&applications))?;
342        let mut count = 0;
343        while let Some(row) = rows.next()? {
344            count += 1;
345            if count > MAX_CATALOG_ROWS {
346                return Err(ProfileError::ResourceLimit);
347            }
348            if let (Some(app), Some(language_id)) = (text(row, 0)?, integer(row, 1)?) {
349                app_records
350                    .entry(app)
351                    .or_default()
352                    .push(PeerOpenImApplication {
353                        language_id,
354                        account_type_id: text(row, 2)?,
355                        account_type_cached: integer(row, 3)? == Some(1),
356                    });
357            }
358        }
359    }
360    for item in metadata
361        .values_mut()
362        .filter_map(|item| item.openim.as_mut())
363    {
364        if let (Some(app), Some(id)) = (&item.app_id, &item.wording_id) {
365            item.wordings = wordings
366                .get(&(app.clone(), id.clone()))
367                .cloned()
368                .unwrap_or_default();
369        }
370        if let Some(app) = &item.app_id {
371            item.applications = app_records.get(app).cloned().unwrap_or_default();
372        }
373    }
374    Ok(())
375}
376
377fn decode_service_type(bytes: &[u8]) -> Result<Option<i32>> {
378    let mut service_type = None;
379    walk_fields(bytes, |number, value| {
380        if number == 13 {
381            let WireValue::Varint(value) = value else {
382                return Err(ProfileError::InvalidMetadata);
383            };
384            service_type = Some(value as u32 as i32);
385        }
386        Ok(())
387    })?;
388    Ok(service_type)
389}
390
391fn decode_openim(bytes: &[u8]) -> Result<PeerOpenImMetadata> {
392    let mut item = PeerOpenImMetadata {
393        app_id: None,
394        wording_id: None,
395        wordings: Vec::new(),
396        applications: Vec::new(),
397    };
398    walk_fields(bytes, |number, value| {
399        if matches!(number, 1 | 2) {
400            let WireValue::Bytes(bytes) = value else {
401                return Err(ProfileError::InvalidMetadata);
402            };
403            if bytes.len() > MAX_METADATA_TEXT_BYTES {
404                return Err(ProfileError::ResourceLimit);
405            }
406            let value = std::str::from_utf8(bytes)
407                .map_err(|_| ProfileError::InvalidMetadata)?
408                .to_owned();
409            if number == 1 {
410                item.app_id = Some(value);
411            } else {
412                item.wording_id = Some(value);
413            }
414        }
415        Ok(())
416    })?;
417    Ok(item)
418}
419
420enum WireValue<'a> {
421    Varint(u64),
422    Bytes(&'a [u8]),
423    Other,
424}
425
426fn varint(bytes: &[u8], cursor: &mut usize) -> Result<u64> {
427    let mut value = 0u64;
428    for shift in 0..10 {
429        let byte = *bytes.get(*cursor).ok_or(ProfileError::InvalidMetadata)?;
430        *cursor += 1;
431        if shift == 9 && byte > 1 {
432            return Err(ProfileError::InvalidMetadata);
433        }
434        value |= u64::from(byte & 0x7f) << (shift * 7);
435        if byte & 0x80 == 0 {
436            return Ok(value);
437        }
438    }
439    Err(ProfileError::InvalidMetadata)
440}
441
442fn walk_fields<'a>(
443    bytes: &'a [u8],
444    mut field: impl FnMut(u32, WireValue<'a>) -> Result<()>,
445) -> Result<()> {
446    let mut cursor = 0;
447    let mut groups = Vec::new();
448    let mut count = 0;
449    while cursor < bytes.len() {
450        count += 1;
451        if count > 100_000 {
452            return Err(ProfileError::ResourceLimit);
453        }
454        let tag = varint(bytes, &mut cursor)?;
455        let number = tag >> 3;
456        if number == 0 || number >= 1 << 29 {
457            return Err(ProfileError::InvalidMetadata);
458        }
459        let wire = tag & 7;
460        if wire == 3 {
461            if groups.len() >= 32 {
462                return Err(ProfileError::ResourceLimit);
463            }
464            if groups.is_empty() {
465                field(number as u32, WireValue::Other)?;
466            }
467            groups.push(number);
468            continue;
469        }
470        if wire == 4 {
471            if groups.pop() != Some(number) {
472                return Err(ProfileError::InvalidMetadata);
473            }
474            continue;
475        }
476        let value = match wire {
477            0 => WireValue::Varint(varint(bytes, &mut cursor)?),
478            1 | 2 | 5 => {
479                let length = match wire {
480                    1 => 8,
481                    5 => 4,
482                    _ => usize::try_from(varint(bytes, &mut cursor)?)
483                        .map_err(|_| ProfileError::InvalidMetadata)?,
484                };
485                let end = cursor
486                    .checked_add(length)
487                    .ok_or(ProfileError::InvalidMetadata)?;
488                let value = bytes
489                    .get(cursor..end)
490                    .ok_or(ProfileError::InvalidMetadata)?;
491                cursor = end;
492                if wire == 2 {
493                    WireValue::Bytes(value)
494                } else {
495                    WireValue::Other
496                }
497            }
498            _ => return Err(ProfileError::InvalidMetadata),
499        };
500        if groups.is_empty() {
501            field(number as u32, value)?;
502        }
503    }
504    if groups.is_empty() {
505        Ok(())
506    } else {
507        Err(ProfileError::InvalidMetadata)
508    }
509}
510
511fn record_query() {
512    #[cfg(test)]
513    tests::QUERIES.with(|count| count.set(count.get() + 1));
514}
515
516#[cfg(test)]
517mod tests;