1use 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 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 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}
271pub(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}
309pub 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 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 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}
552pub 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}