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