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 IS NULL OR 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::contact_not_deleted(&fields) {
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 if fields.len() + 1 != schema.columns.len()
774 || schema
775 .columns
776 .iter()
777 .any(|col| col.name != "local_id" && !fields.contains_key(&col.name))
778 {
779 return Err(ProfileError::InvalidMetadata);
780 }
781 let sort = match fields.get("sort_seq") {
784 Some(SqlValue::Integer(n)) => n
785 .parse::<i64>()
786 .map_err(|_| ProfileError::InvalidMetadata)?,
787 _ => return Err(ProfileError::InvalidMetadata),
788 };
789 let count: i64 = tx.query_row(
790 &format!(
791 "SELECT count(*) FROM {} WHERE sort_seq=?1",
792 quote_identifier(&table)
793 ),
794 [sort],
795 |r| r.get(0),
796 )?;
797 if count != 0 {
798 return Err(ProfileError::WritesUnsupported);
799 }
800 let names = fields.keys().cloned().collect::<Vec<_>>();
803 let mut vals = vec![];
804 let mut expr = vec![];
805 for v in fields.values() {
806 let (value, text) = bound(v)?;
807 vals.push(value);
808 let p = format!("?{}", vals.len());
809 expr.push(if text {
810 format!("CAST({p} AS TEXT)")
811 } else {
812 p
813 })
814 }
815 tx.execute(
816 &format!(
817 "INSERT INTO {}({})VALUES({})",
818 quote_identifier(&table),
819 names
820 .iter()
821 .map(|s| quote_identifier(s))
822 .collect::<Vec<_>>()
823 .join(","),
824 expr.join(",")
825 ),
826 rusqlite::params_from_iter(vals),
827 )?;
828 (tx.last_insert_rowid(), true)
829 };
830 tx.commit()?;
831 Ok(AppliedMessage {
832 source_id: SourceMessageId {
833 database: db,
834 table,
835 local_id: local.to_string(),
836 },
837 inserted,
838 })
839}
840pub fn apply_session_projection(
843 location: &ProfileLocation,
844 keys: &KeyFile,
845 peer: &str,
846 fields: &RawFields,
847 options: &WriteOptions,
848) -> Result<bool> {
849 ownership(location, keys, options)?;
850 identity(peer)?;
851 let db = find_database(location, "session.db")?;
852 let mut c = open_write(location, &db, keys, options.allow_plaintext)?;
853 let tx = c.transaction()?;
854 map_id(&tx, "Name2Id", "user_name", peer)?;
855 let changed = apply_row(
856 &tx,
857 "SessionTable",
858 "username",
859 SqlValue::Text(peer.into()),
860 fields,
861 true,
862 )?;
863 tx.commit()?;
864 Ok(changed)
865}
866
867pub fn apply_contact_patch(
871 location: &ProfileLocation,
872 keys: &KeyFile,
873 input: &ContactProjection,
874 options: &WriteOptions,
875) -> Result<AppliedContact> {
876 ownership(location, keys, options)?;
877 identity(&input.username)?;
878 let db = find_database(location, "contact.db")?;
879 let c = open_write(location, &db, keys, options.allow_plaintext)?;
880 check_known_table(&c, "contact")?;
881 let existing = c
882 .query_row(
883 "SELECT id FROM contact WHERE username=?1",
884 [&input.username],
885 |r| r.get::<_, i64>(0),
886 )
887 .optional()?;
888 if let Some(id) = existing.filter(|_| input.fields.is_empty()) {
889 return Ok(AppliedContact {
890 local_id: id,
891 changed: false,
892 });
893 }
894 let mut input = input.clone();
895 if existing.is_none() {
896 let mut fields = nullable_fields(&c, &db, "contact", &["id", "username"])?;
897 fields.insert("delete_flag".into(), SqlValue::Integer("0".into()));
900 fields.extend(input.fields);
901 input.fields = fields;
902 }
903 drop(c);
904 apply_contact_projection(location, keys, &input, options)
905}
906fn nullable_fields(
907 c: &Connection,
908 database: &str,
909 table: &str,
910 exclude: &[&str],
911) -> Result<RawFields> {
912 let report = crate::schema::inspect(c, database)?;
913 let t = report
914 .tables
915 .iter()
916 .find(|t| t.name == table)
917 .ok_or(ProfileError::InvalidMetadata)?;
918 let mut fields = RawFields::new();
919 for col in &t.columns {
920 if exclude.contains(&col.name.as_str()) {
921 continue;
922 }
923 if col.not_null || col.default_sql.is_some() {
924 return Err(ProfileError::WritesUnsupported);
925 }
926 fields.insert(col.name.clone(), SqlValue::Null);
927 }
928 Ok(fields)
929}
930pub fn apply_group_patch(
933 location: &ProfileLocation,
934 keys: &KeyFile,
935 input: &GroupProjection,
936 options: &WriteOptions,
937) -> Result<()> {
938 ownership(location, keys, options)?;
939 let db = find_database(location, "contact.db")?;
940 let c = open_write(location, &db, keys, options.allow_plaintext)?;
941 check_known_table(&c, "chat_room")?;
942 let exists = c
943 .query_row(
944 "SELECT id FROM chat_room WHERE username=?1",
945 [&input.username],
946 |r| r.get::<_, i64>(0),
947 )
948 .optional()?
949 .is_some();
950 let mut input = input.clone();
951 if !exists {
952 let mut fields = nullable_fields(&c, &db, "chat_room", &["id", "username"])?;
953 fields.extend(input.fields);
954 input.fields = fields;
955 }
956 if let Some(detail) = &input.detail_fields {
957 check_known_table(&c, "chat_room_info_detail")?;
958 let exists = c
959 .query_row(
960 "SELECT room_id_ FROM chat_room_info_detail WHERE username_=?1",
961 [&input.username],
962 |r| r.get::<_, i64>(0),
963 )
964 .optional()?
965 .is_some();
966 if !exists {
967 let mut fields = nullable_fields(&c, &db, "chat_room_info_detail", &["room_id_"])?;
968 fields.extend(detail.clone());
969 input.detail_fields = Some(fields);
970 }
971 }
972 drop(c);
973 apply_group_projection(location, keys, &input, options)
974}
975
976#[derive(Clone, Debug, Serialize, Deserialize)]
977#[serde(rename_all = "camelCase", deny_unknown_fields)]
978pub struct NativeMemberPatch {
979 pub username: String,
980 pub display: Option<String>,
981 #[serde(default)]
982 pub flags: Option<u32>,
983 #[serde(default)]
984 pub opaque: Option<Vec<u8>>,
985}
986#[derive(Clone, Debug, Serialize, Deserialize)]
987#[serde(rename_all = "camelCase", deny_unknown_fields)]
988pub struct NativeRosterPatch {
989 pub username: String,
990 pub members: Vec<NativeMemberPatch>,
991 pub complete_roster: bool,
992 #[serde(default)]
994 pub administrator_ids: Option<Vec<String>>,
995}
996
997pub enum NativeGroupEdit<'a> {
1000 RemoveMembers(&'a [String]),
1001 Leave {
1002 own: &'a str,
1003 },
1004 MyName {
1005 own: &'a str,
1006 display: &'a str,
1007 },
1008 Notifications {
1009 muted: bool,
1010 clear_minimized: bool,
1011 own: &'a str,
1012 },
1013 Unfold {
1014 own: String,
1015 },
1016 ClearAnnouncement,
1017}
1018
1019pub fn apply_native_group_edit(
1020 location: &ProfileLocation,
1021 keys: &KeyFile,
1022 room: &str,
1023 edit: NativeGroupEdit<'_>,
1024 options: &WriteOptions,
1025) -> Result<()> {
1026 ownership(location, keys, options)?;
1027 identity(room)?;
1028 if !room.ends_with("@chatroom") {
1029 return Err(ProfileError::InvalidMetadata);
1030 }
1031 let db = find_database(location, "contact.db")?;
1032 let mut c = open_write(location, &db, keys, options.allow_plaintext)?;
1033 for table in [
1034 "contact",
1035 "chat_room",
1036 "chatroom_member",
1037 "chat_room_info_detail",
1038 ] {
1039 check_known_table(&c, table)?;
1040 }
1041 let tx = c.transaction()?;
1042 let (id, old): (i64, Option<Vec<u8>>) = tx.query_row(
1043 "SELECT id,ext_buffer FROM chat_room WHERE username=?1",
1044 [room],
1045 |r| Ok((r.get(0)?, r.get(1)?)),
1046 )?;
1047 let mut packed = vec![];
1048 let mut remove = Vec::new();
1049 let mut seen = std::collections::BTreeSet::new();
1050 let mut matched_name = false;
1051 let unfold_own = match &edit {
1052 NativeGroupEdit::Notifications {
1053 own,
1054 clear_minimized: true,
1055 ..
1056 } => Some(*own),
1057 NativeGroupEdit::Unfold { own } => {
1058 identity(own)?;
1059 Some(own.as_str())
1060 }
1061 _ => None,
1062 };
1063 match &edit {
1064 NativeGroupEdit::RemoveMembers(names) => remove.extend_from_slice(names),
1065 NativeGroupEdit::Leave { own } => remove.push((*own).to_owned()),
1066 _ => {}
1067 }
1068 for name in &remove {
1069 identity(name)?;
1070 }
1071 for chunk in proto_chunks(old.as_deref().unwrap_or_default())? {
1072 if chunk.field != 1 {
1073 packed.extend_from_slice(chunk.raw);
1074 continue;
1075 }
1076 if chunk.wire != 2 {
1077 return Err(ProfileError::InvalidMetadata);
1078 }
1079 let name = member_name(chunk.value)?;
1080 if !seen.insert(name.clone()) {
1081 return Err(ProfileError::InvalidMetadata);
1082 }
1083 if remove.contains(&name) {
1084 continue;
1085 }
1086 let updated = match &edit {
1087 NativeGroupEdit::MyName { own, display } if name == *own => {
1088 matched_name = true;
1089 Some(patch_member(
1090 Some(chunk.value),
1091 &NativeMemberPatch {
1092 flags: None,
1093 opaque: None,
1094 username: name,
1095 display: Some((*display).into()),
1096 },
1097 )?)
1098 }
1099 _ if unfold_own == Some(name.as_str()) => {
1100 let mut member = vec![];
1101 let mut flag_seen = false;
1102 for field in proto_chunks(chunk.value)? {
1103 if field.field == 3 {
1104 if field.wire != 0 || flag_seen {
1105 return Err(ProfileError::InvalidMetadata);
1106 }
1107 flag_seen = true;
1108 let mut at = 0;
1109 let value = proto_var(field.value, &mut at)?;
1110 var_out(3 << 3, &mut member);
1111 var_out(value & !0x2000, &mut member);
1112 } else {
1113 member.extend_from_slice(field.raw);
1114 }
1115 }
1116 Some(member)
1117 }
1118 _ => None,
1119 };
1120 if let Some(member) = updated {
1121 proto_bytes(1, &member, &mut packed);
1122 } else {
1123 packed.extend_from_slice(chunk.raw);
1124 }
1125 }
1126 if matches!(edit, NativeGroupEdit::MyName { .. }) && !matched_name {
1127 return Err(ProfileError::InvalidMetadata);
1128 }
1129 if packed != old.as_deref().unwrap_or_default() {
1130 tx.execute(
1131 "UPDATE chat_room SET ext_buffer=?2 WHERE id=?1",
1132 rusqlite::params![id, packed],
1133 )?;
1134 }
1135 for name in &remove {
1136 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])?;
1137 }
1138 match edit {
1139 NativeGroupEdit::Leave { .. } => {
1140 if tx.execute("UPDATE contact SET is_in_chat_room=2 WHERE username=?1", [room])? != 1 { return Err(ProfileError::InvalidMetadata); }
1141 }
1142 NativeGroupEdit::Notifications { muted, clear_minimized, .. } => {
1143 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); }
1144 }
1145 NativeGroupEdit::Unfold { .. } => {
1146 let flag: Option<i64> = tx.query_row("SELECT flag FROM contact WHERE username=?1 AND local_type=2 AND (delete_flag IS NULL OR delete_flag=0) AND is_in_chat_room=1", [room], |r| r.get(0)).optional()?.flatten();
1147 let flag = flag.and_then(|flag| u32::try_from(flag).ok()).filter(|flag| flag & 2 != 0).ok_or(ProfileError::InvalidMetadata)?;
1148 if tx.execute("UPDATE contact SET flag=?2 WHERE username=?1", rusqlite::params![room,flag & !0x10000000])? != 1 { return Err(ProfileError::InvalidMetadata); }
1149 }
1150 NativeGroupEdit::ClearAnnouncement => {
1151 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])?;
1152 }
1153 _ => {}
1154 }
1155 tx.commit()?;
1156 Ok(())
1157}
1158struct ProtoChunk<'a> {
1159 field: u64,
1160 wire: u8,
1161 raw: &'a [u8],
1162 value: &'a [u8],
1163}
1164fn proto_chunks(data: &[u8]) -> Result<Vec<ProtoChunk<'_>>> {
1165 if data.len() > 1024 * 1024 {
1166 return Err(ProfileError::ResourceLimit);
1167 }
1168 let mut at = 0;
1169 let mut chunks = vec![];
1170 while at < data.len() {
1171 let start = at;
1172 let tag = proto_var(data, &mut at)?;
1173 let field = tag >> 3;
1174 let wire = (tag & 7) as u8;
1175 if field == 0 || field >= 1 << 29 {
1176 return Err(ProfileError::InvalidMetadata);
1177 }
1178 let mut value_start = at;
1179 match wire {
1180 0 => {
1181 proto_var(data, &mut at)?;
1182 }
1183 1 => at = at.checked_add(8).ok_or(ProfileError::ResourceLimit)?,
1184 5 => at = at.checked_add(4).ok_or(ProfileError::ResourceLimit)?,
1185 2 => {
1186 let size = usize::try_from(proto_var(data, &mut at)?)
1187 .map_err(|_| ProfileError::ResourceLimit)?;
1188 value_start = at;
1189 at = at.checked_add(size).ok_or(ProfileError::ResourceLimit)?
1190 }
1191 _ => return Err(ProfileError::InvalidMetadata),
1192 }
1193 let raw = data.get(start..at).ok_or(ProfileError::InvalidMetadata)?;
1194 let value = data
1195 .get(value_start..at)
1196 .ok_or(ProfileError::InvalidMetadata)?;
1197 chunks.push(ProtoChunk {
1198 field,
1199 wire,
1200 raw,
1201 value,
1202 });
1203 if chunks.len() > 65536 {
1204 return Err(ProfileError::ResourceLimit);
1205 }
1206 }
1207 Ok(chunks)
1208}
1209fn proto_bytes(field: u64, value: &[u8], out: &mut Vec<u8>) {
1210 var_out((field << 3) | 2, out);
1211 var_out(value.len() as u64, out);
1212 out.extend_from_slice(value)
1213}
1214fn member_name(data: &[u8]) -> Result<String> {
1215 let mut name = None;
1216 let mut display_seen = false;
1217 for chunk in proto_chunks(data)? {
1218 match chunk.field {
1219 1 => {
1220 if chunk.wire != 2 || name.is_some() {
1221 return Err(ProfileError::InvalidMetadata);
1222 }
1223 let value =
1224 std::str::from_utf8(chunk.value).map_err(|_| ProfileError::InvalidMetadata)?;
1225 identity(value)?;
1226 name = Some(value.to_owned());
1227 }
1228 2 => {
1229 if chunk.wire != 2 || display_seen {
1230 return Err(ProfileError::InvalidMetadata);
1231 }
1232 std::str::from_utf8(chunk.value).map_err(|_| ProfileError::InvalidMetadata)?;
1233 display_seen = true;
1234 }
1235 _ => {}
1236 }
1237 }
1238 name.ok_or(ProfileError::InvalidMetadata)
1239}
1240fn patch_member(old: Option<&[u8]>, patch: &NativeMemberPatch) -> Result<Vec<u8>> {
1241 identity(&patch.username)?;
1242 if patch.opaque.as_ref().is_some_and(|b| b.len() > 65536) {
1243 return Err(ProfileError::ResourceLimit);
1244 }
1245 if patch
1246 .display
1247 .as_ref()
1248 .is_some_and(|s| s.len() > 65536 || s.contains('\0'))
1249 {
1250 return Err(ProfileError::InvalidMetadata);
1251 }
1252 let mut out = vec![];
1253 let mut wrote_display = false;
1254 let mut wrote_flags = false;
1255 let mut wrote_opaque = false;
1256 if let Some(old) = old {
1257 if member_name(old)? != patch.username {
1258 return Err(ProfileError::InvalidMetadata);
1259 }
1260 for chunk in proto_chunks(old)? {
1261 if chunk.field == 2 {
1262 if let Some(display) = &patch.display {
1263 proto_bytes(2, display.as_bytes(), &mut out);
1264 wrote_display = true;
1265 } else {
1266 out.extend_from_slice(chunk.raw);
1267 }
1268 } else if chunk.field == 3 && patch.flags.is_some() {
1269 if chunk.wire != 0 || wrote_flags {
1270 return Err(ProfileError::InvalidMetadata);
1271 }
1272 var_out(3 << 3, &mut out);
1273 var_out(u64::from(patch.flags.unwrap()), &mut out);
1274 wrote_flags = true;
1275 } else if chunk.field == 4 && patch.opaque.is_some() {
1276 if chunk.wire != 2 || wrote_opaque {
1277 return Err(ProfileError::InvalidMetadata);
1278 }
1279 proto_bytes(4, patch.opaque.as_deref().unwrap(), &mut out);
1280 wrote_opaque = true;
1281 } else {
1282 out.extend_from_slice(chunk.raw);
1283 }
1284 }
1285 } else {
1286 proto_bytes(1, patch.username.as_bytes(), &mut out);
1287 }
1288 if !wrote_display {
1289 if let Some(display) = &patch.display {
1290 proto_bytes(2, display.as_bytes(), &mut out)
1291 }
1292 }
1293 if !wrote_flags {
1294 if let Some(flags) = patch.flags {
1295 var_out(3 << 3, &mut out);
1296 var_out(u64::from(flags), &mut out);
1297 }
1298 }
1299 if !wrote_opaque {
1300 if let Some(opaque) = &patch.opaque {
1301 proto_bytes(4, opaque, &mut out);
1302 }
1303 }
1304 Ok(out)
1305}
1306pub fn apply_native_roster_patch(
1312 location: &ProfileLocation,
1313 keys: &KeyFile,
1314 input: &NativeRosterPatch,
1315 options: &WriteOptions,
1316) -> Result<()> {
1317 ownership(location, keys, options)?;
1318 identity(&input.username)?;
1319 if !input.username.ends_with("@chatroom") || input.members.len() > 10000 {
1320 return Err(ProfileError::InvalidMetadata);
1321 }
1322 let mut patches = std::collections::BTreeMap::new();
1323 if let Some(ids) = &input.administrator_ids {
1324 let mut seen = std::collections::BTreeSet::new();
1325 for id in ids {
1326 identity(id)?;
1327 if !seen.insert(id) || seen.len() > 10000 {
1328 return Err(ProfileError::InvalidMetadata);
1329 }
1330 }
1331 }
1332 for member in &input.members {
1333 identity(&member.username)?;
1334 if patches.insert(member.username.clone(), member).is_some() {
1335 return Err(ProfileError::InvalidMetadata);
1336 }
1337 }
1338 let db = find_database(location, "contact.db")?;
1339 let c = open_write(location, &db, keys, options.allow_plaintext)?;
1340 check_known_table(&c, "chat_room")?;
1341 check_known_table(&c, "chatroom_member")?;
1342 let room = c
1343 .query_row(
1344 "SELECT id,ext_buffer FROM chat_room WHERE username=?1",
1345 [&input.username],
1346 |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Option<Vec<u8>>>(1)?)),
1347 )
1348 .optional()?;
1349 let old = room
1350 .as_ref()
1351 .and_then(|(_, bytes)| bytes.as_deref())
1352 .unwrap_or(&[]);
1353 let chunks = proto_chunks(old)?;
1354 let mut old_members = std::collections::BTreeMap::new();
1355 for chunk in &chunks {
1356 if chunk.field == 1 {
1357 if chunk.wire != 2 {
1358 return Err(ProfileError::InvalidMetadata);
1359 }
1360 let name = member_name(chunk.value)?;
1361 if old_members.insert(name, chunk.value).is_some() {
1362 return Err(ProfileError::InvalidMetadata);
1363 }
1364 }
1365 }
1366 let mut result_members = if input.complete_roster {
1367 std::collections::BTreeSet::new()
1368 } else {
1369 old_members.keys().cloned().collect()
1370 };
1371 if !input.complete_roster {
1372 if let Some((id, _)) = &room {
1373 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<_>,_>>()?;
1374 result_members.extend(names);
1375 }
1376 }
1377 result_members.extend(patches.keys().cloned());
1378 let mut packed = vec![];
1379 let mut written = std::collections::BTreeSet::new();
1380 for chunk in chunks {
1381 if chunk.field == 5 && input.administrator_ids.is_some() {
1382 if chunk.wire != 2 {
1383 return Err(ProfileError::InvalidMetadata);
1384 }
1385 continue;
1386 }
1387 if chunk.field != 1 {
1388 packed.extend_from_slice(chunk.raw);
1389 continue;
1390 }
1391 let name = member_name(chunk.value)?;
1392 if !result_members.contains(&name) {
1393 continue;
1394 }
1395 if let Some(patch) = patches.get(&name) {
1396 proto_bytes(1, &patch_member(Some(chunk.value), patch)?, &mut packed)
1397 } else {
1398 packed.extend_from_slice(chunk.raw)
1399 }
1400 written.insert(name);
1401 }
1402 for name in result_members.difference(&written) {
1405 let empty = NativeMemberPatch {
1406 flags: None,
1407 opaque: None,
1408 username: name.clone(),
1409 display: None,
1410 };
1411 let patch = patches.get(name).copied().unwrap_or(&empty);
1412 proto_bytes(1, &patch_member(None, patch)?, &mut packed);
1413 }
1414 let mut fields = RawFields::new();
1415 if let Some(ids) = &input.administrator_ids {
1416 for id in ids {
1417 proto_bytes(5, id.as_bytes(), &mut packed);
1418 }
1419 }
1420 fields.insert("ext_buffer".into(), SqlValue::Blob(STANDARD.encode(packed)));
1421 drop(c);
1422 apply_group_patch(
1423 location,
1424 keys,
1425 &GroupProjection {
1426 username: input.username.clone(),
1427 fields,
1428 detail_fields: None,
1429 members: result_members.into_iter().collect(),
1430 complete_roster: input.complete_roster,
1431 },
1432 options,
1433 )
1434}
1435
1436pub fn compressed_profile_field(bytes: &[u8]) -> Result<SqlValue> {
1439 if bytes.len() > 4 * 1024 * 1024 {
1440 return Err(ProfileError::ResourceLimit);
1441 }
1442 let compressed =
1443 zstd::stream::encode_all(bytes, 3).map_err(|_| ProfileError::InvalidMetadata)?;
1444 Ok(SqlValue::Blob(STANDARD.encode(compressed)))
1445}
1446pub fn find_projected_message(
1449 location: &ProfileLocation,
1450 keys: &KeyFile,
1451 peer: &str,
1452 server_id: &str,
1453 options: &WriteOptions,
1454) -> Result<Option<SourceMessageId>> {
1455 ownership(location, keys, options)?;
1456 identity(peer)?;
1457 crate::writer::require_single_message_shard(location)?;
1458 let sid = server_id
1459 .parse::<u64>()
1460 .map_err(|_| ProfileError::InvalidMetadata)?;
1461 if sid == 0 {
1462 return Err(ProfileError::InvalidMetadata);
1463 }
1464 let db = find_database(location, "message_0.db")?;
1465 let c = open_write(location, &db, keys, options.allow_plaintext)?;
1466 let table = format!("Msg_{:x}", Md5::digest(peer.as_bytes()));
1467 let present = c.query_row(
1468 "SELECT count(*)FROM sqlite_master WHERE type='table' AND name=?1",
1469 [&table],
1470 |r| r.get::<_, i64>(0),
1471 )?;
1472 if present == 0 {
1473 return Ok(None);
1474 }
1475 crate::writer::exact_columns(&c, &db, &table, crate::writer::MESSAGE_COLUMNS)?;
1476 let ids = c
1477 .prepare(&format!(
1478 "SELECT local_id FROM {} WHERE server_id=?1 LIMIT 2",
1479 quote_identifier(&table)
1480 ))?
1481 .query_map([sid as i64], |r| r.get::<_, i64>(0))?
1482 .collect::<std::result::Result<Vec<_>, _>>()?;
1483 if ids.len() > 1 {
1484 return Err(ProfileError::InvalidMetadata);
1485 }
1486 Ok(ids.first().map(|id| SourceMessageId {
1487 database: db,
1488 table,
1489 local_id: id.to_string(),
1490 }))
1491}
1492pub fn choose_message_sort_sequence(
1497 location: &ProfileLocation,
1498 keys: &KeyFile,
1499 peer: &str,
1500 server_id: &str,
1501 time: i64,
1502 options: &WriteOptions,
1503) -> Result<i64> {
1504 ownership(location, keys, options)?;
1505 identity(peer)?;
1506 if time < 0 {
1507 return Err(ProfileError::InvalidMetadata);
1508 }
1509 let base = time.checked_mul(1000).ok_or(ProfileError::ResourceLimit)?;
1510 let end = base.checked_add(999).ok_or(ProfileError::ResourceLimit)?;
1511 let db = find_database(location, "message_0.db")?;
1512 let c = open_write(location, &db, keys, options.allow_plaintext)?;
1513 let table = format!("Msg_{:x}", Md5::digest(peer.as_bytes()));
1514 if c.query_row(
1515 "SELECT count(*)FROM sqlite_master WHERE type='table' AND name=?1",
1516 [&table],
1517 |r| r.get::<_, i64>(0),
1518 )? == 0
1519 {
1520 return Ok(base);
1521 }
1522 if let Some(id) = find_projected_message(location, keys, peer, server_id, options)? {
1523 let value = c.query_row(
1524 &format!(
1525 "SELECT create_time,sort_seq FROM {} WHERE local_id=?1",
1526 quote_identifier(&table)
1527 ),
1528 [id.local_id
1529 .parse::<i64>()
1530 .map_err(|_| ProfileError::InvalidMetadata)?],
1531 |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)),
1532 )?;
1533 if value.0 != time {
1534 return Err(ProfileError::InvalidMetadata);
1535 }
1536 return Ok(value.1);
1537 }
1538 let used = c
1539 .prepare(&format!(
1540 "SELECT sort_seq FROM {} WHERE sort_seq BETWEEN ?1 AND ?2",
1541 quote_identifier(&table)
1542 ))?
1543 .query_map(params![base, end], |r| r.get::<_, i64>(0))?
1544 .collect::<std::result::Result<std::collections::BTreeSet<_>, _>>()?;
1545 (base..=end)
1546 .find(|n| !used.contains(n))
1547 .ok_or(ProfileError::ResourceLimit)
1548}
1549#[derive(Clone, Debug, Serialize, Deserialize)]
1550#[serde(rename_all = "camelCase", deny_unknown_fields)]
1551pub struct MessageSessionProjection {
1552 pub source: SourceMessageId,
1553 pub peer: String,
1554 pub summary: String,
1555 pub outgoing: bool,
1556 pub unread: bool,
1557 pub sender: Option<String>,
1558 pub sender_display: Option<String>,
1559 pub created_by_this_intent: bool,
1562}
1563pub fn apply_message_session_projection(
1567 location: &ProfileLocation,
1568 keys: &KeyFile,
1569 input: &MessageSessionProjection,
1570 options: &WriteOptions,
1571) -> Result<()> {
1572 ownership(location, keys, options)?;
1573 identity(&input.peer)?;
1574 if input.summary.len() > 4 * 1024 * 1024
1575 || input.source.table != format!("Msg_{:x}", Md5::digest(input.peer.as_bytes()))
1576 {
1577 return Err(ProfileError::InvalidMetadata);
1578 }
1579 let messages = open_write(
1580 location,
1581 &input.source.database,
1582 keys,
1583 options.allow_plaintext,
1584 )?;
1585 crate::writer::exact_columns(
1586 &messages,
1587 &input.source.database,
1588 &input.source.table,
1589 crate::writer::MESSAGE_COLUMNS,
1590 )?;
1591 let local = input
1592 .source
1593 .local_id
1594 .parse::<i64>()
1595 .map_err(|_| ProfileError::InvalidMetadata)?;
1596 let (server, time, kind) = messages.query_row(
1597 &format!(
1598 "SELECT server_id,create_time,local_type FROM {} WHERE local_id=?1",
1599 quote_identifier(&input.source.table)
1600 ),
1601 [local],
1602 |r| {
1603 Ok((
1604 r.get::<_, i64>(0)?,
1605 r.get::<_, i64>(1)?,
1606 r.get::<_, i64>(2)?,
1607 ))
1608 },
1609 )?;
1610 drop(messages);
1611 let db = find_database(location, "session.db")?;
1612 let mut c = open_write(location, &db, keys, options.allow_plaintext)?;
1613 for t in [
1614 "SessionTable",
1615 "SessionUnreadListTable_1",
1616 "SessionUnreadStatTable_1",
1617 ] {
1618 check_known_table(&c, t)?;
1619 }
1620 let tx = c.transaction()?;
1621 let session = map_id(&tx, "Name2Id", "user_name", &input.peer)?;
1622 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()?;
1623 if old.is_none() {
1624 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])?;
1625 }
1626 let old = old.unwrap_or((0, 0, 0, 0));
1627 if old.0 < 0 {
1628 return Err(ProfileError::InvalidMetadata);
1629 }
1630 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()?;
1631 if unread.is_some_and(|v| v != (local, time)) {
1632 return Err(ProfileError::InvalidMetadata);
1633 }
1634 let done = old.1 == local && old.2 == time;
1635 if !input.outgoing
1636 && input.unread
1637 && input.created_by_this_intent
1638 && unread.is_none()
1639 && !done
1640 && old.3 < time
1641 {
1642 tx.execute("INSERT INTO SessionUnreadListTable_1(username_id,server_id,local_id,create_time)VALUES(?1,?2,?3,?4)",params![session,server,local,time])?;
1646 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])?;
1647 }
1648 if old.2 <= time && (old.2 < time || old.1 <= local) {
1649 let sender = if input.peer.ends_with("@chatroom") {
1650 input.sender.as_deref().unwrap_or("")
1651 } else {
1652 ""
1653 };
1654 let display = if input.peer.ends_with("@chatroom") {
1655 input.sender_display.as_deref().unwrap_or("")
1656 } else {
1657 ""
1658 };
1659 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])?;
1660 }
1661 tx.commit()?;
1662 Ok(())
1663}
1664
1665#[cfg(test)]
1666mod pin_tests {
1667 use super::*;
1668
1669 fn fixture(encrypted: bool) -> (tempfile::TempDir, ProfileLocation, KeyFile, WriteOptions) {
1670 let temp = tempfile::tempdir().unwrap();
1671 let root = temp.path().join("account");
1672 let (location, keys) = if encrypted {
1673 let (location, keys, _) = initialize_profile(&root, &[42; 32]).unwrap();
1674 (location, keys)
1675 } else {
1676 for (relative, sql) in [
1677 ("contact/contact.db", include_str!("layout/contact.db.sql")),
1678 ("session/session.db", include_str!("layout/session.db.sql")),
1679 ] {
1680 let path = root.join("db_storage").join(relative);
1681 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1682 Connection::open(path).unwrap().execute_batch(sql).unwrap();
1683 }
1684 (
1685 ProfileLocation::new(&root).unwrap(),
1686 KeyFile::empty("account"),
1687 )
1688 };
1689 let options = WriteOptions {
1690 exclusive_ownership_confirmed: true,
1691 allow_plaintext: !encrypted,
1692 durable_intent_id: "synthetic-pin-receipt".into(),
1693 };
1694 (temp, location, keys, options)
1695 }
1696
1697 #[test]
1698 fn sparse_new_contacts_initialize_zero_without_resetting_existing_deletion_state() {
1699 for encrypted in [false, true] {
1700 let (_temp, location, keys, options) = fixture(encrypted);
1701 let contact = open_write(&location, "contact/contact.db", &keys, !encrypted).unwrap();
1702 let patch = |name: &str, fields: RawFields| {
1703 apply_contact_patch(
1704 &location,
1705 &keys,
1706 &ContactProjection {
1707 username: name.into(),
1708 fields,
1709 },
1710 &options,
1711 )
1712 .unwrap();
1713 };
1714 patch(
1715 "new_peer",
1716 [("flag".into(), SqlValue::Integer("1".into()))]
1717 .into_iter()
1718 .collect(),
1719 );
1720 let deletion = |name: &str| {
1721 contact
1722 .query_row(
1723 "SELECT delete_flag FROM contact WHERE username=?1",
1724 [name],
1725 |row| row.get::<_, Option<i64>>(0),
1726 )
1727 .unwrap()
1728 };
1729 assert_eq!(deletion("new_peer"), Some(0));
1730 for (name, value) in [
1731 ("legacy_peer", SqlValue::Null),
1732 ("deleted_peer", SqlValue::Integer("1".into())),
1733 ] {
1734 patch(name, [("delete_flag".into(), value)].into_iter().collect());
1735 patch(
1736 name,
1737 [("nick_name".into(), SqlValue::Text("Updated name".into()))]
1738 .into_iter()
1739 .collect(),
1740 );
1741 assert_eq!(
1742 deletion(name),
1743 if name == "legacy_peer" { None } else { Some(1) }
1744 );
1745 }
1746 }
1747 }
1748 #[test]
1749 fn null_delete_flags_allow_mute_projection_without_normalizing_the_contact_row() {
1750 for encrypted in [false, true] {
1751 let (_temp, location, keys, options) = fixture(encrypted);
1752 let contact = open_write(&location, "contact/contact.db", &keys, !encrypted).unwrap();
1753 contact.execute_batch("INSERT INTO contact(username,local_type,flag,delete_flag) VALUES('legacy',1,2049,NULL),('deleted',1,2049,1),('malformed',1,2049,'bad');").unwrap();
1754 assert!(
1755 apply_contact_mute_patch(&location, &keys, "legacy", true, &options)
1756 .unwrap()
1757 .unwrap()
1758 .changed
1759 );
1760 let saved = contact
1761 .query_row(
1762 "SELECT flag,delete_flag FROM contact WHERE username='legacy'",
1763 [],
1764 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option<i64>>(1)?)),
1765 )
1766 .unwrap();
1767 assert_eq!(saved, (2561, None));
1768 for name in ["deleted", "malformed"] {
1769 assert!(
1770 apply_contact_mute_patch(&location, &keys, name, true, &options)
1771 .unwrap()
1772 .is_none()
1773 );
1774 }
1775 }
1776 }
1777 #[test]
1778 fn pin_projection_preserves_other_state_and_replays_without_rewinding_activity() {
1779 for encrypted in [false, true] {
1780 let (_temp, location, keys, options) = fixture(encrypted);
1781 let contact = open_write(&location, "contact/contact.db", &keys, !encrypted).unwrap();
1782 let session = open_write(&location, "session/session.db", &keys, !encrypted).unwrap();
1783 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,NULL,0,0),(2,'room@chatroom',268435586,2,NULL,1,0);").unwrap();
1784 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();
1785 let saved_flag = || {
1786 contact
1787 .query_row(
1788 "SELECT flag FROM contact WHERE username='peer'",
1789 [],
1790 |row| row.get::<_, i64>(0),
1791 )
1792 .unwrap()
1793 };
1794 let saved_sort = || {
1795 session
1796 .query_row(
1797 "SELECT sort_timestamp FROM SessionTable WHERE username='peer'",
1798 [],
1799 |row| row.get::<_, i64>(0),
1800 )
1801 .unwrap()
1802 };
1803 let baseline = saved_flag();
1804 let result =
1805 apply_contact_pin_patch(&location, &keys, "peer", true, false, 200, &options)
1806 .unwrap()
1807 .unwrap();
1808 assert!(result.changed && result.session_projected);
1809 assert!(!result.cross_database_atomic);
1810 assert_eq!(saved_flag(), baseline | 0x800);
1811 assert_eq!(saved_sort(), 200);
1812 assert!(
1813 !apply_contact_pin_patch(&location, &keys, "peer", true, false, 300, &options)
1814 .unwrap()
1815 .unwrap()
1816 .changed
1817 );
1818 assert_eq!(saved_sort(), 200);
1819 session
1820 .execute(
1821 "UPDATE SessionTable SET sort_timestamp=400 WHERE username='peer'",
1822 [],
1823 )
1824 .unwrap();
1825 apply_contact_pin_patch(&location, &keys, "peer", false, false, 250, &options)
1826 .unwrap()
1827 .unwrap();
1828 assert_eq!(saved_flag(), baseline);
1829 assert_eq!(saved_sort(), 400);
1830 session
1832 .execute(
1833 "UPDATE SessionTable SET sort_timestamp=500 WHERE username='peer'",
1834 [],
1835 )
1836 .unwrap();
1837 apply_contact_pin_patch(&location, &keys, "peer", true, true, 450, &options)
1838 .unwrap()
1839 .unwrap();
1840 assert_eq!(saved_flag(), (baseline & !0x10000080) | 0x800);
1841 assert_eq!(saved_sort(), 500);
1842 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();
1843 assert_eq!(unrelated, (4, "Keep draft".into(), 7, 0, 90, 12));
1844 apply_contact_pin_patch(&location, &keys, "room@chatroom", true, true, 600, &options)
1845 .unwrap()
1846 .unwrap();
1847 assert_eq!(
1848 contact
1849 .query_row(
1850 "SELECT flag,chat_room_notify FROM contact WHERE username='room@chatroom'",
1851 [],
1852 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
1853 )
1854 .unwrap(),
1855 (2050, 0)
1856 );
1857 apply_contact_pin_patch(
1858 &location,
1859 &keys,
1860 "room@chatroom",
1861 false,
1862 false,
1863 700,
1864 &options,
1865 )
1866 .unwrap()
1867 .unwrap();
1868 assert_eq!(
1869 session
1870 .query_row(
1871 "SELECT sort_timestamp FROM SessionTable WHERE username='room@chatroom'",
1872 [],
1873 |row| row.get::<_, i64>(0)
1874 )
1875 .unwrap(),
1876 700
1877 );
1878 }
1879 }
1880
1881 #[test]
1882 fn pin_projection_preflights_both_schemas_and_keeps_wal_modes() {
1883 let (_temp, location, keys, options) = fixture(false);
1884 let contact = open_write(&location, "contact/contact.db", &keys, true).unwrap();
1885 let session = open_write(&location, "session/session.db", &keys, true).unwrap();
1886 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();
1887 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();
1888 for name in [
1889 "missing",
1890 "no_session",
1891 "missing_flag",
1892 "filehelper",
1893 "left@chatroom",
1894 ] {
1895 assert!(
1896 apply_contact_pin_patch(&location, &keys, name, true, false, 200, &options)
1897 .unwrap()
1898 .is_none(),
1899 "{name}"
1900 );
1901 }
1902 session
1903 .execute_batch("ALTER TABLE SessionTable ADD COLUMN unknown_column TEXT;")
1904 .unwrap();
1905 assert!(matches!(
1906 apply_contact_pin_patch(&location, &keys, "peer", true, false, 200, &options),
1907 Err(ProfileError::UnsupportedSchema(_))
1908 ));
1909 assert_eq!(
1910 contact
1911 .query_row(
1912 "SELECT flag FROM contact WHERE username='peer'",
1913 [],
1914 |row| row.get::<_, i64>(0)
1915 )
1916 .unwrap(),
1917 1
1918 );
1919 assert_eq!(
1920 session
1921 .query_row(
1922 "SELECT sort_timestamp FROM SessionTable WHERE username='peer'",
1923 [],
1924 |row| row.get::<_, i64>(0)
1925 )
1926 .unwrap(),
1927 100
1928 );
1929 session
1930 .execute_batch("ALTER TABLE SessionTable DROP COLUMN unknown_column;")
1931 .unwrap();
1932 let mut unowned = options.clone();
1933 unowned.exclusive_ownership_confirmed = false;
1934 assert!(matches!(
1935 apply_contact_pin_patch(&location, &keys, "peer", true, false, 200, &unowned),
1936 Err(ProfileError::SnapshotRequired)
1937 ));
1938 assert!(
1939 apply_contact_pin_patch(&location, &keys, "peer", false, true, 200, &options).is_err()
1940 );
1941 apply_contact_pin_patch(&location, &keys, "peer", true, false, 200, &options)
1942 .unwrap()
1943 .unwrap();
1944 for db in [&contact, &session] {
1945 assert_eq!(
1946 db.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
1947 .unwrap(),
1948 "wal"
1949 );
1950 }
1951 }
1952
1953 #[test]
1954 fn group_unfold_preserves_notification_state_other_members_and_opaque_fields() {
1955 let (_temp, location, keys, options) = fixture(false);
1956 let contact = open_write(&location, "contact/contact.db", &keys, true).unwrap();
1957 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,NULL,1,0);INSERT INTO chat_room(id,username)VALUES(1,'room@chatroom');").unwrap();
1958 let before = [
1959 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',
1960 b'e', b'e', b'r', 24, 129, 64, 42, 3, 9, 8, 7,
1961 ];
1962 let expected = [
1963 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',
1964 b'e', b'r', 24, 129, 64, 42, 3, 9, 8, 7,
1965 ];
1966 contact
1967 .execute("UPDATE chat_room SET ext_buffer=?1", [before.as_slice()])
1968 .unwrap();
1969 for _ in 0..2 {
1970 apply_native_group_edit(
1971 &location,
1972 &keys,
1973 "room@chatroom",
1974 NativeGroupEdit::Unfold { own: "self".into() },
1975 &options,
1976 )
1977 .unwrap();
1978 }
1979 assert_eq!(
1980 contact
1981 .query_row(
1982 "SELECT flag,chat_room_notify FROM contact WHERE username='room@chatroom'",
1983 [],
1984 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
1985 )
1986 .unwrap(),
1987 (2, 0)
1988 );
1989 assert_eq!(
1990 contact
1991 .query_row("SELECT ext_buffer FROM chat_room", [], |row| row
1992 .get::<_, Vec<u8>>(0))
1993 .unwrap(),
1994 expected
1995 );
1996 contact
1998 .execute("UPDATE contact SET flag=4294967296", [])
1999 .unwrap();
2000 contact
2001 .execute("UPDATE chat_room SET ext_buffer=?1", [before.as_slice()])
2002 .unwrap();
2003 assert!(apply_native_group_edit(
2004 &location,
2005 &keys,
2006 "room@chatroom",
2007 NativeGroupEdit::Unfold { own: "self".into() },
2008 &options
2009 )
2010 .is_err());
2011 assert_eq!(
2012 contact
2013 .query_row("SELECT ext_buffer FROM chat_room", [], |row| row
2014 .get::<_, Vec<u8>>(0))
2015 .unwrap(),
2016 before
2017 );
2018 }
2019}