1use crate::{
5 schema::quote_identifier,
6 writer::{find_database, open_write},
7 *,
8};
9use base64::{engine::general_purpose::STANDARD, Engine};
10use md5::{Digest, Md5};
11use rusqlite::{params, types::Value, Connection, OptionalExtension};
12use serde::{Deserialize, Serialize};
13#[path = "business_projection.rs"]
14mod business_projection;
15pub use business_projection::*;
16
17pub(crate) fn ownership(
18 location: &ProfileLocation,
19 keys: &KeyFile,
20 options: &WriteOptions,
21) -> Result<()> {
22 if !options.exclusive_ownership_confirmed {
23 return Err(ProfileError::SnapshotRequired);
24 }
25 if options.durable_intent_id.is_empty() || options.durable_intent_id.len() > 256 {
26 return Err(ProfileError::InvalidQuery);
27 }
28 if location.account_directory() != keys.account_binding {
29 return Err(ProfileError::AccountMismatch);
30 }
31 Ok(())
32}
33pub(crate) fn identity(value: &str) -> Result<()> {
34 if value.is_empty() || value.len() > 512 || value.chars().any(char::is_control) {
35 Err(ProfileError::InvalidQuery)
36 } else {
37 Ok(())
38 }
39}
40pub(crate) fn map_id(c: &Connection, table: &str, column: &str, name: &str) -> Result<i64> {
41 identity(name)?;
42 let table = quote_identifier(table);
43 let column = quote_identifier(column);
44 c.execute(
45 &format!("INSERT INTO {table}({column})VALUES(?1)ON CONFLICT({column})DO NOTHING"),
46 [name],
47 )?;
48 c.query_row(
49 &format!("SELECT rowid FROM {table} WHERE {column}=?1"),
50 [name],
51 |r| r.get(0),
52 )
53 .map_err(Into::into)
54}
55pub(crate) fn bound(v: &SqlValue) -> Result<(Value, bool)> {
56 Ok(match v {
57 SqlValue::Null => (Value::Null, false),
58 SqlValue::Integer(s) => (
59 Value::Integer(s.parse().map_err(|_| ProfileError::InvalidMetadata)?),
60 false,
61 ),
62 SqlValue::Real(s) => {
63 let f: f64 = s.parse().map_err(|_| ProfileError::InvalidMetadata)?;
64 if !f.is_finite() {
65 return Err(ProfileError::InvalidMetadata);
66 }
67 (Value::Real(f), false)
68 }
69 SqlValue::Text(s) => {
70 if s.len() > 4 * 1024 * 1024 {
71 return Err(ProfileError::ResourceLimit);
72 }
73 (Value::Text(s.clone()), false)
74 }
75 SqlValue::TextBytes(s) | SqlValue::Blob(s) => {
76 let b = STANDARD
77 .decode(s)
78 .map_err(|_| ProfileError::InvalidMetadata)?;
79 if b.len() > 4 * 1024 * 1024 {
80 return Err(ProfileError::ResourceLimit);
81 }
82 (Value::Blob(b), matches!(v, SqlValue::TextBytes(_)))
83 }
84 })
85}
86fn row_values(
87 c: &Connection,
88 table: &str,
89 columns: &[String],
90 key_column: &str,
91 key: &Value,
92) -> Result<Option<RawFields>> {
93 if columns.is_empty() {
94 return Err(ProfileError::InvalidMetadata);
95 }
96 let q = format!(
97 "SELECT {} FROM {} WHERE {}=?1",
98 columns
99 .iter()
100 .map(|s| quote_identifier(s))
101 .collect::<Vec<_>>()
102 .join(","),
103 quote_identifier(table),
104 quote_identifier(key_column)
105 );
106 c.query_row(&q, [key], crate::reader::raw)
107 .optional()
108 .map_err(Into::into)
109}
110pub(crate) fn check_known_table(c: &Connection, table: &str) -> Result<()> {
111 let relative = match table {
112 "contact"
113 | "biz_info"
114 | "chat_room"
115 | "chat_room_info_detail"
116 | "chatroom_member"
117 | "name2id" => "contact/contact.db",
118 "SessionTable" | "SessionUnreadListTable_1" | "SessionUnreadStatTable_1" => {
119 "session/session.db"
120 }
121 "MessageResourceInfo" | "MessageResourceDetail" | "ChatName2Id" | "SenderName2Id" => {
122 "message/message_resource.db"
123 }
124 "VoiceInfo" => "message/media_0.db",
125 "image_hardlink_info_v4"
126 | "file_hardlink_info_v4"
127 | "video_hardlink_info_v4"
128 | "dir2id"
129 | "db_info" => "hardlink/hardlink.db",
130 "head_image" => "head_image/head_image.db",
131 _ => return Err(ProfileError::WritesUnsupported),
132 };
133 let sql = crate::initialize::LAYOUTS
134 .iter()
135 .find(|x| x.0 == relative)
136 .ok_or(ProfileError::WritesUnsupported)?
137 .1;
138 let expected = Connection::open_in_memory()?;
139 expected.execute_batch(sql)?;
140 let expected = crate::schema::inspect(&expected, relative)?;
141 let actual = crate::schema::inspect(c, relative)?;
142 let a = actual
143 .tables
144 .iter()
145 .find(|t| t.name == table)
146 .ok_or_else(|| ProfileError::UnsupportedSchema(relative.into()))?;
147 let e = expected
148 .tables
149 .iter()
150 .find(|t| t.name == table)
151 .ok_or(ProfileError::InvalidMetadata)?;
152 let index = |t: &crate::TableSchema| {
153 t.indexes
154 .iter()
155 .map(|i| (i.name.clone(), i.unique, i.partial, i.columns.clone()))
156 .collect::<Vec<_>>()
157 };
158 let columns_match = a.columns.len() == e.columns.len()
161 && a.columns.iter().all(|column| e.columns.contains(column));
162 if !columns_match || !a.triggers.is_empty() || index(a) != index(e) {
163 return Err(ProfileError::UnsupportedSchema(relative.into()));
164 }
165 Ok(())
166}
167fn apply_row(
168 c: &Connection,
169 table: &str,
170 key_column: &str,
171 key: SqlValue,
172 fields: &RawFields,
173 require_complete_new: bool,
174) -> Result<bool> {
175 check_known_table(c, table)?;
176 let report = crate::schema::inspect(c, "projection")?;
177 let t = report
178 .tables
179 .iter()
180 .find(|t| t.name == table)
181 .ok_or_else(|| ProfileError::UnsupportedSchema(table.into()))?;
182 if !t.triggers.is_empty() {
183 return Err(ProfileError::UnsupportedSchema(table.into()));
184 }
185 let columns = t.columns.iter().map(|c| c.name.clone()).collect::<Vec<_>>();
186 if fields.contains_key(key_column) || fields.keys().any(|f| !columns.contains(f)) {
187 return Err(ProfileError::InvalidMetadata);
188 }
189 let (key_value, _) = bound(&key)?;
190 let selected = fields.keys().cloned().collect::<Vec<_>>();
191 let existing = row_values(c, table, &selected, key_column, &key_value)?;
192 if existing.as_ref().is_some_and(|r| r == fields) {
193 return Ok(false);
194 }
195 if existing.is_none()
196 && require_complete_new
197 && columns
198 .iter()
199 .any(|col| col != key_column && !fields.contains_key(col))
200 {
201 return Err(ProfileError::InvalidMetadata);
202 }
203 let mut all = fields.clone();
204 all.insert(key_column.into(), key);
205 let mut values = vec![];
206 let mut expr = vec![];
207 let names = all.keys().cloned().collect::<Vec<_>>();
208 for v in all.values() {
209 let (value, as_text) = bound(v)?;
210 values.push(value);
211 let p = format!("?{}", values.len());
212 expr.push(if as_text {
213 format!("CAST({p} AS TEXT)")
214 } else {
215 p
216 });
217 }
218 let updates = names
219 .iter()
220 .filter(|n| n.as_str() != key_column)
221 .map(|n| format!("{}=excluded.{}", quote_identifier(n), quote_identifier(n)))
222 .collect::<Vec<_>>()
223 .join(",");
224 let sql = format!(
225 "INSERT INTO {}({})VALUES({})ON CONFLICT({})DO UPDATE SET {}",
226 quote_identifier(table),
227 names
228 .iter()
229 .map(|s| quote_identifier(s))
230 .collect::<Vec<_>>()
231 .join(","),
232 expr.join(","),
233 quote_identifier(key_column),
234 updates
235 );
236 c.execute(&sql, rusqlite::params_from_iter(values))?;
237 Ok(true)
238}
239#[derive(Clone, Debug, Serialize, Deserialize)]
242#[serde(rename_all = "camelCase", deny_unknown_fields)]
243pub struct ContactProjection {
244 pub username: String,
245 pub fields: RawFields,
246}
247#[derive(Clone, Debug, Serialize)]
248#[serde(rename_all = "camelCase")]
249pub struct AppliedContact {
250 pub local_id: i64,
251 pub changed: bool,
252}
253pub fn apply_contact_projection(
254 location: &ProfileLocation,
255 keys: &KeyFile,
256 input: &ContactProjection,
257 options: &WriteOptions,
258) -> Result<AppliedContact> {
259 ownership(location, keys, options)?;
260 identity(&input.username)?;
261 if input.fields.contains_key("id") || input.fields.contains_key("username") {
262 return Err(ProfileError::InvalidMetadata);
263 }
264 let db = find_database(location, "contact.db")?;
265 let mut c = open_write(location, &db, keys, options.allow_plaintext)?;
266 let tx = c.transaction()?;
267 let id = map_id(&tx, "name2id", "username", &input.username)?;
268 let mut fields = input.fields.clone();
269 fields.insert("username".into(), SqlValue::Text(input.username.clone()));
270 let changed = apply_row(
271 &tx,
272 "contact",
273 "id",
274 SqlValue::Integer(id.to_string()),
275 &fields,
276 true,
277 )?;
278 tx.commit()?;
279 Ok(AppliedContact {
280 local_id: id,
281 changed,
282 })
283}
284pub fn apply_contact_mute_patch(
287 location: &ProfileLocation,
288 keys: &KeyFile,
289 username: &str,
290 muted: bool,
291 options: &WriteOptions,
292) -> Result<Option<AppliedContact>> {
293 ownership(location, keys, options)?;
294 identity(username)?;
295 if username.ends_with("@chatroom") {
296 return Err(ProfileError::InvalidMetadata);
297 }
298 let db = find_database(location, "contact.db")?;
299 let mut c = open_write(location, &db, keys, options.allow_plaintext)?;
300 let tx = c.transaction()?;
301 check_known_table(&tx, "contact")?;
302 let existing = tx
303 .query_row(
304 "SELECT id,flag FROM contact WHERE username=?1 AND local_type=1 AND delete_flag=0",
305 [username],
306 |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Option<i64>>(1)?)),
307 )
308 .optional()?;
309 let Some((id, Some(flag))) = existing else {
310 return Ok(None);
311 };
312 let Some(flag) = u32::try_from(flag).ok().filter(|flag| flag & 1 != 0) else {
313 return Ok(None);
314 };
315 let flag = (flag & !0x200) | if muted { 0x200 } else { 0 };
316 let changed = apply_row(
317 &tx,
318 "contact",
319 "id",
320 SqlValue::Integer(id.to_string()),
321 &[("flag".into(), SqlValue::Integer(flag.to_string()))]
322 .into_iter()
323 .collect(),
324 true,
325 )?;
326 tx.commit()?;
327 Ok(Some(AppliedContact {
328 local_id: id,
329 changed,
330 }))
331}
332
333#[derive(Clone, Debug, Serialize)]
334#[serde(rename_all = "camelCase")]
335pub struct AppliedContactPin {
336 pub local_id: i64,
337 pub changed: bool,
338 pub session_projected: bool,
339 pub cross_database_atomic: bool,
340}
341
342pub fn apply_contact_pin_patch(
347 location: &ProfileLocation,
348 keys: &KeyFile,
349 username: &str,
350 pinned: bool,
351 clear_folded_primary: bool,
352 sort_timestamp: u32,
353 options: &WriteOptions,
354) -> Result<Option<AppliedContactPin>> {
355 ownership(location, keys, options)?;
356 identity(username)?;
357 if sort_timestamp == 0 || clear_folded_primary && !pinned {
358 return Err(ProfileError::InvalidQuery);
359 }
360 let contact_db = find_database(location, "contact.db")?;
361 let session_db = find_database(location, "session.db")?;
362 let mut contact = open_write(location, &contact_db, keys, options.allow_plaintext)?;
363 let mut session = open_write(location, &session_db, keys, options.allow_plaintext)?;
364 check_known_table(&contact, "contact")?;
366 check_known_table(&session, "SessionTable")?;
367 let contact_tx = contact.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
368 let session_tx = session.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
369 let existing = contact_tx
370 .query_row(
371 "SELECT id,flag,local_type,delete_flag,is_in_chat_room FROM contact WHERE username=?1",
372 [username],
373 crate::reader::raw,
374 )
375 .optional()?;
376 let Some(fields) = existing else {
377 return Ok(None);
378 };
379 let Some(flag) = crate::reader::num(&fields, "flag").and_then(|flag| u32::try_from(flag).ok())
380 else {
381 return Ok(None);
382 };
383 let ordinary = if username.ends_with("@chatroom") {
384 crate::reader::num(&fields, "local_type") == Some(2)
385 && crate::reader::num(&fields, "is_in_chat_room") == Some(1)
386 && flag & 2 != 0
387 } else {
388 !username.contains('@')
389 && username != "filehelper"
390 && !username.starts_with("weixin")
391 && !username.starts_with("gh_")
392 && ![
393 "brandsessionholder",
394 "brandservicesessionholder",
395 "qmessage",
396 "medianote",
397 "qqsync",
398 "lbsapp",
399 "shakeapp",
400 "floatbottle",
401 "masssendapp",
402 "appbrandcustomerservicemsg",
403 "service_officialaccounts",
404 "notifymessage",
405 "notification_messages",
406 ]
407 .contains(&username)
408 && crate::reader::num(&fields, "local_type") == Some(1)
409 && flag & 1 != 0
410 };
411 if !ordinary || crate::reader::num(&fields, "delete_flag") != Some(0) {
412 return Ok(None);
413 }
414 let id = crate::reader::num(&fields, "id").ok_or(ProfileError::InvalidMetadata)?;
415 let existing_sort: Option<Option<i64>> = session_tx
416 .query_row(
417 "SELECT sort_timestamp FROM SessionTable WHERE username=?1",
418 [username],
419 |row| row.get(0),
420 )
421 .optional()?;
422 let Some(existing_sort) = existing_sort else {
423 return Ok(None);
424 };
425 let transition = (flag & 0x800 != 0) != pinned;
426 let flag = (flag & !0x800) | if pinned { 0x800 } else { 0 };
427 let flag = if clear_folded_primary {
428 flag & !0x10000080
429 } else {
430 flag
431 };
432 if transition && existing_sort.unwrap_or(0) < i64::from(sort_timestamp) {
433 session_tx.execute(
434 "UPDATE SessionTable SET sort_timestamp=?2 WHERE username=?1",
435 params![username, sort_timestamp],
436 )?;
437 }
438 let changed = apply_row(
439 &contact_tx,
440 "contact",
441 "id",
442 SqlValue::Integer(id.to_string()),
443 &[("flag".into(), SqlValue::Integer(flag.to_string()))]
444 .into_iter()
445 .collect(),
446 true,
447 )?;
448 session_tx.commit()?;
449 contact_tx.commit()?;
450 Ok(Some(AppliedContactPin {
451 local_id: id,
452 changed,
453 session_projected: true,
454 cross_database_atomic: false,
455 }))
456}
457#[derive(Clone, Debug, Serialize, Deserialize)]
460#[serde(rename_all = "camelCase", deny_unknown_fields)]
461pub struct GroupProjection {
462 pub username: String,
463 pub fields: RawFields,
464 pub detail_fields: Option<RawFields>,
465 pub members: Vec<String>,
466 pub complete_roster: bool,
467}
468fn proto_var(data: &[u8], at: &mut usize) -> Result<u64> {
469 let mut n = 0;
470 for i in 0..10 {
471 let b = *data.get(*at).ok_or(ProfileError::InvalidMetadata)?;
472 *at += 1;
473 if i == 9 && b > 1 {
474 return Err(ProfileError::InvalidMetadata);
475 }
476 n |= u64::from(b & 127) << (i * 7);
477 if b & 128 == 0 {
478 return Ok(n);
479 }
480 }
481 Err(ProfileError::InvalidMetadata)
482}
483pub(crate) fn proto_fields(data: &[u8]) -> Result<Vec<(u64, u8, &[u8])>> {
484 let mut at = 0;
485 let mut out = vec![];
486 if data.len() > 1024 * 1024 {
487 return Err(ProfileError::ResourceLimit);
488 }
489 while at < data.len() {
490 let tag = proto_var(data, &mut at)?;
491 let wire = (tag & 7) as u8;
492 if tag >> 3 == 0 {
493 return Err(ProfileError::InvalidMetadata);
494 }
495 let start = at;
496 match wire {
497 0 => {
498 proto_var(data, &mut at)?;
499 }
500 1 => at = at.checked_add(8).ok_or(ProfileError::ResourceLimit)?,
501 2 => {
502 let n = usize::try_from(proto_var(data, &mut at)?)
503 .map_err(|_| ProfileError::ResourceLimit)?;
504 let start = at;
505 at = at.checked_add(n).ok_or(ProfileError::ResourceLimit)?;
506 out.push((
507 tag >> 3,
508 wire,
509 data.get(start..at).ok_or(ProfileError::InvalidMetadata)?,
510 ));
511 continue;
512 }
513 5 => at = at.checked_add(4).ok_or(ProfileError::ResourceLimit)?,
514 _ => return Err(ProfileError::InvalidMetadata),
515 }
516 out.push((
517 tag >> 3,
518 wire,
519 data.get(start..at).ok_or(ProfileError::InvalidMetadata)?,
520 ));
521 if out.len() > 65536 {
522 return Err(ProfileError::ResourceLimit);
523 }
524 }
525 Ok(out)
526}
527pub fn apply_group_projection(
528 location: &ProfileLocation,
529 keys: &KeyFile,
530 input: &GroupProjection,
531 options: &WriteOptions,
532) -> Result<()> {
533 ownership(location, keys, options)?;
534 identity(&input.username)?;
535 if !input.username.ends_with("@chatroom")
536 || input.members.len() > 10000
537 || input
538 .members
539 .iter()
540 .collect::<std::collections::BTreeSet<_>>()
541 .len()
542 != input.members.len()
543 {
544 return Err(ProfileError::InvalidMetadata);
545 }
546 let db = find_database(location, "contact.db")?;
547 let mut c = open_write(location, &db, keys, options.allow_plaintext)?;
548 let tx = c.transaction()?;
549 let id = map_id(&tx, "name2id", "username", &input.username)?;
550 let exists = tx
551 .query_row(
552 "SELECT id FROM contact WHERE id=?1 AND username=?2",
553 params![id, input.username],
554 |r| r.get::<_, i64>(0),
555 )
556 .optional()?;
557 if exists.is_none() {
558 return Err(ProfileError::InvalidMetadata);
559 }
560 let mut fields = input.fields.clone();
561 if fields.contains_key("id") || fields.contains_key("username") {
562 return Err(ProfileError::InvalidMetadata);
563 }
564 fields.insert("username".into(), SqlValue::Text(input.username.clone()));
565 if input.complete_roster {
566 let raw = match fields.get("ext_buffer") {
567 Some(SqlValue::Blob(v)) => STANDARD
568 .decode(v)
569 .map_err(|_| ProfileError::InvalidMetadata)?,
570 _ => return Err(ProfileError::InvalidMetadata),
571 };
572 let mut members = std::collections::BTreeSet::new();
573 for (_, wire, nested) in proto_fields(&raw)?.into_iter().filter(|f| f.0 == 1) {
574 if wire != 2 {
575 return Err(ProfileError::InvalidMetadata);
576 }
577 for (field, wire, v) in proto_fields(nested)? {
578 if field == 1 {
579 if wire != 2 {
580 return Err(ProfileError::InvalidMetadata);
581 }
582 members.insert(
583 std::str::from_utf8(v)
584 .map_err(|_| ProfileError::InvalidMetadata)?
585 .to_owned(),
586 );
587 }
588 }
589 }
590 if members != input.members.iter().cloned().collect() {
591 return Err(ProfileError::InvalidMetadata);
592 }
593 }
594 apply_row(
595 &tx,
596 "chat_room",
597 "id",
598 SqlValue::Integer(id.to_string()),
599 &fields,
600 true,
601 )?;
602 if let Some(fields) = &input.detail_fields {
603 apply_row(
604 &tx,
605 "chat_room_info_detail",
606 "room_id_",
607 SqlValue::Integer(id.to_string()),
608 fields,
609 true,
610 )?;
611 }
612 let mut member_ids = vec![];
613 for name in &input.members {
614 identity(name)?;
615 let member = tx
616 .query_row("SELECT id FROM contact WHERE username=?1", [name], |r| {
617 r.get::<_, i64>(0)
618 })
619 .optional()?
620 .ok_or(ProfileError::InvalidMetadata)?;
621 member_ids.push(member)
622 }
623 if input.complete_roster {
624 let existing = tx
625 .prepare("SELECT member_id FROM chatroom_member WHERE room_id=?1")?
626 .query_map([id], |r| r.get::<_, i64>(0))?
627 .collect::<std::result::Result<Vec<_>, _>>()?;
628 for old in existing {
629 if !member_ids.contains(&old) {
630 tx.execute(
631 "DELETE FROM chatroom_member WHERE room_id=?1 AND member_id=?2",
632 params![id, old],
633 )?;
634 }
635 }
636 }
637 for member in member_ids {
638 tx.execute("INSERT INTO chatroom_member(room_id,member_id)VALUES(?1,?2)ON CONFLICT(room_id,member_id)DO NOTHING",params![id,member])?;
639 }
640 tx.commit()?;
641 Ok(())
642}
643#[derive(Clone, Debug, Serialize, Deserialize)]
646#[serde(rename_all = "camelCase", deny_unknown_fields)]
647pub struct MessageProjection {
648 pub peer: String,
649 pub sender: String,
650 pub from: String,
651 pub to: String,
652 pub server_message_id: String,
653 pub fields: RawFields,
654 pub packed_other_fields: Vec<u8>,
656}
657#[derive(Clone, Debug, Serialize)]
658#[serde(rename_all = "camelCase")]
659pub struct AppliedMessage {
660 pub source_id: SourceMessageId,
661 pub inserted: bool,
662}
663pub(crate) fn var_out(mut n: u64, out: &mut Vec<u8>) {
664 while n >= 128 {
665 out.push((n as u8 & 127) | 128);
666 n >>= 7
667 }
668 out.push(n as u8)
669}
670pub fn apply_message_projection(
671 location: &ProfileLocation,
672 keys: &KeyFile,
673 input: &MessageProjection,
674 options: &WriteOptions,
675) -> Result<AppliedMessage> {
676 ownership(location, keys, options)?;
677 for s in [&input.peer, &input.sender, &input.from, &input.to] {
678 identity(s)?
679 }
680 let sid = input
681 .server_message_id
682 .parse::<u64>()
683 .map_err(|_| ProfileError::InvalidMetadata)?;
684 if sid == 0 {
685 return Err(ProfileError::InvalidMetadata);
686 }
687 let table = format!("Msg_{:x}", Md5::digest(input.peer.as_bytes()));
688 for (f, _, _) in proto_fields(&input.packed_other_fields)? {
689 if f == 1 || f == 2 {
690 return Err(ProfileError::InvalidMetadata);
691 }
692 }
693 let db = find_database(location, "message_0.db")?;
694 crate::writer::require_single_message_shard(location)?;
695 let mut c = open_write(location, &db, keys, options.allow_plaintext)?;
696 let tx = c.transaction()?;
697 let sender = map_id(&tx, "Name2Id", "user_name", &input.sender)?;
698 let from = map_id(&tx, "Name2Id", "user_name", &input.from)?;
699 let to = map_id(&tx, "Name2Id", "user_name", &input.to)?;
700 tx.execute_batch(
701 &include_str!("layout/conversation.sql")
702 .replace("__MESSAGE_TABLE__", &table)
703 .replace("CREATE TABLE ", "CREATE TABLE IF NOT EXISTS ")
704 .replace("CREATE INDEX ", "CREATE INDEX IF NOT EXISTS "),
705 )?;
706 let schema = crate::writer::exact_columns(&tx, &db, &table, crate::writer::MESSAGE_COLUMNS)?;
707 let indexes = [
708 ("_SERVERID", vec!["server_id"]),
709 ("_SORTSEQ", vec!["sort_seq"]),
710 ("_TYPE_SEQ", vec!["local_type", "sort_seq"]),
711 ("_SENDERID", vec!["real_sender_id"]),
712 ];
713 if schema.indexes.len() != 4
714 || !indexes.iter().all(|(suffix, cols)| {
715 schema.indexes.iter().any(|i| {
716 i.name == format!("{table}{suffix}")
717 && !i.unique
718 && !i.partial
719 && i.columns.iter().map(|x| x.as_deref()).collect::<Vec<_>>()
720 == cols.iter().map(|x| Some(*x)).collect::<Vec<_>>()
721 })
722 })
723 {
724 return Err(ProfileError::UnsupportedSchema(db));
725 }
726 let mut fields = input.fields.clone();
727 for name in [
728 "local_id",
729 "server_id",
730 "real_sender_id",
731 "packed_info_data",
732 ] {
733 if fields.contains_key(name) {
734 return Err(ProfileError::InvalidMetadata);
735 }
736 }
737 let mut packed = vec![8];
738 var_out(from as u64, &mut packed);
739 packed.push(16);
740 var_out(to as u64, &mut packed);
741 packed.extend(&input.packed_other_fields);
742 fields.insert(
743 "server_id".into(),
744 SqlValue::Integer((sid as i64).to_string()),
745 );
746 fields.insert(
747 "real_sender_id".into(),
748 SqlValue::Integer(sender.to_string()),
749 );
750 fields.insert(
751 "packed_info_data".into(),
752 SqlValue::Blob(STANDARD.encode(packed)),
753 );
754 let ids = tx
755 .prepare(&format!(
756 "SELECT local_id FROM {} WHERE server_id=?1",
757 quote_identifier(&table)
758 ))?
759 .query_map([sid as i64], |r| r.get::<_, i64>(0))?
760 .collect::<std::result::Result<Vec<_>, _>>()?;
761 if ids.len() > 1 {
762 return Err(ProfileError::InvalidMetadata);
763 }
764 let (local, inserted) = if let Some(id) = ids.first() {
765 let cols = fields.keys().cloned().collect::<Vec<_>>();
766 if row_values(&tx, &table, &cols, "local_id", &Value::Integer(*id))?.as_ref()
767 != Some(&fields)
768 {
769 return Err(ProfileError::InvalidMetadata);
770 }
771 (*id, false)
772 } else {
773 let schema = crate::schema::inspect(&tx, &db)?;
774 let expected = schema
775 .tables
776 .iter()
777 .find(|t| t.name == table)
778 .ok_or(ProfileError::InvalidMetadata)?;
779 if fields.len() + 1 != expected.columns.len()
780 || expected
781 .columns
782 .iter()
783 .any(|col| col.name != "local_id" && !fields.contains_key(&col.name))
784 {
785 return Err(ProfileError::InvalidMetadata);
786 }
787 let sort = match fields.get("sort_seq") {
790 Some(SqlValue::Integer(n)) => n
791 .parse::<i64>()
792 .map_err(|_| ProfileError::InvalidMetadata)?,
793 _ => return Err(ProfileError::InvalidMetadata),
794 };
795 let count: i64 = tx.query_row(
796 &format!(
797 "SELECT count(*) FROM {} WHERE sort_seq=?1",
798 quote_identifier(&table)
799 ),
800 [sort],
801 |r| r.get(0),
802 )?;
803 if count != 0 {
804 return Err(ProfileError::WritesUnsupported);
805 }
806 let names = fields.keys().cloned().collect::<Vec<_>>();
809 let mut vals = vec![];
810 let mut expr = vec![];
811 for v in fields.values() {
812 let (value, text) = bound(v)?;
813 vals.push(value);
814 let p = format!("?{}", vals.len());
815 expr.push(if text {
816 format!("CAST({p} AS TEXT)")
817 } else {
818 p
819 })
820 }
821 tx.execute(
822 &format!(
823 "INSERT INTO {}({})VALUES({})",
824 quote_identifier(&table),
825 names
826 .iter()
827 .map(|s| quote_identifier(s))
828 .collect::<Vec<_>>()
829 .join(","),
830 expr.join(",")
831 ),
832 rusqlite::params_from_iter(vals),
833 )?;
834 (tx.last_insert_rowid(), true)
835 };
836 tx.commit()?;
837 Ok(AppliedMessage {
838 source_id: SourceMessageId {
839 database: db,
840 table,
841 local_id: local.to_string(),
842 },
843 inserted,
844 })
845}
846pub fn apply_session_projection(
849 location: &ProfileLocation,
850 keys: &KeyFile,
851 peer: &str,
852 fields: &RawFields,
853 options: &WriteOptions,
854) -> Result<bool> {
855 ownership(location, keys, options)?;
856 identity(peer)?;
857 let db = find_database(location, "session.db")?;
858 let mut c = open_write(location, &db, keys, options.allow_plaintext)?;
859 let tx = c.transaction()?;
860 map_id(&tx, "Name2Id", "user_name", peer)?;
861 let changed = apply_row(
862 &tx,
863 "SessionTable",
864 "username",
865 SqlValue::Text(peer.into()),
866 fields,
867 true,
868 )?;
869 tx.commit()?;
870 Ok(changed)
871}
872
873pub fn apply_contact_patch(
877 location: &ProfileLocation,
878 keys: &KeyFile,
879 input: &ContactProjection,
880 options: &WriteOptions,
881) -> Result<AppliedContact> {
882 ownership(location, keys, options)?;
883 identity(&input.username)?;
884 let db = find_database(location, "contact.db")?;
885 let c = open_write(location, &db, keys, options.allow_plaintext)?;
886 check_known_table(&c, "contact")?;
887 let existing = c
888 .query_row(
889 "SELECT id FROM contact WHERE username=?1",
890 [&input.username],
891 |r| r.get::<_, i64>(0),
892 )
893 .optional()?;
894 if let Some(id) = existing.filter(|_| input.fields.is_empty()) {
895 return Ok(AppliedContact {
896 local_id: id,
897 changed: false,
898 });
899 }
900 let mut input = input.clone();
901 if existing.is_none() {
902 let mut fields = nullable_fields(&c, &db, "contact", &["id", "username"])?;
903 fields.extend(input.fields);
904 input.fields = fields;
905 }
906 drop(c);
907 apply_contact_projection(location, keys, &input, options)
908}
909fn nullable_fields(
910 c: &Connection,
911 database: &str,
912 table: &str,
913 exclude: &[&str],
914) -> Result<RawFields> {
915 let report = crate::schema::inspect(c, database)?;
916 let t = report
917 .tables
918 .iter()
919 .find(|t| t.name == table)
920 .ok_or(ProfileError::InvalidMetadata)?;
921 let mut fields = RawFields::new();
922 for col in &t.columns {
923 if exclude.contains(&col.name.as_str()) {
924 continue;
925 }
926 if col.not_null || col.default_sql.is_some() {
927 return Err(ProfileError::WritesUnsupported);
928 }
929 fields.insert(col.name.clone(), SqlValue::Null);
930 }
931 Ok(fields)
932}
933pub fn apply_group_patch(
936 location: &ProfileLocation,
937 keys: &KeyFile,
938 input: &GroupProjection,
939 options: &WriteOptions,
940) -> Result<()> {
941 ownership(location, keys, options)?;
942 let db = find_database(location, "contact.db")?;
943 let c = open_write(location, &db, keys, options.allow_plaintext)?;
944 check_known_table(&c, "chat_room")?;
945 let exists = c
946 .query_row(
947 "SELECT id FROM chat_room WHERE username=?1",
948 [&input.username],
949 |r| r.get::<_, i64>(0),
950 )
951 .optional()?
952 .is_some();
953 let mut input = input.clone();
954 if !exists {
955 let mut fields = nullable_fields(&c, &db, "chat_room", &["id", "username"])?;
956 fields.extend(input.fields);
957 input.fields = fields;
958 }
959 if let Some(detail) = &input.detail_fields {
960 check_known_table(&c, "chat_room_info_detail")?;
961 let exists = c
962 .query_row(
963 "SELECT room_id_ FROM chat_room_info_detail WHERE username_=?1",
964 [&input.username],
965 |r| r.get::<_, i64>(0),
966 )
967 .optional()?
968 .is_some();
969 if !exists {
970 let mut fields = nullable_fields(&c, &db, "chat_room_info_detail", &["room_id_"])?;
971 fields.extend(detail.clone());
972 input.detail_fields = Some(fields);
973 }
974 }
975 drop(c);
976 apply_group_projection(location, keys, &input, options)
977}
978
979#[derive(Clone, Debug, Serialize, Deserialize)]
980#[serde(rename_all = "camelCase", deny_unknown_fields)]
981pub struct NativeMemberPatch {
982 pub username: String,
983 pub display: Option<String>,
984 #[serde(default)]
985 pub flags: Option<u32>,
986 #[serde(default)]
987 pub opaque: Option<Vec<u8>>,
988}
989#[derive(Clone, Debug, Serialize, Deserialize)]
990#[serde(rename_all = "camelCase", deny_unknown_fields)]
991pub struct NativeRosterPatch {
992 pub username: String,
993 pub members: Vec<NativeMemberPatch>,
994 pub complete_roster: bool,
995 #[serde(default)]
997 pub administrator_ids: Option<Vec<String>>,
998}
999
1000pub enum NativeGroupEdit<'a> {
1003 RemoveMembers(&'a [String]),
1004 Leave {
1005 own: &'a str,
1006 },
1007 MyName {
1008 own: &'a str,
1009 display: &'a str,
1010 },
1011 Notifications {
1012 muted: bool,
1013 clear_minimized: bool,
1014 own: &'a str,
1015 },
1016 Unfold {
1017 own: String,
1018 },
1019 ClearAnnouncement,
1020}
1021
1022pub fn apply_native_group_edit(
1023 location: &ProfileLocation,
1024 keys: &KeyFile,
1025 room: &str,
1026 edit: NativeGroupEdit<'_>,
1027 options: &WriteOptions,
1028) -> Result<()> {
1029 ownership(location, keys, options)?;
1030 identity(room)?;
1031 if !room.ends_with("@chatroom") {
1032 return Err(ProfileError::InvalidMetadata);
1033 }
1034 let db = find_database(location, "contact.db")?;
1035 let mut c = open_write(location, &db, keys, options.allow_plaintext)?;
1036 for table in [
1037 "contact",
1038 "chat_room",
1039 "chatroom_member",
1040 "chat_room_info_detail",
1041 ] {
1042 check_known_table(&c, table)?;
1043 }
1044 let tx = c.transaction()?;
1045 let (id, old): (i64, Option<Vec<u8>>) = tx.query_row(
1046 "SELECT id,ext_buffer FROM chat_room WHERE username=?1",
1047 [room],
1048 |r| Ok((r.get(0)?, r.get(1)?)),
1049 )?;
1050 let mut packed = vec![];
1051 let mut remove = Vec::new();
1052 let mut seen = std::collections::BTreeSet::new();
1053 let mut matched_name = false;
1054 let unfold_own = match &edit {
1055 NativeGroupEdit::Notifications {
1056 own,
1057 clear_minimized: true,
1058 ..
1059 } => Some(*own),
1060 NativeGroupEdit::Unfold { own } => {
1061 identity(own)?;
1062 Some(own.as_str())
1063 }
1064 _ => None,
1065 };
1066 match &edit {
1067 NativeGroupEdit::RemoveMembers(names) => remove.extend_from_slice(names),
1068 NativeGroupEdit::Leave { own } => remove.push((*own).to_owned()),
1069 _ => {}
1070 }
1071 for name in &remove {
1072 identity(name)?;
1073 }
1074 for chunk in proto_chunks(old.as_deref().unwrap_or_default())? {
1075 if chunk.field != 1 {
1076 packed.extend_from_slice(chunk.raw);
1077 continue;
1078 }
1079 if chunk.wire != 2 {
1080 return Err(ProfileError::InvalidMetadata);
1081 }
1082 let name = member_name(chunk.value)?;
1083 if !seen.insert(name.clone()) {
1084 return Err(ProfileError::InvalidMetadata);
1085 }
1086 if remove.contains(&name) {
1087 continue;
1088 }
1089 let updated = match &edit {
1090 NativeGroupEdit::MyName { own, display } if name == *own => {
1091 matched_name = true;
1092 Some(patch_member(
1093 Some(chunk.value),
1094 &NativeMemberPatch {
1095 flags: None,
1096 opaque: None,
1097 username: name,
1098 display: Some((*display).into()),
1099 },
1100 )?)
1101 }
1102 _ if unfold_own == Some(name.as_str()) => {
1103 let mut member = vec![];
1104 let mut flag_seen = false;
1105 for field in proto_chunks(chunk.value)? {
1106 if field.field == 3 {
1107 if field.wire != 0 || flag_seen {
1108 return Err(ProfileError::InvalidMetadata);
1109 }
1110 flag_seen = true;
1111 let mut at = 0;
1112 let value = proto_var(field.value, &mut at)?;
1113 var_out(3 << 3, &mut member);
1114 var_out(value & !0x2000, &mut member);
1115 } else {
1116 member.extend_from_slice(field.raw);
1117 }
1118 }
1119 Some(member)
1120 }
1121 _ => None,
1122 };
1123 if let Some(member) = updated {
1124 proto_bytes(1, &member, &mut packed);
1125 } else {
1126 packed.extend_from_slice(chunk.raw);
1127 }
1128 }
1129 if matches!(edit, NativeGroupEdit::MyName { .. }) && !matched_name {
1130 return Err(ProfileError::InvalidMetadata);
1131 }
1132 if packed != old.as_deref().unwrap_or_default() {
1133 tx.execute(
1134 "UPDATE chat_room SET ext_buffer=?2 WHERE id=?1",
1135 rusqlite::params![id, packed],
1136 )?;
1137 }
1138 for name in &remove {
1139 tx.execute("DELETE FROM chatroom_member WHERE room_id=?1 AND member_id IN (SELECT id FROM contact WHERE username=?2)", rusqlite::params![id,name])?;
1140 }
1141 match edit {
1142 NativeGroupEdit::Leave { .. } => {
1143 if tx.execute("UPDATE contact SET is_in_chat_room=2 WHERE username=?1", [room])? != 1 { return Err(ProfileError::InvalidMetadata); }
1144 }
1145 NativeGroupEdit::Notifications { muted, clear_minimized, .. } => {
1146 if tx.execute("UPDATE contact SET chat_room_notify=?2,flag=CASE WHEN ?3 THEN flag & ~268435456 ELSE flag END WHERE username=?1 AND flag IS NOT NULL", rusqlite::params![room,i64::from(!muted),clear_minimized])? != 1 { return Err(ProfileError::InvalidMetadata); }
1147 }
1148 NativeGroupEdit::Unfold { .. } => {
1149 let flag: Option<i64> = tx.query_row("SELECT flag FROM contact WHERE username=?1 AND local_type=2 AND delete_flag=0 AND is_in_chat_room=1", [room], |r| r.get(0)).optional()?.flatten();
1150 let flag = flag.and_then(|flag| u32::try_from(flag).ok()).filter(|flag| flag & 2 != 0).ok_or(ProfileError::InvalidMetadata)?;
1151 if tx.execute("UPDATE contact SET flag=?2 WHERE username=?1", rusqlite::params![room,flag & !0x10000000])? != 1 { return Err(ProfileError::InvalidMetadata); }
1152 }
1153 NativeGroupEdit::ClearAnnouncement => {
1154 tx.execute("INSERT INTO chat_room_info_detail(room_id_,username_,announcement_,xml_announcement_)VALUES(?1,?2,'','') ON CONFLICT(room_id_) DO UPDATE SET announcement_='',xml_announcement_=''", rusqlite::params![id,room])?;
1155 }
1156 _ => {}
1157 }
1158 tx.commit()?;
1159 Ok(())
1160}
1161struct ProtoChunk<'a> {
1162 field: u64,
1163 wire: u8,
1164 raw: &'a [u8],
1165 value: &'a [u8],
1166}
1167fn proto_chunks(data: &[u8]) -> Result<Vec<ProtoChunk<'_>>> {
1168 if data.len() > 1024 * 1024 {
1169 return Err(ProfileError::ResourceLimit);
1170 }
1171 let mut at = 0;
1172 let mut chunks = vec![];
1173 while at < data.len() {
1174 let start = at;
1175 let tag = proto_var(data, &mut at)?;
1176 let field = tag >> 3;
1177 let wire = (tag & 7) as u8;
1178 if field == 0 || field >= 1 << 29 {
1179 return Err(ProfileError::InvalidMetadata);
1180 }
1181 let mut value_start = at;
1182 match wire {
1183 0 => {
1184 proto_var(data, &mut at)?;
1185 }
1186 1 => at = at.checked_add(8).ok_or(ProfileError::ResourceLimit)?,
1187 5 => at = at.checked_add(4).ok_or(ProfileError::ResourceLimit)?,
1188 2 => {
1189 let size = usize::try_from(proto_var(data, &mut at)?)
1190 .map_err(|_| ProfileError::ResourceLimit)?;
1191 value_start = at;
1192 at = at.checked_add(size).ok_or(ProfileError::ResourceLimit)?
1193 }
1194 _ => return Err(ProfileError::InvalidMetadata),
1195 }
1196 let raw = data.get(start..at).ok_or(ProfileError::InvalidMetadata)?;
1197 let value = data
1198 .get(value_start..at)
1199 .ok_or(ProfileError::InvalidMetadata)?;
1200 chunks.push(ProtoChunk {
1201 field,
1202 wire,
1203 raw,
1204 value,
1205 });
1206 if chunks.len() > 65536 {
1207 return Err(ProfileError::ResourceLimit);
1208 }
1209 }
1210 Ok(chunks)
1211}
1212fn proto_bytes(field: u64, value: &[u8], out: &mut Vec<u8>) {
1213 var_out((field << 3) | 2, out);
1214 var_out(value.len() as u64, out);
1215 out.extend_from_slice(value)
1216}
1217fn member_name(data: &[u8]) -> Result<String> {
1218 let mut name = None;
1219 let mut display_seen = false;
1220 for chunk in proto_chunks(data)? {
1221 match chunk.field {
1222 1 => {
1223 if chunk.wire != 2 || name.is_some() {
1224 return Err(ProfileError::InvalidMetadata);
1225 }
1226 let value =
1227 std::str::from_utf8(chunk.value).map_err(|_| ProfileError::InvalidMetadata)?;
1228 identity(value)?;
1229 name = Some(value.to_owned());
1230 }
1231 2 => {
1232 if chunk.wire != 2 || display_seen {
1233 return Err(ProfileError::InvalidMetadata);
1234 }
1235 std::str::from_utf8(chunk.value).map_err(|_| ProfileError::InvalidMetadata)?;
1236 display_seen = true;
1237 }
1238 _ => {}
1239 }
1240 }
1241 name.ok_or(ProfileError::InvalidMetadata)
1242}
1243fn patch_member(old: Option<&[u8]>, patch: &NativeMemberPatch) -> Result<Vec<u8>> {
1244 identity(&patch.username)?;
1245 if patch.opaque.as_ref().is_some_and(|b| b.len() > 65536) {
1246 return Err(ProfileError::ResourceLimit);
1247 }
1248 if patch
1249 .display
1250 .as_ref()
1251 .is_some_and(|s| s.len() > 65536 || s.contains('\0'))
1252 {
1253 return Err(ProfileError::InvalidMetadata);
1254 }
1255 let mut out = vec![];
1256 let mut wrote_display = false;
1257 let mut wrote_flags = false;
1258 let mut wrote_opaque = false;
1259 if let Some(old) = old {
1260 if member_name(old)? != patch.username {
1261 return Err(ProfileError::InvalidMetadata);
1262 }
1263 for chunk in proto_chunks(old)? {
1264 if chunk.field == 2 {
1265 if let Some(display) = &patch.display {
1266 proto_bytes(2, display.as_bytes(), &mut out);
1267 wrote_display = true;
1268 } else {
1269 out.extend_from_slice(chunk.raw);
1270 }
1271 } else if chunk.field == 3 && patch.flags.is_some() {
1272 if chunk.wire != 0 || wrote_flags {
1273 return Err(ProfileError::InvalidMetadata);
1274 }
1275 var_out(3 << 3, &mut out);
1276 var_out(u64::from(patch.flags.unwrap()), &mut out);
1277 wrote_flags = true;
1278 } else if chunk.field == 4 && patch.opaque.is_some() {
1279 if chunk.wire != 2 || wrote_opaque {
1280 return Err(ProfileError::InvalidMetadata);
1281 }
1282 proto_bytes(4, patch.opaque.as_deref().unwrap(), &mut out);
1283 wrote_opaque = true;
1284 } else {
1285 out.extend_from_slice(chunk.raw);
1286 }
1287 }
1288 } else {
1289 proto_bytes(1, patch.username.as_bytes(), &mut out);
1290 }
1291 if !wrote_display {
1292 if let Some(display) = &patch.display {
1293 proto_bytes(2, display.as_bytes(), &mut out)
1294 }
1295 }
1296 if !wrote_flags {
1297 if let Some(flags) = patch.flags {
1298 var_out(3 << 3, &mut out);
1299 var_out(u64::from(flags), &mut out);
1300 }
1301 }
1302 if !wrote_opaque {
1303 if let Some(opaque) = &patch.opaque {
1304 proto_bytes(4, opaque, &mut out);
1305 }
1306 }
1307 Ok(out)
1308}
1309pub fn apply_native_roster_patch(
1315 location: &ProfileLocation,
1316 keys: &KeyFile,
1317 input: &NativeRosterPatch,
1318 options: &WriteOptions,
1319) -> Result<()> {
1320 ownership(location, keys, options)?;
1321 identity(&input.username)?;
1322 if !input.username.ends_with("@chatroom") || input.members.len() > 10000 {
1323 return Err(ProfileError::InvalidMetadata);
1324 }
1325 let mut patches = std::collections::BTreeMap::new();
1326 if let Some(ids) = &input.administrator_ids {
1327 let mut seen = std::collections::BTreeSet::new();
1328 for id in ids {
1329 identity(id)?;
1330 if !seen.insert(id) || seen.len() > 10000 {
1331 return Err(ProfileError::InvalidMetadata);
1332 }
1333 }
1334 }
1335 for member in &input.members {
1336 identity(&member.username)?;
1337 if patches.insert(member.username.clone(), member).is_some() {
1338 return Err(ProfileError::InvalidMetadata);
1339 }
1340 }
1341 let db = find_database(location, "contact.db")?;
1342 let c = open_write(location, &db, keys, options.allow_plaintext)?;
1343 check_known_table(&c, "chat_room")?;
1344 check_known_table(&c, "chatroom_member")?;
1345 let room = c
1346 .query_row(
1347 "SELECT id,ext_buffer FROM chat_room WHERE username=?1",
1348 [&input.username],
1349 |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Option<Vec<u8>>>(1)?)),
1350 )
1351 .optional()?;
1352 let old = room
1353 .as_ref()
1354 .and_then(|(_, bytes)| bytes.as_deref())
1355 .unwrap_or(&[]);
1356 let chunks = proto_chunks(old)?;
1357 let mut old_members = std::collections::BTreeMap::new();
1358 for chunk in &chunks {
1359 if chunk.field == 1 {
1360 if chunk.wire != 2 {
1361 return Err(ProfileError::InvalidMetadata);
1362 }
1363 let name = member_name(chunk.value)?;
1364 if old_members.insert(name, chunk.value).is_some() {
1365 return Err(ProfileError::InvalidMetadata);
1366 }
1367 }
1368 }
1369 let mut result_members = if input.complete_roster {
1370 std::collections::BTreeSet::new()
1371 } else {
1372 old_members.keys().cloned().collect()
1373 };
1374 if !input.complete_roster {
1375 if let Some((id, _)) = &room {
1376 let names=c.prepare("SELECT c.username FROM chatroom_member m JOIN contact c ON c.id=m.member_id WHERE m.room_id=?1")?.query_map([id],|r|r.get::<_,String>(0))?.collect::<std::result::Result<Vec<_>,_>>()?;
1377 result_members.extend(names);
1378 }
1379 }
1380 result_members.extend(patches.keys().cloned());
1381 let mut packed = vec![];
1382 let mut written = std::collections::BTreeSet::new();
1383 for chunk in chunks {
1384 if chunk.field == 5 && input.administrator_ids.is_some() {
1385 if chunk.wire != 2 {
1386 return Err(ProfileError::InvalidMetadata);
1387 }
1388 continue;
1389 }
1390 if chunk.field != 1 {
1391 packed.extend_from_slice(chunk.raw);
1392 continue;
1393 }
1394 let name = member_name(chunk.value)?;
1395 if !result_members.contains(&name) {
1396 continue;
1397 }
1398 if let Some(patch) = patches.get(&name) {
1399 proto_bytes(1, &patch_member(Some(chunk.value), patch)?, &mut packed)
1400 } else {
1401 packed.extend_from_slice(chunk.raw)
1402 }
1403 written.insert(name);
1404 }
1405 for name in result_members.difference(&written) {
1408 let empty = NativeMemberPatch {
1409 flags: None,
1410 opaque: None,
1411 username: name.clone(),
1412 display: None,
1413 };
1414 let patch = patches.get(name).copied().unwrap_or(&empty);
1415 proto_bytes(1, &patch_member(None, patch)?, &mut packed);
1416 }
1417 let mut fields = RawFields::new();
1418 if let Some(ids) = &input.administrator_ids {
1419 for id in ids {
1420 proto_bytes(5, id.as_bytes(), &mut packed);
1421 }
1422 }
1423 fields.insert("ext_buffer".into(), SqlValue::Blob(STANDARD.encode(packed)));
1424 drop(c);
1425 apply_group_patch(
1426 location,
1427 keys,
1428 &GroupProjection {
1429 username: input.username.clone(),
1430 fields,
1431 detail_fields: None,
1432 members: result_members.into_iter().collect(),
1433 complete_roster: input.complete_roster,
1434 },
1435 options,
1436 )
1437}
1438
1439pub fn compressed_profile_field(bytes: &[u8]) -> Result<SqlValue> {
1442 if bytes.len() > 4 * 1024 * 1024 {
1443 return Err(ProfileError::ResourceLimit);
1444 }
1445 let compressed =
1446 zstd::stream::encode_all(bytes, 3).map_err(|_| ProfileError::InvalidMetadata)?;
1447 Ok(SqlValue::Blob(STANDARD.encode(compressed)))
1448}
1449pub fn find_projected_message(
1452 location: &ProfileLocation,
1453 keys: &KeyFile,
1454 peer: &str,
1455 server_id: &str,
1456 options: &WriteOptions,
1457) -> Result<Option<SourceMessageId>> {
1458 ownership(location, keys, options)?;
1459 identity(peer)?;
1460 crate::writer::require_single_message_shard(location)?;
1461 let sid = server_id
1462 .parse::<u64>()
1463 .map_err(|_| ProfileError::InvalidMetadata)?;
1464 if sid == 0 {
1465 return Err(ProfileError::InvalidMetadata);
1466 }
1467 let db = find_database(location, "message_0.db")?;
1468 let c = open_write(location, &db, keys, options.allow_plaintext)?;
1469 let table = format!("Msg_{:x}", Md5::digest(peer.as_bytes()));
1470 let present = c.query_row(
1471 "SELECT count(*)FROM sqlite_master WHERE type='table' AND name=?1",
1472 [&table],
1473 |r| r.get::<_, i64>(0),
1474 )?;
1475 if present == 0 {
1476 return Ok(None);
1477 }
1478 crate::writer::exact_columns(&c, &db, &table, crate::writer::MESSAGE_COLUMNS)?;
1479 let ids = c
1480 .prepare(&format!(
1481 "SELECT local_id FROM {} WHERE server_id=?1 LIMIT 2",
1482 quote_identifier(&table)
1483 ))?
1484 .query_map([sid as i64], |r| r.get::<_, i64>(0))?
1485 .collect::<std::result::Result<Vec<_>, _>>()?;
1486 if ids.len() > 1 {
1487 return Err(ProfileError::InvalidMetadata);
1488 }
1489 Ok(ids.first().map(|id| SourceMessageId {
1490 database: db,
1491 table,
1492 local_id: id.to_string(),
1493 }))
1494}
1495pub fn choose_message_sort_sequence(
1500 location: &ProfileLocation,
1501 keys: &KeyFile,
1502 peer: &str,
1503 server_id: &str,
1504 time: i64,
1505 options: &WriteOptions,
1506) -> Result<i64> {
1507 ownership(location, keys, options)?;
1508 identity(peer)?;
1509 if time < 0 {
1510 return Err(ProfileError::InvalidMetadata);
1511 }
1512 let base = time.checked_mul(1000).ok_or(ProfileError::ResourceLimit)?;
1513 let end = base.checked_add(999).ok_or(ProfileError::ResourceLimit)?;
1514 let db = find_database(location, "message_0.db")?;
1515 let c = open_write(location, &db, keys, options.allow_plaintext)?;
1516 let table = format!("Msg_{:x}", Md5::digest(peer.as_bytes()));
1517 if c.query_row(
1518 "SELECT count(*)FROM sqlite_master WHERE type='table' AND name=?1",
1519 [&table],
1520 |r| r.get::<_, i64>(0),
1521 )? == 0
1522 {
1523 return Ok(base);
1524 }
1525 if let Some(id) = find_projected_message(location, keys, peer, server_id, options)? {
1526 let value = c.query_row(
1527 &format!(
1528 "SELECT create_time,sort_seq FROM {} WHERE local_id=?1",
1529 quote_identifier(&table)
1530 ),
1531 [id.local_id
1532 .parse::<i64>()
1533 .map_err(|_| ProfileError::InvalidMetadata)?],
1534 |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)),
1535 )?;
1536 if value.0 != time {
1537 return Err(ProfileError::InvalidMetadata);
1538 }
1539 return Ok(value.1);
1540 }
1541 let used = c
1542 .prepare(&format!(
1543 "SELECT sort_seq FROM {} WHERE sort_seq BETWEEN ?1 AND ?2",
1544 quote_identifier(&table)
1545 ))?
1546 .query_map(params![base, end], |r| r.get::<_, i64>(0))?
1547 .collect::<std::result::Result<std::collections::BTreeSet<_>, _>>()?;
1548 (base..=end)
1549 .find(|n| !used.contains(n))
1550 .ok_or(ProfileError::ResourceLimit)
1551}
1552#[derive(Clone, Debug, Serialize, Deserialize)]
1553#[serde(rename_all = "camelCase", deny_unknown_fields)]
1554pub struct MessageSessionProjection {
1555 pub source: SourceMessageId,
1556 pub peer: String,
1557 pub summary: String,
1558 pub outgoing: bool,
1559 pub unread: bool,
1560 pub sender: Option<String>,
1561 pub sender_display: Option<String>,
1562 pub created_by_this_intent: bool,
1565}
1566pub fn apply_message_session_projection(
1570 location: &ProfileLocation,
1571 keys: &KeyFile,
1572 input: &MessageSessionProjection,
1573 options: &WriteOptions,
1574) -> Result<()> {
1575 ownership(location, keys, options)?;
1576 identity(&input.peer)?;
1577 if input.summary.len() > 4 * 1024 * 1024
1578 || input.source.table != format!("Msg_{:x}", Md5::digest(input.peer.as_bytes()))
1579 {
1580 return Err(ProfileError::InvalidMetadata);
1581 }
1582 let messages = open_write(
1583 location,
1584 &input.source.database,
1585 keys,
1586 options.allow_plaintext,
1587 )?;
1588 crate::writer::exact_columns(
1589 &messages,
1590 &input.source.database,
1591 &input.source.table,
1592 crate::writer::MESSAGE_COLUMNS,
1593 )?;
1594 let local = input
1595 .source
1596 .local_id
1597 .parse::<i64>()
1598 .map_err(|_| ProfileError::InvalidMetadata)?;
1599 let (server, time, kind) = messages.query_row(
1600 &format!(
1601 "SELECT server_id,create_time,local_type FROM {} WHERE local_id=?1",
1602 quote_identifier(&input.source.table)
1603 ),
1604 [local],
1605 |r| {
1606 Ok((
1607 r.get::<_, i64>(0)?,
1608 r.get::<_, i64>(1)?,
1609 r.get::<_, i64>(2)?,
1610 ))
1611 },
1612 )?;
1613 drop(messages);
1614 let db = find_database(location, "session.db")?;
1615 let mut c = open_write(location, &db, keys, options.allow_plaintext)?;
1616 for t in [
1617 "SessionTable",
1618 "SessionUnreadListTable_1",
1619 "SessionUnreadStatTable_1",
1620 ] {
1621 check_known_table(&c, t)?;
1622 }
1623 let tx = c.transaction()?;
1624 let session = map_id(&tx, "Name2Id", "user_name", &input.peer)?;
1625 let old=tx.query_row("SELECT unread_count,last_msg_locald_id,last_timestamp,last_clear_unread_timestamp FROM SessionTable WHERE username=?1",[&input.peer],|r|Ok((r.get::<_,Option<i64>>(0)?.unwrap_or(0),r.get::<_,Option<i64>>(1)?.unwrap_or(0),r.get::<_,Option<i64>>(2)?.unwrap_or(0),r.get::<_,Option<i64>>(3)?.unwrap_or(0)))).optional()?;
1626 if old.is_none() {
1627 tx.execute("INSERT INTO SessionTable(username,type,unread_count,unread_first_msg_srv_id,unread_first_pat_msg_local_id,unread_first_pat_msg_sort_seq,is_hidden,summary,draft,status,last_timestamp,sort_timestamp,last_clear_unread_timestamp,last_msg_locald_id,last_msg_type,last_msg_sub_type,last_msg_sender,last_sender_display_name,last_msg_ext_type)VALUES(?1,0,0,0,0,0,0,'','',0,0,0,0,0,0,0,'','',0)",[&input.peer])?;
1628 }
1629 let old = old.unwrap_or((0, 0, 0, 0));
1630 if old.0 < 0 {
1631 return Err(ProfileError::InvalidMetadata);
1632 }
1633 let unread=tx.query_row("SELECT local_id,create_time FROM SessionUnreadListTable_1 WHERE username_id=?1 AND server_id=?2",params![session,server],|r|Ok((r.get::<_,i64>(0)?,r.get::<_,i64>(1)?))).optional()?;
1634 if unread.is_some_and(|v| v != (local, time)) {
1635 return Err(ProfileError::InvalidMetadata);
1636 }
1637 let done = old.1 == local && old.2 == time;
1638 if !input.outgoing
1639 && input.unread
1640 && input.created_by_this_intent
1641 && unread.is_none()
1642 && !done
1643 && old.3 < time
1644 {
1645 tx.execute("INSERT INTO SessionUnreadListTable_1(username_id,server_id,local_id,create_time)VALUES(?1,?2,?3,?4)",params![session,server,local,time])?;
1649 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![server,input.peer])?;
1650 }
1651 if old.2 <= time && (old.2 < time || old.1 <= local) {
1652 let sender = if input.peer.ends_with("@chatroom") {
1653 input.sender.as_deref().unwrap_or("")
1654 } else {
1655 ""
1656 };
1657 let display = if input.peer.ends_with("@chatroom") {
1658 input.sender_display.as_deref().unwrap_or("")
1659 } else {
1660 ""
1661 };
1662 tx.execute("UPDATE SessionTable SET summary=?1,last_timestamp=?2,sort_timestamp=?2,last_msg_locald_id=?3,last_msg_type=?4,last_msg_sub_type=?5,last_msg_sender=?6,last_sender_display_name=?7,last_msg_ext_type=0 WHERE username=?8",params![input.summary,time,local,kind&0x7fffffff,kind>>32,sender,display,input.peer])?;
1663 }
1664 tx.commit()?;
1665 Ok(())
1666}
1667
1668#[cfg(test)]
1669mod pin_tests {
1670 use super::*;
1671
1672 fn fixture(encrypted: bool) -> (tempfile::TempDir, ProfileLocation, KeyFile, WriteOptions) {
1673 let temp = tempfile::tempdir().unwrap();
1674 let root = temp.path().join("account");
1675 let (location, keys) = if encrypted {
1676 let (location, keys, _) = initialize_profile(&root, &[42; 32]).unwrap();
1677 (location, keys)
1678 } else {
1679 for (relative, sql) in [
1680 ("contact/contact.db", include_str!("layout/contact.db.sql")),
1681 ("session/session.db", include_str!("layout/session.db.sql")),
1682 ] {
1683 let path = root.join("db_storage").join(relative);
1684 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1685 Connection::open(path).unwrap().execute_batch(sql).unwrap();
1686 }
1687 (
1688 ProfileLocation::new(&root).unwrap(),
1689 KeyFile::empty("account"),
1690 )
1691 };
1692 let options = WriteOptions {
1693 exclusive_ownership_confirmed: true,
1694 allow_plaintext: !encrypted,
1695 durable_intent_id: "synthetic-pin-receipt".into(),
1696 };
1697 (temp, location, keys, options)
1698 }
1699
1700 #[test]
1701 fn pin_projection_preserves_other_state_and_replays_without_rewinding_activity() {
1702 for encrypted in [false, true] {
1703 let (_temp, location, keys, options) = fixture(encrypted);
1704 let contact = open_write(&location, "contact/contact.db", &keys, !encrypted).unwrap();
1705 let session = open_write(&location, "session/session.db", &keys, !encrypted).unwrap();
1706 contact.execute_batch("INSERT INTO contact(id,username,flag,local_type,delete_flag,is_in_chat_room,chat_room_notify)VALUES(1,'peer',268436099,1,0,0,0),(2,'room@chatroom',268435586,2,0,1,0);").unwrap();
1707 session.execute_batch("INSERT INTO SessionTable(username,unread_count,draft,status,is_hidden,sort_timestamp,last_timestamp,last_msg_locald_id)VALUES('peer',4,'Keep draft',7,0,100,90,12),('room@chatroom',2,'Room draft',3,0,100,99,15);").unwrap();
1708 let saved_flag = || {
1709 contact
1710 .query_row(
1711 "SELECT flag FROM contact WHERE username='peer'",
1712 [],
1713 |row| row.get::<_, i64>(0),
1714 )
1715 .unwrap()
1716 };
1717 let saved_sort = || {
1718 session
1719 .query_row(
1720 "SELECT sort_timestamp FROM SessionTable WHERE username='peer'",
1721 [],
1722 |row| row.get::<_, i64>(0),
1723 )
1724 .unwrap()
1725 };
1726 let baseline = saved_flag();
1727 let result =
1728 apply_contact_pin_patch(&location, &keys, "peer", true, false, 200, &options)
1729 .unwrap()
1730 .unwrap();
1731 assert!(result.changed && result.session_projected);
1732 assert!(!result.cross_database_atomic);
1733 assert_eq!(saved_flag(), baseline | 0x800);
1734 assert_eq!(saved_sort(), 200);
1735 assert!(
1736 !apply_contact_pin_patch(&location, &keys, "peer", true, false, 300, &options)
1737 .unwrap()
1738 .unwrap()
1739 .changed
1740 );
1741 assert_eq!(saved_sort(), 200);
1742 session
1743 .execute(
1744 "UPDATE SessionTable SET sort_timestamp=400 WHERE username='peer'",
1745 [],
1746 )
1747 .unwrap();
1748 apply_contact_pin_patch(&location, &keys, "peer", false, false, 250, &options)
1749 .unwrap()
1750 .unwrap();
1751 assert_eq!(saved_flag(), baseline);
1752 assert_eq!(saved_sort(), 400);
1753 session
1755 .execute(
1756 "UPDATE SessionTable SET sort_timestamp=500 WHERE username='peer'",
1757 [],
1758 )
1759 .unwrap();
1760 apply_contact_pin_patch(&location, &keys, "peer", true, true, 450, &options)
1761 .unwrap()
1762 .unwrap();
1763 assert_eq!(saved_flag(), (baseline & !0x10000080) | 0x800);
1764 assert_eq!(saved_sort(), 500);
1765 let unrelated = session.query_row("SELECT unread_count,draft,status,is_hidden,last_timestamp,last_msg_locald_id FROM SessionTable WHERE username='peer'", [], |row| Ok((row.get::<_,i64>(0)?,row.get::<_,String>(1)?,row.get::<_,i64>(2)?,row.get::<_,i64>(3)?,row.get::<_,i64>(4)?,row.get::<_,i64>(5)?))).unwrap();
1766 assert_eq!(unrelated, (4, "Keep draft".into(), 7, 0, 90, 12));
1767 apply_contact_pin_patch(&location, &keys, "room@chatroom", true, true, 600, &options)
1768 .unwrap()
1769 .unwrap();
1770 assert_eq!(
1771 contact
1772 .query_row(
1773 "SELECT flag,chat_room_notify FROM contact WHERE username='room@chatroom'",
1774 [],
1775 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
1776 )
1777 .unwrap(),
1778 (2050, 0)
1779 );
1780 apply_contact_pin_patch(
1781 &location,
1782 &keys,
1783 "room@chatroom",
1784 false,
1785 false,
1786 700,
1787 &options,
1788 )
1789 .unwrap()
1790 .unwrap();
1791 assert_eq!(
1792 session
1793 .query_row(
1794 "SELECT sort_timestamp FROM SessionTable WHERE username='room@chatroom'",
1795 [],
1796 |row| row.get::<_, i64>(0)
1797 )
1798 .unwrap(),
1799 700
1800 );
1801 }
1802 }
1803
1804 #[test]
1805 fn pin_projection_preflights_both_schemas_and_keeps_wal_modes() {
1806 let (_temp, location, keys, options) = fixture(false);
1807 let contact = open_write(&location, "contact/contact.db", &keys, true).unwrap();
1808 let session = open_write(&location, "session/session.db", &keys, true).unwrap();
1809 contact.execute_batch("PRAGMA journal_mode=WAL;INSERT INTO contact(id,username,flag,local_type,delete_flag,is_in_chat_room)VALUES(1,'peer',1,1,0,0),(2,'no_session',1,1,0,0),(3,'missing_flag',NULL,1,0,0),(4,'filehelper',1,1,0,0),(5,'left@chatroom',2,2,0,2);").unwrap();
1810 session.execute_batch("PRAGMA journal_mode=WAL;INSERT INTO SessionTable(username,sort_timestamp)VALUES('peer',100),('missing_flag',100),('filehelper',100),('left@chatroom',100);").unwrap();
1811 for name in [
1812 "missing",
1813 "no_session",
1814 "missing_flag",
1815 "filehelper",
1816 "left@chatroom",
1817 ] {
1818 assert!(
1819 apply_contact_pin_patch(&location, &keys, name, true, false, 200, &options)
1820 .unwrap()
1821 .is_none(),
1822 "{name}"
1823 );
1824 }
1825 session
1826 .execute_batch("ALTER TABLE SessionTable ADD COLUMN unknown_column TEXT;")
1827 .unwrap();
1828 assert!(matches!(
1829 apply_contact_pin_patch(&location, &keys, "peer", true, false, 200, &options),
1830 Err(ProfileError::UnsupportedSchema(_))
1831 ));
1832 assert_eq!(
1833 contact
1834 .query_row(
1835 "SELECT flag FROM contact WHERE username='peer'",
1836 [],
1837 |row| row.get::<_, i64>(0)
1838 )
1839 .unwrap(),
1840 1
1841 );
1842 assert_eq!(
1843 session
1844 .query_row(
1845 "SELECT sort_timestamp FROM SessionTable WHERE username='peer'",
1846 [],
1847 |row| row.get::<_, i64>(0)
1848 )
1849 .unwrap(),
1850 100
1851 );
1852 session
1853 .execute_batch("ALTER TABLE SessionTable DROP COLUMN unknown_column;")
1854 .unwrap();
1855 let mut unowned = options.clone();
1856 unowned.exclusive_ownership_confirmed = false;
1857 assert!(matches!(
1858 apply_contact_pin_patch(&location, &keys, "peer", true, false, 200, &unowned),
1859 Err(ProfileError::SnapshotRequired)
1860 ));
1861 assert!(
1862 apply_contact_pin_patch(&location, &keys, "peer", false, true, 200, &options).is_err()
1863 );
1864 apply_contact_pin_patch(&location, &keys, "peer", true, false, 200, &options)
1865 .unwrap()
1866 .unwrap();
1867 for db in [&contact, &session] {
1868 assert_eq!(
1869 db.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
1870 .unwrap(),
1871 "wal"
1872 );
1873 }
1874 }
1875
1876 #[test]
1877 fn group_unfold_preserves_notification_state_other_members_and_opaque_fields() {
1878 let (_temp, location, keys, options) = fixture(false);
1879 let contact = open_write(&location, "contact/contact.db", &keys, true).unwrap();
1880 contact.execute_batch("INSERT INTO contact(id,username,flag,local_type,delete_flag,is_in_chat_room,chat_room_notify)VALUES(1,'room@chatroom',268435458,2,0,1,0);INSERT INTO chat_room(id,username)VALUES(1,'room@chatroom');").unwrap();
1881 let before = [
1882 10, 14, 10, 4, b's', b'e', b'l', b'f', 24, 129, 64, 34, 3, 1, 2, 3, 10, 9, 10, 4, b'p',
1883 b'e', b'e', b'r', 24, 129, 64, 42, 3, 9, 8, 7,
1884 ];
1885 let expected = [
1886 10, 13, 10, 4, b's', b'e', b'l', b'f', 24, 1, 34, 3, 1, 2, 3, 10, 9, 10, 4, b'p', b'e',
1887 b'e', b'r', 24, 129, 64, 42, 3, 9, 8, 7,
1888 ];
1889 contact
1890 .execute("UPDATE chat_room SET ext_buffer=?1", [before.as_slice()])
1891 .unwrap();
1892 for _ in 0..2 {
1893 apply_native_group_edit(
1894 &location,
1895 &keys,
1896 "room@chatroom",
1897 NativeGroupEdit::Unfold { own: "self".into() },
1898 &options,
1899 )
1900 .unwrap();
1901 }
1902 assert_eq!(
1903 contact
1904 .query_row(
1905 "SELECT flag,chat_room_notify FROM contact WHERE username='room@chatroom'",
1906 [],
1907 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
1908 )
1909 .unwrap(),
1910 (2, 0)
1911 );
1912 assert_eq!(
1913 contact
1914 .query_row("SELECT ext_buffer FROM chat_room", [], |row| row
1915 .get::<_, Vec<u8>>(0))
1916 .unwrap(),
1917 expected
1918 );
1919 contact
1921 .execute("UPDATE contact SET flag=4294967296", [])
1922 .unwrap();
1923 contact
1924 .execute("UPDATE chat_room SET ext_buffer=?1", [before.as_slice()])
1925 .unwrap();
1926 assert!(apply_native_group_edit(
1927 &location,
1928 &keys,
1929 "room@chatroom",
1930 NativeGroupEdit::Unfold { own: "self".into() },
1931 &options
1932 )
1933 .is_err());
1934 assert_eq!(
1935 contact
1936 .query_row("SELECT ext_buffer FROM chat_room", [], |row| row
1937 .get::<_, Vec<u8>>(0))
1938 .unwrap(),
1939 before
1940 );
1941 }
1942}