Skip to main content

wechat_profile/
schema.rs

1use crate::{ProfileError, Result};
2use rusqlite::{Connection, OptionalExtension};
3use serde::Serialize;
4use sha2::{Digest, Sha256};
5use std::collections::{BTreeMap, BTreeSet};
6
7#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
8#[serde(rename_all = "camelCase")]
9pub struct ColumnSchema {
10    pub name: String,
11    pub declared_type: String,
12    pub not_null: bool,
13    pub default_sql: Option<String>,
14    pub primary_key_position: i64,
15    pub hidden: i64,
16}
17#[derive(Clone, Debug, Serialize)]
18#[serde(rename_all = "camelCase")]
19pub struct IndexSchema {
20    pub name: String,
21    pub unique: bool,
22    pub origin: String,
23    pub partial: bool,
24    pub columns: Vec<Option<String>>,
25    pub definition: Option<String>,
26}
27#[derive(Clone, Debug, Serialize)]
28#[serde(rename_all = "camelCase")]
29pub struct TableSchema {
30    pub name: String,
31    pub definition: Option<String>,
32    pub columns: Vec<ColumnSchema>,
33    pub indexes: Vec<IndexSchema>,
34    pub triggers: Vec<(String, Option<String>)>,
35}
36#[derive(Clone, Debug, Serialize)]
37#[serde(rename_all = "camelCase")]
38pub struct SchemaReport {
39    pub database: String,
40    pub fingerprint: String,
41    pub user_version: i64,
42    pub application_id: i64,
43    /// SQLite's schema counter is diagnostic; it is not a WeChat release.
44    pub sqlite_schema_counter: i64,
45    pub tables: Vec<TableSchema>,
46    pub readable_capabilities: Vec<String>,
47    pub writes_supported: bool,
48}
49pub(crate) fn quote_identifier(s: &str) -> String {
50    format!("\"{}\"", s.replace('"', "\"\""))
51}
52pub(crate) fn message_table(s: &str) -> bool {
53    s.len() == 36 && s.starts_with("Msg_") && s.as_bytes()[4..].iter().all(u8::is_ascii_hexdigit)
54}
55
56pub(crate) fn inspect(c: &Connection, database: &str) -> Result<SchemaReport> {
57    let user_version = c.query_row("PRAGMA user_version", [], |r| r.get(0))?;
58    let application_id = c.query_row("PRAGMA application_id", [], |r| r.get(0))?;
59    let counter = c.query_row("PRAGMA schema_version", [], |r| r.get(0))?;
60    let mut tables = Vec::new();
61    let mut stm =
62        c.prepare("SELECT name,sql FROM sqlite_master WHERE type='table' ORDER BY name")?;
63    let names = stm
64        .query_map([], |r| {
65            Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?))
66        })?
67        .collect::<std::result::Result<Vec<_>, _>>()?;
68    if names.len() > 16384 {
69        return Err(ProfileError::ResourceLimit);
70    }
71    for (name, definition) in names {
72        // Custom WCDB tokenizers/modules are not part of this reader. Retain
73        // their SQL in the fingerprint without asking SQLite to instantiate it.
74        if definition.as_ref().is_some_and(|s| {
75            s.trim_start()
76                .to_ascii_uppercase()
77                .starts_with("CREATE VIRTUAL TABLE")
78        }) {
79            tables.push(TableSchema {
80                name,
81                definition,
82                columns: Vec::new(),
83                indexes: Vec::new(),
84                triggers: Vec::new(),
85            });
86            continue;
87        }
88        let q = quote_identifier(&name);
89        let mut s = c.prepare(&format!("PRAGMA table_xinfo({q})"))?;
90        let columns = s
91            .query_map([], |r| {
92                Ok(ColumnSchema {
93                    name: r.get(1)?,
94                    declared_type: r.get(2)?,
95                    not_null: r.get::<_, i64>(3)? != 0,
96                    default_sql: r.get(4)?,
97                    primary_key_position: r.get(5)?,
98                    hidden: r.get(6)?,
99                })
100            })?
101            .collect::<std::result::Result<Vec<_>, _>>()?;
102        let mut s = c.prepare(&format!("PRAGMA index_list({q})"))?;
103        let raw = s
104            .query_map([], |r| {
105                Ok((
106                    r.get::<_, String>(1)?,
107                    r.get::<_, i64>(2)? != 0,
108                    r.get::<_, String>(3)?,
109                    r.get::<_, i64>(4)? != 0,
110                ))
111            })?
112            .collect::<std::result::Result<Vec<_>, _>>()?;
113        let mut indexes = Vec::new();
114        for (name, unique, origin, partial) in raw {
115            let mut s = c.prepare(&format!("PRAGMA index_info({})", quote_identifier(&name)))?;
116            let columns = s
117                .query_map([], |r| r.get::<_, Option<String>>(2))?
118                .collect::<std::result::Result<Vec<_>, _>>()?;
119            let definition = c
120                .query_row(
121                    "SELECT sql FROM sqlite_master WHERE type='index' AND name=?1",
122                    [&name],
123                    |r| r.get::<_, Option<String>>(0),
124                )
125                .optional()?
126                .flatten();
127            indexes.push(IndexSchema {
128                name,
129                unique,
130                origin,
131                partial,
132                columns,
133                definition,
134            });
135        }
136        indexes.sort_by(|a, b| a.name.cmp(&b.name));
137        let mut s = c.prepare(
138            "SELECT name,sql FROM sqlite_master WHERE type='trigger' AND tbl_name=?1 ORDER BY name",
139        )?;
140        let triggers = s
141            .query_map([&name], |r| Ok((r.get(0)?, r.get(1)?)))?
142            .collect::<std::result::Result<Vec<_>, _>>()?;
143        tables.push(TableSchema {
144            name,
145            definition,
146            columns,
147            indexes,
148            triggers,
149        });
150    }
151    // Counter excluded: rebuilding an identical schema need not change the
152    // compatibility fingerprint. The actual definitions/columns/indexes bind it.
153    let canonical = serde_json::to_vec(&(user_version, application_id, &tables))
154        .map_err(|_| ProfileError::InvalidMetadata)?;
155    let fingerprint = hex::encode(Sha256::digest(&canonical));
156    let map: BTreeMap<_, BTreeSet<_>> = tables
157        .iter()
158        .map(|t| {
159            (
160                t.name.as_str(),
161                t.columns.iter().map(|v| v.name.as_str()).collect(),
162            )
163        })
164        .collect();
165    let has = |table: &str, columns: &[&str]| {
166        map.get(table)
167            .is_some_and(|s| columns.iter().all(|c| s.contains(c)))
168    };
169    let mut caps = Vec::new();
170    if has(
171        "contact",
172        &[
173            "id",
174            "username",
175            "nick_name",
176            "remark",
177            "alias",
178            "local_type",
179        ],
180    ) {
181        caps.push("contacts".into());
182    }
183    if has("chat_room", &["id", "username", "owner", "ext_buffer"])
184        && has("chatroom_member", &["room_id", "member_id"])
185    {
186        caps.push("groups".into());
187    }
188    if has(
189        "SessionTable",
190        &[
191            "username",
192            "unread_count",
193            "sort_timestamp",
194            "last_timestamp",
195            "is_hidden",
196        ],
197    ) {
198        caps.push("threads".into());
199    }
200    if has(
201        "head_image",
202        &["username", "md5", "image_buffer", "update_time"],
203    ) {
204        caps.push("avatars".into());
205    }
206    if has(
207        "VoiceInfo",
208        &["chat_name_id", "local_id", "svr_id", "voice_data"],
209    ) && has("Name2Id", &["user_name"])
210    {
211        caps.push("voice".into());
212    }
213    if has("Name2Id", &["user_name"])
214        && tables.iter().filter(|t| message_table(&t.name)).any(|t| {
215            [
216                "local_id",
217                "server_id",
218                "local_type",
219                "create_time",
220                "real_sender_id",
221                "message_content",
222                "source",
223                "WCDB_CT_message_content",
224                "WCDB_CT_source",
225            ]
226            .iter()
227            .all(|n| t.columns.iter().any(|c| c.name == *n))
228        })
229    {
230        caps.push("messages".into());
231    }
232    Ok(SchemaReport {
233        database: database.into(),
234        fingerprint,
235        user_version,
236        application_id,
237        sqlite_schema_counter: counter,
238        tables,
239        readable_capabilities: caps,
240        writes_supported: false,
241    })
242}
243
244impl SchemaReport {
245    pub fn supports(&self, capability: &str) -> bool {
246        self.readable_capabilities.iter().any(|v| v == capability)
247    }
248    /// No generic writer is authorized merely by a readable or additive schema.
249    pub fn require_writable(&self) -> Result<()> {
250        Err(ProfileError::WritesUnsupported)
251    }
252}