use crate::error::WireError;
use chromium_storage_indexeddb::{IdbKey, IndexedDbRecord, RecordValue, V8Value};
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WireRecordKind {
Conversation,
Event,
User,
Client,
Unknown,
}
impl WireRecordKind {
#[must_use]
pub fn from_store_name(name: &str) -> WireRecordKind {
match name {
"conversations" => WireRecordKind::Conversation,
"events" => WireRecordKind::Event,
"users" => WireRecordKind::User,
"clients" => WireRecordKind::Client,
_ => WireRecordKind::Unknown,
}
}
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
WireRecordKind::Conversation => "conversation",
WireRecordKind::Event => "event",
WireRecordKind::User => "user",
WireRecordKind::Client => "client",
WireRecordKind::Unknown => "unknown",
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PayloadState {
Cleartext,
Encrypted {
scheme: &'static str,
reason: &'static str,
},
Undecoded {
error: String,
},
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)]
pub struct WireRecord {
pub store: String,
pub kind: WireRecordKind,
pub primary_key: String,
pub id: Option<String>,
pub conversation: Option<String>,
pub sender: Option<String>,
pub time: Option<String>,
pub message_type: Option<String>,
pub name: Option<String>,
pub text: Option<String>,
pub payload: PayloadState,
pub seq: u64,
pub deleted: bool,
}
impl WireRecord {
pub fn decrypted_text(&self) -> Result<&str, WireError> {
match &self.payload {
PayloadState::Cleartext => Ok(self.text.as_deref().unwrap_or("")),
PayloadState::Encrypted { .. } => Err(WireError::EncryptedPayloadUnrecoverable),
PayloadState::Undecoded { error } => Err(WireError::UndecodedValue(error.clone())),
}
}
#[must_use]
pub fn is_encrypted(&self) -> bool {
matches!(self.payload, PayloadState::Encrypted { .. })
}
}
#[must_use]
pub fn interpret_records(records: &[IndexedDbRecord]) -> WireStore {
let mut out = Vec::with_capacity(records.len());
let mut summaries: Vec<ObjectStoreSummary> = Vec::new();
for r in records {
let store = r.object_store.as_deref().unwrap_or("");
let wr = interpret_one(store, r);
if !store.is_empty() {
let enc = usize::from(wr.is_encrypted());
let del = usize::from(wr.deleted);
match summaries.iter_mut().find(|s| s.name == store) {
Some(sum) => {
sum.records += 1;
sum.encrypted_payloads += enc;
sum.deleted += del;
}
None => summaries.push(ObjectStoreSummary {
name: store.to_string(),
kind: wr.kind,
records: 1,
encrypted_payloads: enc,
deleted: del,
}),
}
}
out.push(wr);
}
WireStore {
object_stores: summaries,
records: out,
}
}
fn interpret_one(store: &str, r: &IndexedDbRecord) -> WireRecord {
let kind = WireRecordKind::from_store_name(store);
let mut wr = WireRecord {
store: store.to_string(),
kind,
primary_key: render_key(&r.key),
id: None,
conversation: None,
sender: None,
time: None,
message_type: None,
name: None,
text: None,
payload: base_payload(&r.value),
seq: r.seq,
deleted: r.deleted,
};
if let RecordValue::V8(v) = &r.value {
match kind {
WireRecordKind::Conversation => fill_conversation(&mut wr, v),
WireRecordKind::Event => fill_event(&mut wr, v),
WireRecordKind::User => fill_user(&mut wr, v),
WireRecordKind::Client => fill_client(&mut wr, v),
WireRecordKind::Unknown => {}
}
}
wr
}
fn fill_conversation(wr: &mut WireRecord, v: &V8Value) {
wr.id = obj_field(v, "id").and_then(as_text);
wr.conversation = wr.id.clone();
wr.name = obj_field(v, "name").and_then(as_text);
}
fn fill_event(wr: &mut WireRecord, v: &V8Value) {
wr.id = obj_field(v, "id").and_then(as_text);
wr.conversation = obj_field(v, "conversation").and_then(as_text);
wr.sender = obj_field(v, "from")
.or_else(|| obj_field(v, "sender"))
.and_then(as_text);
wr.time = obj_field(v, "time").and_then(as_text);
wr.message_type = obj_field(v, "type").and_then(as_text);
if let Some(text) = message_text(v) {
wr.text = Some(text);
wr.payload = PayloadState::Cleartext;
} else if is_encrypted_payload(v) {
wr.payload = PayloadState::Encrypted {
scheme: "Proteus",
reason: "Wire message key is not held in the Chromium OS Safe Storage",
};
}
}
fn is_encrypted_payload(v: &V8Value) -> bool {
const CIPHER_MARKERS: [&str; 4] = ["cipher_text", "cipherText", "otr", "encrypted"];
if matches!(v, V8Value::ArrayBuffer(_)) {
return true;
}
let data = obj_field(v, "data");
if matches!(data, Some(V8Value::ArrayBuffer(_))) {
return true;
}
CIPHER_MARKERS
.iter()
.any(|m| obj_field(v, m).is_some() || data.is_some_and(|d| obj_field(d, m).is_some()))
}
fn message_text(v: &V8Value) -> Option<String> {
if let Some(data) = obj_field(v, "data") {
if let Some(t) = obj_field(data, "content")
.or_else(|| obj_field(data, "text"))
.and_then(as_text)
{
return Some(t);
}
}
obj_field(v, "content")
.or_else(|| obj_field(v, "text"))
.and_then(as_text)
}
fn fill_user(wr: &mut WireRecord, v: &V8Value) {
wr.id = obj_field(v, "id").and_then(as_text);
wr.name = obj_field(v, "name").and_then(as_text);
}
fn fill_client(wr: &mut WireRecord, v: &V8Value) {
wr.id = obj_field(v, "id").and_then(as_text);
wr.name = obj_field(v, "model")
.or_else(|| obj_field(v, "class"))
.or_else(|| obj_field(v, "label"))
.and_then(as_text);
}
#[non_exhaustive]
#[derive(Debug, Clone, Default, PartialEq)]
pub struct WireStore {
pub object_stores: Vec<ObjectStoreSummary>,
pub records: Vec<WireRecord>,
}
impl WireStore {
pub fn records_in<'a>(&'a self, store: &'a str) -> impl Iterator<Item = &'a WireRecord> {
self.records.iter().filter(move |r| r.store == store)
}
pub fn events(&self) -> impl Iterator<Item = &WireRecord> {
self.records
.iter()
.filter(|r| r.kind == WireRecordKind::Event)
}
pub fn encrypted_events(&self) -> impl Iterator<Item = &WireRecord> {
self.events().filter(|r| r.is_encrypted())
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ObjectStoreSummary {
pub name: String,
pub kind: WireRecordKind,
pub records: usize,
pub encrypted_payloads: usize,
pub deleted: usize,
}
pub(crate) fn render_key(key: &IdbKey) -> String {
match key {
IdbKey::String(s) => s.clone(),
IdbKey::Number(n) | IdbKey::Date(n) => n.to_string(),
IdbKey::Binary(b) => format!("0x{}", hex(b)),
IdbKey::Array(items) => {
let parts: Vec<String> = items.iter().map(render_key).collect();
format!("[{}]", parts.join(","))
}
IdbKey::Null => "null".to_string(),
IdbKey::Min => "min".to_string(),
IdbKey::Invalid(b) => format!("invalid:0x{}", hex(b)),
}
}
fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push(char::from_digit(u32::from(b >> 4), 16).unwrap_or('0'));
s.push(char::from_digit(u32::from(b & 0x0f), 16).unwrap_or('0'));
}
s
}
pub(crate) fn obj_field<'a>(v: &'a V8Value, key: &str) -> Option<&'a V8Value> {
match v {
V8Value::Object(kv) => kv.iter().find(|(k, _)| k == key).map(|(_, val)| val),
_ => None,
}
}
pub(crate) fn as_text(v: &V8Value) -> Option<String> {
match v {
V8Value::String(s) | V8Value::StringObject(s) | V8Value::BigInt(s) => Some(s.clone()),
V8Value::Int(i) => Some(i.to_string()),
V8Value::Double(d) | V8Value::Date(d) | V8Value::NumberObject(d) => Some(d.to_string()),
V8Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
pub(crate) fn base_payload(value: &RecordValue) -> PayloadState {
match value {
RecordValue::V8(_) => PayloadState::Cleartext,
RecordValue::Undecoded { error, .. } => PayloadState::Undecoded {
error: error.clone(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::WireError;
#[test]
fn render_key_covers_every_idbkey_variant() {
assert_eq!(render_key(&IdbKey::String("k".into())), "k");
assert_eq!(render_key(&IdbKey::Number(3.0)), "3");
assert_eq!(render_key(&IdbKey::Date(5.0)), "5");
assert_eq!(render_key(&IdbKey::Binary(vec![0xab, 0x0f])), "0xab0f");
assert_eq!(render_key(&IdbKey::Null), "null");
assert_eq!(render_key(&IdbKey::Min), "min");
assert_eq!(render_key(&IdbKey::Invalid(vec![0x01])), "invalid:0x01");
assert_eq!(
render_key(&IdbKey::Array(vec![
IdbKey::String("a".into()),
IdbKey::Number(1.0)
])),
"[a,1]"
);
}
#[test]
fn as_text_covers_scalar_and_container_variants() {
assert_eq!(as_text(&V8Value::String("s".into())).as_deref(), Some("s"));
assert_eq!(
as_text(&V8Value::StringObject("o".into())).as_deref(),
Some("o")
);
assert_eq!(as_text(&V8Value::BigInt("9".into())).as_deref(), Some("9"));
assert_eq!(as_text(&V8Value::Int(7)).as_deref(), Some("7"));
assert_eq!(as_text(&V8Value::Double(2.0)).as_deref(), Some("2"));
assert_eq!(as_text(&V8Value::Date(4.0)).as_deref(), Some("4"));
assert_eq!(as_text(&V8Value::NumberObject(6.0)).as_deref(), Some("6"));
assert_eq!(as_text(&V8Value::Bool(true)).as_deref(), Some("true"));
assert_eq!(as_text(&V8Value::Null), None);
assert_eq!(as_text(&V8Value::Array(vec![])), None);
}
#[test]
fn obj_field_returns_none_for_non_objects_and_missing_keys() {
assert!(obj_field(&V8Value::Null, "x").is_none());
let o = V8Value::Object(vec![("a".into(), V8Value::Int(1))]);
assert!(obj_field(&o, "missing").is_none());
assert!(obj_field(&o, "a").is_some());
}
#[test]
fn base_payload_maps_decode_state() {
assert_eq!(
base_payload(&RecordValue::V8(V8Value::Null)),
PayloadState::Cleartext
);
let u = RecordValue::Undecoded {
raw: vec![0xff],
error: "boom".into(),
};
assert!(matches!(base_payload(&u), PayloadState::Undecoded { .. }));
}
#[test]
fn kind_mapping_and_labels() {
assert_eq!(
WireRecordKind::from_store_name("conversations"),
WireRecordKind::Conversation
);
assert_eq!(
WireRecordKind::from_store_name("events"),
WireRecordKind::Event
);
assert_eq!(
WireRecordKind::from_store_name("users"),
WireRecordKind::User
);
assert_eq!(
WireRecordKind::from_store_name("clients"),
WireRecordKind::Client
);
assert_eq!(
WireRecordKind::from_store_name("keys"),
WireRecordKind::Unknown
);
for k in [
WireRecordKind::Conversation,
WireRecordKind::Event,
WireRecordKind::User,
WireRecordKind::Client,
WireRecordKind::Unknown,
] {
assert!(!k.as_str().is_empty());
}
}
fn rec(payload: PayloadState, text: Option<&str>) -> WireRecord {
WireRecord {
store: "events".into(),
kind: WireRecordKind::Event,
primary_key: "k".into(),
id: None,
conversation: None,
sender: None,
time: None,
message_type: None,
name: None,
text: text.map(str::to_string),
payload,
seq: 0,
deleted: false,
}
}
#[test]
fn decrypted_text_returns_or_fails_loud_per_payload() {
assert_eq!(
rec(PayloadState::Cleartext, Some("hi"))
.decrypted_text()
.unwrap(),
"hi"
);
assert_eq!(
rec(PayloadState::Cleartext, None).decrypted_text().unwrap(),
""
);
let enc = rec(
PayloadState::Encrypted {
scheme: "Proteus",
reason: "no key",
},
None,
);
assert!(matches!(
enc.decrypted_text(),
Err(WireError::EncryptedPayloadUnrecoverable)
));
let und = rec(
PayloadState::Undecoded {
error: "bad".into(),
},
None,
);
assert!(matches!(
und.decrypted_text(),
Err(WireError::UndecodedValue(_))
));
}
#[test]
fn is_encrypted_payload_covers_top_level_and_marker_paths() {
assert!(is_encrypted_payload(&V8Value::ArrayBuffer(vec![1, 2])));
let top_marker = V8Value::Object(vec![("otr".into(), V8Value::String("x".into()))]);
assert!(is_encrypted_payload(&top_marker));
let nested = V8Value::Object(vec![(
"data".into(),
V8Value::Object(vec![("encrypted".into(), V8Value::Bool(true))]),
)]);
assert!(is_encrypted_payload(&nested));
let plain = V8Value::Object(vec![("content".into(), V8Value::String("hi".into()))]);
assert!(!is_encrypted_payload(&plain));
}
#[test]
fn interpret_skips_summary_for_unnamed_store() {
let r = IndexedDbRecord {
database_id: 0,
object_store_id: 0,
database: None,
object_store: None,
key: IdbKey::Null,
value: RecordValue::V8(V8Value::Null),
seq: 0,
deleted: false,
};
let store = interpret_records(&[r]);
assert_eq!(store.records.len(), 1);
assert!(store.object_stores.is_empty());
assert!(store.records_in("").next().is_some());
}
}