Skip to main content

wechat_profile/
reader.rs

1use crate::{
2    model::*,
3    schema::{inspect, message_table, quote_identifier},
4    snapshot::Snapshot,
5    KeyFile, ProfileError, ProfileLocation, ReadOptions, Result, SchemaReport,
6};
7use base64::{
8    engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
9    Engine,
10};
11use md5::Md5;
12use rusqlite::{params, types::ValueRef, Connection, OptionalExtension, Row};
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15use std::{
16    collections::{BTreeMap, BTreeSet},
17    io::Read,
18};
19#[path = "thread_previews.rs"]
20mod thread_previews;
21
22pub struct ProfileReader {
23    pub(crate) snapshot: Snapshot,
24    pub(crate) schemas: Vec<SchemaReport>,
25}
26#[derive(Serialize, Deserialize)]
27#[serde(deny_unknown_fields)]
28struct Cursor {
29    version: u32,
30    generation: String,
31    query: String,
32    key: CursorKey,
33}
34#[derive(Clone, Serialize, Deserialize)]
35#[serde(tag = "type", deny_unknown_fields)]
36enum CursorKey {
37    Name {
38        name: String,
39    },
40    Thread {
41        time: i64,
42        name: String,
43    },
44    Message {
45        time: i64,
46        database: String,
47        table: String,
48        local: i64,
49    },
50}
51
52fn limit(n: Option<usize>) -> Result<usize> {
53    let n = n.unwrap_or(50);
54    if !(1..=200).contains(&n) {
55        Err(ProfileError::InvalidQuery)
56    } else {
57        Ok(n)
58    }
59}
60fn query_hash<T: Serialize>(kind: &str, q: &T) -> Result<String> {
61    let b = serde_json::to_vec(&(kind, q)).map_err(|_| ProfileError::InvalidQuery)?;
62    Ok(hex::encode(Sha256::digest(b)))
63}
64fn encode_cursor(generation: &str, query: &str, key: CursorKey) -> Result<String> {
65    Ok(URL_SAFE_NO_PAD.encode(
66        serde_json::to_vec(&Cursor {
67            version: 1,
68            generation: generation.into(),
69            query: query.into(),
70            key,
71        })
72        .map_err(|_| ProfileError::InvalidCursor)?,
73    ))
74}
75fn decode_cursor(s: Option<&str>, generation: &str, query: &str) -> Result<Option<CursorKey>> {
76    let Some(s) = s else {
77        return Ok(None);
78    };
79    if s.len() > 8192 {
80        return Err(ProfileError::InvalidCursor);
81    }
82    let b = URL_SAFE_NO_PAD
83        .decode(s)
84        .map_err(|_| ProfileError::InvalidCursor)?;
85    let c: Cursor = serde_json::from_slice(&b).map_err(|_| ProfileError::InvalidCursor)?;
86    if c.version != 1 || c.query != query {
87        return Err(ProfileError::InvalidCursor);
88    }
89    if c.generation != generation {
90        return Err(ProfileError::CursorExpired);
91    }
92    Ok(Some(c.key))
93}
94pub(crate) fn raw(row: &Row<'_>) -> rusqlite::Result<RawFields> {
95    let mut out = BTreeMap::new();
96    for (i, name) in row.as_ref().column_names().iter().enumerate() {
97        let value = match row.get_ref(i)? {
98            ValueRef::Null => SqlValue::Null,
99            ValueRef::Integer(n) => SqlValue::Integer(n.to_string()),
100            ValueRef::Real(n) => SqlValue::Real(n.to_string()),
101            ValueRef::Blob(b) => SqlValue::Blob(STANDARD.encode(b)),
102            ValueRef::Text(b) => match std::str::from_utf8(b) {
103                Ok(s) => SqlValue::Text(s.into()),
104                Err(_) => SqlValue::TextBytes(STANDARD.encode(b)),
105            },
106        };
107        out.insert((*name).into(), value);
108    }
109    Ok(out)
110}
111fn text(r: &RawFields, k: &str) -> Option<String> {
112    match r.get(k) {
113        Some(SqlValue::Text(s)) => Some(s.clone()),
114        _ => None,
115    }
116}
117pub(crate) fn num(r: &RawFields, k: &str) -> Option<i64> {
118    match r.get(k) {
119        Some(SqlValue::Integer(s)) => s.parse().ok(),
120        _ => None,
121    }
122}
123fn blob(r: &RawFields, k: &str) -> Result<Option<Vec<u8>>> {
124    match r.get(k) {
125        Some(SqlValue::Text(s)) => Ok(Some(s.as_bytes().to_vec())),
126        Some(SqlValue::Blob(s) | SqlValue::TextBytes(s)) => Ok(Some(
127            STANDARD
128                .decode(s)
129                .map_err(|_| ProfileError::InvalidMetadata)?,
130        )),
131        _ => Ok(None),
132    }
133}
134fn label(r: &RawFields, username: &str) -> String {
135    text(r, "remark")
136        .filter(|s| !s.is_empty())
137        .or_else(|| text(r, "nick_name").filter(|s| !s.is_empty()))
138        .unwrap_or_else(|| username.into())
139}
140fn contact(r: RawFields) -> Result<Contact> {
141    let username = text(&r, "username").ok_or(ProfileError::InvalidMetadata)?;
142    let id = num(&r, "id").ok_or(ProfileError::InvalidMetadata)?;
143    Ok(Contact {
144        label: label(&r, &username),
145        is_group: username.ends_with("@chatroom"),
146        username,
147        local_id: id.to_string(),
148        nickname: text(&r, "nick_name"),
149        remark: text(&r, "remark"),
150        alias: text(&r, "alias"),
151        local_type: num(&r, "local_type"),
152        raw_fields: r,
153    })
154}
155fn group_member(contact: Contact, display: Option<String>) -> GroupMember {
156    let label = contact
157        .remark
158        .clone()
159        .filter(|s| !s.is_empty())
160        .or_else(|| display.clone().filter(|s| !s.is_empty()))
161        .or_else(|| contact.nickname.clone().filter(|s| !s.is_empty()))
162        .unwrap_or_else(|| contact.username.clone());
163    GroupMember {
164        username: contact.username.clone(),
165        local_id: contact.local_id.clone(),
166        label,
167        room_display_name: display,
168        contact,
169    }
170}
171pub(crate) fn decode(r: &RawFields, column: &str, flag: &str, max: usize) -> Result<DecodedField> {
172    let flag = num(r, flag);
173    let Some(mut bytes) = blob(r, column)? else {
174        return Ok(DecodedField {
175            text: None,
176            state: DecodeState::Null,
177            compression_flag: flag,
178        });
179    };
180    if bytes.len() > max {
181        return Err(ProfileError::ResourceLimit);
182    }
183    let mut state = DecodeState::Utf8;
184    if flag.is_some_and(|f| f != 0 && f != 4) {
185        return Ok(DecodedField {
186            text: None,
187            state: DecodeState::UnsupportedCompression,
188            compression_flag: flag,
189        });
190    }
191    if bytes.starts_with(&[0x28, 0xb5, 0x2f, 0xfd]) {
192        let result = (|| {
193            let mut decoder = zstd::stream::read::Decoder::new(bytes.as_slice())
194                .ok()?
195                .take((max + 1) as u64);
196            let mut output = Vec::new();
197            decoder.read_to_end(&mut output).ok()?;
198            if output.len() > max {
199                return None;
200            }
201            Some(output)
202        })();
203        let Some(decoded) = result else {
204            return Ok(DecodedField {
205                text: None,
206                state: DecodeState::InvalidCompressedData,
207                compression_flag: flag,
208            });
209        };
210        bytes = decoded;
211        state = DecodeState::Zstd;
212    }
213    match String::from_utf8(bytes) {
214        Ok(s) => Ok(DecodedField {
215            text: Some(s),
216            state,
217            compression_flag: flag,
218        }),
219        Err(_) => Ok(DecodedField {
220            text: None,
221            state: DecodeState::InvalidUtf8,
222            compression_flag: flag,
223        }),
224    }
225}
226fn name_map(c: &Connection) -> Result<BTreeMap<i64, String>> {
227    let present: bool = c.query_row(
228        "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='Name2Id')",
229        [],
230        |r| r.get(0),
231    )?;
232    if !present {
233        // The stock client can create an empty next-ordinal database before
234        // initializing its message tables. It has no sender namespace yet.
235        let messages: bool = c.query_row(
236            "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name GLOB 'Msg_*')",
237            [],
238            |r| r.get(0),
239        )?;
240        if messages {
241            return Err(ProfileError::UnsupportedSchema(
242                "message sender namespace".into(),
243            ));
244        }
245        return Ok(BTreeMap::new());
246    }
247    let mut s = c.prepare("SELECT rowid,user_name FROM Name2Id")?;
248    let map = s
249        .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
250        .collect::<std::result::Result<BTreeMap<_, _>, _>>()?;
251    if map.len() > 1_000_000 {
252        return Err(ProfileError::ResourceLimit);
253    }
254    Ok(map)
255}
256fn shard(name: &str) -> bool {
257    let b = name.rsplit('/').next().unwrap_or("");
258    let stem = b.strip_suffix(".db").unwrap_or("");
259    ["message_", "biz_message_"].iter().any(|p| {
260        stem.strip_prefix(p)
261            .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
262    })
263}
264fn validate_name(s: &str) -> Result<()> {
265    if s.is_empty() || s.len() > 4096 || s.contains('\0') {
266        Err(ProfileError::InvalidQuery)
267    } else {
268        Ok(())
269    }
270}
271
272impl ProfileReader {
273    pub fn open(location: ProfileLocation, keys: KeyFile, options: ReadOptions) -> Result<Self> {
274        let snapshot = Snapshot::create(location, keys, options)?;
275        let mut schemas = Vec::new();
276        for db in &snapshot.inventory {
277            if db.state == DatabaseState::Readable {
278                let connection = snapshot.open(&db.relative_path)?;
279                schemas.push(
280                    inspect(&connection, &db.relative_path)
281                        .map_err(|_| ProfileError::UnsupportedSchema(db.relative_path.clone()))?,
282                );
283            }
284        }
285        Ok(Self { snapshot, schemas })
286    }
287    pub fn generation(&self) -> &str {
288        &self.snapshot.generation
289    }
290    pub fn schemas(&self) -> &[SchemaReport] {
291        &self.schemas
292    }
293    pub fn coverage(&self) -> Coverage {
294        Coverage {
295            partial: self
296                .snapshot
297                .inventory
298                .iter()
299                .any(|d| d.state != DatabaseState::Readable),
300            databases: self.snapshot.inventory.clone(),
301        }
302    }
303    pub fn inventory(&self) -> &[DatabaseInventory] {
304        &self.snapshot.inventory
305    }
306    pub fn require_writable(&self) -> Result<()> {
307        Err(ProfileError::WritesUnsupported)
308    }
309    fn role(&self, basename: &str, capability: &str) -> Result<&str> {
310        let candidates: Vec<_> = self
311            .snapshot
312            .inventory
313            .iter()
314            .filter(|v| v.relative_path.rsplit('/').next() == Some(basename))
315            .collect();
316        if candidates.len() != 1 {
317            return Err(ProfileError::UnsupportedSchema(basename.into()));
318        }
319        let db = candidates[0];
320        match db.state {
321            DatabaseState::KeyUnavailable => {
322                return Err(ProfileError::KeyUnavailable(db.relative_path.clone()))
323            }
324            DatabaseState::KeyMismatch => {
325                return Err(ProfileError::KeyMismatch(db.relative_path.clone()))
326            }
327            _ => {}
328        }
329        if !self
330            .schemas
331            .iter()
332            .any(|s| s.database == db.relative_path && s.supports(capability))
333        {
334            return Err(ProfileError::UnsupportedSchema(db.relative_path.clone()));
335        }
336        Ok(&db.relative_path)
337    }
338    fn page<T>(
339        &self,
340        mut items: Vec<T>,
341        n: usize,
342        q: &str,
343        key: impl Fn(&T) -> CursorKey,
344    ) -> Result<Page<T>> {
345        let more = items.len() > n;
346        items.truncate(n);
347        let next_cursor = if more {
348            items
349                .last()
350                .map(|v| encode_cursor(self.generation(), q, key(v)))
351                .transpose()?
352        } else {
353            None
354        };
355        Ok(Page {
356            items,
357            next_cursor,
358            source_generation: self.generation().into(),
359            coverage: self.coverage(),
360        })
361    }
362    pub fn contacts(&self, q: &ListQuery) -> Result<Page<Contact>> {
363        let n = limit(q.limit)?;
364        let hash = query_hash("contacts", &q.search)?;
365        let after = match decode_cursor(q.cursor.as_deref(), self.generation(), &hash)? {
366            None => String::new(),
367            Some(CursorKey::Name { name }) => name,
368            _ => return Err(ProfileError::InvalidCursor),
369        };
370        let c = self.snapshot.open(self.role("contact.db", "contacts")?)?;
371        let mut s=c.prepare("SELECT * FROM contact WHERE username>?1 AND (?2 IS NULL OR instr(username,?2)>0 OR instr(coalesce(nick_name,''),?2)>0 OR instr(coalesce(remark,''),?2)>0 OR instr(coalesce(alias,''),?2)>0) ORDER BY username LIMIT ?3")?;
372        let records = s
373            .query_map(params![after, q.search, n + 1], raw)?
374            .collect::<std::result::Result<Vec<_>, _>>()?;
375        let items = records
376            .into_iter()
377            .map(contact)
378            .collect::<Result<Vec<_>>>()?;
379        self.page(items, n, &hash, |v| CursorKey::Name {
380            name: v.username.clone(),
381        })
382    }
383    pub fn thread_exists(&self, username: &str) -> Result<bool> {
384        validate_name(username)?;
385        let c = self.snapshot.open(self.role("session.db", "threads")?)?;
386        Ok(c.query_row(
387            "SELECT EXISTS(SELECT 1 FROM SessionTable WHERE username=?1)",
388            [username],
389            |row| row.get(0),
390        )?)
391    }
392    pub fn threads(&self, q: &ListQuery) -> Result<Page<Thread>> {
393        let n = limit(q.limit)?;
394        let hash = query_hash("threads", &q.search)?;
395        let after = match decode_cursor(q.cursor.as_deref(), self.generation(), &hash)? {
396            None => None,
397            Some(CursorKey::Thread { time, name }) => Some((time, name)),
398            _ => return Err(ProfileError::InvalidCursor),
399        };
400        let c = self.snapshot.open(self.role("session.db", "threads")?)?;
401        let mut s=c.prepare("SELECT * FROM SessionTable WHERE (?1 IS NULL OR coalesce(sort_timestamp,0)<?1 OR (coalesce(sort_timestamp,0)=?1 AND username>?2)) AND (?3 IS NULL OR instr(username,?3)>0) ORDER BY coalesce(sort_timestamp,0) DESC,username ASC LIMIT ?4")?;
402        let records = s
403            .query_map(
404                params![
405                    after.as_ref().map(|a| a.0),
406                    after.as_ref().map(|a| &a.1),
407                    q.search,
408                    n + 1
409                ],
410                raw,
411            )?
412            .collect::<std::result::Result<Vec<_>, _>>()?;
413        let labels = self.labels()?;
414        let items = records
415            .into_iter()
416            .map(|r| {
417                let username = text(&r, "username").ok_or(ProfileError::InvalidMetadata)?;
418                Ok(Thread {
419                    label: labels
420                        .get(&username)
421                        .cloned()
422                        .unwrap_or_else(|| username.clone()),
423                    username,
424                    unread_count: num(&r, "unread_count"),
425                    hidden: num(&r, "is_hidden").map(|n| n != 0),
426                    sort_timestamp: num(&r, "sort_timestamp"),
427                    last_timestamp: num(&r, "last_timestamp"),
428                    raw_fields: r,
429                })
430            })
431            .collect::<Result<Vec<_>>>()?;
432        self.page(items, n, &hash, |v| CursorKey::Thread {
433            time: v.sort_timestamp.unwrap_or(0),
434            name: v.username.clone(),
435        })
436    }
437    pub fn labels(&self) -> Result<BTreeMap<String, String>> {
438        // Contact coverage can be unavailable without preventing thread reads.
439        let Ok(rel) = self.role("contact.db", "contacts") else {
440            return Ok(BTreeMap::new());
441        };
442        let c = self.snapshot.open(rel)?;
443        let mut s = c.prepare("SELECT username,remark,nick_name FROM contact LIMIT 1000001")?;
444        let mut out = BTreeMap::new();
445        let mut rows = s.query([])?;
446        while let Some(r) = rows.next()? {
447            let name: String = r.get(0)?;
448            let remark: Option<String> = r.get(1)?;
449            let nick: Option<String> = r.get(2)?;
450            out.insert(
451                name.clone(),
452                remark
453                    .filter(|s| !s.is_empty())
454                    .or_else(|| nick.filter(|s| !s.is_empty()))
455                    .unwrap_or(name),
456            );
457            if out.len() > 1_000_000 {
458                return Err(ProfileError::ResourceLimit);
459            }
460        }
461        Ok(out)
462    }
463    /// Capture complete history membership in one pass per shard. This reads
464    /// only IDs/timestamps; callers resolve delivered content with
465    /// `messages_by_ids` against this same snapshot. No state is cached across
466    /// snapshots, and incompatible message tables remain errors.
467    pub fn message_references(
468        &self,
469        chat: Option<&str>,
470        max_items: usize,
471    ) -> Result<Vec<MessageReference>> {
472        if max_items == 0 || max_items > 100_000 {
473            return Err(ProfileError::InvalidQuery);
474        }
475        if let Some(chat) = chat {
476            validate_name(chat)?;
477        }
478        let target = chat.map(|s| format!("Msg_{:x}", Md5::digest(s.as_bytes())));
479        let mut names: BTreeSet<String> = self.labels()?.into_keys().collect();
480        if let Some(chat) = chat {
481            names.insert(chat.to_owned());
482        }
483        let mut records = Vec::new();
484        let mut found = false;
485        for db in self
486            .snapshot
487            .inventory
488            .iter()
489            .filter(|v| shard(&v.relative_path))
490        {
491            if db.state != DatabaseState::Readable {
492                continue;
493            }
494            let schema = self
495                .schemas
496                .iter()
497                .find(|s| s.database == db.relative_path)
498                .ok_or(ProfileError::InvalidMetadata)?;
499            let c = self.snapshot.open(&db.relative_path)?;
500            names.extend(name_map(&c)?.into_values());
501            for table in schema.tables.iter().filter(|t| {
502                message_table(&t.name)
503                    && target
504                        .as_ref()
505                        .is_none_or(|wanted| wanted.eq_ignore_ascii_case(&t.name))
506            }) {
507                require_message_columns(table, &db.relative_path)?;
508                found = true;
509                let sql = format!(
510                    "SELECT local_id,create_time FROM {} LIMIT ?1",
511                    quote_identifier(&table.name)
512                );
513                let mut statement = c.prepare(&sql)?;
514                let mut rows = statement.query([max_items + 1 - records.len()])?;
515                while let Some(row) = rows.next()? {
516                    let local: i64 = row.get(0)?;
517                    let time: i64 = row.get(1)?;
518                    records.push((time, db.relative_path.clone(), table.name.clone(), local));
519                    if records.len() > max_items {
520                        return Err(ProfileError::ResourceLimit);
521                    }
522                }
523            }
524        }
525        if !found
526            && self
527                .snapshot
528                .inventory
529                .iter()
530                .any(|d| shard(&d.relative_path) && d.state != DatabaseState::Readable)
531        {
532            return Err(ProfileError::KeyUnavailable("message shards".into()));
533        }
534        let mut hashes: BTreeMap<String, Vec<String>> = BTreeMap::new();
535        for name in names {
536            hashes
537                .entry(format!("{:x}", Md5::digest(name.as_bytes())))
538                .or_default()
539                .push(name);
540        }
541        records.sort_unstable_by(|a, b| b.cmp(a));
542        Ok(records
543            .into_iter()
544            .map(|(_, database, table, local)| {
545                let peer = hashes
546                    .get(&table[4..].to_ascii_lowercase())
547                    .filter(|v| v.len() == 1)
548                    .map(|v| v[0].clone());
549                MessageReference {
550                    id: SourceMessageId {
551                        database,
552                        table,
553                        local_id: local.to_string(),
554                    },
555                    peer,
556                }
557            })
558            .collect())
559    }
560
561    /// Read the latest saved message for each requested conversation in one
562    /// snapshot. One compound query per shard selects one indexed row per
563    /// conversation; no point lookups or older-body decoding are needed.
564    pub fn latest_messages(
565        &self,
566        chats: &[String],
567        own_username: Option<&str>,
568    ) -> Result<BTreeMap<String, Message>> {
569        if chats.len() > 200 {
570            return Err(ProfileError::InvalidQuery);
571        }
572        for chat in chats {
573            validate_name(chat)?;
574        }
575        if let Some(own) = own_username {
576            validate_name(own)?;
577        }
578        if chats.is_empty() {
579            return Ok(BTreeMap::new());
580        }
581        let shards: Vec<_> = self
582            .inventory()
583            .iter()
584            .filter(|db| shard(&db.relative_path))
585            .collect();
586        if shards.is_empty() || shards.iter().any(|db| db.state != DatabaseState::Readable) {
587            return Err(ProfileError::KeyUnavailable("message shards".into()));
588        }
589        // The selected conversation already supplies the peer identity, so do
590        // not rescan the contact namespace for every preview's table hash.
591        let labels = BTreeMap::new();
592        let mut result: BTreeMap<String, Message> = BTreeMap::new();
593        for db in shards {
594            let schema = self
595                .schemas
596                .iter()
597                .find(|s| s.database == db.relative_path)
598                .ok_or(ProfileError::InvalidMetadata)?;
599            let c = self.snapshot.open(&db.relative_path)?;
600            let names = name_map(&c)?;
601            let mut selected = Vec::new();
602            let mut columns = BTreeSet::new();
603            for chat in chats {
604                let table = format!("Msg_{:x}", Md5::digest(chat.as_bytes()));
605                let Some(table) = schema
606                    .tables
607                    .iter()
608                    .find(|t| t.name.eq_ignore_ascii_case(&table))
609                else {
610                    continue;
611                };
612                if ![
613                    "local_id",
614                    "server_id",
615                    "local_type",
616                    "create_time",
617                    "real_sender_id",
618                    "message_content",
619                    "source",
620                    "WCDB_CT_message_content",
621                    "WCDB_CT_source",
622                ]
623                .iter()
624                .all(|name| table.columns.iter().any(|c| c.name == *name))
625                {
626                    return Err(ProfileError::UnsupportedSchema(db.relative_path.clone()));
627                }
628                columns.extend(table.columns.iter().map(|column| column.name.clone()));
629                selected.push((chat, table));
630            }
631            if selected.is_empty() {
632                continue;
633            }
634            // Preserve unknown columns even when conversation tables differ.
635            // A private discriminator must not collide with a profile column.
636            let mut discriminator = "__agent_wechat_preview".to_string();
637            while columns.contains(&discriminator) {
638                discriminator.push('_');
639            }
640            let sql = selected.iter().enumerate().map(|(index, (_, table))| {
641                let projection = columns.iter().map(|column| {
642                    if table.columns.iter().any(|c| c.name == *column) {
643                        quote_identifier(column)
644                    } else {
645                        format!("NULL AS {}", quote_identifier(column))
646                    }
647                }).collect::<Vec<_>>().join(",");
648                format!("SELECT {index} AS {},{projection} FROM (SELECT * FROM {} ORDER BY create_time DESC,local_id DESC LIMIT 1)", quote_identifier(&discriminator), quote_identifier(&table.name))
649            }).collect::<Vec<_>>().join(" UNION ALL ");
650            #[cfg(test)]
651            batch_lookup_tests::record_batch_query();
652            let mut statement = c.prepare(&sql)?;
653            let mut rows = statement.query([])?;
654            while let Some(row) = rows.next()? {
655                let index: usize = row.get(0)?;
656                let (chat, table) = selected.get(index).ok_or(ProfileError::InvalidMetadata)?;
657                let mut fields = raw(row)?;
658                fields.retain(|name, _| table.columns.iter().any(|column| column.name == *name));
659                let local = num(&fields, "local_id")
660                    .filter(|id| *id > 0)
661                    .ok_or(ProfileError::InvalidMetadata)?;
662                let message = self.message_from_fields_with_peer(
663                    fields,
664                    &SourceMessageId {
665                        database: db.relative_path.clone(),
666                        table: table.name.clone(),
667                        local_id: local.to_string(),
668                    },
669                    own_username,
670                    &labels,
671                    &names,
672                    Some(chat),
673                )?;
674                if result
675                    .get(*chat)
676                    .is_none_or(|old| message_order(&message) > message_order(old))
677                {
678                    result.insert((*chat).clone(), message);
679                }
680            }
681        }
682        Ok(result)
683    }
684
685    pub fn messages(&self, q: &MessageQuery) -> Result<Page<Message>> {
686        let n = limit(q.limit)?;
687        if let Some(chat) = &q.chat {
688            validate_name(chat)?;
689        }
690        if let Some(own) = &q.own_username {
691            validate_name(own)?;
692        }
693        let hash = query_hash("messages", &(&q.chat, &q.own_username, q.message_type))?;
694        let after = match decode_cursor(q.cursor.as_deref(), self.generation(), &hash)? {
695            None => None,
696            Some(CursorKey::Message {
697                time,
698                database,
699                table,
700                local,
701            }) if message_table(&table) => Some((time, database, table, local)),
702            _ => return Err(ProfileError::InvalidCursor),
703        };
704        let target = q
705            .chat
706            .as_ref()
707            .map(|s| format!("Msg_{:x}", Md5::digest(s.as_bytes())));
708        let mut all_names: BTreeSet<String> = self.labels()?.into_keys().collect();
709        for db in self
710            .snapshot
711            .inventory
712            .iter()
713            .filter(|v| shard(&v.relative_path) && v.state == DatabaseState::Readable)
714        {
715            let c = self.snapshot.open(&db.relative_path)?;
716            all_names.extend(name_map(&c)?.into_values());
717        }
718        if let Some(chat) = &q.chat {
719            all_names.insert(chat.clone());
720        }
721        let mut hashes: BTreeMap<String, Vec<String>> = BTreeMap::new();
722        for name in all_names {
723            hashes
724                .entry(format!("{:x}", Md5::digest(name.as_bytes())))
725                .or_default()
726                .push(name);
727        }
728        let mut items = Vec::new();
729        let mut found = false;
730        for db in self
731            .snapshot
732            .inventory
733            .iter()
734            .filter(|v| shard(&v.relative_path))
735        {
736            if db.state != DatabaseState::Readable {
737                continue;
738            }
739            let schema = self
740                .schemas
741                .iter()
742                .find(|s| s.database == db.relative_path)
743                .ok_or(ProfileError::InvalidMetadata)?;
744            let c = self.snapshot.open(&db.relative_path)?;
745            let names = name_map(&c)?;
746            for table in schema.tables.iter().filter(|t| {
747                message_table(&t.name)
748                    && target
749                        .as_ref()
750                        .is_none_or(|wanted| wanted.eq_ignore_ascii_case(&t.name))
751            }) {
752                let table_name = &table.name;
753                require_message_columns(table, &db.relative_path)?;
754                found = true;
755                let (max_time, equal_mode, local_bound) =
756                    if let Some((time, ref database, ref last_table, local)) = after {
757                        let cmp = (&db.relative_path, table_name).cmp(&(database, last_table));
758                        (
759                            Some(time),
760                            if cmp.is_lt() {
761                                1
762                            } else if cmp.is_gt() {
763                                -1
764                            } else {
765                                0
766                            },
767                            Some(local),
768                        )
769                    } else {
770                        (None, 0, None)
771                    };
772                let sql=format!("SELECT * FROM {} WHERE (?1 IS NULL OR create_time<?1 OR (create_time=?1 AND (?2=1 OR (?2=0 AND local_id<?3)))) AND (?4 IS NULL OR (local_type & 2147483647)=?4) ORDER BY create_time DESC,local_id DESC LIMIT ?5",quote_identifier(table_name));
773                let mut s = c.prepare(&sql)?;
774                let records = s
775                    .query_map(
776                        params![max_time, equal_mode, local_bound, q.message_type, n + 1],
777                        raw,
778                    )?
779                    .collect::<std::result::Result<Vec<_>, _>>()?;
780                let peer = hashes
781                    .get(&table_name[4..].to_ascii_lowercase())
782                    .filter(|v| v.len() == 1)
783                    .map(|v| v[0].clone());
784                for r in records {
785                    let local = num(&r, "local_id").ok_or(ProfileError::InvalidMetadata)?;
786                    let signed = num(&r, "server_id");
787                    let sender = num(&r, "real_sender_id").and_then(|id| names.get(&id).cloned());
788                    let outgoing = q
789                        .own_username
790                        .as_ref()
791                        .and_then(|own| sender.as_ref().map(|s| s == own));
792                    let local_type = num(&r, "local_type").ok_or(ProfileError::InvalidMetadata)?;
793                    items.push(Message {
794                        id: SourceMessageId {
795                            database: db.relative_path.clone(),
796                            table: table_name.clone(),
797                            local_id: local.to_string(),
798                        },
799                        server_message_id: signed
800                            .filter(|v| *v != 0)
801                            .map(|v| (v as u64).to_string()),
802                        server_id_signed: signed.map(|n| n.to_string()),
803                        peer: peer.clone(),
804                        sender,
805                        outgoing,
806                        message_type: local_type & 0x7fff_ffff,
807                        local_type: local_type.to_string(),
808                        create_time: num(&r, "create_time").ok_or(ProfileError::InvalidMetadata)?,
809                        sort_seq: num(&r, "sort_seq").map(|n| n.to_string()),
810                        content: decode(
811                            &r,
812                            "message_content",
813                            "WCDB_CT_message_content",
814                            self.snapshot.options.max_blob_bytes,
815                        )?,
816                        source: decode(
817                            &r,
818                            "source",
819                            "WCDB_CT_source",
820                            self.snapshot.options.max_blob_bytes,
821                        )?,
822                        raw_fields: r,
823                    });
824                }
825                items.sort_by(|a, b| message_order(b).cmp(&message_order(a)));
826                items.truncate(n + 1);
827            }
828        }
829        if !found
830            && self
831                .snapshot
832                .inventory
833                .iter()
834                .any(|d| shard(&d.relative_path) && d.state != DatabaseState::Readable)
835        {
836            return Err(ProfileError::KeyUnavailable("message shards".into()));
837        }
838        self.page(items, n, &hash, |v| CursorKey::Message {
839            time: v.create_time,
840            database: v.id.database.clone(),
841            table: v.id.table.clone(),
842            local: v.id.local_id.parse().unwrap_or(0),
843        })
844    }
845
846    /// Read one source-qualified observation without paging through history.
847    /// Resolve sender numbers only through this database's own Name2Id map.
848    pub fn message(&self, id: &SourceMessageId, own_username: Option<&str>) -> Result<Message> {
849        if !message_table(&id.table) || !shard(&id.database) {
850            return Err(ProfileError::InvalidQuery);
851        }
852        let c = self.snapshot.open(&id.database)?;
853        let names = name_map(&c)?;
854        let labels = self.labels()?;
855        self.message_from_connection(&c, id, own_username, &labels, &names)
856    }
857
858    /// Resolve at most one page without decoding unrelated history. Missing rows
859    /// are omitted; unreadable databases and incompatible records remain errors.
860    /// Source-qualified IDs preserve each shard's own sender namespace.
861    pub fn messages_by_ids(
862        &self,
863        ids: &[SourceMessageId],
864        own_username: Option<&str>,
865    ) -> Result<Vec<Message>> {
866        if ids.len() > 200 {
867            return Err(ProfileError::InvalidQuery);
868        }
869        if ids.is_empty() {
870            return Ok(Vec::new());
871        }
872        if let Some(own) = own_username {
873            validate_name(own)?;
874        }
875        let mut groups: BTreeMap<&str, Vec<&SourceMessageId>> = BTreeMap::new();
876        for id in ids {
877            if !message_table(&id.table) || !shard(&id.database) {
878                return Err(ProfileError::InvalidQuery);
879            }
880            groups.entry(&id.database).or_default().push(id);
881        }
882        let labels = self.labels()?;
883        let mut result = Vec::with_capacity(ids.len());
884        for (database, group) in groups {
885            let c = self.snapshot.open(database)?;
886            let names = name_map(&c)?;
887            for id in group {
888                match self.message_from_connection(&c, id, own_username, &labels, &names) {
889                    Ok(message) => result.push(message),
890                    Err(ProfileError::NotCached) => {}
891                    Err(error) => return Err(error),
892                }
893            }
894        }
895        Ok(result)
896    }
897
898    /// Resolve an unsigned server ID in one conversation only. Return up to two
899    /// observations so callers can reject ambiguous IDs across shards.
900    pub fn messages_by_server_id(
901        &self,
902        chat: &str,
903        server_id: &str,
904        own_username: Option<&str>,
905    ) -> Result<Vec<Message>> {
906        validate_name(chat)?;
907        let server = server_id
908            .parse::<u64>()
909            .map_err(|_| ProfileError::InvalidQuery)?;
910        if server == 0 || server.to_string() != server_id {
911            return Err(ProfileError::InvalidQuery);
912        }
913        let table_name = format!("Msg_{:x}", Md5::digest(chat.as_bytes()));
914        let labels = self.labels()?;
915        let mut result = Vec::new();
916        let shards: Vec<_> = self.schemas.iter().filter(|s| shard(&s.database)).collect();
917        if shards.len() > 128 {
918            return Err(ProfileError::InvalidQuery);
919        }
920        if self
921            .coverage()
922            .databases
923            .iter()
924            .any(|d| shard(&d.relative_path) && d.state != DatabaseState::Readable)
925        {
926            return Err(ProfileError::KeyUnavailable("message shards".into()));
927        }
928        for schema in shards {
929            let Some(table) = schema.tables.iter().find(|t| t.name == table_name) else {
930                continue;
931            };
932            if !["local_id", "server_id"]
933                .iter()
934                .all(|name| table.columns.iter().any(|c| c.name == *name))
935            {
936                return Err(ProfileError::UnsupportedSchema(schema.database.clone()));
937            }
938            let c = self.snapshot.open(&schema.database)?;
939            let names = name_map(&c)?;
940            let mut statement = c.prepare(&format!(
941                "SELECT local_id FROM {} WHERE server_id=?1 LIMIT 2",
942                quote_identifier(&table_name)
943            ))?;
944            let ids = statement
945                .query_map([server as i64], |row| row.get::<_, i64>(0))?
946                .collect::<std::result::Result<Vec<_>, _>>()?;
947            for local in ids {
948                let mut message = self.message_from_connection(
949                    &c,
950                    &SourceMessageId {
951                        database: schema.database.clone(),
952                        table: table_name.clone(),
953                        local_id: local.to_string(),
954                    },
955                    own_username,
956                    &labels,
957                    &names,
958                )?;
959                // The exact table hash proves the peer even when Name2Id no
960                // longer contains this conversation's label.
961                message.peer = Some(chat.into());
962                result.push(message);
963                if result.len() == 2 {
964                    return Ok(result);
965                }
966            }
967        }
968        Ok(result)
969    }
970
971    /// Read quoted originals for one page from this captured snapshot. Group by
972    /// conversation/shard, decode only matching rows, and retain at most two
973    /// observations per identity just like the single-reference lookup. The
974    /// visitor consumes each row before the next, avoiding a page of large bodies.
975    pub fn visit_messages_by_server_ids<E>(
976        &self,
977        targets: &[(String, String)],
978        own_username: Option<&str>,
979        mut visit: impl FnMut(Message) -> std::result::Result<(), E>,
980    ) -> std::result::Result<(), E>
981    where
982        E: From<ProfileError> + From<rusqlite::Error>,
983    {
984        if targets.len() > 200 {
985            return Err(ProfileError::InvalidQuery.into());
986        }
987        if targets.is_empty() {
988            return Ok(());
989        }
990        if let Some(own) = own_username {
991            validate_name(own)?;
992        }
993        let mut groups: BTreeMap<&str, BTreeSet<i64>> = BTreeMap::new();
994        for (chat, server) in targets {
995            validate_name(chat)?;
996            let number = server
997                .parse::<u64>()
998                .map_err(|_| ProfileError::InvalidQuery)?;
999            if number == 0 || number.to_string() != *server {
1000                return Err(ProfileError::InvalidQuery.into());
1001            }
1002            groups.entry(chat).or_default().insert(number as i64);
1003        }
1004        let shards: Vec<_> = self.schemas.iter().filter(|s| shard(&s.database)).collect();
1005        if shards.len() > 128 {
1006            return Err(ProfileError::InvalidQuery.into());
1007        }
1008        if self
1009            .coverage()
1010            .databases
1011            .iter()
1012            .any(|d| shard(&d.relative_path) && d.state != DatabaseState::Readable)
1013        {
1014            return Err(ProfileError::KeyUnavailable("message shards".into()).into());
1015        }
1016        let labels = self.labels()?;
1017        let mut counts: BTreeMap<(&str, i64), usize> = BTreeMap::new();
1018        for schema in shards {
1019            let tables: Vec<_> = groups
1020                .iter()
1021                .filter_map(|(&chat, servers)| {
1022                    let table_name = format!("Msg_{:x}", Md5::digest(chat.as_bytes()));
1023                    let table = schema.tables.iter().find(|t| t.name == table_name)?;
1024                    let pending: Vec<_> = servers
1025                        .iter()
1026                        .copied()
1027                        .filter(|server| counts.get(&(chat, *server)).copied().unwrap_or(0) < 2)
1028                        .collect();
1029                    (!pending.is_empty()).then_some((chat, table, pending))
1030                })
1031                .collect();
1032            if tables.is_empty() {
1033                continue;
1034            }
1035            let c = self.snapshot.open(&schema.database)?;
1036            let names = name_map(&c)?;
1037            for (chat, table, servers) in tables {
1038                require_message_columns(table, &schema.database)?;
1039                let table_name = quote_identifier(&table.name);
1040                let placeholders = vec!["?"; servers.len()].join(",");
1041                // The window bounds duplicate observations without fetching or
1042                // decoding unrelated bodies, even if a profile has duplicate IDs.
1043                let sql = format!("SELECT * FROM {table_name} WHERE local_id IN (SELECT local_id FROM (SELECT local_id,ROW_NUMBER() OVER (PARTITION BY server_id ORDER BY local_id) AS quote_rank FROM {table_name} WHERE server_id IN ({placeholders})) WHERE quote_rank<=2) ORDER BY server_id,local_id");
1044                let mut statement = c.prepare(&sql)?;
1045                #[cfg(test)]
1046                batch_lookup_tests::record_batch_query();
1047                let mut rows = statement.query(rusqlite::params_from_iter(servers))?;
1048                while let Some(row) = rows.next()? {
1049                    let server: i64 = row.get("server_id")?;
1050                    let count = counts.entry((chat, server)).or_default();
1051                    if *count == 2 {
1052                        continue;
1053                    }
1054                    let local: i64 = row.get("local_id")?;
1055                    if local <= 0 {
1056                        return Err(ProfileError::InvalidQuery.into());
1057                    }
1058                    let id = SourceMessageId {
1059                        database: schema.database.clone(),
1060                        table: table.name.clone(),
1061                        local_id: local.to_string(),
1062                    };
1063                    let mut message =
1064                        self.message_from_fields(raw(row)?, &id, own_username, &labels, &names)?;
1065                    message.peer = Some(chat.into());
1066                    visit(message)?;
1067                    *count += 1;
1068                }
1069            }
1070        }
1071        Ok(())
1072    }
1073
1074    fn message_from_connection(
1075        &self,
1076        c: &Connection,
1077        id: &SourceMessageId,
1078        own_username: Option<&str>,
1079        labels: &BTreeMap<String, String>,
1080        names: &BTreeMap<i64, String>,
1081    ) -> Result<Message> {
1082        if !message_table(&id.table) || !shard(&id.database) {
1083            return Err(ProfileError::InvalidQuery);
1084        }
1085        if let Some(own) = own_username {
1086            validate_name(own)?;
1087        }
1088        let local = id
1089            .local_id
1090            .parse::<i64>()
1091            .map_err(|_| ProfileError::InvalidQuery)?;
1092        if local <= 0 || local.to_string() != id.local_id {
1093            return Err(ProfileError::InvalidQuery);
1094        }
1095        let table = self
1096            .schemas
1097            .iter()
1098            .find(|s| s.database == id.database)
1099            .and_then(|s| s.tables.iter().find(|t| t.name == id.table))
1100            .ok_or(ProfileError::InvalidQuery)?;
1101        if ![
1102            "local_id",
1103            "server_id",
1104            "local_type",
1105            "create_time",
1106            "real_sender_id",
1107            "message_content",
1108            "source",
1109            "WCDB_CT_message_content",
1110            "WCDB_CT_source",
1111        ]
1112        .iter()
1113        .all(|n| table.columns.iter().any(|c| c.name == *n))
1114        {
1115            return Err(ProfileError::UnsupportedSchema(id.database.clone()));
1116        }
1117        #[cfg(test)]
1118        batch_lookup_tests::record_point_query();
1119        let r = c
1120            .query_row(
1121                &format!(
1122                    "SELECT * FROM {} WHERE local_id=?1",
1123                    quote_identifier(&id.table)
1124                ),
1125                [local],
1126                raw,
1127            )
1128            .optional()?
1129            .ok_or(ProfileError::NotCached)?;
1130        self.message_from_fields(r, id, own_username, labels, names)
1131    }
1132
1133    fn message_from_fields(
1134        &self,
1135        r: RawFields,
1136        id: &SourceMessageId,
1137        own_username: Option<&str>,
1138        labels: &BTreeMap<String, String>,
1139        names: &BTreeMap<i64, String>,
1140    ) -> Result<Message> {
1141        self.message_from_fields_with_peer(r, id, own_username, labels, names, None)
1142    }
1143
1144    fn message_from_fields_with_peer(
1145        &self,
1146        r: RawFields,
1147        id: &SourceMessageId,
1148        own_username: Option<&str>,
1149        labels: &BTreeMap<String, String>,
1150        names: &BTreeMap<i64, String>,
1151        known_peer: Option<&str>,
1152    ) -> Result<Message> {
1153        let peer = if let Some(peer) = known_peer {
1154            Some(peer.to_owned())
1155        } else {
1156            let mut candidates = labels
1157                .keys()
1158                .cloned()
1159                .chain(names.values().cloned())
1160                .filter(|name| {
1161                    format!("{:x}", Md5::digest(name.as_bytes()))
1162                        .eq_ignore_ascii_case(&id.table[4..])
1163                })
1164                .collect::<BTreeSet<_>>();
1165            if candidates.len() == 1 {
1166                candidates.pop_first()
1167            } else {
1168                None
1169            }
1170        };
1171        let signed = num(&r, "server_id");
1172        let sender = num(&r, "real_sender_id").and_then(|n| names.get(&n).cloned());
1173        let outgoing = own_username.and_then(|own| sender.as_ref().map(|s| s == own));
1174        let local_type = num(&r, "local_type").ok_or(ProfileError::InvalidMetadata)?;
1175        Ok(Message {
1176            id: id.clone(),
1177            server_message_id: signed.filter(|v| *v != 0).map(|v| (v as u64).to_string()),
1178            server_id_signed: signed.map(|n| n.to_string()),
1179            peer,
1180            sender,
1181            outgoing,
1182            message_type: local_type & 0x7fff_ffff,
1183            local_type: local_type.to_string(),
1184            create_time: num(&r, "create_time").ok_or(ProfileError::InvalidMetadata)?,
1185            sort_seq: num(&r, "sort_seq").map(|n| n.to_string()),
1186            content: decode(
1187                &r,
1188                "message_content",
1189                "WCDB_CT_message_content",
1190                self.snapshot.options.max_blob_bytes,
1191            )?,
1192            source: decode(
1193                &r,
1194                "source",
1195                "WCDB_CT_source",
1196                self.snapshot.options.max_blob_bytes,
1197            )?,
1198            raw_fields: r,
1199        })
1200    }
1201    pub fn groups(&self, q: &ListQuery) -> Result<Page<Group>> {
1202        let n = limit(q.limit)?;
1203        let hash = query_hash("groups", &q.search)?;
1204        let after = match decode_cursor(q.cursor.as_deref(), self.generation(), &hash)? {
1205            None => String::new(),
1206            Some(CursorKey::Name { name }) => name,
1207            _ => return Err(ProfileError::InvalidCursor),
1208        };
1209        let c = self.snapshot.open(self.role("contact.db", "groups")?)?;
1210        let mut s=c.prepare("SELECT * FROM chat_room WHERE username>?1 AND (?2 IS NULL OR instr(username,?2)>0) ORDER BY username LIMIT ?3")?;
1211        let records = s
1212            .query_map(params![after, q.search, n + 1], raw)?
1213            .collect::<std::result::Result<Vec<_>, _>>()?;
1214        let labels = self.labels()?;
1215        let mut items = Vec::new();
1216        for r in records {
1217            let username = text(&r, "username").ok_or(ProfileError::InvalidMetadata)?;
1218            let id = num(&r, "id").ok_or(ProfileError::InvalidMetadata)?;
1219            let announcement = c
1220                .query_row(
1221                    "SELECT username_,announcement_ FROM chat_room_info_detail WHERE room_id_=?1",
1222                    [id],
1223                    |r| Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?)),
1224                )
1225                .optional()?;
1226            if announcement.as_ref().is_some_and(|a| a.0 != username) {
1227                return Err(ProfileError::InvalidMetadata);
1228            }
1229            items.push(Group {
1230                label: labels
1231                    .get(&username)
1232                    .cloned()
1233                    .unwrap_or_else(|| username.clone()),
1234                username,
1235                local_id: id.to_string(),
1236                owner: text(&r, "owner"),
1237                announcement: announcement.and_then(|v| v.1),
1238                raw_fields: r,
1239            });
1240        }
1241        self.page(items, n, &hash, |v| CursorKey::Name {
1242            name: v.username.clone(),
1243        })
1244    }
1245    pub fn group_mention_permissions(
1246        &self,
1247        room: &str,
1248        own: Option<&str>,
1249    ) -> Result<GroupMentionPermissions> {
1250        validate_name(room)?;
1251        let c = self.snapshot.open(self.role("contact.db", "groups")?)?;
1252        let (id, owner, packed, membership) = c
1253            .query_row(
1254                "SELECT r.id,r.owner,r.ext_buffer,c.is_in_chat_room FROM chat_room r LEFT JOIN contact c ON c.username=r.username WHERE r.username=?1",
1255                [room],
1256                |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Option<String>>(1)?, r.get::<_, Option<Vec<u8>>>(2)?, r.get::<_, Option<i64>>(3)?)),
1257            )
1258            .optional()?
1259            .ok_or(ProfileError::NotCached)?;
1260        let administrator_ids = packed.as_deref().map(room_administrators).transpose()?;
1261        let can_mention_all = if membership == Some(2) {
1262            // Official self-leave can retain the former owner/admin bytes.
1263            Some(false)
1264        } else if let Some(own) = own {
1265            validate_name(own)?;
1266            let is_member: bool = c.query_row(
1267                "SELECT EXISTS(SELECT 1 FROM chatroom_member m JOIN contact c ON c.id=m.member_id WHERE m.room_id=?1 AND c.username=?2)",
1268                params![id, own],
1269                |r| r.get(0),
1270            )?;
1271            if !is_member {
1272                None
1273            } else if owner.as_deref() == Some(own) {
1274                Some(true)
1275            } else {
1276                administrator_ids.as_ref().and_then(|ids| {
1277                    if ids.iter().any(|id| id == own) {
1278                        Some(true)
1279                    } else {
1280                        owner.as_ref().filter(|id| !id.is_empty()).map(|_| false)
1281                    }
1282                })
1283            }
1284        } else {
1285            None
1286        };
1287        Ok(GroupMentionPermissions {
1288            administrator_ids,
1289            can_mention_all,
1290        })
1291    }
1292    pub fn group_members(&self, room: &str, q: &ListQuery) -> Result<Page<GroupMember>> {
1293        validate_name(room)?;
1294        let n = limit(q.limit)?;
1295        let hash = query_hash("members", &(room, &q.search))?;
1296        let after = match decode_cursor(q.cursor.as_deref(), self.generation(), &hash)? {
1297            None => String::new(),
1298            Some(CursorKey::Name { name }) => name,
1299            _ => return Err(ProfileError::InvalidCursor),
1300        };
1301        let c = self.snapshot.open(self.role("contact.db", "groups")?)?;
1302        let room_row = c
1303            .query_row(
1304                "SELECT id,ext_buffer FROM chat_room WHERE username=?1",
1305                [room],
1306                |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Option<Vec<u8>>>(1)?)),
1307            )
1308            .optional()?
1309            .ok_or(ProfileError::NotCached)?;
1310        let displays = room_displays(room_row.1.as_deref().unwrap_or(&[]))?;
1311        let mut sql_members = BTreeSet::new();
1312        let mut stmt=c.prepare("SELECT contact.username FROM chatroom_member JOIN contact ON contact.id=chatroom_member.member_id WHERE chatroom_member.room_id=?1")?;
1313        for row in stmt.query_map([room_row.0], |r| r.get::<_, String>(0))? {
1314            sql_members.insert(row?);
1315            if sql_members.len() > 100000 {
1316                return Err(ProfileError::ResourceLimit);
1317            }
1318        }
1319        if !displays.is_empty() && displays.keys().cloned().collect::<BTreeSet<_>>() != sql_members
1320        {
1321            return Err(ProfileError::InvalidMetadata);
1322        }
1323        let mut stmt=c.prepare("SELECT c.* FROM contact c JOIN chatroom_member m ON m.member_id=c.id WHERE m.room_id=?1 AND c.username>?2 AND (?3 IS NULL OR instr(c.username,?3)>0 OR instr(coalesce(c.nick_name,''),?3)>0 OR instr(coalesce(c.remark,''),?3)>0) ORDER BY c.username LIMIT ?4")?;
1324        let records = stmt
1325            .query_map(params![room_row.0, after, q.search, n + 1], raw)?
1326            .collect::<std::result::Result<Vec<_>, _>>()?;
1327        let mut items = Vec::new();
1328        for r in records {
1329            let contact = contact(r)?;
1330            let display = displays.get(&contact.username).cloned().flatten();
1331            items.push(group_member(contact, display));
1332        }
1333        self.page(items, n, &hash, |v| CursorKey::Name {
1334            name: v.username.clone(),
1335        })
1336    }
1337    pub fn avatar(&self, username: &str) -> Result<CachedBytes> {
1338        validate_name(username)?;
1339        let rel = self.role("head_image.db", "avatars")?;
1340        let c = self.snapshot.open(rel)?;
1341        let bytes = c
1342            .query_row(
1343                "SELECT image_buffer FROM head_image WHERE username=?1",
1344                [username],
1345                |r| r.get::<_, Vec<u8>>(0),
1346            )
1347            .optional()?
1348            .ok_or(ProfileError::NotCached)?;
1349        let (media_type, _, _) = super::images::validate_image(&bytes)?;
1350        Ok(CachedBytes {
1351            media_type,
1352            sha256: hex::encode(Sha256::digest(&bytes)),
1353            bytes,
1354            source_database: rel.into(),
1355            source_table: "head_image".into(),
1356        })
1357    }
1358    /// Read one exact cached voice record. The supplied media-shard namespace is
1359    /// explicit; its Name2Id is not interchangeable with the message shard.
1360    pub fn cached_voice(
1361        &self,
1362        database: &str,
1363        chat: &str,
1364        local_id: &str,
1365        server_id: Option<&str>,
1366    ) -> Result<CachedBytes> {
1367        validate_name(chat)?;
1368        let local: i64 = local_id.parse().map_err(|_| ProfileError::InvalidQuery)?;
1369        let sid = server_id
1370            .map(|s| s.parse::<u64>().map(|n| n as i64))
1371            .transpose()
1372            .map_err(|_| ProfileError::InvalidQuery)?;
1373        let c = self.snapshot.open(database)?;
1374        if !self
1375            .schemas
1376            .iter()
1377            .any(|s| s.database == database && s.supports("voice"))
1378        {
1379            return Err(ProfileError::UnsupportedSchema(database.into()));
1380        }
1381        let mut s=c.prepare("SELECT voice_data FROM VoiceInfo JOIN Name2Id n ON n.rowid=VoiceInfo.chat_name_id WHERE n.user_name=?1 AND VoiceInfo.local_id=?2 AND (?3 IS NULL OR VoiceInfo.svr_id=?3) AND length(voice_data)>0 LIMIT 2")?;
1382        let mut found = s
1383            .query_map(params![chat, local, sid], |r| r.get::<_, Vec<u8>>(0))?
1384            .collect::<std::result::Result<Vec<_>, _>>()?;
1385        if found.len() != 1 {
1386            return Err(if found.is_empty() {
1387                ProfileError::NotCached
1388            } else {
1389                ProfileError::InvalidQuery
1390            });
1391        }
1392        let bytes = found.remove(0);
1393        Ok(CachedBytes {
1394            media_type: "application/octet-stream".into(),
1395            sha256: hex::encode(Sha256::digest(&bytes)),
1396            bytes,
1397            source_database: database.into(),
1398            source_table: "VoiceInfo".into(),
1399        })
1400    }
1401    pub fn decode_dat(&self, bytes: &[u8]) -> Result<(Vec<u8>, crate::DatLayout)> {
1402        crate::decode_dat_v2(
1403            bytes,
1404            self.snapshot
1405                .keys
1406                .image_dat
1407                .as_ref()
1408                .ok_or(ProfileError::ImageKeyUnavailable)?,
1409        )
1410    }
1411}
1412fn message_order(m: &Message) -> (i64, &str, &str, i64) {
1413    (
1414        m.create_time,
1415        &m.id.database,
1416        &m.id.table,
1417        m.id.local_id.parse().unwrap_or(0),
1418    )
1419}
1420fn require_message_columns(table: &crate::TableSchema, database: &str) -> Result<()> {
1421    let required = [
1422        "local_id",
1423        "server_id",
1424        "local_type",
1425        "create_time",
1426        "real_sender_id",
1427        "message_content",
1428        "source",
1429        "WCDB_CT_message_content",
1430        "WCDB_CT_source",
1431    ];
1432    if required
1433        .iter()
1434        .all(|name| table.columns.iter().any(|c| c.name == *name))
1435    {
1436        Ok(())
1437    } else {
1438        Err(ProfileError::UnsupportedSchema(database.to_owned()))
1439    }
1440}
1441
1442pub(crate) fn protobuf_fields(b: &[u8]) -> Result<Vec<(u32, u8, &[u8])>> {
1443    fn var(b: &[u8], i: &mut usize) -> Result<u64> {
1444        let mut n = 0u64;
1445        for k in 0..10 {
1446            let x = *b.get(*i).ok_or(ProfileError::InvalidMetadata)?;
1447            *i += 1;
1448            if k == 9 && x > 1 {
1449                return Err(ProfileError::InvalidMetadata);
1450            }
1451            n |= u64::from(x & 127) << (7 * k);
1452            if x < 128 {
1453                return Ok(n);
1454            }
1455        }
1456        Err(ProfileError::InvalidMetadata)
1457    }
1458    if b.len() > 16 * 1024 * 1024 {
1459        return Err(ProfileError::ResourceLimit);
1460    }
1461    let mut out = Vec::new();
1462    let mut i = 0;
1463    while i < b.len() {
1464        let tag = var(b, &mut i)?;
1465        let n = (tag >> 3) as u32;
1466        let w = (tag & 7) as u8;
1467        if tag >> 3 == 0 || tag >> 3 >= 1 << 29 {
1468            return Err(ProfileError::InvalidMetadata);
1469        }
1470        let (start, end) = match w {
1471            0 => {
1472                let start = i;
1473                var(b, &mut i)?;
1474                (start, i)
1475            }
1476            1 => (i, i.checked_add(8).ok_or(ProfileError::InvalidMetadata)?),
1477            5 => (i, i.checked_add(4).ok_or(ProfileError::InvalidMetadata)?),
1478            2 => {
1479                let z =
1480                    usize::try_from(var(b, &mut i)?).map_err(|_| ProfileError::InvalidMetadata)?;
1481                (i, i.checked_add(z).ok_or(ProfileError::InvalidMetadata)?)
1482            }
1483            _ => return Err(ProfileError::InvalidMetadata),
1484        };
1485        let value = b.get(start..end).ok_or(ProfileError::InvalidMetadata)?;
1486        i = end;
1487        out.push((n, w, value));
1488        if out.len() > 100000 {
1489            return Err(ProfileError::ResourceLimit);
1490        }
1491    }
1492    Ok(out)
1493}
1494
1495fn room_administrators(b: &[u8]) -> Result<Vec<String>> {
1496    let mut ids = Vec::new();
1497    let mut seen = BTreeSet::new();
1498    // The supported Linux room model stores the administrator vector as
1499    // repeated string field5 of chat_room.ext_buffer, separate from member flags.
1500    for (n, w, v) in protobuf_fields(b)? {
1501        if n != 5 {
1502            continue;
1503        }
1504        if w != 2 {
1505            return Err(ProfileError::InvalidMetadata);
1506        }
1507        let id = std::str::from_utf8(v).map_err(|_| ProfileError::InvalidMetadata)?;
1508        validate_name(id)?;
1509        if !seen.insert(id.to_owned()) || ids.len() >= 10000 {
1510            return Err(ProfileError::InvalidMetadata);
1511        }
1512        ids.push(id.to_owned());
1513    }
1514    Ok(ids)
1515}
1516
1517fn room_displays(b: &[u8]) -> Result<BTreeMap<String, Option<String>>> {
1518    let mut out = BTreeMap::new();
1519    for (n, w, v) in protobuf_fields(b)? {
1520        if n != 1 {
1521            continue;
1522        }
1523        if w != 2 {
1524            return Err(ProfileError::InvalidMetadata);
1525        }
1526        let fields = protobuf_fields(v)?;
1527        let scalar = |number| -> Result<Option<String>> {
1528            let mut values = fields.iter().filter(|(n, _, _)| *n == number);
1529            let one = values.next();
1530            if values.next().is_some() {
1531                return Err(ProfileError::InvalidMetadata);
1532            }
1533            one.map(|(_, w, v)| {
1534                if *w == 2 {
1535                    String::from_utf8(v.to_vec()).map_err(|_| ProfileError::InvalidMetadata)
1536                } else {
1537                    Err(ProfileError::InvalidMetadata)
1538                }
1539            })
1540            .transpose()
1541        };
1542        let username = scalar(1)?.ok_or(ProfileError::InvalidMetadata)?;
1543        let display = scalar(2)?;
1544        if out.insert(username, display).is_some() {
1545            return Err(ProfileError::InvalidMetadata);
1546        }
1547    }
1548    Ok(out)
1549}
1550
1551#[cfg(test)]
1552mod batch_lookup_tests {
1553    use super::*;
1554    use std::cell::Cell;
1555
1556    thread_local! {
1557        static BATCH_QUERIES: Cell<usize> = const { Cell::new(0) };
1558        static POINT_QUERIES: Cell<usize> = const { Cell::new(0) };
1559    }
1560    pub(super) fn record_batch_query() {
1561        BATCH_QUERIES.with(|n| n.set(n.get() + 1));
1562    }
1563    pub(super) fn record_point_query() {
1564        POINT_QUERIES.with(|n| n.set(n.get() + 1));
1565    }
1566
1567    fn fixture() -> (tempfile::TempDir, ProfileReader) {
1568        let temp = tempfile::tempdir().unwrap();
1569        let directory = temp.path().join("account/db_storage/message");
1570        std::fs::create_dir_all(&directory).unwrap();
1571        let table = format!("Msg_{:x}", Md5::digest(b"peer"));
1572        for shard in 0..2 {
1573            let db = Connection::open(directory.join(format!("message_{shard}.db"))).unwrap();
1574            db.execute_batch("CREATE TABLE Name2Id(user_name TEXT PRIMARY KEY,is_session INTEGER)")
1575                .unwrap();
1576            for user in if shard == 0 {
1577                ["self", "peer"]
1578            } else {
1579                ["peer", "self"]
1580            } {
1581                db.execute("INSERT INTO Name2Id VALUES(?1,1)", [user])
1582                    .unwrap();
1583            }
1584            db.execute_batch(&format!("CREATE TABLE {table}(local_id INTEGER PRIMARY KEY,server_id INTEGER,local_type INTEGER,create_time INTEGER,real_sender_id INTEGER,message_content TEXT,source TEXT,WCDB_CT_message_content INTEGER,WCDB_CT_source INTEGER)")).unwrap();
1585            for local in 1..=25 {
1586                db.execute(
1587                    &format!("INSERT INTO {table} VALUES(?1,?2,1,100,?3,?4,'',0,0)"),
1588                    params![
1589                        local,
1590                        shard * 25 + local,
1591                        if shard == 0 { 2 } else { 1 },
1592                        format!("Shard {shard} row {local}")
1593                    ],
1594                )
1595                .unwrap();
1596            }
1597            db.execute(
1598                &format!("INSERT INTO {table} VALUES(1000,-1,1,100,1,'Unsigned ID','',0,0)"),
1599                [],
1600            )
1601            .unwrap();
1602            for local in 2000..2003 {
1603                db.execute(
1604                    &format!("INSERT INTO {table} VALUES(?1,99,1,100,1,'Duplicate','',0,0)"),
1605                    [local],
1606                )
1607                .unwrap();
1608            }
1609        }
1610        let reader = ProfileReader::open(
1611            ProfileLocation::new(temp.path().join("account")).unwrap(),
1612            KeyFile::empty("account"),
1613            ReadOptions {
1614                allow_plaintext: true,
1615                stopped_or_consistent_snapshot: true,
1616                ..Default::default()
1617            },
1618        )
1619        .unwrap();
1620        (temp, reader)
1621    }
1622
1623    #[test]
1624    fn notification_preferences_use_one_query_for_a_full_page() {
1625        let (temp, original) = fixture();
1626        drop(original);
1627        let directory = temp.path().join("account/db_storage/contact");
1628        std::fs::create_dir_all(&directory).unwrap();
1629        let db = Connection::open(directory.join("contact.db")).unwrap();
1630        db.execute_batch(include_str!("layout/contact.db.sql"))
1631            .unwrap();
1632        let chats: Vec<_> = (0..200).map(|n| format!("peer_{n}")).collect();
1633        for (id, chat) in chats.iter().enumerate() {
1634            db.execute(
1635                "INSERT INTO contact(id,username,flag,local_type,delete_flag) VALUES(?1,?2,?3,1,0)",
1636                params![id, chat, if id % 2 == 0 { 3 } else { 515 }],
1637            )
1638            .unwrap();
1639        }
1640        let reader = ProfileReader::open(
1641            ProfileLocation::new(temp.path().join("account")).unwrap(),
1642            KeyFile::empty("account"),
1643            ReadOptions {
1644                allow_plaintext: true,
1645                stopped_or_consistent_snapshot: true,
1646                ..Default::default()
1647            },
1648        )
1649        .unwrap();
1650        BATCH_QUERIES.with(|n| n.set(0));
1651        let states = reader.thread_muted(&chats).unwrap();
1652        assert_eq!(states.len(), 200);
1653        for (id, chat) in chats.iter().enumerate() {
1654            assert_eq!(states[chat], Some(id % 2 != 0));
1655        }
1656        BATCH_QUERIES.with(|n| assert_eq!(n.get(), 1));
1657        assert!(reader.thread_muted(&[]).unwrap().is_empty());
1658        assert!(matches!(
1659            reader.thread_muted(&vec!["peer".into(); 201]),
1660            Err(ProfileError::InvalidQuery)
1661        ));
1662    }
1663
1664    #[test]
1665    fn pin_preferences_use_one_query_and_keep_incomplete_or_special_peers_unknown() {
1666        let (temp, original) = fixture();
1667        drop(original);
1668        let directory = temp.path().join("account/db_storage/contact");
1669        std::fs::create_dir_all(&directory).unwrap();
1670        let db = Connection::open(directory.join("contact.db")).unwrap();
1671        db.execute_batch(include_str!("layout/contact.db.sql"))
1672            .unwrap();
1673        let chats: Vec<_> = (0..200)
1674            .map(|n| {
1675                if n % 2 == 0 {
1676                    format!("peer_{n}")
1677                } else {
1678                    format!("room_{n}@chatroom")
1679                }
1680            })
1681            .collect();
1682        for (id, chat) in chats.iter().enumerate() {
1683            let kind = if id % 2 == 0 { 1 } else { 2 };
1684            // Other flag bits must not affect the independently saved pin bit.
1685            let flag = kind | 0x200 | if id % 4 < 2 { 0x800 } else { 0 };
1686            db.execute(
1687                "INSERT INTO contact(id,username,flag,local_type,delete_flag,is_in_chat_room) VALUES(?1,?2,?3,?4,0,1)",
1688                params![id, chat, flag, kind],
1689            )
1690            .unwrap();
1691        }
1692        db.execute_batch(
1693            "INSERT INTO contact(id,username,flag,local_type,delete_flag,is_in_chat_room) VALUES
1694            (201,'missing_flag',NULL,1,0,1),
1695            (202,'negative_flag',-1,1,0,1),
1696            (203,'wide_flag',4294967296,1,0,1),
1697            (204,'malformed_flag','not a flag',1,0,1),
1698            (205,'not_friend',2048,1,0,1),
1699            (206,'deleted',2049,1,1,1),
1700            (207,'wrong_type',2049,7,0,1),
1701            (208,'left@chatroom',2050,2,0,2),
1702            (209,'not_group@chatroom',2049,1,0,1),
1703            (210,'filehelper',2049,1,0,1),
1704            (211,'weixin',2049,1,0,1),
1705            (212,'gh_service',2049,1,0,1),
1706            (213,'notification_messages',2049,1,0,1);",
1707        )
1708        .unwrap();
1709        let reader = ProfileReader::open(
1710            ProfileLocation::new(temp.path().join("account")).unwrap(),
1711            KeyFile::empty("account"),
1712            ReadOptions {
1713                allow_plaintext: true,
1714                stopped_or_consistent_snapshot: true,
1715                ..Default::default()
1716            },
1717        )
1718        .unwrap();
1719        BATCH_QUERIES.with(|n| n.set(0));
1720        let states = reader.thread_pinned(&chats).unwrap();
1721        assert_eq!(states.len(), 200);
1722        for (id, chat) in chats.iter().enumerate() {
1723            assert_eq!(states[chat], Some(id % 4 < 2));
1724        }
1725        BATCH_QUERIES.with(|n| assert_eq!(n.get(), 1));
1726        let unknown = [
1727            "missing",
1728            "missing_flag",
1729            "negative_flag",
1730            "wide_flag",
1731            "malformed_flag",
1732            "not_friend",
1733            "deleted",
1734            "wrong_type",
1735            "left@chatroom",
1736            "not_group@chatroom",
1737            "filehelper",
1738            "weixin",
1739            "gh_service",
1740            "notification_messages",
1741        ]
1742        .map(str::to_owned);
1743        let states = reader.thread_pinned(&unknown).unwrap();
1744        for name in unknown {
1745            assert_eq!(states.get(&name).copied().flatten(), None, "{name}");
1746        }
1747        assert!(reader.thread_pinned(&[]).unwrap().is_empty());
1748        assert!(matches!(
1749            reader.thread_pinned(&vec!["peer".into(); 201]),
1750            Err(ProfileError::InvalidQuery)
1751        ));
1752    }
1753
1754    fn collect(
1755        reader: &ProfileReader,
1756        targets: &[(String, String)],
1757        own: Option<&str>,
1758    ) -> Result<Vec<Message>> {
1759        let mut messages = Vec::new();
1760        reader.visit_messages_by_server_ids::<ProfileError>(targets, own, |message| {
1761            messages.push(message);
1762            Ok(())
1763        })?;
1764        Ok(messages)
1765    }
1766
1767    #[test]
1768    fn sidebar_previews_use_one_compound_query_per_shard_for_a_full_page() {
1769        let (temp, original) = fixture();
1770        drop(original);
1771        let source = format!("Msg_{:x}", Md5::digest(b"peer"));
1772        let chats: Vec<_> = (0..50).map(|n| format!("peer_{n}")).collect();
1773        for shard in 0..2 {
1774            let db = Connection::open(
1775                temp.path()
1776                    .join(format!("account/db_storage/message/message_{shard}.db")),
1777            )
1778            .unwrap();
1779            for chat in &chats {
1780                let table = format!("Msg_{:x}", Md5::digest(chat.as_bytes()));
1781                db.execute_batch(&format!("CREATE TABLE {table} AS SELECT * FROM {source}; CREATE INDEX {table}_time ON {table}(create_time DESC,local_id DESC)")).unwrap();
1782            }
1783            let table = format!("Msg_{:x}", Md5::digest(chats[0].as_bytes()));
1784            db.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN __agent_wechat_preview BLOB; UPDATE {table} SET __agent_wechat_preview=X'0102'")).unwrap();
1785        }
1786        let reader = ProfileReader::open(
1787            ProfileLocation::new(temp.path().join("account")).unwrap(),
1788            KeyFile::empty("account"),
1789            ReadOptions {
1790                allow_plaintext: true,
1791                stopped_or_consistent_snapshot: true,
1792                ..Default::default()
1793            },
1794        )
1795        .unwrap();
1796        BATCH_QUERIES.with(|n| n.set(0));
1797        POINT_QUERIES.with(|n| n.set(0));
1798        let latest = reader.latest_messages(&chats, Some("self")).unwrap();
1799        assert_eq!(latest.len(), chats.len());
1800        assert_eq!(BATCH_QUERIES.with(Cell::get), 2);
1801        assert_eq!(POINT_QUERIES.with(Cell::get), 0);
1802        for chat in &chats {
1803            let expected = reader.message(&latest[chat].id, Some("self")).unwrap();
1804            assert_eq!(latest[chat].raw_fields, expected.raw_fields);
1805            assert_eq!(latest[chat].sender, expected.sender);
1806        }
1807        assert!(!latest[&chats[1]]
1808            .raw_fields
1809            .contains_key("__agent_wechat_preview"));
1810    }
1811
1812    #[test]
1813    fn a_page_uses_one_bulk_lookup_per_shard_and_no_original_point_reads() {
1814        let (_temp, reader) = fixture();
1815        let targets: Vec<_> = (1..=50).map(|n| ("peer".into(), n.to_string())).collect();
1816        BATCH_QUERIES.with(|n| n.set(0));
1817        POINT_QUERIES.with(|n| n.set(0));
1818        let batch = collect(&reader, &targets, Some("self")).unwrap();
1819        assert_eq!(BATCH_QUERIES.with(Cell::get), 2);
1820        assert_eq!(POINT_QUERIES.with(Cell::get), 0);
1821        assert_eq!(batch.len(), 50);
1822        assert!(batch
1823            .iter()
1824            .all(|m| m.sender.as_deref() == Some("peer") && m.outgoing == Some(false)));
1825        let individual: Vec<_> = targets
1826            .iter()
1827            .flat_map(|(thread, server)| {
1828                reader
1829                    .messages_by_server_id(thread, server, Some("self"))
1830                    .unwrap()
1831            })
1832            .collect();
1833        assert_eq!(POINT_QUERIES.with(Cell::get), 50);
1834        let by_id = |messages: Vec<Message>| {
1835            messages
1836                .into_iter()
1837                .map(|m| (format!("{:?}", m.id), serde_json::to_value(m).unwrap()))
1838                .collect::<BTreeMap<_, _>>()
1839        };
1840        assert_eq!(by_id(batch), by_id(individual));
1841    }
1842
1843    #[test]
1844    fn batch_lookup_keeps_unsigned_ids_duplicates_and_conversations_separate() {
1845        let (_temp, reader) = fixture();
1846        let targets = vec![
1847            ("peer".into(), u64::MAX.to_string()),
1848            ("peer".into(), "99".into()),
1849            ("peer".into(), "99".into()),
1850            ("other".into(), "1".into()),
1851            ("peer".into(), "99999".into()),
1852        ];
1853        let found = collect(&reader, &targets, None).unwrap();
1854        assert_eq!(found.len(), 4);
1855        assert_eq!(
1856            found
1857                .iter()
1858                .filter(|m| m.server_message_id.as_deref() == Some("99"))
1859                .count(),
1860            2
1861        );
1862        assert_eq!(
1863            found
1864                .iter()
1865                .filter(|m| m.server_message_id.as_deref() == Some(u64::MAX.to_string().as_str()))
1866                .count(),
1867            2
1868        );
1869        assert!(found.iter().all(|m| m.peer.as_deref() == Some("peer")));
1870        for invalid in ["0", "01", "-1", "18446744073709551616"] {
1871            assert!(matches!(
1872                collect(&reader, &[("peer".into(), invalid.into())], None),
1873                Err(ProfileError::InvalidQuery)
1874            ));
1875        }
1876        assert!(matches!(
1877            collect(&reader, &vec![("peer".into(), "1".into()); 201], None),
1878            Err(ProfileError::InvalidQuery)
1879        ));
1880        assert!(collect(&reader, &[], None).unwrap().is_empty());
1881    }
1882
1883    #[test]
1884    fn batch_visitor_stops_on_a_consumer_error_without_buffering_more_originals() {
1885        let (_temp, reader) = fixture();
1886        let targets: Vec<_> = (1..=50).map(|n| ("peer".into(), n.to_string())).collect();
1887        let mut visited = 0;
1888        let error = reader.visit_messages_by_server_ids::<ProfileError>(&targets, None, |_| {
1889            visited += 1;
1890            Err(ProfileError::ResourceLimit)
1891        });
1892        assert!(matches!(error, Err(ProfileError::ResourceLimit)));
1893        assert_eq!(visited, 1);
1894    }
1895}