Skip to main content

wechat_profile/
writer.rs

1//! Narrow incoming plain-text projection into an initialized official profile.
2//! Callers must persist an application intent before entry and recover it before
3//! advancing a native sync checkpoint. Message/session transactions are separate;
4//! retries reconcile the same server identity rather than resend anything.
5use crate::{
6    keys::verify_first_page,
7    schema::{inspect, inspect_named_table, message_table, quote_identifier},
8    snapshot::configure_cipher,
9    KeyFile, ProfileError, ProfileLocation, ReadOptions, Result, SourceMessageId,
10};
11use md5::{Digest, Md5};
12use rusqlite::{params, Connection, OptionalExtension};
13use serde::{Deserialize, Serialize};
14use std::{fs, io::Read, path::Path, time::Duration};
15
16#[derive(Clone, Debug, Deserialize, Serialize)]
17#[serde(rename_all = "camelCase", deny_unknown_fields)]
18pub struct IncomingText {
19    pub peer: String,
20    pub own_username: String,
21    pub server_message_id: String,
22    pub server_sequence: i64,
23    pub create_time: i64,
24    pub content: String,
25    pub source_xml: String,
26    pub unread: bool,
27}
28#[derive(Clone, Debug)]
29pub struct WriteOptions {
30    pub exclusive_ownership_confirmed: bool,
31    pub allow_plaintext: bool,
32    /// Reference to the caller's already durable apply intent, never a SyncKey.
33    pub durable_intent_id: String,
34}
35#[derive(Clone, Debug, Serialize)]
36#[serde(rename_all = "camelCase")]
37pub struct AppliedText {
38    pub source_id: SourceMessageId,
39    pub message_inserted: bool,
40    pub session_projected: bool,
41    pub cross_database_atomic: bool,
42}
43
44pub(crate) fn open_write(
45    location: &ProfileLocation,
46    rel: &str,
47    keys: &KeyFile,
48    allow_plaintext: bool,
49) -> Result<Connection> {
50    if !crate::keys::valid_relative_path(rel) {
51        return Err(ProfileError::InvalidQuery);
52    }
53    let path = location.storage.join(rel);
54    let canonical = fs::canonicalize(&path)?;
55    if !canonical.starts_with(&location.storage) || canonical != path {
56        return Err(ProfileError::UnsupportedFile);
57    }
58    let m = fs::symlink_metadata(&path)?;
59    if !m.is_file() || wechat_platform::is_link(&m) {
60        return Err(ProfileError::UnsupportedFile);
61    }
62    let mut page = [0u8; 4096];
63    fs::File::open(&path)?.read_exact(&mut page)?;
64    let c = Connection::open_with_flags(
65        &path,
66        rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
67    )?;
68    c.busy_timeout(Duration::from_secs(2))?;
69    if &page[..16] == b"SQLite format 3\0" {
70        if !allow_plaintext {
71            return Err(ProfileError::KeyUnavailable(rel.into()));
72        }
73    } else {
74        let k = keys
75            .databases
76            .get(rel)
77            .ok_or_else(|| ProfileError::KeyUnavailable(rel.into()))?;
78        if !verify_first_page(&page, k.raw_page_key.expose(), &k.salt) {
79            return Err(ProfileError::KeyMismatch(rel.into()));
80        }
81        configure_cipher(&c, &k.raw_page_key, rel)?;
82    }
83    c.query_row("SELECT count(*) FROM sqlite_master", [], |r| {
84        r.get::<_, i64>(0)
85    })
86    .map_err(|_| ProfileError::KeyMismatch(rel.into()))?;
87    c.execute_batch(
88        "PRAGMA trusted_schema=OFF; PRAGMA synchronous=FULL; PRAGMA temp_store=MEMORY;",
89    )?;
90    Ok(c)
91}
92pub(crate) const MESSAGE_COLUMNS: &[&str] = &[
93    "local_id",
94    "server_id",
95    "local_type",
96    "sort_seq",
97    "real_sender_id",
98    "create_time",
99    "status",
100    "upload_status",
101    "download_status",
102    "server_seq",
103    "origin_source",
104    "source",
105    "message_content",
106    "compress_content",
107    "packed_info_data",
108    "WCDB_CT_message_content",
109    "WCDB_CT_source",
110];
111const SESSION_COLUMNS: &[&str] = &[
112    "username",
113    "type",
114    "unread_count",
115    "unread_first_msg_srv_id",
116    "unread_first_pat_msg_local_id",
117    "unread_first_pat_msg_sort_seq",
118    "is_hidden",
119    "summary",
120    "draft",
121    "status",
122    "last_timestamp",
123    "sort_timestamp",
124    "last_clear_unread_timestamp",
125    "last_msg_locald_id",
126    "last_msg_type",
127    "last_msg_sub_type",
128    "last_msg_sender",
129    "last_sender_display_name",
130    "last_msg_ext_type",
131];
132pub(crate) fn exact_columns(
133    c: &Connection,
134    db: &str,
135    table: &str,
136    expected: &[&str],
137) -> Result<crate::TableSchema> {
138    // A message shard may have one table per conversation. Validate the
139    // target table without rebuilding the schema report for every conversation.
140    let t = if message_table(table) {
141        inspect_named_table(c, table)?
142    } else {
143        inspect(c, db)?.tables.into_iter().find(|t| t.name == table)
144    }
145    .ok_or_else(|| ProfileError::UnsupportedSchema(db.into()))?;
146    let names = t
147        .columns
148        .iter()
149        .map(|v| v.name.as_str())
150        .collect::<Vec<_>>();
151    let named_columns =
152        names.len() == expected.len() && names.iter().all(|name| expected.contains(name));
153    if !named_columns || !t.triggers.is_empty() {
154        return Err(ProfileError::UnsupportedSchema(db.into()));
155    }
156    for col in &t.columns {
157        let ty = if [
158            "username",
159            "user_name",
160            "source",
161            "message_content",
162            "compress_content",
163            "summary",
164            "draft",
165            "last_msg_sender",
166            "last_sender_display_name",
167        ]
168        .contains(&col.name.as_str())
169        {
170            "TEXT"
171        } else if col.name == "packed_info_data" {
172            "BLOB"
173        } else {
174            "INTEGER"
175        };
176        let pk = if col.name == "local_id" && crate::schema::message_table(table)
177            || col.name == "user_name" && table == "Name2Id"
178            || col.name == "username" && table == "SessionTable"
179            || col.name == "username_id" && table == "SessionUnreadListTable_1"
180        {
181            1
182        } else if col.name == "server_id" && table == "SessionUnreadListTable_1" {
183            2
184        } else {
185            0
186        };
187        let default = if col.name.starts_with("WCDB_CT_") {
188            Some("NULL")
189        } else {
190            None
191        };
192        if !col.declared_type.eq_ignore_ascii_case(ty)
193            || col.primary_key_position != pk
194            || col.not_null
195            || col.hidden != 0
196            || col
197                .default_sql
198                .as_deref()
199                .map(str::to_ascii_uppercase)
200                .as_deref()
201                != default
202        {
203            return Err(ProfileError::UnsupportedSchema(db.into()));
204        }
205    }
206    Ok(t)
207}
208fn var(mut n: u64, out: &mut Vec<u8>) {
209    while n >= 128 {
210        out.push((n as u8 & 127) | 128);
211        n >>= 7;
212    }
213    out.push(n as u8);
214}
215fn packed(from: i64, to: i64) -> Result<Vec<u8>> {
216    if from <= 0 || to <= 0 {
217        return Err(ProfileError::InvalidMetadata);
218    }
219    let mut v = vec![8];
220    var(from as u64, &mut v);
221    v.push(16);
222    var(to as u64, &mut v);
223    v.extend([88, 0]);
224    Ok(v)
225}
226pub(crate) fn find_database(location: &ProfileLocation, basename: &str) -> Result<String> {
227    fn walk(
228        root: &Path,
229        here: &Path,
230        name: &str,
231        out: &mut Vec<String>,
232        depth: usize,
233    ) -> Result<()> {
234        if depth > 16 {
235            return Err(ProfileError::ResourceLimit);
236        }
237        for e in fs::read_dir(here)? {
238            let e = e?;
239            let m = fs::symlink_metadata(e.path())?;
240            if wechat_platform::is_link(&m) {
241                return Err(ProfileError::UnsupportedFile);
242            }
243            if m.is_dir() {
244                walk(root, &e.path(), name, out, depth + 1)?;
245            } else if e.file_name() == name {
246                out.push(
247                    e.path()
248                        .strip_prefix(root)
249                        .map_err(|_| ProfileError::InvalidProfile)?
250                        .to_str()
251                        .ok_or(ProfileError::InvalidProfile)?
252                        .replace('\\', "/"),
253                );
254            }
255        }
256        Ok(())
257    }
258    let mut found = Vec::new();
259    walk(
260        &location.storage,
261        &location.storage,
262        basename,
263        &mut found,
264        0,
265    )?;
266    if found.len() != 1 {
267        return Err(ProfileError::UnsupportedSchema(basename.into()));
268    }
269    Ok(found.remove(0))
270}
271/// Refuse implicit rotation/routing when another ordinary shard is present.
272pub(crate) fn require_single_message_shard(location: &ProfileLocation) -> Result<()> {
273    fn visit(path: &Path, depth: usize, count: &mut usize, entries: &mut usize) -> Result<()> {
274        if depth > 16 {
275            return Err(ProfileError::ResourceLimit);
276        }
277        for entry in fs::read_dir(path)? {
278            let entry = entry?;
279            *entries += 1;
280            if *entries > 16384 {
281                return Err(ProfileError::ResourceLimit);
282            }
283            let metadata = fs::symlink_metadata(entry.path())?;
284            if wechat_platform::is_link(&metadata) {
285                return Err(ProfileError::UnsupportedFile);
286            }
287            if metadata.is_dir() {
288                visit(&entry.path(), depth + 1, count, entries)?;
289            } else if let Some(name) = entry.file_name().to_str() {
290                if name
291                    .strip_prefix("message_")
292                    .and_then(|s| s.strip_suffix(".db"))
293                    .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
294                {
295                    *count += 1;
296                }
297            }
298        }
299        Ok(())
300    }
301    let (mut count, mut entries) = (0, 0);
302    visit(&location.storage, 0, &mut count, &mut entries)?;
303    if count == 1 {
304        Ok(())
305    } else {
306        Err(ProfileError::WritesUnsupported)
307    }
308}
309/// Supports only the verified existing direct-chat incoming text family. It does
310/// not allocate new chats/maps, infer sort collisions, update FTS or project
311/// unknown media/system commands. A partial error may follow a committed message
312/// transaction; recover by retrying the same durable input.
313pub fn apply_incoming_text(
314    location: &ProfileLocation,
315    keys: &KeyFile,
316    input: &IncomingText,
317    options: &WriteOptions,
318) -> Result<AppliedText> {
319    if !options.exclusive_ownership_confirmed {
320        return Err(ProfileError::SnapshotRequired);
321    }
322    if options.durable_intent_id.is_empty() || options.durable_intent_id.len() > 256 {
323        return Err(ProfileError::InvalidQuery);
324    }
325    if keys.account_binding != location.account {
326        return Err(ProfileError::AccountMismatch);
327    }
328    if input.peer.is_empty()
329        || input.peer == input.own_username
330        || input.peer.ends_with("@chatroom")
331        || input.peer.len() > 512
332        || input.own_username.is_empty()
333        || input.own_username.len() > 512
334        || input.content.len() > 1024 * 1024
335        || input.source_xml.len() > 1024 * 1024
336        || input.content.contains('\0')
337        || input.source_xml.contains('\0')
338        || input.create_time <= 0
339        || input.server_sequence < 0
340    {
341        return Err(ProfileError::WritesUnsupported);
342    }
343    let sid = input
344        .server_message_id
345        .parse::<u64>()
346        .map_err(|_| ProfileError::InvalidQuery)?;
347    if sid == 0 {
348        return Err(ProfileError::InvalidQuery);
349    }
350    let signed = sid as i64;
351    let sort = input
352        .create_time
353        .checked_mul(1000)
354        .ok_or(ProfileError::InvalidQuery)?;
355    let message_db = find_database(location, "message_0.db")?;
356    let session_db = find_database(location, "session.db")?;
357    // Other shards make write routing unknown; do not silently always use zero.
358    let view = crate::ProfileReader::open(
359        location.clone(),
360        keys.clone(),
361        ReadOptions {
362            stopped_or_consistent_snapshot: true,
363            allow_plaintext: options.allow_plaintext,
364            ..Default::default()
365        },
366    )?;
367    if view
368        .inventory()
369        .iter()
370        .filter(|d| {
371            let n = d.relative_path.rsplit('/').next().unwrap_or("");
372            n.starts_with("message_")
373                && n.trim_start_matches("message_")
374                    .strip_suffix(".db")
375                    .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
376        })
377        .count()
378        != 1
379    {
380        return Err(ProfileError::WritesUnsupported);
381    }
382    drop(view);
383    let mut messages = open_write(location, &message_db, keys, options.allow_plaintext)?;
384    let mut sessions = open_write(location, &session_db, keys, options.allow_plaintext)?;
385    let table = format!("Msg_{:x}", Md5::digest(input.peer.as_bytes()));
386    let table_schema = exact_columns(&messages, &message_db, &table, MESSAGE_COLUMNS)?;
387    let expected_indexes = [
388        ("_SERVERID", vec!["server_id"]),
389        ("_SORTSEQ", vec!["sort_seq"]),
390        ("_TYPE_SEQ", vec!["local_type", "sort_seq"]),
391        ("_SENDERID", vec!["real_sender_id"]),
392    ];
393    if table_schema.indexes.len() != 4
394        || !expected_indexes.iter().all(|(suffix, columns)| {
395            table_schema.indexes.iter().any(|i| {
396                i.name == format!("{table}{suffix}")
397                    && !i.unique
398                    && !i.partial
399                    && i.columns.iter().map(|s| s.as_deref()).collect::<Vec<_>>()
400                        == columns.iter().map(|s| Some(*s)).collect::<Vec<_>>()
401            })
402        })
403    {
404        return Err(ProfileError::UnsupportedSchema(message_db));
405    }
406    let local_id = table_schema
407        .columns
408        .iter()
409        .find(|column| column.name == "local_id")
410        .ok_or_else(|| ProfileError::UnsupportedSchema(message_db.clone()))?;
411    if local_id.declared_type.to_uppercase() != "INTEGER"
412        || local_id.primary_key_position != 1
413        || !table_schema
414            .definition
415            .as_ref()
416            .is_some_and(|s| s.to_uppercase().contains("AUTOINCREMENT"))
417    {
418        return Err(ProfileError::UnsupportedSchema(message_db));
419    }
420    exact_columns(&sessions, &session_db, "SessionTable", SESSION_COLUMNS)?;
421    exact_columns(
422        &sessions,
423        &session_db,
424        "SessionUnreadListTable_1",
425        &["username_id", "server_id", "local_id", "create_time"],
426    )?;
427    exact_columns(
428        &messages,
429        &message_db,
430        "Name2Id",
431        &["user_name", "is_session"],
432    )?;
433    exact_columns(&sessions, &session_db, "Name2Id", &["user_name"])?;
434    let sender = messages
435        .query_row(
436            "SELECT rowid FROM Name2Id WHERE user_name=?1",
437            [&input.peer],
438            |r| r.get::<_, i64>(0),
439        )
440        .optional()?
441        .ok_or(ProfileError::WritesUnsupported)?;
442    let recipient = messages
443        .query_row(
444            "SELECT rowid FROM Name2Id WHERE user_name=?1",
445            [&input.own_username],
446            |r| r.get::<_, i64>(0),
447        )
448        .optional()?
449        .ok_or(ProfileError::WritesUnsupported)?;
450    let session_name = sessions
451        .query_row(
452            "SELECT rowid FROM Name2Id WHERE user_name=?1",
453            [&input.peer],
454            |r| r.get::<_, i64>(0),
455        )
456        .optional()?
457        .ok_or(ProfileError::WritesUnsupported)?;
458    let current=sessions.query_row("SELECT unread_count,last_msg_sender,last_sender_display_name,last_timestamp,sort_timestamp FROM SessionTable WHERE username=?1",[&input.peer],|r|Ok((r.get::<_,i64>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?,r.get::<_,i64>(3)?,r.get::<_,i64>(4)?))).optional()?.ok_or(ProfileError::WritesUnsupported)?;
459    if current.0 < 0 || !current.1.is_empty() || !current.2.is_empty() {
460        return Err(ProfileError::WritesUnsupported);
461    }
462    let packed = packed(sender, recipient)?;
463    let source = zstd::stream::encode_all(input.source_xml.as_bytes(), 3)
464        .map_err(|_| ProfileError::InvalidMetadata)?;
465    let q = quote_identifier(&table);
466    let tx = messages.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
467    let existing=tx.query_row(&format!("SELECT local_id,local_type,sort_seq,real_sender_id,create_time,status,upload_status,download_status,server_seq,origin_source,source,message_content,compress_content,packed_info_data,WCDB_CT_message_content,WCDB_CT_source FROM {q} WHERE server_id=?1"),[signed],|r|Ok((r.get::<_,i64>(0)?,r.get::<_,i64>(1)?,r.get::<_,i64>(2)?,r.get::<_,i64>(3)?,r.get::<_,i64>(4)?,r.get::<_,i64>(5)?,r.get::<_,i64>(6)?,r.get::<_,i64>(7)?,r.get::<_,i64>(8)?,r.get::<_,i64>(9)?,r.get::<_,Vec<u8>>(10)?,r.get::<_,String>(11)?,r.get::<_,String>(12)?,r.get::<_,Vec<u8>>(13)?,r.get::<_,i64>(14)?,r.get::<_,i64>(15)?))).optional()?;
468    let (local, inserted) = if let Some(r) = existing {
469        let duplicates: i64 = tx.query_row(
470            &format!("SELECT count(*) FROM {q} WHERE server_id=?1"),
471            [signed],
472            |r| r.get(0),
473        )?;
474        if duplicates != 1
475            || (r.1, r.2, r.3, r.4, r.5, r.6, r.7, r.8, r.9)
476                != (
477                    1,
478                    sort,
479                    sender,
480                    input.create_time,
481                    3,
482                    0,
483                    0,
484                    input.server_sequence,
485                    2,
486                )
487            || r.10 != source
488            || r.11 != input.content
489            || !r.12.is_empty()
490            || r.13 != packed
491            || r.14 != 0
492            || r.15 != 4
493        {
494            return Err(ProfileError::InvalidMetadata);
495        }
496        (r.0, false)
497    } else {
498        let collisions: i64 = tx.query_row(
499            &format!("SELECT count(*) FROM {q} WHERE sort_seq=?1"),
500            [sort],
501            |r| r.get(0),
502        )?;
503        if collisions != 0 {
504            return Err(ProfileError::WritesUnsupported);
505        }
506        tx.execute(&format!("INSERT INTO {q}(server_id,local_type,sort_seq,real_sender_id,create_time,status,upload_status,download_status,server_seq,origin_source,source,message_content,compress_content,packed_info_data,WCDB_CT_message_content,WCDB_CT_source)VALUES(?1,1,?2,?3,?4,3,0,0,?5,2,?6,?7,'',?8,0,4)"),params![signed,sort,sender,input.create_time,input.server_sequence,source,input.content,packed])?;
507        (tx.last_insert_rowid(), true)
508    };
509    tx.commit()?;
510    let tx = sessions.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
511    let unread_existing=tx.query_row("SELECT local_id,create_time FROM SessionUnreadListTable_1 WHERE username_id=?1 AND server_id=?2",params![session_name,signed],|r|Ok((r.get::<_,i64>(0)?,r.get::<_,i64>(1)?))).optional()?;
512    if unread_existing.is_some_and(|(l, t)| l != local || t != input.create_time) {
513        return Err(ProfileError::InvalidMetadata);
514    }
515    // Never resurrect an unread entry already consumed by a later official read.
516    // The summary ID is the durable evidence that this exact projection completed.
517    let already_projected=tx.query_row("SELECT last_msg_locald_id,last_timestamp,last_clear_unread_timestamp FROM SessionTable WHERE username=?1",[&input.peer],|r|Ok((r.get::<_,i64>(0)?,r.get::<_,i64>(1)?,r.get::<_,i64>(2)?)))?;
518    if input.unread
519        && unread_existing.is_none()
520        && !(already_projected.0 == local && already_projected.1 == input.create_time)
521    {
522        if already_projected.1 > input.create_time || already_projected.2 >= input.create_time {
523            return Err(ProfileError::WritesUnsupported);
524        }
525        tx.execute("INSERT INTO SessionUnreadListTable_1(username_id,server_id,local_id,create_time)VALUES(?1,?2,?3,?4)",params![session_name,signed,local,input.create_time])?;
526        tx.execute("UPDATE SessionTable SET unread_first_msg_srv_id=CASE WHEN unread_count=0 THEN ?1 ELSE unread_first_msg_srv_id END,unread_count=unread_count+1 WHERE username=?2",params![signed,input.peer])?;
527    }
528    if current.3 <= input.create_time && current.4 <= input.create_time {
529        tx.execute("UPDATE SessionTable SET summary=?1,last_timestamp=?2,sort_timestamp=?2,last_msg_locald_id=?3,last_msg_type=1,last_msg_sub_type=0,last_msg_ext_type=0 WHERE username=?4",params![input.content,input.create_time,local,input.peer])?;
530    }
531    tx.commit()?;
532    Ok(AppliedText {
533        source_id: SourceMessageId {
534            database: message_db,
535            table,
536            local_id: local.to_string(),
537        },
538        message_inserted: inserted,
539        session_projected: true,
540        cross_database_atomic: false,
541    })
542}
543
544#[derive(Clone, Debug, Serialize)]
545#[serde(rename_all = "camelCase")]
546pub struct ReadProjection {
547    pub removed_unread_rows: usize,
548    pub unread_count_before: i64,
549    pub unread_count_after: i64,
550    pub exact_ids_only: bool,
551}
552/// Project only the selected source-qualified incoming observations. This is
553/// desktop-local state: it neither acknowledges consumer events nor sends a read
554/// receipt to the phone. Counter/list discrepancies already present are retained
555/// by subtracting only removed rows rather than rebuilding from list cardinality.
556pub fn mark_read_messages(
557    location: &ProfileLocation,
558    keys: &KeyFile,
559    thread: &str,
560    ids: &[SourceMessageId],
561    options: &WriteOptions,
562) -> Result<ReadProjection> {
563    if !options.exclusive_ownership_confirmed {
564        return Err(ProfileError::SnapshotRequired);
565    }
566    if options.durable_intent_id.is_empty()
567        || ids.is_empty()
568        || ids.len() > 200
569        || thread.is_empty()
570        || thread.len() > 512
571    {
572        return Err(ProfileError::InvalidQuery);
573    }
574    if keys.account_binding != location.account {
575        return Err(ProfileError::AccountMismatch);
576    }
577    let target = format!("Msg_{:x}", Md5::digest(thread.as_bytes()));
578    let mut identities = std::collections::BTreeSet::new();
579    let mut seen = std::collections::BTreeSet::new();
580    for id in ids {
581        if !seen.insert(id.clone()) {
582            continue;
583        }
584        if id.table != target {
585            return Err(ProfileError::InvalidQuery);
586        }
587        let local = id
588            .local_id
589            .parse::<i64>()
590            .map_err(|_| ProfileError::InvalidQuery)?;
591        let c = open_write(location, &id.database, keys, options.allow_plaintext)?;
592        exact_columns(&c, &id.database, &target, MESSAGE_COLUMNS)?;
593        let row = c
594            .query_row(
595                &format!(
596                    "SELECT server_id,create_time FROM {} WHERE local_id=?1",
597                    quote_identifier(&target)
598                ),
599                [local],
600                |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)),
601            )
602            .optional()?
603            .ok_or(ProfileError::InvalidQuery)?;
604        if row.0 == 0 {
605            return Err(ProfileError::WritesUnsupported);
606        }
607        identities.insert((row.0, local, row.1));
608    }
609    let rel = find_database(location, "session.db")?;
610    let mut c = open_write(location, &rel, keys, options.allow_plaintext)?;
611    exact_columns(&c, &rel, "SessionTable", SESSION_COLUMNS)?;
612    exact_columns(
613        &c,
614        &rel,
615        "SessionUnreadListTable_1",
616        &["username_id", "server_id", "local_id", "create_time"],
617    )?;
618    exact_columns(&c, &rel, "Name2Id", &["user_name"])?;
619    let tx = c.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
620    let nameid = tx
621        .query_row(
622            "SELECT rowid FROM Name2Id WHERE user_name=?1",
623            [thread],
624            |r| r.get::<_, i64>(0),
625        )
626        .optional()?
627        .ok_or(ProfileError::WritesUnsupported)?;
628    let (before, first) = tx
629        .query_row(
630            "SELECT unread_count,unread_first_msg_srv_id FROM SessionTable WHERE username=?1",
631            [thread],
632            |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)),
633        )
634        .optional()?
635        .ok_or(ProfileError::WritesUnsupported)?;
636    if before < 0 {
637        return Err(ProfileError::WritesUnsupported);
638    }
639    let mut removed = 0;
640    let mut removed_first = false;
641    for (server, local, time) in identities {
642        let existing=tx.query_row("SELECT local_id,create_time FROM SessionUnreadListTable_1 WHERE username_id=?1 AND server_id=?2",params![nameid,server],|r|Ok((r.get::<_,i64>(0)?,r.get::<_,i64>(1)?))).optional()?;
643        if let Some((l, t)) = existing {
644            if (l, t) != (local, time) {
645                return Err(ProfileError::WritesUnsupported);
646            }
647            removed+=tx.execute("DELETE FROM SessionUnreadListTable_1 WHERE username_id=?1 AND server_id=?2 AND local_id=?3 AND create_time=?4",params![nameid,server,local,time])?;
648            removed_first |= server == first;
649        }
650    }
651    let after = before.saturating_sub(removed as i64).max(0);
652    if removed > 0 {
653        let next = if removed_first {
654            if after == 0 {
655                0
656            } else {
657                tx.query_row("SELECT server_id FROM SessionUnreadListTable_1 WHERE username_id=?1 ORDER BY create_time,local_id,server_id LIMIT 1",[nameid],|r|r.get::<_,i64>(0)).optional()?.unwrap_or(0)
658            }
659        } else {
660            first
661        };
662        tx.execute(
663            "UPDATE SessionTable SET unread_count=?1,unread_first_msg_srv_id=?2 WHERE username=?3",
664            params![after, next, thread],
665        )?;
666    }
667    tx.commit()?;
668    Ok(ReadProjection {
669        removed_unread_rows: removed,
670        unread_count_before: before,
671        unread_count_after: after,
672        exact_ids_only: true,
673    })
674}
675
676#[cfg(test)]
677mod database_path_tests {
678    use super::*;
679    #[test]
680    fn discovered_databases_use_the_key_manifests_portable_names() {
681        let temporary = tempfile::tempdir().unwrap();
682        let account = temporary.path().join("account");
683        let database = account.join("db_storage/nested/session.db");
684        fs::create_dir_all(database.parent().unwrap()).unwrap();
685        fs::write(&database, b"fixture").unwrap();
686        let location = ProfileLocation::new(account).unwrap();
687        assert_eq!(
688            find_database(&location, "session.db").unwrap(),
689            "nested/session.db"
690        );
691    }
692}