1use 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 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}
269pub(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}
307pub 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 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 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}
550pub 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}