Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 57 additions & 6 deletions src/send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -860,11 +860,14 @@ impl Client {
if device.user == own_sending_jid.user && device.device == own_sending_jid.device {
return false;
}
// O(1) lookups into pre-indexed cache
// WA Web parity (ParticipantStore.js skDistribList): a device is
// warm only when it AND its primary (device 0) hold the key, so a
// forgotten primary redistributes the whole user while a forgotten
// companion redistributes only itself.
!cached_map
.device_has_key(&device.user, device.device)
.unwrap_or(false)
|| cached_map.is_user_forgotten(&device.user)
|| !cached_map.device_has_key(&device.user, 0).unwrap_or(false)
})
.cloned()
.collect();
Expand Down Expand Up @@ -2927,7 +2930,6 @@ mod tests {

let map = SenderKeyDeviceMap::from_db_rows(&[]);
assert_eq!(map.device_has_key("271060335329480", 0), None);
assert!(!map.is_user_forgotten("271060335329480"));

let all_resolved_devices: Vec<Jid> = [
"271060335329480@lid",
Expand All @@ -2943,7 +2945,7 @@ mod tests {
.filter(|device| {
!map.device_has_key(&device.user, device.device)
.unwrap_or(false)
|| map.is_user_forgotten(&device.user)
|| !map.device_has_key(&device.user, 0).unwrap_or(false)
})
.collect();

Expand Down Expand Up @@ -3009,7 +3011,6 @@ mod tests {

let map = SenderKeyDeviceMap::from_db_rows(&[("271060335329480@lid".to_string(), false)]);
assert_eq!(map.device_has_key("271060335329480", 0), Some(false));
assert!(map.is_user_forgotten("271060335329480"));

let all_resolved_devices: Vec<Jid> = [
"271060335329480@lid",
Expand All @@ -3025,7 +3026,7 @@ mod tests {
.filter(|device| {
!map.device_has_key(&device.user, device.device)
.unwrap_or(false)
|| map.is_user_forgotten(&device.user)
|| !map.device_has_key(&device.user, 0).unwrap_or(false)
})
.collect();

Expand All @@ -3037,6 +3038,56 @@ mod tests {
);
}

/// WA Web primary-device gate (ParticipantStore.js): a companion is warm only
/// when it AND its primary (device 0) hold the key. A forgotten companion
/// redistributes only itself (no per-user amplification); a forgotten primary
/// redistributes the whole user. Drives the real `filter_skdm_targets`.
#[tokio::test]
async fn filter_skdm_targets_uses_primary_device_gate() {
use crate::sender_key_device_cache::SenderKeyDeviceMap;

let client = crate::test_utils::create_test_client().await;
let group = "120363161500776365@g.us";
let own = Jid::from_str("999999999999999:1@lid").unwrap();

// Companion forgotten, primary warm: only the companion redistributes.
let map = SenderKeyDeviceMap::from_db_rows(&[
("100100100100100@lid".to_string(), true),
("100100100100100:5@lid".to_string(), false),
]);
let devices = [
Jid::from_str("100100100100100@lid").unwrap(),
Jid::from_str("100100100100100:5@lid").unwrap(),
];
let needs = client.filter_skdm_targets(group, &devices, &map, &own);
assert_eq!(needs.len(), 1, "warm primary keeps the keyed companion out");
assert_eq!(needs[0].device, 5);

// Primary forgotten, companion warm: the whole user redistributes (WA Web
// marks a companion cold when its primary is cold).
let map = SenderKeyDeviceMap::from_db_rows(&[
("200200200200200@lid".to_string(), false),
("200200200200200:5@lid".to_string(), true),
]);
let devices = [
Jid::from_str("200200200200200@lid").unwrap(),
Jid::from_str("200200200200200:5@lid").unwrap(),
];
let needs = client.filter_skdm_targets(group, &devices, &map, &own);
assert_eq!(needs.len(), 2, "cold primary redistributes the whole user");

// Companion warm but the primary row is absent (None): WA Web's `?? false`
// treats a missing primary as cold, so the companion still redistributes.
let map = SenderKeyDeviceMap::from_db_rows(&[("300300300300300:5@lid".to_string(), true)]);
let devices = [Jid::from_str("300300300300300:5@lid").unwrap()];
let needs = client.filter_skdm_targets(group, &devices, &map, &own);
assert_eq!(
needs.len(),
1,
"absent primary is cold, companion redistributes"
);
}

#[test]
fn test_skdm_filtering_large_group() {
use std::collections::HashSet;
Expand Down
19 changes: 3 additions & 16 deletions src/sender_key_device_cache.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! In-memory cache for per-group sender key device tracking.
//! Avoids DB round-trips on group sends after the first.

use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use std::sync::Arc;

use crate::cache::Cache;
Expand All @@ -13,37 +13,28 @@ use wacore_binary::Jid;
pub(crate) struct SenderKeyDeviceMap {
/// user → (device_id → has_key)
devices: HashMap<Arc<str>, HashMap<u16, bool>>,
/// Users with at least one `has_key=false` device.
forgotten_users: HashSet<Arc<str>>,
}

impl SenderKeyDeviceMap {
pub fn from_db_rows(rows: &[(String, bool)]) -> Self {
let mut devices: HashMap<Arc<str>, HashMap<u16, bool>> = HashMap::with_capacity(rows.len());
let mut forgotten_users = HashSet::with_capacity(rows.len() / 4);

for (jid_str, has_key) in rows {
match jid_str.parse::<Jid>() {
Ok(jid) => {
let user: Arc<str> = Arc::from(jid.user.as_str());
devices
.entry(user.clone())
.entry(user)
.or_default()
.insert(jid.device, *has_key);
if !*has_key {
forgotten_users.insert(user);
}
}
Err(e) => {
log::warn!("Skipping malformed device JID '{}': {}", jid_str, e);
}
}
}

Self {
devices,
forgotten_users,
}
Self { devices }
}

#[cfg(test)]
Expand All @@ -54,10 +45,6 @@ impl SenderKeyDeviceMap {
pub fn device_has_key(&self, user: &str, device: u16) -> Option<bool> {
self.devices.get(user)?.get(&device).copied()
}

pub fn is_user_forgotten(&self, user: &str) -> bool {
self.forgotten_users.contains(user)
}
}

pub(crate) struct SenderKeyDeviceCache {
Expand Down
Loading