1use crate::error::WireError;
24use chromium_storage_indexeddb::{IdbKey, IndexedDbRecord, RecordValue, V8Value};
25
26#[non_exhaustive]
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum WireRecordKind {
31 Conversation,
33 Event,
35 User,
37 Client,
39 Unknown,
41}
42
43impl WireRecordKind {
44 #[must_use]
46 pub fn from_store_name(name: &str) -> WireRecordKind {
47 match name {
48 "conversations" => WireRecordKind::Conversation,
49 "events" => WireRecordKind::Event,
50 "users" => WireRecordKind::User,
51 "clients" => WireRecordKind::Client,
52 _ => WireRecordKind::Unknown,
53 }
54 }
55
56 #[must_use]
58 pub fn as_str(&self) -> &'static str {
59 match self {
60 WireRecordKind::Conversation => "conversation",
61 WireRecordKind::Event => "event",
62 WireRecordKind::User => "user",
63 WireRecordKind::Client => "client",
64 WireRecordKind::Unknown => "unknown",
65 }
66 }
67}
68
69#[non_exhaustive]
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum PayloadState {
73 Cleartext,
76 Encrypted {
79 scheme: &'static str,
81 reason: &'static str,
83 },
84 Undecoded {
86 error: String,
88 },
89}
90
91#[non_exhaustive]
97#[derive(Debug, Clone, PartialEq)]
98pub struct WireRecord {
99 pub store: String,
101 pub kind: WireRecordKind,
103 pub primary_key: String,
105 pub id: Option<String>,
107 pub conversation: Option<String>,
109 pub sender: Option<String>,
111 pub time: Option<String>,
113 pub message_type: Option<String>,
115 pub name: Option<String>,
117 pub text: Option<String>,
119 pub payload: PayloadState,
121 pub seq: u64,
123 pub deleted: bool,
125}
126
127impl WireRecord {
128 pub fn decrypted_text(&self) -> Result<&str, WireError> {
134 match &self.payload {
135 PayloadState::Cleartext => Ok(self.text.as_deref().unwrap_or("")),
136 PayloadState::Encrypted { .. } => Err(WireError::EncryptedPayloadUnrecoverable),
137 PayloadState::Undecoded { error } => Err(WireError::UndecodedValue(error.clone())),
138 }
139 }
140
141 #[must_use]
143 pub fn is_encrypted(&self) -> bool {
144 matches!(self.payload, PayloadState::Encrypted { .. })
145 }
146}
147
148#[must_use]
155pub fn interpret_records(records: &[IndexedDbRecord]) -> WireStore {
156 let mut out = Vec::with_capacity(records.len());
157 let mut summaries: Vec<ObjectStoreSummary> = Vec::new();
158
159 for r in records {
160 let store = r.object_store.as_deref().unwrap_or("");
161 let wr = interpret_one(store, r);
162
163 if !store.is_empty() {
164 let enc = usize::from(wr.is_encrypted());
165 let del = usize::from(wr.deleted);
166 match summaries.iter_mut().find(|s| s.name == store) {
167 Some(sum) => {
168 sum.records += 1;
169 sum.encrypted_payloads += enc;
170 sum.deleted += del;
171 }
172 None => summaries.push(ObjectStoreSummary {
173 name: store.to_string(),
174 kind: wr.kind,
175 records: 1,
176 encrypted_payloads: enc,
177 deleted: del,
178 }),
179 }
180 }
181 out.push(wr);
182 }
183
184 WireStore {
185 object_stores: summaries,
186 records: out,
187 }
188}
189
190fn interpret_one(store: &str, r: &IndexedDbRecord) -> WireRecord {
195 let kind = WireRecordKind::from_store_name(store);
196 let mut wr = WireRecord {
197 store: store.to_string(),
198 kind,
199 primary_key: render_key(&r.key),
200 id: None,
201 conversation: None,
202 sender: None,
203 time: None,
204 message_type: None,
205 name: None,
206 text: None,
207 payload: base_payload(&r.value),
208 seq: r.seq,
209 deleted: r.deleted,
210 };
211
212 if let RecordValue::V8(v) = &r.value {
213 match kind {
214 WireRecordKind::Conversation => fill_conversation(&mut wr, v),
215 WireRecordKind::Event => fill_event(&mut wr, v),
216 WireRecordKind::User => fill_user(&mut wr, v),
217 WireRecordKind::Client => fill_client(&mut wr, v),
218 WireRecordKind::Unknown => {}
219 }
220 }
221
222 wr
223}
224
225fn fill_conversation(wr: &mut WireRecord, v: &V8Value) {
228 wr.id = obj_field(v, "id").and_then(as_text);
229 wr.conversation = wr.id.clone();
230 wr.name = obj_field(v, "name").and_then(as_text);
231}
232
233fn fill_event(wr: &mut WireRecord, v: &V8Value) {
237 wr.id = obj_field(v, "id").and_then(as_text);
238 wr.conversation = obj_field(v, "conversation").and_then(as_text);
239 wr.sender = obj_field(v, "from")
240 .or_else(|| obj_field(v, "sender"))
241 .and_then(as_text);
242 wr.time = obj_field(v, "time").and_then(as_text);
243 wr.message_type = obj_field(v, "type").and_then(as_text);
244
245 if let Some(text) = message_text(v) {
246 wr.text = Some(text);
247 wr.payload = PayloadState::Cleartext;
248 } else if is_encrypted_payload(v) {
249 wr.payload = PayloadState::Encrypted {
253 scheme: "Proteus",
254 reason: "Wire message key is not held in the Chromium OS Safe Storage",
255 };
256 }
257}
258
259fn is_encrypted_payload(v: &V8Value) -> bool {
266 const CIPHER_MARKERS: [&str; 4] = ["cipher_text", "cipherText", "otr", "encrypted"];
267
268 if matches!(v, V8Value::ArrayBuffer(_)) {
269 return true;
270 }
271 let data = obj_field(v, "data");
272 if matches!(data, Some(V8Value::ArrayBuffer(_))) {
273 return true;
274 }
275 CIPHER_MARKERS
276 .iter()
277 .any(|m| obj_field(v, m).is_some() || data.is_some_and(|d| obj_field(d, m).is_some()))
278}
279
280fn message_text(v: &V8Value) -> Option<String> {
285 if let Some(data) = obj_field(v, "data") {
286 if let Some(t) = obj_field(data, "content")
287 .or_else(|| obj_field(data, "text"))
288 .and_then(as_text)
289 {
290 return Some(t);
291 }
292 }
293 obj_field(v, "content")
294 .or_else(|| obj_field(v, "text"))
295 .and_then(as_text)
296}
297
298fn fill_user(wr: &mut WireRecord, v: &V8Value) {
300 wr.id = obj_field(v, "id").and_then(as_text);
301 wr.name = obj_field(v, "name").and_then(as_text);
302}
303
304fn fill_client(wr: &mut WireRecord, v: &V8Value) {
307 wr.id = obj_field(v, "id").and_then(as_text);
308 wr.name = obj_field(v, "model")
309 .or_else(|| obj_field(v, "class"))
310 .or_else(|| obj_field(v, "label"))
311 .and_then(as_text);
312}
313
314#[non_exhaustive]
316#[derive(Debug, Clone, Default, PartialEq)]
317pub struct WireStore {
318 pub object_stores: Vec<ObjectStoreSummary>,
320 pub records: Vec<WireRecord>,
322}
323
324impl WireStore {
325 pub fn records_in<'a>(&'a self, store: &'a str) -> impl Iterator<Item = &'a WireRecord> {
327 self.records.iter().filter(move |r| r.store == store)
328 }
329
330 pub fn events(&self) -> impl Iterator<Item = &WireRecord> {
332 self.records
333 .iter()
334 .filter(|r| r.kind == WireRecordKind::Event)
335 }
336
337 pub fn encrypted_events(&self) -> impl Iterator<Item = &WireRecord> {
339 self.events().filter(|r| r.is_encrypted())
340 }
341}
342
343#[non_exhaustive]
345#[derive(Debug, Clone, PartialEq, Eq)]
346pub struct ObjectStoreSummary {
347 pub name: String,
349 pub kind: WireRecordKind,
351 pub records: usize,
353 pub encrypted_payloads: usize,
355 pub deleted: usize,
357}
358
359pub(crate) fn render_key(key: &IdbKey) -> String {
363 match key {
364 IdbKey::String(s) => s.clone(),
365 IdbKey::Number(n) | IdbKey::Date(n) => n.to_string(),
366 IdbKey::Binary(b) => format!("0x{}", hex(b)),
367 IdbKey::Array(items) => {
368 let parts: Vec<String> = items.iter().map(render_key).collect();
369 format!("[{}]", parts.join(","))
370 }
371 IdbKey::Null => "null".to_string(),
372 IdbKey::Min => "min".to_string(),
373 IdbKey::Invalid(b) => format!("invalid:0x{}", hex(b)),
374 }
375}
376
377fn hex(bytes: &[u8]) -> String {
378 let mut s = String::with_capacity(bytes.len() * 2);
379 for b in bytes {
380 s.push(char::from_digit(u32::from(b >> 4), 16).unwrap_or('0'));
381 s.push(char::from_digit(u32::from(b & 0x0f), 16).unwrap_or('0'));
382 }
383 s
384}
385
386pub(crate) fn obj_field<'a>(v: &'a V8Value, key: &str) -> Option<&'a V8Value> {
388 match v {
389 V8Value::Object(kv) => kv.iter().find(|(k, _)| k == key).map(|(_, val)| val),
390 _ => None,
391 }
392}
393
394pub(crate) fn as_text(v: &V8Value) -> Option<String> {
396 match v {
397 V8Value::String(s) | V8Value::StringObject(s) | V8Value::BigInt(s) => Some(s.clone()),
398 V8Value::Int(i) => Some(i.to_string()),
399 V8Value::Double(d) | V8Value::Date(d) | V8Value::NumberObject(d) => Some(d.to_string()),
400 V8Value::Bool(b) => Some(b.to_string()),
401 _ => None,
402 }
403}
404
405pub(crate) fn base_payload(value: &RecordValue) -> PayloadState {
407 match value {
408 RecordValue::V8(_) => PayloadState::Cleartext,
409 RecordValue::Undecoded { error, .. } => PayloadState::Undecoded {
410 error: error.clone(),
411 },
412 }
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418 use crate::error::WireError;
419
420 #[test]
421 fn render_key_covers_every_idbkey_variant() {
422 assert_eq!(render_key(&IdbKey::String("k".into())), "k");
423 assert_eq!(render_key(&IdbKey::Number(3.0)), "3");
424 assert_eq!(render_key(&IdbKey::Date(5.0)), "5");
425 assert_eq!(render_key(&IdbKey::Binary(vec![0xab, 0x0f])), "0xab0f");
426 assert_eq!(render_key(&IdbKey::Null), "null");
427 assert_eq!(render_key(&IdbKey::Min), "min");
428 assert_eq!(render_key(&IdbKey::Invalid(vec![0x01])), "invalid:0x01");
429 assert_eq!(
430 render_key(&IdbKey::Array(vec![
431 IdbKey::String("a".into()),
432 IdbKey::Number(1.0)
433 ])),
434 "[a,1]"
435 );
436 }
437
438 #[test]
439 fn as_text_covers_scalar_and_container_variants() {
440 assert_eq!(as_text(&V8Value::String("s".into())).as_deref(), Some("s"));
441 assert_eq!(
442 as_text(&V8Value::StringObject("o".into())).as_deref(),
443 Some("o")
444 );
445 assert_eq!(as_text(&V8Value::BigInt("9".into())).as_deref(), Some("9"));
446 assert_eq!(as_text(&V8Value::Int(7)).as_deref(), Some("7"));
447 assert_eq!(as_text(&V8Value::Double(2.0)).as_deref(), Some("2"));
448 assert_eq!(as_text(&V8Value::Date(4.0)).as_deref(), Some("4"));
449 assert_eq!(as_text(&V8Value::NumberObject(6.0)).as_deref(), Some("6"));
450 assert_eq!(as_text(&V8Value::Bool(true)).as_deref(), Some("true"));
451 assert_eq!(as_text(&V8Value::Null), None);
452 assert_eq!(as_text(&V8Value::Array(vec![])), None);
453 }
454
455 #[test]
456 fn obj_field_returns_none_for_non_objects_and_missing_keys() {
457 assert!(obj_field(&V8Value::Null, "x").is_none());
458 let o = V8Value::Object(vec![("a".into(), V8Value::Int(1))]);
459 assert!(obj_field(&o, "missing").is_none());
460 assert!(obj_field(&o, "a").is_some());
461 }
462
463 #[test]
464 fn base_payload_maps_decode_state() {
465 assert_eq!(
466 base_payload(&RecordValue::V8(V8Value::Null)),
467 PayloadState::Cleartext
468 );
469 let u = RecordValue::Undecoded {
470 raw: vec![0xff],
471 error: "boom".into(),
472 };
473 assert!(matches!(base_payload(&u), PayloadState::Undecoded { .. }));
474 }
475
476 #[test]
477 fn kind_mapping_and_labels() {
478 assert_eq!(
479 WireRecordKind::from_store_name("conversations"),
480 WireRecordKind::Conversation
481 );
482 assert_eq!(
483 WireRecordKind::from_store_name("events"),
484 WireRecordKind::Event
485 );
486 assert_eq!(
487 WireRecordKind::from_store_name("users"),
488 WireRecordKind::User
489 );
490 assert_eq!(
491 WireRecordKind::from_store_name("clients"),
492 WireRecordKind::Client
493 );
494 assert_eq!(
495 WireRecordKind::from_store_name("keys"),
496 WireRecordKind::Unknown
497 );
498 for k in [
499 WireRecordKind::Conversation,
500 WireRecordKind::Event,
501 WireRecordKind::User,
502 WireRecordKind::Client,
503 WireRecordKind::Unknown,
504 ] {
505 assert!(!k.as_str().is_empty());
506 }
507 }
508
509 fn rec(payload: PayloadState, text: Option<&str>) -> WireRecord {
510 WireRecord {
511 store: "events".into(),
512 kind: WireRecordKind::Event,
513 primary_key: "k".into(),
514 id: None,
515 conversation: None,
516 sender: None,
517 time: None,
518 message_type: None,
519 name: None,
520 text: text.map(str::to_string),
521 payload,
522 seq: 0,
523 deleted: false,
524 }
525 }
526
527 #[test]
528 fn decrypted_text_returns_or_fails_loud_per_payload() {
529 assert_eq!(
530 rec(PayloadState::Cleartext, Some("hi"))
531 .decrypted_text()
532 .unwrap(),
533 "hi"
534 );
535 assert_eq!(
537 rec(PayloadState::Cleartext, None).decrypted_text().unwrap(),
538 ""
539 );
540 let enc = rec(
541 PayloadState::Encrypted {
542 scheme: "Proteus",
543 reason: "no key",
544 },
545 None,
546 );
547 assert!(matches!(
548 enc.decrypted_text(),
549 Err(WireError::EncryptedPayloadUnrecoverable)
550 ));
551 let und = rec(
552 PayloadState::Undecoded {
553 error: "bad".into(),
554 },
555 None,
556 );
557 assert!(matches!(
558 und.decrypted_text(),
559 Err(WireError::UndecodedValue(_))
560 ));
561 }
562
563 #[test]
564 fn is_encrypted_payload_covers_top_level_and_marker_paths() {
565 assert!(is_encrypted_payload(&V8Value::ArrayBuffer(vec![1, 2])));
567 let top_marker = V8Value::Object(vec![("otr".into(), V8Value::String("x".into()))]);
569 assert!(is_encrypted_payload(&top_marker));
570 let nested = V8Value::Object(vec![(
572 "data".into(),
573 V8Value::Object(vec![("encrypted".into(), V8Value::Bool(true))]),
574 )]);
575 assert!(is_encrypted_payload(&nested));
576 let plain = V8Value::Object(vec![("content".into(), V8Value::String("hi".into()))]);
578 assert!(!is_encrypted_payload(&plain));
579 }
580
581 #[test]
582 fn interpret_skips_summary_for_unnamed_store() {
583 let r = IndexedDbRecord {
586 database_id: 0,
587 object_store_id: 0,
588 database: None,
589 object_store: None,
590 key: IdbKey::Null,
591 value: RecordValue::V8(V8Value::Null),
592 seq: 0,
593 deleted: false,
594 };
595 let store = interpret_records(&[r]);
596 assert_eq!(store.records.len(), 1);
597 assert!(store.object_stores.is_empty());
598 assert!(store.records_in("").next().is_some());
599 }
600}