use std::collections::{HashMap, HashSet, VecDeque};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use ciborium::value::Value;
use sha2::{Digest as _, Sha256};
use tracing::debug;
pub const RRC_VERSION: u64 = 1;
pub const K_V: u64 = 0;
pub const K_T: u64 = 1;
pub const K_ID: u64 = 2;
pub const K_TS: u64 = 3;
pub const K_SRC: u64 = 4;
pub const K_ROOM: u64 = 5;
pub const K_BODY: u64 = 6;
pub const K_NICK: u64 = 7;
pub const T_HELLO: u64 = 1;
pub const T_WELCOME: u64 = 2;
pub const T_JOIN: u64 = 10;
pub const T_JOINED: u64 = 11;
pub const T_PART: u64 = 12;
pub const T_PARTED: u64 = 13;
pub const T_MSG: u64 = 20;
pub const T_NOTICE: u64 = 21;
pub const T_ACTION: u64 = 22;
pub const T_PING: u64 = 30;
pub const T_PONG: u64 = 31;
pub const T_ERROR: u64 = 40;
pub const T_RESOURCE_ENVELOPE: u64 = 50;
pub const B_HELLO_NAME: u64 = 0;
pub const B_HELLO_VER: u64 = 1;
pub const B_HELLO_CAPS: u64 = 2;
pub const B_WELCOME_HUB: u64 = 0;
pub const B_WELCOME_VER: u64 = 1;
pub const B_WELCOME_CAPS: u64 = 2;
pub const B_WELCOME_LIMITS: u64 = 3;
pub const L_MAX_NICK_BYTES: u64 = 0;
pub const L_MAX_ROOM_NAME_BYTES: u64 = 1;
pub const L_MAX_MSG_BODY_BYTES: u64 = 2;
pub const L_MAX_ROOMS_PER_SESSION: u64 = 3;
pub const L_RATE_LIMIT_MSGS_PER_MINUTE: u64 = 4;
pub const CAP_RESOURCE_ENVELOPE: u64 = 0;
pub const CAP_ACTION: u64 = 1;
pub const B_RES_ID: u64 = 0;
pub const B_RES_KIND: u64 = 1;
pub const B_RES_SIZE: u64 = 2;
pub const B_RES_SHA256: u64 = 3;
pub const B_RES_ENCODING: u64 = 4;
pub const RES_KIND_NOTICE: &str = "notice";
pub const RES_KIND_MOTD: &str = "motd";
pub const RES_KIND_BLOB: &str = "blob";
pub const DEFAULT_DEST_NAME: &str = "rrc.hub";
pub const DEFAULT_MAX_NICK_BYTES: usize = 32;
pub const DEFAULT_MAX_ROOM_BYTES: usize = 64;
pub const DEFAULT_MAX_MSG_BYTES: usize = 350;
pub const DEFAULT_MAX_ROOMS: usize = 32;
pub const DEFAULT_RATE_PER_MINUTE: u64 = 240;
pub const SENT_IDS_CAPACITY: usize = 256;
pub const DEFAULT_EPHEMERAL_NOTICES_SECS: u64 = 600;
pub const HISTORY_CLEAN_INTERVAL_MS: u64 = 5_000;
pub const H_KIND: &str = "k";
pub const H_SRC: &str = "s";
pub const H_NICK: &str = "n";
pub const H_TEXT: &str = "t";
pub const H_TS: &str = "ts";
pub const H_MENTION: &str = "m";
pub const HISTORY_DIR_NAME: &str = "rrc_history";
pub const REGISTRY_FILENAME: &str = "rrc_hubs";
pub const MAX_RECONNECT_BACKOFF_SECS: f64 = 60.0;
pub const MIN_RECONNECT_BACKOFF_SECS: f64 = 1.0;
pub const RECONNECT_BASE_SECS: f64 = 2.0;
pub const MAX_RECONNECT_EXPONENT: u32 = 6;
pub fn encode_envelope(env: &Value) -> Vec<u8> {
let mut buf = Vec::new();
ciborium::ser::into_writer(env, &mut buf).expect("CBOR encoding should not fail");
buf
}
pub fn decode_envelope(data: &[u8]) -> Option<Value> {
ciborium::de::from_reader(data).ok()
}
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
fn msg_id() -> [u8; 8] {
let mut buf = [0u8; 8];
getrandom::fill(&mut buf).unwrap_or_default();
buf
}
pub(crate) fn get_int(v: &Value) -> Option<i128> {
match v {
Value::Integer(i) => Some((*i).into()),
_ => None,
}
}
pub(crate) fn get_u64(v: &Value) -> Option<u64> {
get_int(v).and_then(|i| i.try_into().ok())
}
pub(crate) fn get_str(v: &Value) -> Option<&str> {
match v {
Value::Text(s) => Some(s),
_ => None,
}
}
pub(crate) fn get_bytes(v: &Value) -> Option<&[u8]> {
match v {
Value::Bytes(b) => Some(b),
_ => None,
}
}
pub(crate) fn get_map(v: &Value) -> Option<&Vec<(Value, Value)>> {
match v {
Value::Map(pairs) => Some(pairs),
_ => None,
}
}
pub(crate) fn map_get(pairs: &[(Value, Value)], key: u64) -> Option<&Value> {
pairs
.iter()
.find(|(k, _)| get_u64(k) == Some(key))
.map(|(_, v)| v)
}
pub(crate) fn map_get_str(pairs: &[(Value, Value)], key: u64) -> Option<&str> {
map_get(pairs, key).and_then(get_str)
}
pub(crate) fn map_get_bytes(pairs: &[(Value, Value)], key: u64) -> Option<&[u8]> {
map_get(pairs, key).and_then(get_bytes)
}
pub(crate) fn map_get_map(pairs: &[(Value, Value)], key: u64) -> Option<&Vec<(Value, Value)>> {
map_get(pairs, key).and_then(get_map)
}
pub fn make_envelope(
msg_type: u64,
src: &[u8],
room: Option<&str>,
body: Option<Value>,
nick: Option<&str>,
mid: Option<&[u8]>,
ts: Option<u64>,
) -> Value {
let mut pairs = vec![
(Value::from(K_V), Value::from(RRC_VERSION)),
(Value::from(K_T), Value::from(msg_type)),
(
Value::from(K_ID),
match mid {
Some(b) => Value::Bytes(b.to_vec()),
None => Value::Bytes(msg_id().to_vec()),
},
),
(Value::from(K_TS), Value::from(ts.unwrap_or_else(now_ms))),
(Value::from(K_SRC), Value::Bytes(src.to_vec())),
];
if let Some(r) = room {
pairs.push((Value::from(K_ROOM), Value::Text(r.to_string())));
}
if let Some(b) = body {
pairs.push((Value::from(K_BODY), b));
}
if let Some(n) = nick {
if !n.is_empty() {
pairs.push((Value::from(K_NICK), Value::Text(n.to_string())));
}
}
Value::Map(pairs)
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct HubLimits {
pub max_nick_bytes: usize,
pub max_room_name_bytes: usize,
pub max_msg_body_bytes: usize,
pub max_rooms_per_session: usize,
pub rate_limit_msgs_per_minute: u64,
}
impl Default for HubLimits {
fn default() -> Self {
Self {
max_nick_bytes: DEFAULT_MAX_NICK_BYTES,
max_room_name_bytes: DEFAULT_MAX_ROOM_BYTES,
max_msg_body_bytes: DEFAULT_MAX_MSG_BYTES,
max_rooms_per_session: DEFAULT_MAX_ROOMS,
rate_limit_msgs_per_minute: DEFAULT_RATE_PER_MINUTE,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HubStatus {
Disconnected,
Connecting,
Connected,
Failed,
}
#[derive(Debug, Clone)]
pub struct RRCMessage {
pub kind: String,
pub room: Option<String>,
pub src: Option<Vec<u8>>,
pub nick: Option<String>,
pub text: String,
pub ts: u64,
pub mention: bool,
}
#[derive(Debug, Clone)]
pub enum RrcEvent {
Connected {
hub_hash: Vec<u8>,
hub_name: Option<String>,
hub_version: Option<String>,
limits: HubLimits,
},
Disconnected {
hub_hash: Vec<u8>,
reason: String,
},
Joined {
hub_hash: Vec<u8>,
room: String,
nick: Option<String>,
},
Parted {
hub_hash: Vec<u8>,
room: String,
nick: Option<String>,
},
Message {
hub_hash: Vec<u8>,
room: Option<String>,
nick: Option<String>,
text: String,
ts: u64,
mention: bool,
},
Action {
hub_hash: Vec<u8>,
room: Option<String>,
nick: Option<String>,
text: String,
ts: u64,
mention: bool,
},
Notice {
hub_hash: Vec<u8>,
room: Option<String>,
text: String,
},
Error {
hub_hash: Vec<u8>,
room: Option<String>,
text: String,
},
RoomList {
hub_hash: Vec<u8>,
rooms: HashMap<String, Option<String>>,
},
WhoReply {
hub_hash: Vec<u8>,
room: String,
members: Vec<WhoEntry>,
},
PongRequired {
hub_hash: Vec<u8>,
payload: Vec<u8>,
},
ResourceAnnounced {
hub_hash: Vec<u8>,
resource_id: Vec<u8>,
kind: String,
size: u64,
sha256: Option<Vec<u8>>,
encoding: Option<String>,
room: Option<String>,
},
}
fn normalize_room(room: &str) -> String {
room.trim().trim_start_matches('#').to_lowercase()
}
fn check_mention(text: &str, nick: &str) -> bool {
if nick.is_empty() {
return false;
}
let escaped = fancy_regex::escape(nick);
let pattern = format!(r"(?i)(?<![A-Za-z0-9_])@{}(?![A-Za-z0-9_])", escaped);
fancy_regex::Regex::new(&pattern)
.map(|re| re.is_match(text).unwrap_or(false))
.unwrap_or(false)
}
type WhoEntry = (Option<String>, String);
fn parse_who_notice(text: &str) -> Option<(String, Vec<WhoEntry>)> {
let prefix = "members in ";
if !text.starts_with(prefix) {
return None;
}
let sep_idx = text.find(": ")?;
let room = text[prefix.len()..sep_idx].trim().to_lowercase();
if room.is_empty() {
return None;
}
let body = text[sep_idx + 2..].trim();
if body == "(none)" || body.is_empty() {
return Some((room, vec![]));
}
let mut entries = Vec::new();
for part in body.split(", ") {
let part = part.trim();
if part.is_empty() {
continue;
}
if let Some(paren_start) = part.rfind(" (") {
let nick_str = &part[..paren_start];
let rest = &part[paren_start + 2..];
if let Some(closing) = rest.find(')') {
let hex_part = &rest[..closing];
if hex_part.len() == 12 && hex_part.chars().all(|c| c.is_ascii_hexdigit()) {
entries.push((Some(nick_str.trim().to_string()), hex_part.to_lowercase()));
continue;
}
}
}
if part.len() == 32 && part.chars().all(|c| c.is_ascii_hexdigit()) {
entries.push((None, part.to_lowercase()));
}
}
Some((room, entries))
}
fn parse_room_list_notice(text: &str) -> Option<HashMap<String, Option<String>>> {
let stripped = text.trim();
if stripped == "No public rooms registered" {
return Some(HashMap::new());
}
let lines: Vec<&str> = stripped.split('\n').collect();
if lines.is_empty() || !lines[0].trim().starts_with("Registered public rooms") {
return None;
}
let mut rooms = HashMap::new();
for line in &lines[1..] {
let s = line.trim();
if s.is_empty() {
continue;
}
if let Some(idx) = s.find(" - ") {
let name = s[..idx].trim().trim_start_matches('#').to_lowercase();
let topic = s[idx + 3..].trim();
rooms.insert(
name,
if topic.is_empty() {
None
} else {
Some(topic.to_string())
},
);
} else {
let name = s.trim_start_matches('#').to_lowercase();
rooms.insert(name, None);
}
}
Some(rooms)
}
pub struct RRCHubState {
pub hub_hash: Vec<u8>,
pub dest_name: String,
pub name: String,
pub status: HubStatus,
pub status_text: String,
pub welcomed: bool,
pub hub_name: Option<String>,
pub hub_version: Option<String>,
pub limits: HubLimits,
pub hub_caps: HashMap<u64, bool>,
pub motd: Option<String>,
pub rooms: HashSet<String>,
pub messages: HashMap<String, Vec<RRCMessage>>,
pub notices: Vec<RRCMessage>,
pub unread_rooms: HashSet<String>,
pub mention_rooms: HashSet<String>,
pub members: HashMap<String, HashSet<Vec<u8>>>,
pub nicks: HashMap<Vec<u8>, String>,
pub auto_reconnect: bool,
pub auto_list: bool,
pub auto_who: bool,
pub available_rooms: HashMap<String, Option<String>>,
pub nick_override: Option<String>,
pub reconnect_attempts: u32,
pub manual_disconnect: bool,
pub active_room: Option<String>,
pub parted_rooms: HashSet<String>,
pending_joins: HashSet<String>,
pending_parts: HashSet<String>,
silent_joins: HashSet<String>,
silent_list_pending: usize,
silent_who_rooms: HashSet<String>,
sent_ids: VecDeque<Vec<u8>>,
pending_pings: HashMap<Vec<u8>, (u64, Option<String>)>,
own_hash: Vec<u8>,
nickname_fn: Arc<dyn Fn() -> Option<String> + Send + Sync>,
per_room_cap: Option<usize>,
filter_loaded_history: bool,
ephemeral_notices_secs: Option<u64>,
last_history_clean_ms: u64,
history_write_failed: bool,
}
impl std::fmt::Debug for RRCHubState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RRCHubState")
.field("hub_hash", &hex::encode(&self.hub_hash))
.field("status", &self.status)
.finish()
}
}
impl RRCHubState {
pub fn new(
hub_hash: Vec<u8>,
own_hash: Vec<u8>,
dest_name: Option<&str>,
name: Option<&str>,
nickname_fn: Arc<dyn Fn() -> Option<String> + Send + Sync>,
) -> Self {
Self {
hub_hash: hub_hash.clone(),
dest_name: dest_name.unwrap_or(DEFAULT_DEST_NAME).to_string(),
name: name
.map(|s| s.to_string())
.unwrap_or_else(|| hex::encode(&hub_hash)),
status: HubStatus::Disconnected,
status_text: "Disconnected".to_string(),
welcomed: false,
hub_name: None,
hub_version: None,
limits: HubLimits::default(),
hub_caps: HashMap::new(),
motd: None,
rooms: HashSet::new(),
messages: HashMap::new(),
notices: Vec::new(),
unread_rooms: HashSet::new(),
mention_rooms: HashSet::new(),
members: HashMap::new(),
nicks: HashMap::new(),
auto_reconnect: false,
auto_list: false,
auto_who: false,
available_rooms: HashMap::new(),
nick_override: None,
reconnect_attempts: 0,
manual_disconnect: false,
active_room: None,
parted_rooms: HashSet::new(),
pending_joins: HashSet::new(),
pending_parts: HashSet::new(),
silent_joins: HashSet::new(),
silent_list_pending: 0,
silent_who_rooms: HashSet::new(),
sent_ids: VecDeque::with_capacity(SENT_IDS_CAPACITY),
own_hash,
nickname_fn,
pending_pings: HashMap::new(),
per_room_cap: None,
filter_loaded_history: true,
ephemeral_notices_secs: Some(DEFAULT_EPHEMERAL_NOTICES_SECS),
last_history_clean_ms: 0,
history_write_failed: false,
}
}
pub fn get_effective_nick(&self) -> Option<String> {
if let Some(ref n) = self.nick_override {
if !n.is_empty() {
return Some(n.clone());
}
}
(self.nickname_fn)()
}
pub fn display_name_for(&self, peer: &[u8]) -> String {
if let Some(nick) = self.nicks.get(peer) {
return nick.clone();
}
if peer.len() >= 6 {
hex::encode(&peer[..6])
} else {
hex::encode(peer)
}
}
pub fn per_room_cap(&self) -> Option<usize> {
self.per_room_cap
}
pub fn set_per_room_cap(&mut self, cap: Option<usize>) {
self.per_room_cap = cap;
}
pub fn filter_loaded_history(&self) -> bool {
self.filter_loaded_history
}
pub fn set_filter_loaded_history(&mut self, filter: bool) {
self.filter_loaded_history = filter;
}
pub fn ephemeral_notices_secs(&self) -> Option<u64> {
self.ephemeral_notices_secs
}
pub fn set_ephemeral_notices_secs(&mut self, seconds: u64) {
self.ephemeral_notices_secs = (seconds > 0).then_some(seconds);
}
pub fn clean_ephemeral_history(&mut self, current_time_ms: u64) {
let Some(retention_secs) = self.ephemeral_notices_secs else {
return;
};
let retention_ms = retention_secs.saturating_mul(1_000);
for messages in self.messages.values_mut() {
messages.retain(|message| {
!matches!(message.kind.as_str(), "system" | "notice")
|| current_time_ms.saturating_sub(message.ts) <= retention_ms
});
}
self.notices.retain(|message| {
!matches!(message.kind.as_str(), "system" | "notice")
|| current_time_ms.saturating_sub(message.ts) <= retention_ms
});
self.last_history_clean_ms = current_time_ms;
}
fn clean_ephemeral_history_if_due(&mut self, current_time_ms: u64) {
if current_time_ms.saturating_sub(self.last_history_clean_ms) >= HISTORY_CLEAN_INTERVAL_MS {
self.clean_ephemeral_history(current_time_ms);
}
}
pub fn reconnect_backoff_secs(&self) -> f64 {
let exp = self.reconnect_attempts.min(MAX_RECONNECT_EXPONENT);
(RECONNECT_BASE_SECS.powi(exp as i32))
.clamp(MIN_RECONNECT_BACKOFF_SECS, MAX_RECONNECT_BACKOFF_SECS)
}
fn record_message(&mut self, msg: RRCMessage, local: bool) {
let cap = self.per_room_cap();
let room_key = msg.room.clone().unwrap_or_else(|| "*".to_string());
let buf = self.messages.entry(room_key.clone()).or_default();
buf.push(msg.clone());
if let Some(c) = cap {
if buf.len() > c {
let excess = buf.len() - c;
self.messages.get_mut(&room_key).unwrap().drain(..excess);
}
}
if !local {
if let Some(room) = &msg.room {
let is_active = self.active_room.as_ref().is_some_and(|ar| ar == room);
if !is_active {
self.unread_rooms.insert(room.clone());
if msg.mention {
self.mention_rooms.insert(room.clone());
}
}
}
}
self.clean_ephemeral_history_if_due(now_ms());
}
fn record_system(&mut self, room: Option<&str>, text: &str) {
let room = match room {
Some(r) => r.to_string(),
None => return,
};
let msg = RRCMessage {
kind: "system".to_string(),
room: Some(room.clone()),
src: None,
nick: None,
text: text.to_string(),
ts: now_ms(),
mention: false,
};
let cap = self.per_room_cap();
let buf = self.messages.entry(room).or_default();
buf.push(msg);
if let Some(c) = cap {
if buf.len() > c {
let key = buf[0].room.clone().unwrap();
let excess = buf.len() - c;
self.messages.get_mut(&key).unwrap().drain(..excess);
}
}
self.clean_ephemeral_history_if_due(now_ms());
}
pub(crate) fn record_notice(&mut self, msg: RRCMessage) {
let cap = self.per_room_cap();
self.notices.push(msg.clone());
if self.notices.len() > 200 {
self.notices.drain(..self.notices.len() - 200);
}
if let Some(room) = &msg.room {
let buf = self.messages.entry(room.clone()).or_default();
buf.push(msg.clone());
if let Some(c) = cap {
if buf.len() > c {
let excess = buf.len() - c;
self.messages.get_mut(room).unwrap().drain(..excess);
}
}
self.unread_rooms.insert(room.clone());
}
self.clean_ephemeral_history_if_due(now_ms());
}
pub fn handle_packet(&mut self, data: &[u8]) -> Vec<RrcEvent> {
let mut events = Vec::new();
let env = match decode_envelope(data) {
Some(v) => v,
None => {
debug!("RRC: failed to decode CBOR packet");
return events;
}
};
let pairs = match &env {
Value::Map(p) => p,
_ => return events,
};
let t = match map_get(pairs, K_T).and_then(get_u64) {
Some(t) => t,
None => return events,
};
match t {
T_PING => {
if let Some(body) = map_get(pairs, K_BODY) {
let pong = make_envelope(
T_PONG,
&self.own_hash,
None,
Some(body.clone()),
None,
None,
None,
);
let payload = encode_envelope(&pong);
events.push(RrcEvent::PongRequired {
hub_hash: self.hub_hash.clone(),
payload,
});
}
}
T_PONG => {
if let Some(body) = map_get(pairs, K_BODY).and_then(get_bytes) {
let key = body.to_vec();
if let Some((sent_ms, _room)) = self.pending_pings.remove(&key) {
let rtt = now_ms().saturating_sub(sent_ms);
self.record_system(_room.as_deref(), &format!("Pong from hub: {} ms", rtt));
}
}
}
T_WELCOME => {
self.welcomed = true;
let mut hub_name = None;
let mut hub_version = None;
if let Some(body_pairs) = map_get_map(pairs, K_BODY) {
if let Some(name) = map_get_str(body_pairs, B_WELCOME_HUB) {
hub_name = Some(name.to_string());
}
if let Some(ver) = map_get_str(body_pairs, B_WELCOME_VER) {
hub_version = Some(ver.to_string());
}
if let Some(caps_pairs) = map_get_map(body_pairs, B_WELCOME_CAPS) {
self.hub_caps = caps_pairs
.iter()
.filter_map(|(key, value)| {
let key = get_u64(key)?;
let Value::Bool(enabled) = value else {
return None;
};
Some((key, *enabled))
})
.collect();
}
if let Some(limits_pairs) = map_get_map(body_pairs, B_WELCOME_LIMITS) {
if let Some(v) = map_get(limits_pairs, L_MAX_NICK_BYTES).and_then(get_u64) {
self.limits.max_nick_bytes = v as usize;
}
if let Some(v) =
map_get(limits_pairs, L_MAX_ROOM_NAME_BYTES).and_then(get_u64)
{
self.limits.max_room_name_bytes = v as usize;
}
if let Some(v) =
map_get(limits_pairs, L_MAX_MSG_BODY_BYTES).and_then(get_u64)
{
self.limits.max_msg_body_bytes = v as usize;
}
if let Some(v) =
map_get(limits_pairs, L_MAX_ROOMS_PER_SESSION).and_then(get_u64)
{
self.limits.max_rooms_per_session = v as usize;
}
if let Some(v) =
map_get(limits_pairs, L_RATE_LIMIT_MSGS_PER_MINUTE).and_then(get_u64)
{
self.limits.rate_limit_msgs_per_minute = v;
}
}
}
self.hub_name = hub_name.clone();
self.hub_version = hub_version.clone();
self.status = HubStatus::Connected;
self.status_text = "Connected".to_string();
events.push(RrcEvent::Connected {
hub_hash: self.hub_hash.clone(),
hub_name,
hub_version,
limits: self.limits.clone(),
});
}
T_JOINED => {
if let Some(room_raw) = map_get_str(pairs, K_ROOM) {
let room = normalize_room(room_raw);
let body_hashes: Vec<Vec<u8>> = map_get(pairs, K_BODY)
.and_then(get_map)
.map(|_| vec![])
.unwrap_or_else(|| {
map_get(pairs, K_BODY)
.and_then(|v| match v {
Value::Array(arr) => Some(
arr.iter()
.filter_map(|e| get_bytes(e).map(|b| b.to_vec()))
.collect(),
),
_ => None,
})
.unwrap_or_default()
});
let advisory_nick = map_get_str(pairs, K_NICK);
let self_join = self.pending_joins.remove(&room);
let silent = self.silent_joins.remove(&room);
self.rooms.insert(room.clone());
self.messages.entry(room.clone()).or_default();
let members = self.members.entry(room.clone()).or_default();
for h in &body_hashes {
members.insert(h.clone());
}
members.insert(self.own_hash.clone());
if self_join {
if !silent {
self.record_system(Some(&room), &format!("You joined #{}", room));
}
events.push(RrcEvent::Joined {
hub_hash: self.hub_hash.clone(),
room: room.clone(),
nick: self.get_effective_nick(),
});
} else {
let joiner = if body_hashes.len() == 1 && body_hashes[0] != self.own_hash {
Some(body_hashes[0].clone())
} else {
None
};
if let (Some(joiner), Some(nick)) = (&joiner, advisory_nick) {
if !nick.is_empty() {
self.nicks.insert(joiner.clone(), nick.to_string());
}
}
if let Some(j) = joiner {
let dn = self.display_name_for(&j);
self.record_system(Some(&room), &format!("{} joined", dn));
}
}
}
}
T_PARTED => {
if let Some(room_raw) = map_get_str(pairs, K_ROOM) {
let room = normalize_room(room_raw);
let body_hashes: Vec<Vec<u8>> = map_get(pairs, K_BODY)
.and_then(|v| match v {
Value::Array(arr) => Some(
arr.iter()
.filter_map(|e| get_bytes(e).map(|b| b.to_vec()))
.collect(),
),
_ => None,
})
.unwrap_or_default();
let advisory_nick = map_get_str(pairs, K_NICK);
let self_part = self.pending_parts.remove(&room);
if let Some(members) = self.members.get_mut(&room) {
for h in &body_hashes {
members.remove(h);
}
}
if self_part {
self.rooms.remove(&room);
self.members.remove(&room);
events.push(RrcEvent::Parted {
hub_hash: self.hub_hash.clone(),
room: room.clone(),
nick: self.get_effective_nick(),
});
} else {
let parter = if body_hashes.len() == 1 && body_hashes[0] != self.own_hash {
Some(body_hashes[0].clone())
} else {
None
};
if let (Some(parter), Some(nick)) = (&parter, advisory_nick) {
if !nick.is_empty() {
self.nicks.insert(parter.clone(), nick.to_string());
}
}
if let Some(p) = parter {
let dn = self.display_name_for(&p);
self.record_system(Some(&room), &format!("{} left", dn));
}
}
}
}
T_MSG | T_ACTION => {
let body = map_get_str(pairs, K_BODY).unwrap_or("");
let room_raw = map_get_str(pairs, K_ROOM);
let src = map_get_bytes(pairs, K_SRC).map(|b| b.to_vec());
let nick = map_get_str(pairs, K_NICK).map(|s| s.to_string());
let mid = map_get_bytes(pairs, K_ID);
if let (Some(s), Some(mid_bytes)) = (&src, &mid) {
if s == &self.own_hash && self.sent_ids.iter().any(|id| id == mid_bytes) {
return events;
}
}
if let (Some(s), Some(n)) = (&src, &nick) {
self.nicks.insert(s.clone(), n.clone());
if let Some(r) = room_raw {
let rn = normalize_room(r);
self.members.entry(rn).or_default().insert(s.clone());
}
}
let is_own = src.as_ref() == Some(&self.own_hash);
let mut mention = false;
if !is_own {
if let Some(own_nick) = self.get_effective_nick().as_deref() {
mention = check_mention(body, own_nick);
}
}
let room = room_raw.map(normalize_room);
let kind = if t == T_ACTION { "action" } else { "msg" };
let msg = RRCMessage {
kind: kind.to_string(),
room,
src,
nick,
text: body.to_string(),
ts: now_ms(),
mention,
};
let event = if t == T_ACTION {
RrcEvent::Action {
hub_hash: self.hub_hash.clone(),
room: msg.room.clone(),
nick: msg.nick.clone(),
text: msg.text.clone(),
ts: msg.ts,
mention: msg.mention,
}
} else {
RrcEvent::Message {
hub_hash: self.hub_hash.clone(),
room: msg.room.clone(),
nick: msg.nick.clone(),
text: msg.text.clone(),
ts: msg.ts,
mention: msg.mention,
}
};
events.push(event);
self.record_message(msg, false);
}
T_NOTICE => {
let body = match map_get_str(pairs, K_BODY) {
Some(b) => b,
None => return events,
};
let room_raw = map_get_str(pairs, K_ROOM);
let parsed_list = parse_room_list_notice(body);
if let Some(rooms) = parsed_list {
self.available_rooms = rooms.clone();
let silent = self.silent_list_pending > 0;
if silent {
self.silent_list_pending = self.silent_list_pending.saturating_sub(1);
}
events.push(RrcEvent::RoomList {
hub_hash: self.hub_hash.clone(),
rooms,
});
if silent {
return events;
}
}
let parsed_who = parse_who_notice(body);
if let Some((who_room, who_entries)) = parsed_who {
let members = self.members.entry(who_room.clone()).or_default();
for (nick, hash_hex) in &who_entries {
let hash_bytes = match hex::decode(hash_hex) {
Ok(b) => b,
Err(_) => continue,
};
if nick.is_none() {
members.insert(hash_bytes);
continue;
}
for ph in members.iter() {
if ph.starts_with(&hash_bytes) {
self.nicks
.insert(ph.clone(), nick.as_ref().unwrap().clone());
break;
}
}
}
let silent_who = self.silent_who_rooms.remove(&who_room);
events.push(RrcEvent::WhoReply {
hub_hash: self.hub_hash.clone(),
room: who_room,
members: who_entries,
});
if silent_who {
return events;
}
}
let room = room_raw.map(normalize_room);
if room.is_none() && !body.trim().is_empty() {
self.motd = Some(body.to_string());
}
let msg = RRCMessage {
kind: "notice".to_string(),
room: room.clone(),
src: map_get_bytes(pairs, K_SRC).map(|b| b.to_vec()),
nick: None,
text: body.to_string(),
ts: now_ms(),
mention: false,
};
events.push(RrcEvent::Notice {
hub_hash: self.hub_hash.clone(),
room: msg.room.clone(),
text: msg.text.clone(),
});
self.record_notice(msg);
}
T_ERROR => {
let body = map_get_str(pairs, K_BODY).unwrap_or("(error)");
let room_raw = map_get_str(pairs, K_ROOM);
let room = room_raw.map(normalize_room);
if let Some(ref r) = room {
let rollback = self.pending_joins.remove(r);
self.silent_joins.remove(r);
self.pending_parts.remove(r);
if rollback {
self.rooms.remove(r);
}
}
events.push(RrcEvent::Error {
hub_hash: self.hub_hash.clone(),
room: room.clone(),
text: body.to_string(),
});
let msg = RRCMessage {
kind: "error".to_string(),
room,
src: None,
nick: None,
text: body.to_string(),
ts: now_ms(),
mention: false,
};
self.record_notice(msg);
}
T_RESOURCE_ENVELOPE => {
if let Some(body_pairs) = map_get_map(pairs, K_BODY) {
let rid = map_get_bytes(body_pairs, B_RES_ID).map(|b| b.to_vec());
let kind = map_get_str(body_pairs, B_RES_KIND);
let size = map_get(body_pairs, B_RES_SIZE).and_then(get_u64);
let sha256 = map_get_bytes(body_pairs, B_RES_SHA256).map(|b| b.to_vec());
let encoding = map_get_str(body_pairs, B_RES_ENCODING).map(|s| s.to_string());
let room_raw = map_get_str(pairs, K_ROOM);
let room = room_raw.map(normalize_room);
if let (Some(rid), Some(kind)) = (rid, kind) {
let size = size.unwrap_or(0);
debug!(
"RRC: resource envelope from {}: kind={} size={}",
hex::encode(&self.hub_hash),
kind,
size
);
events.push(RrcEvent::ResourceAnnounced {
hub_hash: self.hub_hash.clone(),
resource_id: rid,
kind: kind.to_string(),
size,
sha256,
encoding,
room,
});
}
}
}
_ => {
debug!(
"RRC: unknown packet type {} from {}",
t,
hex::encode(&self.hub_hash)
);
}
}
events
}
pub fn build_hello(&mut self) -> Vec<u8> {
let caps = Value::Map(vec![
(Value::from(CAP_RESOURCE_ENVELOPE), Value::Bool(true)),
(Value::from(CAP_ACTION), Value::Bool(true)),
]);
let body = Value::Map(vec![
(
Value::from(B_HELLO_NAME),
Value::Text("nomadnet".to_string()),
),
(Value::from(B_HELLO_VER), Value::Text("0.1".to_string())),
(Value::from(B_HELLO_CAPS), caps),
]);
let nick = self.get_effective_nick();
let env = make_envelope(
T_HELLO,
&self.own_hash,
None,
Some(body),
nick.as_deref(),
None,
None,
);
encode_envelope(&env)
}
pub fn build_join(&mut self, room: &str, key: Option<&str>, silent: bool) -> Vec<u8> {
let r = normalize_room(room);
let body = key
.filter(|k| !k.is_empty())
.map(|k| Value::Text(k.to_string()));
let nick = self.get_effective_nick();
self.pending_joins.insert(r.clone());
if silent {
self.silent_joins.insert(r.clone());
}
let env = make_envelope(
T_JOIN,
&self.own_hash,
Some(&r),
body,
nick.as_deref(),
None,
None,
);
self.messages.entry(r).or_default();
encode_envelope(&env)
}
pub fn build_part(&mut self, room: &str) -> Vec<u8> {
let r = normalize_room(room);
self.pending_parts.insert(r.clone());
let env = make_envelope(T_PART, &self.own_hash, Some(&r), None, None, None, None);
encode_envelope(&env)
}
pub fn build_message(&mut self, room: &str, text: &str) -> Result<Vec<u8>, String> {
let r = normalize_room(room);
if text.trim().is_empty() {
return Err("message text must be non-empty".to_string());
}
if text.len() > self.limits.max_msg_body_bytes {
return Err("message too long for hub limit".to_string());
}
let nick = self.get_effective_nick();
let env = make_envelope(
T_MSG,
&self.own_hash,
Some(&r),
Some(Value::Text(text.to_string())),
nick.as_deref(),
None,
None,
);
if let Some(Value::Bytes(id)) = map_get(
match &env {
Value::Map(p) => p,
_ => unreachable!(),
},
K_ID,
) {
self.sent_ids.push_back(id.clone());
if self.sent_ids.len() > SENT_IDS_CAPACITY {
self.sent_ids.pop_front();
}
}
let payload = encode_envelope(&env);
let msg = RRCMessage {
kind: "msg".to_string(),
room: Some(r),
src: Some(self.own_hash.clone()),
nick: nick.clone(),
text: text.to_string(),
ts: now_ms(),
mention: false,
};
self.record_message(msg, true);
Ok(payload)
}
pub fn build_action(&mut self, room: &str, text: &str) -> Result<Vec<u8>, String> {
let room = normalize_room(room);
if text.trim().is_empty() {
return Err("action text must be non-empty".to_string());
}
if text.len() > self.limits.max_msg_body_bytes {
return Err("action too long for hub limit".to_string());
}
let nick = self.get_effective_nick();
let env = make_envelope(
T_ACTION,
&self.own_hash,
Some(&room),
Some(Value::Text(text.to_string())),
nick.as_deref(),
None,
None,
);
if let Some(Value::Bytes(id)) = map_get(
match &env {
Value::Map(pairs) => pairs,
_ => unreachable!(),
},
K_ID,
) {
self.sent_ids.push_back(id.clone());
if self.sent_ids.len() > SENT_IDS_CAPACITY {
self.sent_ids.pop_front();
}
}
let payload = encode_envelope(&env);
self.record_message(
RRCMessage {
kind: "action".to_string(),
room: Some(room),
src: Some(self.own_hash.clone()),
nick,
text: text.to_string(),
ts: now_ms(),
mention: false,
},
true,
);
Ok(payload)
}
pub fn build_ping(&mut self, room: Option<&str>) -> Vec<u8> {
let mut id = [0u8; 8];
let _ = getrandom::fill(&mut id);
let body = Value::Bytes(id.to_vec());
let now = now_ms();
self.pending_pings
.insert(id.to_vec(), (now, room.map(|r| r.to_string())));
let expired_cutoff = now.saturating_sub(15000);
self.pending_pings.retain(|_, (ts, _)| *ts > expired_cutoff);
let env = make_envelope(T_PING, &self.own_hash, None, Some(body), None, None, None);
encode_envelope(&env)
}
pub fn build_command(&mut self, text: &str, room: Option<&str>) -> Vec<u8> {
let nick = self.get_effective_nick();
let env = make_envelope(
T_MSG,
&self.own_hash,
room,
Some(Value::Text(text.to_string())),
nick.as_deref(),
None,
None,
);
if let Some(Value::Bytes(id)) = map_get(
match &env {
Value::Map(p) => p,
_ => unreachable!(),
},
K_ID,
) {
self.sent_ids.push_back(id.clone());
if self.sent_ids.len() > SENT_IDS_CAPACITY {
self.sent_ids.pop_front();
}
}
encode_envelope(&env)
}
pub fn add_room(&mut self, room: &str) -> String {
let r = normalize_room(room);
self.rooms.insert(r.clone());
self.messages.entry(r.clone()).or_default();
r
}
pub fn remove_room(&mut self, room: &str) {
let r = normalize_room(room);
self.rooms.remove(&r);
self.messages.remove(&r);
self.unread_rooms.remove(&r);
self.mention_rooms.remove(&r);
self.members.remove(&r);
}
pub fn get_messages(&self, room: &str) -> Vec<&RRCMessage> {
self.messages
.get(room)
.map(|buf| buf.iter().collect())
.unwrap_or_default()
}
pub fn get_members(&self, room: &str) -> Vec<&[u8]> {
self.members
.get(room)
.map(|s| s.iter().map(|v| v.as_slice()).collect())
.unwrap_or_default()
}
pub fn mark_read(&mut self, room: &str) {
let r = normalize_room(room);
self.unread_rooms.remove(&r);
self.mention_rooms.remove(&r);
}
pub fn set_status(&mut self, status: HubStatus, text: &str) {
self.status = status;
self.status_text = text.to_string();
}
pub fn reset_on_disconnect(&mut self) {
self.welcomed = false;
self.motd = None;
self.members.clear();
self.pending_joins.clear();
self.pending_parts.clear();
self.silent_joins.clear();
self.silent_who_rooms.clear();
self.status = HubStatus::Disconnected;
self.status_text = "Disconnected".to_string();
self.manual_disconnect = false;
}
pub fn history_dir(&self, history_root: &Path) -> PathBuf {
let mut hub_key = hex::encode(&self.hub_hash);
if self.dest_name != DEFAULT_DEST_NAME {
let suffix = hex::encode(&Sha256::digest(self.dest_name.as_bytes())[..4]);
hub_key = format!("{}__{}", hub_key, suffix);
}
history_root.join(&hub_key)
}
pub fn history_path(&self, history_root: &Path, room: &str) -> PathBuf {
let room_hash = hex::encode(&Sha256::digest(room.as_bytes())[..4]);
let sanitized: String = room
.to_lowercase()
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect();
let truncated = if sanitized.len() > 64 {
&sanitized[..64]
} else {
&sanitized
};
let filename = if truncated.is_empty() {
format!("{}.log", room_hash)
} else {
format!("{}_{}.log", truncated, room_hash)
};
self.history_dir(history_root).join(filename)
}
pub fn ensure_history_dir(&self, history_root: &Path) -> std::io::Result<PathBuf> {
let dir = self.history_dir(history_root);
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
pub fn append_history(&mut self, history_root: &Path, room: &str, msg: &RRCMessage) {
if room.is_empty() || room == "*" {
return;
}
if let Err(e) = self.ensure_history_dir(history_root) {
if !self.history_write_failed {
self.history_write_failed = true;
debug!(
"history persistence failed, suppressing further warnings: {}",
e
);
}
return;
}
let path = self.history_path(history_root, room);
let entry = self.history_entry_for(msg);
let mut buf = Vec::new();
if ciborium::ser::into_writer(&entry, &mut buf).is_err() {
return;
}
match std::fs::OpenOptions::new()
.append(true)
.create(true)
.open(&path)
{
Ok(mut f) => {
if std::io::Write::write_all(&mut f, &buf).is_ok() {
self.history_write_failed = false;
} else if !self.history_write_failed {
self.history_write_failed = true;
debug!("history write failed for room {}", room);
}
}
Err(e) => {
if !self.history_write_failed {
self.history_write_failed = true;
debug!("history open failed for room {}: {}", room, e);
}
}
}
}
pub fn delete_history(&self, history_root: &Path, room: &str) {
if room.is_empty() || room == "*" {
return;
}
let path = self.history_path(history_root, room);
if path.is_file() {
let _ = std::fs::remove_file(path);
}
}
pub fn load_history(&mut self, history_root: &Path) {
let rooms: Vec<String> = self.messages.keys().cloned().collect();
for room in rooms {
if room.is_empty() || room == "*" {
continue;
}
let path = self.history_path(history_root, &room);
if !path.is_file() {
continue;
}
let data = match std::fs::read(&path) {
Ok(d) => d,
Err(_) => continue,
};
let cap = self.per_room_cap;
let mut window: VecDeque<Value> = if let Some(c) = cap {
VecDeque::with_capacity(c)
} else {
VecDeque::new()
};
let mut cursor = std::io::Cursor::new(&data);
while let Ok(v) = ciborium::de::from_reader::<Value, _>(&mut cursor) {
if let Some(c) = cap {
if window.len() >= c {
window.pop_front();
}
}
window.push_back(v);
}
let mut msgs = Vec::new();
for entry in &window {
if let Some(message) = self.msg_from_history_entry(&room, entry) {
if self.filter_loaded_history
&& matches!(message.kind.as_str(), "system" | "notice")
{
continue;
}
msgs.push(message);
}
}
self.messages.insert(room, msgs);
}
}
fn history_entry_for(&self, msg: &RRCMessage) -> Value {
let mut pairs = vec![
(
Value::Text(H_KIND.to_string()),
Value::Text(msg.kind.clone()),
),
(
Value::Text(H_TEXT.to_string()),
Value::Text(msg.text.clone()),
),
(Value::Text(H_TS.to_string()), Value::Integer(msg.ts.into())),
(Value::Text(H_MENTION.to_string()), Value::Bool(msg.mention)),
];
if let Some(ref src) = msg.src {
pairs.insert(
1,
(Value::Text(H_SRC.to_string()), Value::Bytes(src.clone())),
);
}
if let Some(ref nick) = msg.nick {
pairs.insert(
pairs.len() - 2,
(Value::Text(H_NICK.to_string()), Value::Text(nick.clone())),
);
}
Value::Map(pairs)
}
fn msg_from_history_entry(&self, room: &str, entry: &Value) -> Option<RRCMessage> {
let pairs = match entry {
Value::Map(p) => p,
_ => return None,
};
let get_h = |key: &str| -> Option<&Value> {
pairs
.iter()
.find(|(k, _)| get_str(k) == Some(key))
.map(|(_, v)| v)
};
let kind = get_h(H_KIND).and_then(get_str).unwrap_or("msg").to_string();
let src = get_h(H_SRC).and_then(get_bytes).map(|b| b.to_vec());
let nick = get_h(H_NICK).and_then(get_str).map(|s| s.to_string());
let text = get_h(H_TEXT).and_then(get_str).unwrap_or("").to_string();
let ts = get_h(H_TS).and_then(get_u64).unwrap_or(0);
let mention = get_h(H_MENTION)
.and_then(|v| match v {
Value::Bool(b) => Some(*b),
_ => None,
})
.unwrap_or(false);
Some(RRCMessage {
kind,
room: Some(room.to_string()),
src,
nick,
text,
ts,
mention,
})
}
pub fn compute_parted_rooms(&mut self) {
let joined: HashSet<String> = self.rooms.clone();
self.parted_rooms = self
.messages
.keys()
.filter(|r| !joined.contains(*r) && *r != "*")
.cloned()
.collect();
}
pub fn ensure_parted_room_buffer(&mut self, room: &str) {
self.messages.entry(room.to_string()).or_default();
}
}
pub struct RRCManager {
hubs: Vec<RRCHubState>,
active_hub_hash: Option<Vec<u8>>,
active_room: Option<String>,
storage_path: PathBuf,
history_per_room_cap: Option<usize>,
own_hash: Vec<u8>,
nickname_fn: Arc<dyn Fn() -> Option<String> + Send + Sync>,
loaded: bool,
loading: bool,
}
impl std::fmt::Debug for RRCManager {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RRCManager")
.field("hub_count", &self.hubs.len())
.field(
"active_hub",
&self.active_hub_hash.as_ref().map(hex::encode),
)
.field("active_room", &self.active_room)
.field("loaded", &self.loaded)
.finish()
}
}
impl RRCManager {
pub fn new(
storage_path: PathBuf,
own_hash: Vec<u8>,
nickname_fn: Arc<dyn Fn() -> Option<String> + Send + Sync>,
) -> Self {
Self {
hubs: Vec::new(),
active_hub_hash: None,
active_room: None,
storage_path,
history_per_room_cap: None,
own_hash,
nickname_fn,
loaded: false,
loading: false,
}
}
pub fn set_history_per_room_cap(&mut self, cap: Option<usize>) {
self.history_per_room_cap = cap;
for hub in &mut self.hubs {
hub.set_per_room_cap(cap);
}
}
pub fn add_hub(
&mut self,
hub_hash: Vec<u8>,
dest_name: Option<&str>,
name: Option<&str>,
) -> usize {
let dn = dest_name.unwrap_or(DEFAULT_DEST_NAME);
for (idx, h) in self.hubs.iter().enumerate() {
if h.hub_hash == hub_hash && h.dest_name == dn {
return idx;
}
}
let mut hub = RRCHubState::new(
hub_hash,
self.own_hash.clone(),
Some(dn),
name,
Arc::clone(&self.nickname_fn),
);
hub.set_per_room_cap(self.history_per_room_cap);
self.hubs.push(hub);
let idx = self.hubs.len() - 1;
self.save();
idx
}
pub fn remove_hub(&mut self, hub_hash: &[u8], dest_name: Option<&str>) -> bool {
let dn = dest_name.unwrap_or(DEFAULT_DEST_NAME);
let pos = self
.hubs
.iter()
.position(|h| h.hub_hash == hub_hash && h.dest_name == dn);
if let Some(pos) = pos {
self.hubs.remove(pos);
if self.active_hub_hash.as_ref() == Some(&hub_hash.to_vec()) {
self.active_hub_hash = None;
self.active_room = None;
}
self.save();
true
} else {
false
}
}
pub fn find_hub(&self, hub_hash: &[u8], dest_name: Option<&str>) -> Option<&RRCHubState> {
let dn = dest_name.unwrap_or(DEFAULT_DEST_NAME);
self.hubs
.iter()
.find(|h| h.hub_hash == hub_hash && h.dest_name == dn)
}
pub fn find_hub_mut(
&mut self,
hub_hash: &[u8],
dest_name: Option<&str>,
) -> Option<&mut RRCHubState> {
let dn = dest_name.unwrap_or(DEFAULT_DEST_NAME);
self.hubs
.iter_mut()
.find(|h| h.hub_hash == hub_hash && h.dest_name == dn)
}
pub fn hubs(&self) -> &[RRCHubState] {
&self.hubs
}
pub fn hubs_mut(&mut self) -> &mut [RRCHubState] {
&mut self.hubs
}
pub fn hub(&self, index: usize) -> Option<&RRCHubState> {
self.hubs.get(index)
}
pub fn hub_mut(&mut self, index: usize) -> Option<&mut RRCHubState> {
self.hubs.get_mut(index)
}
pub fn set_active(&mut self, hub_hash: Option<&[u8]>, room: Option<&str>) {
if let Some(prev_hash) = self.active_hub_hash.take() {
if let Some(prev_hub) = self.find_hub_mut(&prev_hash, None) {
prev_hub.active_room = None;
}
}
if let Some(hh) = hub_hash {
self.active_hub_hash = Some(hh.to_vec());
self.active_room = room.map(|r| r.to_string());
if let Some(hub) = self.find_hub_mut(hh, None) {
hub.active_room = room.map(|r| r.to_string());
if let Some(r) = room {
hub.mark_read(r);
}
}
} else {
self.active_hub_hash = None;
self.active_room = None;
}
}
pub fn active_room_for(&self, hub_hash: &[u8]) -> Option<&str> {
if self.active_hub_hash.as_ref() == Some(&hub_hash.to_vec()) {
self.active_room.as_deref()
} else {
None
}
}
pub fn has_unread(&self) -> bool {
self.hubs.iter().any(|h| !h.unread_rooms.is_empty())
}
pub fn registry_path(&self) -> PathBuf {
self.storage_path.join(REGISTRY_FILENAME)
}
pub fn history_root(&self) -> PathBuf {
self.storage_path.join(HISTORY_DIR_NAME)
}
pub fn load(&mut self) {
if self.loaded {
return;
}
self.loaded = true;
let path = self.registry_path();
if !path.is_file() {
return;
}
self.loading = true;
let data = match std::fs::read(&path) {
Ok(d) => d,
Err(_) => {
self.loading = false;
return;
}
};
let root: Value = match ciborium::de::from_reader(&data[..]) {
Ok(v) => v,
Err(_) => {
self.loading = false;
return;
}
};
let root_pairs = match &root {
Value::Map(p) => p,
_ => {
self.loading = false;
return;
}
};
let entries_val = match root_pairs
.iter()
.find(|(k, _)| get_str(k) == Some("hubs"))
.map(|(_, v)| v)
{
Some(v) => v,
None => {
self.loading = false;
return;
}
};
let entries = match entries_val {
Value::Array(arr) => arr,
_ => {
self.loading = false;
return;
}
};
for entry_val in entries {
let entry_pairs = match entry_val {
Value::Map(p) => p,
_ => continue,
};
let get_s = |key: &str| -> Option<&str> {
entry_pairs
.iter()
.find(|(k, _)| get_str(k) == Some(key))
.and_then(|(_, v)| get_str(v))
};
let get_b = |key: &str| -> Option<&[u8]> {
entry_pairs
.iter()
.find(|(k, _)| get_str(k) == Some(key))
.and_then(|(_, v)| get_bytes(v))
};
let get_bool = |key: &str| -> Option<bool> {
entry_pairs
.iter()
.find(|(k, _)| get_str(k) == Some(key))
.and_then(|(_, v)| match v {
Value::Bool(b) => Some(*b),
_ => None,
})
};
let get_str_list = |key: &str| -> Vec<String> {
entry_pairs
.iter()
.find(|(k, _)| get_str(k) == Some(key))
.and_then(|(_, v)| match v {
Value::Array(arr) => Some(
arr.iter()
.filter_map(|e| get_str(e).map(|s| s.to_string()))
.collect(),
),
_ => None,
})
.unwrap_or_default()
};
let hh = match get_b("hash") {
Some(b) => b.to_vec(),
None => continue,
};
let dn = get_s("dest_name");
let nm = get_s("name");
let rooms_to_join = get_str_list("rooms");
let parted = get_str_list("parted_rooms");
let ar = get_bool("auto_reconnect");
let al = get_bool("auto_list");
let aw = get_bool("auto_who");
let no = get_s("nick");
let idx = self.add_hub(hh, dn, nm);
let hub = &mut self.hubs[idx];
for r in rooms_to_join {
hub.add_room(&r);
}
for r in parted {
hub.ensure_parted_room_buffer(&r);
}
if let Some(ar) = ar {
hub.auto_reconnect = ar;
}
if let Some(al) = al {
hub.auto_list = al;
}
if let Some(aw) = aw {
hub.auto_who = aw;
}
if let Some(no) = no {
if !no.is_empty() {
hub.nick_override = Some(no.to_string());
}
}
}
let history_root = self.history_root();
for hub in &mut self.hubs {
hub.load_history(&history_root);
}
self.loading = false;
}
pub fn save(&self) {
if self.loading {
return;
}
let path = self.registry_path();
let tmp_path = path.with_extension("tmp");
let mut entries = Vec::new();
for h in &self.hubs {
let joined: HashSet<String> = h.rooms.clone();
let parted: HashSet<String> = h
.messages
.keys()
.filter(|r| !joined.contains(*r) && *r != "*")
.cloned()
.collect();
let mut joined_sorted: Vec<String> = joined.into_iter().collect();
joined_sorted.sort();
let mut parted_sorted: Vec<String> = parted.into_iter().collect();
parted_sorted.sort();
let mut entry_pairs = vec![
(
Value::Text("hash".to_string()),
Value::Bytes(h.hub_hash.clone()),
),
(
Value::Text("dest_name".to_string()),
Value::Text(h.dest_name.clone()),
),
(Value::Text("name".to_string()), Value::Text(h.name.clone())),
(
Value::Text("rooms".to_string()),
Value::Array(joined_sorted.into_iter().map(Value::Text).collect()),
),
(
Value::Text("parted_rooms".to_string()),
Value::Array(parted_sorted.into_iter().map(Value::Text).collect()),
),
(
Value::Text("auto_reconnect".to_string()),
Value::Bool(h.auto_reconnect),
),
(
Value::Text("auto_list".to_string()),
Value::Bool(h.auto_list),
),
(Value::Text("auto_who".to_string()), Value::Bool(h.auto_who)),
];
if let Some(ref nick) = h.nick_override {
if !nick.is_empty() {
entry_pairs.push((Value::Text("nick".to_string()), Value::Text(nick.clone())));
}
}
entries.push(Value::Map(entry_pairs));
}
let root = Value::Map(vec![(
Value::Text("hubs".to_string()),
Value::Array(entries),
)]);
let mut buf = Vec::new();
if ciborium::ser::into_writer(&root, &mut buf).is_err() {
return;
}
match std::fs::File::create(&tmp_path) {
Ok(mut f) => {
use std::io::Write;
if f.write_all(&buf).is_err() {
let _ = std::fs::remove_file(&tmp_path);
return;
}
let _ = f.flush();
let _ = f.sync_data();
}
Err(_) => return,
}
if std::fs::rename(&tmp_path, &path).is_err() {
let _ = std::fs::remove_file(&tmp_path);
}
}
pub fn shutdown(&mut self) {
for hub in &mut self.hubs {
hub.manual_disconnect = true;
hub.reset_on_disconnect();
}
self.save();
}
}
pub fn parse_rrc_url(url: &str) -> Option<(Vec<u8>, Option<String>, Option<String>)> {
let rest = url.strip_prefix("rrc://")?;
let (hash_part, room) = if let Some(slash_pos) = rest.find('/') {
(&rest[..slash_pos], Some(rest[slash_pos + 1..].to_string()))
} else {
(rest, None)
};
let (hash_hex, dest_name) = if let Some(colon_pos) = hash_part.find(':') {
(
&hash_part[..colon_pos],
Some(hash_part[colon_pos + 1..].to_string()),
)
} else {
(hash_part, None)
};
let hash_bytes = hex::decode(hash_hex).ok()?;
if hash_bytes.len() != 16 {
return None;
}
let room = room.map(|r| {
if let Some(stripped) = r.strip_prefix('#') {
stripped.to_string()
} else {
r
}
});
Some((hash_bytes, dest_name, room))
}
#[cfg(test)]
mod tests {
use super::*;
fn make_test_hub() -> RRCHubState {
RRCHubState::new(
vec![0xAA; 16],
vec![0xBB; 16],
None,
None,
Arc::new(|| Some("testnick".to_string())),
)
}
#[test]
fn test_make_envelope_basic() {
let env = make_envelope(T_HELLO, &[0xAA; 16], None, None, None, None, Some(12345));
let pairs = match &env {
Value::Map(p) => p,
_ => panic!("expected map"),
};
assert_eq!(map_get(pairs, K_V).and_then(get_u64), Some(RRC_VERSION));
assert_eq!(map_get(pairs, K_T).and_then(get_u64), Some(T_HELLO));
assert_eq!(map_get(pairs, K_TS).and_then(get_u64), Some(12345));
assert_eq!(
map_get(pairs, K_SRC).and_then(get_bytes),
Some(&[0xAA; 16][..])
);
}
#[test]
fn test_make_envelope_with_room_and_nick() {
let env = make_envelope(
T_MSG,
&[0xBB; 16],
Some("general"),
Some(Value::Text("hello".to_string())),
Some("nick"),
None,
None,
);
let pairs = match &env {
Value::Map(p) => p,
_ => panic!("expected map"),
};
assert_eq!(map_get_str(pairs, K_ROOM), Some("general"));
assert_eq!(map_get_str(pairs, K_NICK), Some("nick"));
assert_eq!(map_get_str(pairs, K_BODY), Some("hello"));
}
#[test]
fn test_encode_decode_roundtrip() {
let env = make_envelope(
T_MSG,
&[0xCC; 16],
Some("test"),
Some(Value::Text("body text".to_string())),
Some("nick"),
Some(&[1, 2, 3, 4, 5, 6, 7, 8]),
Some(99999),
);
let encoded = encode_envelope(&env);
let decoded = decode_envelope(&encoded).expect("should decode");
let pairs = match &decoded {
Value::Map(p) => p,
_ => panic!("expected map"),
};
assert_eq!(map_get(pairs, K_T).and_then(get_u64), Some(T_MSG));
assert_eq!(map_get_str(pairs, K_ROOM), Some("test"));
assert_eq!(map_get_str(pairs, K_BODY), Some("body text"));
assert_eq!(map_get_str(pairs, K_NICK), Some("nick"));
}
#[test]
fn test_handle_welcome() {
let mut hub = make_test_hub();
let limits = Value::Map(vec![
(Value::from(L_MAX_NICK_BYTES), Value::from(16u64)),
(Value::from(L_MAX_MSG_BODY_BYTES), Value::from(500u64)),
]);
let body = Value::Map(vec![
(
Value::from(B_WELCOME_HUB),
Value::Text("TestHub".to_string()),
),
(Value::from(B_WELCOME_VER), Value::Text("1.0".to_string())),
(Value::from(B_WELCOME_LIMITS), limits),
]);
let env = make_envelope(T_WELCOME, &[0xAA; 16], None, Some(body), None, None, None);
let encoded = encode_envelope(&env);
let events = hub.handle_packet(&encoded);
assert_eq!(events.len(), 1);
match &events[0] {
RrcEvent::Connected {
hub_name,
hub_version,
limits,
..
} => {
assert_eq!(hub_name.as_deref(), Some("TestHub"));
assert_eq!(hub_version.as_deref(), Some("1.0"));
assert_eq!(limits.max_nick_bytes, 16);
assert_eq!(limits.max_msg_body_bytes, 500);
}
_ => panic!("expected Connected event"),
}
assert_eq!(hub.status, HubStatus::Connected);
assert!(hub.welcomed);
}
#[test]
fn test_handle_joined() {
let mut hub = make_test_hub();
hub.pending_joins.insert("general".to_string());
let env = make_envelope(
T_JOINED,
&[0xAA; 16],
Some("general"),
Some(Value::Array(vec![Value::Bytes(vec![0xDD; 16])])),
Some("testnick"),
None,
None,
);
let encoded = encode_envelope(&env);
let events = hub.handle_packet(&encoded);
assert!(hub.rooms.contains("general"));
assert!(hub.members.contains_key("general"));
assert!(hub.members["general"].contains(&vec![0xDD; 16]));
assert!(hub.members["general"].contains(&vec![0xBB; 16]));
assert!(events.iter().any(|e| matches!(
e,
RrcEvent::Joined { room, .. } if room == "general"
)));
}
#[test]
fn test_handle_message() {
let mut hub = make_test_hub();
let env = make_envelope(
T_MSG,
&[0xDD; 16],
Some("general"),
Some(Value::Text("hello world".to_string())),
Some("otheruser"),
None,
None,
);
let encoded = encode_envelope(&env);
let events = hub.handle_packet(&encoded);
assert_eq!(events.len(), 1);
match &events[0] {
RrcEvent::Message {
room, nick, text, ..
} => {
assert_eq!(room.as_deref(), Some("general"));
assert_eq!(nick.as_deref(), Some("otheruser"));
assert_eq!(text, "hello world");
}
_ => panic!("expected Message event"),
}
assert!(hub.messages.contains_key("general"));
assert_eq!(hub.messages["general"].len(), 1);
assert_eq!(
hub.nicks.get(&vec![0xDD; 16]),
Some(&"otheruser".to_string())
);
}
#[test]
fn test_mention_detection() {
let mut hub = make_test_hub();
let env = make_envelope(
T_MSG,
&[0xDD; 16],
Some("general"),
Some(Value::Text("hey @testnick check this".to_string())),
Some("other"),
None,
None,
);
let encoded = encode_envelope(&env);
let events = hub.handle_packet(&encoded);
match &events[0] {
RrcEvent::Message { mention, .. } => {
assert!(*mention);
}
_ => panic!("expected Message"),
}
}
#[test]
fn test_no_mention_for_partial_nick() {
let mut hub = make_test_hub();
let env = make_envelope(
T_MSG,
&[0xDD; 16],
Some("general"),
Some(Value::Text("testnickABC is here".to_string())),
Some("other"),
None,
None,
);
let encoded = encode_envelope(&env);
let events = hub.handle_packet(&encoded);
match &events[0] {
RrcEvent::Message { mention, .. } => {
assert!(!mention);
}
_ => panic!("expected Message"),
}
}
#[test]
fn test_handle_parted() {
let mut hub = make_test_hub();
hub.rooms.insert("general".to_string());
hub.pending_parts.insert("general".to_string());
let env = make_envelope(
T_PARTED,
&[0xAA; 16],
Some("general"),
Some(Value::Array(vec![Value::Bytes(hub.own_hash.clone())])),
None,
None,
None,
);
let encoded = encode_envelope(&env);
let events = hub.handle_packet(&encoded);
assert!(!hub.rooms.contains("general"));
assert!(events.iter().any(|e| matches!(
e,
RrcEvent::Parted { room, .. } if room == "general"
)));
}
#[test]
fn test_handle_error() {
let mut hub = make_test_hub();
hub.pending_joins.insert("locked".to_string());
let env = make_envelope(
T_ERROR,
&[0xAA; 16],
Some("locked"),
Some(Value::Text("room requires key".to_string())),
None,
None,
None,
);
let encoded = encode_envelope(&env);
let events = hub.handle_packet(&encoded);
assert!(!hub.rooms.contains("locked"));
assert!(hub.pending_joins.is_empty());
assert!(events.iter().any(|e| matches!(
e,
RrcEvent::Error { room, text, .. } if room.as_deref() == Some("locked") && text == "room requires key"
)));
}
#[test]
fn test_handle_notice_room_list() {
let mut hub = make_test_hub();
let body = "Registered public rooms:\n#general - General chat\n#random";
let env = make_envelope(
T_NOTICE,
&[0xAA; 16],
None,
Some(Value::Text(body.to_string())),
None,
None,
None,
);
let encoded = encode_envelope(&env);
let events = hub.handle_packet(&encoded);
assert!(events.iter().any(|e| matches!(
e,
RrcEvent::RoomList { rooms, .. } if rooms.contains_key("general") && rooms.contains_key("random")
)));
assert_eq!(
hub.available_rooms["general"],
Some("General chat".to_string())
);
assert_eq!(hub.available_rooms["random"], None);
}
#[test]
fn test_handle_notice_who_reply() {
let mut hub = make_test_hub();
let body = "members in general: alice (aabbccddeeff), bb00aaff00112233445566778899aabb";
let env = make_envelope(
T_NOTICE,
&[0xAA; 16],
Some("general"),
Some(Value::Text(body.to_string())),
None,
None,
None,
);
let encoded = encode_envelope(&env);
let events = hub.handle_packet(&encoded);
assert!(events.iter().any(|e| matches!(
e,
RrcEvent::WhoReply { room, members, .. } if room == "general" && members.len() == 2
)));
}
#[test]
fn test_build_hello() {
let mut hub = make_test_hub();
let payload = hub.build_hello();
assert!(!payload.is_empty());
let decoded = decode_envelope(&payload).unwrap();
let pairs = match &decoded {
Value::Map(p) => p,
_ => panic!("expected map"),
};
assert_eq!(map_get(pairs, K_T).and_then(get_u64), Some(T_HELLO));
assert_eq!(map_get_str(pairs, K_NICK), Some("testnick"));
}
#[test]
fn test_build_join() {
let mut hub = make_test_hub();
let payload = hub.build_join("General", None, false);
let decoded = decode_envelope(&payload).unwrap();
let pairs = match &decoded {
Value::Map(p) => p,
_ => panic!("expected map"),
};
assert_eq!(map_get(pairs, K_T).and_then(get_u64), Some(T_JOIN));
assert_eq!(map_get_str(pairs, K_ROOM), Some("general"));
assert!(hub.pending_joins.contains("general"));
}
#[test]
fn test_build_message() {
let mut hub = make_test_hub();
let payload = hub.build_message("general", "hello there").unwrap();
let decoded = decode_envelope(&payload).unwrap();
let pairs = match &decoded {
Value::Map(p) => p,
_ => panic!("expected map"),
};
assert_eq!(map_get(pairs, K_T).and_then(get_u64), Some(T_MSG));
assert_eq!(map_get_str(pairs, K_BODY), Some("hello there"));
assert_eq!(map_get_str(pairs, K_ROOM), Some("general"));
assert!(hub.messages["general"]
.iter()
.any(|m| m.text == "hello there"));
}
#[test]
fn test_build_message_rejects_empty() {
let mut hub = make_test_hub();
assert!(hub.build_message("general", "").is_err());
assert!(hub.build_message("general", " ").is_err());
}
#[test]
fn test_build_message_rejects_too_long() {
let mut hub = make_test_hub();
hub.limits.max_msg_body_bytes = 10;
assert!(hub
.build_message("general", "this is way too long")
.is_err());
}
#[test]
fn test_build_ping() {
let mut hub = make_test_hub();
let payload = hub.build_ping(None);
let decoded = decode_envelope(&payload).unwrap();
let pairs = match &decoded {
Value::Map(p) => p,
_ => panic!("expected map"),
};
assert_eq!(map_get(pairs, K_T).and_then(get_u64), Some(T_PING));
assert!(!hub.pending_pings.is_empty());
}
#[test]
fn test_build_part() {
let mut hub = make_test_hub();
let payload = hub.build_part("general");
let decoded = decode_envelope(&payload).unwrap();
let pairs = match &decoded {
Value::Map(p) => p,
_ => panic!("expected map"),
};
assert_eq!(map_get(pairs, K_T).and_then(get_u64), Some(T_PART));
assert!(hub.pending_parts.contains("general"));
}
#[test]
fn test_own_message_echo_dedup() {
let mut hub = make_test_hub();
let msg = hub.build_message("general", "hello").unwrap();
let decoded = decode_envelope(&msg).unwrap();
let pairs = match &decoded {
Value::Map(p) => p,
_ => panic!("expected map"),
};
let mid = map_get(pairs, K_ID).and_then(get_bytes).unwrap().to_vec();
let echo = make_envelope(
T_MSG,
&hub.own_hash,
Some("general"),
Some(Value::Text("hello".to_string())),
Some("testnick"),
Some(&mid),
None,
);
let echo_encoded = encode_envelope(&echo);
let events = hub.handle_packet(&echo_encoded);
assert!(events.is_empty());
}
#[test]
fn test_display_name_for_known_nick() {
let hub = make_test_hub();
assert_eq!(hub.display_name_for(&[0xDD; 16]), "dddddddddddd");
}
#[test]
fn test_display_name_for_unknown() {
let mut hub = make_test_hub();
hub.nicks.insert(vec![0xDD; 16], "alice".to_string());
assert_eq!(hub.display_name_for(&[0xDD; 16]), "alice");
}
#[test]
fn test_normalize_room() {
assert_eq!(normalize_room(" General "), "general");
assert_eq!(normalize_room("#Random"), "random");
assert_eq!(normalize_room(" #Chat "), "chat");
}
#[test]
fn test_parse_rrc_url_basic() {
let (hash, dest, room) =
parse_rrc_url("rrc://aabbccddeeff00112233445566778899/general").unwrap();
assert_eq!(
hash,
hex::decode("aabbccddeeff00112233445566778899").unwrap()
);
assert!(dest.is_none());
assert_eq!(room, Some("general".to_string()));
}
#[test]
fn test_parse_rrc_url_with_dest_name() {
let (hash, dest, room) =
parse_rrc_url("rrc://aabbccddeeff00112233445566778899:myhub/general").unwrap();
assert_eq!(
hash,
hex::decode("aabbccddeeff00112233445566778899").unwrap()
);
assert_eq!(dest, Some("myhub".to_string()));
assert_eq!(room, Some("general".to_string()));
}
#[test]
fn test_parse_rrc_url_no_room() {
let (hash, dest, room) = parse_rrc_url("rrc://aabbccddeeff00112233445566778899").unwrap();
assert_eq!(hash.len(), 16);
assert!(dest.is_none());
assert!(room.is_none());
}
#[test]
fn test_parse_rrc_url_strips_hash_prefix() {
let (_, _, room) =
parse_rrc_url("rrc://aabbccddeeff00112233445566778899/#general").unwrap();
assert_eq!(room, Some("general".to_string()));
}
#[test]
fn test_parse_rrc_url_invalid_hash() {
assert!(parse_rrc_url("rrc://not_hex/general").is_none());
assert!(parse_rrc_url("rrc://aabb/general").is_none());
assert!(parse_rrc_url("http://example.com").is_none());
}
#[test]
fn test_parse_who_notice_basic() {
let (room, entries) = parse_who_notice(
"members in general: alice (aabbccddeeff), bb00aaff00112233445566778899aabb",
)
.unwrap();
assert_eq!(room, "general");
assert_eq!(entries.len(), 2);
assert_eq!(
entries[0],
(Some("alice".to_string()), "aabbccddeeff".to_string())
);
assert_eq!(
entries[1],
(None, "bb00aaff00112233445566778899aabb".to_string())
);
}
#[test]
fn test_parse_who_notice_none() {
let (room, entries) = parse_who_notice("members in empty: (none)").unwrap();
assert_eq!(room, "empty");
assert!(entries.is_empty());
}
#[test]
fn test_parse_who_notice_invalid() {
assert!(parse_who_notice("not a who reply").is_none());
}
#[test]
fn test_parse_room_list_notice() {
let rooms = parse_room_list_notice(
"Registered public rooms:\n#general - General chat\n#random\n#help - Help & support",
)
.unwrap();
assert_eq!(rooms.len(), 3);
assert_eq!(rooms["general"], Some("General chat".to_string()));
assert_eq!(rooms["random"], None);
assert_eq!(rooms["help"], Some("Help & support".to_string()));
}
#[test]
fn test_parse_room_list_notice_empty() {
let rooms = parse_room_list_notice("No public rooms registered").unwrap();
assert!(rooms.is_empty());
}
#[test]
fn test_parse_room_list_notice_invalid() {
assert!(parse_room_list_notice("random text").is_none());
}
#[test]
fn test_check_mention() {
assert!(check_mention("@testnick hello", "testnick"));
assert!(check_mention("hi @testnick", "testnick"));
assert!(check_mention("@TestNick hello", "testnick"));
assert!(!check_mention("testnick hello", "testnick"));
assert!(!check_mention("@testnick2 hello", "testnick"));
assert!(!check_mention("a_testnick hello", "testnick"));
}
#[test]
fn test_check_mention_empty_nick() {
assert!(!check_mention("@ hello", ""));
}
#[test]
fn test_add_remove_room() {
let mut hub = make_test_hub();
let r = hub.add_room("General");
assert_eq!(r, "general");
assert!(hub.rooms.contains("general"));
assert!(hub.messages.contains_key("general"));
hub.remove_room("General");
assert!(!hub.rooms.contains("general"));
assert!(!hub.messages.contains_key("general"));
}
#[test]
fn test_mark_read() {
let mut hub = make_test_hub();
hub.unread_rooms.insert("general".to_string());
hub.mention_rooms.insert("general".to_string());
hub.mark_read("general");
assert!(!hub.unread_rooms.contains("general"));
assert!(!hub.mention_rooms.contains("general"));
}
#[test]
fn test_reset_on_disconnect() {
let mut hub = make_test_hub();
hub.status = HubStatus::Connected;
hub.welcomed = true;
hub.members.insert("general".to_string(), HashSet::new());
hub.pending_joins.insert("test".to_string());
hub.reset_on_disconnect();
assert_eq!(hub.status, HubStatus::Disconnected);
assert!(!hub.welcomed);
assert!(hub.members.is_empty());
assert!(hub.pending_joins.is_empty());
}
#[test]
fn test_build_command() {
let mut hub = make_test_hub();
assert!(hub.sent_ids.is_empty());
let payload = hub.build_command("/list", None);
let decoded = decode_envelope(&payload).unwrap();
let pairs = match &decoded {
Value::Map(p) => p,
_ => panic!("expected map"),
};
assert_eq!(map_get(pairs, K_T).and_then(get_u64), Some(T_MSG));
assert_eq!(map_get_str(pairs, K_BODY), Some("/list"));
assert_eq!(hub.sent_ids.len(), 1);
}
#[test]
fn test_effective_nick_override() {
let mut hub = make_test_hub();
assert_eq!(hub.get_effective_nick(), Some("testnick".to_string()));
hub.nick_override = Some("override".to_string());
assert_eq!(hub.get_effective_nick(), Some("override".to_string()));
hub.nick_override = Some("".to_string());
assert_eq!(hub.get_effective_nick(), Some("testnick".to_string()));
hub.nick_override = None;
assert_eq!(hub.get_effective_nick(), Some("testnick".to_string()));
}
#[test]
fn test_handle_notice_empty_rooms() {
let mut hub = make_test_hub();
let body = "No public rooms registered";
let env = make_envelope(
T_NOTICE,
&[0xAA; 16],
None,
Some(Value::Text(body.to_string())),
None,
None,
None,
);
let encoded = encode_envelope(&env);
let events = hub.handle_packet(&encoded);
assert!(events.iter().any(|e| matches!(
e,
RrcEvent::RoomList { rooms, .. } if rooms.is_empty()
)));
}
#[test]
fn test_handle_ping_pong() {
let mut hub = make_test_hub();
let ping_body = vec![1, 2, 3, 4, 5, 6, 7, 8];
let env = make_envelope(
T_PING,
&[0xAA; 16],
None,
Some(Value::Bytes(ping_body.clone())),
None,
None,
None,
);
let encoded = encode_envelope(&env);
let events = hub.handle_packet(&encoded);
assert_eq!(events.len(), 1);
match &events[0] {
RrcEvent::PongRequired { payload, hub_hash } => {
assert_eq!(*hub_hash, hub.hub_hash);
assert!(!payload.is_empty());
let decoded = decode_envelope(payload).unwrap();
let pairs = match &decoded {
Value::Map(p) => p,
_ => panic!("expected map"),
};
assert_eq!(map_get(pairs, K_T).and_then(get_u64), Some(T_PONG));
assert_eq!(
map_get(pairs, K_BODY).and_then(get_bytes),
Some(&ping_body[..])
);
}
_ => panic!("expected PongRequired event, got {:?}", events[0]),
}
}
#[test]
fn test_handle_pong() {
let mut hub = make_test_hub();
let ping_id = vec![1, 2, 3, 4, 5, 6, 7, 8];
hub.pending_pings
.insert(ping_id.clone(), (now_ms() - 100, None));
let env = make_envelope(
T_PONG,
&[0xAA; 16],
None,
Some(Value::Bytes(ping_id)),
None,
None,
None,
);
let encoded = encode_envelope(&env);
let _events = hub.handle_packet(&encoded);
assert!(hub.pending_pings.is_empty());
}
#[test]
fn test_nick_tracking_from_messages() {
let mut hub = make_test_hub();
let env = make_envelope(
T_MSG,
&[0xEE; 16],
Some("general"),
Some(Value::Text("hi".to_string())),
Some("bob"),
None,
None,
);
let encoded = encode_envelope(&env);
hub.handle_packet(&encoded);
assert_eq!(hub.nicks.get(&vec![0xEE; 16]), Some(&"bob".to_string()));
assert!(hub.members["general"].contains(&vec![0xEE; 16]));
}
#[test]
fn test_silent_join() {
let mut hub = make_test_hub();
let _payload = hub.build_join("secret", None, true);
assert!(hub.silent_joins.contains("secret"));
let env = make_envelope(
T_JOINED,
&[0xAA; 16],
Some("secret"),
Some(Value::Array(vec![Value::Bytes(hub.own_hash.clone())])),
None,
None,
None,
);
let encoded = encode_envelope(&env);
let _events = hub.handle_packet(&encoded);
assert!(hub.rooms.contains("secret"));
assert!(!hub.silent_joins.contains("secret"));
let has_system_msg = hub.messages["secret"]
.iter()
.any(|m| m.kind == "system" && m.text.contains("joined"));
assert!(!has_system_msg);
}
#[test]
fn test_hub_limits_default() {
let limits = HubLimits::default();
assert_eq!(limits.max_nick_bytes, DEFAULT_MAX_NICK_BYTES);
assert_eq!(limits.max_room_name_bytes, DEFAULT_MAX_ROOM_BYTES);
assert_eq!(limits.max_msg_body_bytes, DEFAULT_MAX_MSG_BYTES);
assert_eq!(limits.max_rooms_per_session, DEFAULT_MAX_ROOMS);
assert_eq!(limits.rate_limit_msgs_per_minute, DEFAULT_RATE_PER_MINUTE);
}
#[test]
fn test_reconnect_backoff_sequence() {
let mut hub = make_test_hub();
assert_eq!(hub.reconnect_backoff_secs(), 1.0);
hub.reconnect_attempts = 1;
assert_eq!(hub.reconnect_backoff_secs(), 2.0);
hub.reconnect_attempts = 2;
assert_eq!(hub.reconnect_backoff_secs(), 4.0);
hub.reconnect_attempts = 3;
assert_eq!(hub.reconnect_backoff_secs(), 8.0);
hub.reconnect_attempts = 4;
assert_eq!(hub.reconnect_backoff_secs(), 16.0);
hub.reconnect_attempts = 5;
assert_eq!(hub.reconnect_backoff_secs(), 32.0);
hub.reconnect_attempts = 6;
assert_eq!(hub.reconnect_backoff_secs(), 60.0);
hub.reconnect_attempts = 10;
assert_eq!(hub.reconnect_backoff_secs(), 60.0);
}
#[test]
fn test_per_room_cap_field() {
let mut hub = make_test_hub();
assert!(hub.per_room_cap().is_none());
hub.set_per_room_cap(Some(50));
assert_eq!(hub.per_room_cap(), Some(50));
}
#[test]
fn test_active_room_suppresses_unread() {
let mut hub = make_test_hub();
hub.active_room = Some("general".to_string());
hub.add_room("general");
let msg = RRCMessage {
kind: "msg".to_string(),
room: Some("general".to_string()),
src: Some(vec![0xDD; 16]),
nick: Some("alice".to_string()),
text: "hello".to_string(),
ts: 1000,
mention: true,
};
hub.record_message(msg, false);
assert!(!hub.unread_rooms.contains("general"));
assert!(!hub.mention_rooms.contains("general"));
let msg2 = RRCMessage {
kind: "msg".to_string(),
room: Some("other".to_string()),
src: Some(vec![0xDD; 16]),
nick: Some("alice".to_string()),
text: "hello other".to_string(),
ts: 1001,
mention: false,
};
hub.record_message(msg2, false);
assert!(hub.unread_rooms.contains("other"));
}
#[test]
fn test_manual_disconnect_field() {
let mut hub = make_test_hub();
assert!(!hub.manual_disconnect);
hub.manual_disconnect = true;
hub.reset_on_disconnect();
assert!(!hub.manual_disconnect);
}
#[test]
fn test_history_dir_default_dest() {
let hub = make_test_hub();
let root = Path::new("/tmp/test_history");
let dir = hub.history_dir(root);
assert_eq!(
dir,
PathBuf::from(format!("/tmp/test_history/{}", hex::encode([0xAA; 16])))
);
}
#[test]
fn test_history_dir_custom_dest() {
let hub = RRCHubState::new(
vec![0xAA; 16],
vec![0xBB; 16],
Some("custom.hub"),
None,
Arc::new(|| Some("nick".to_string())),
);
let root = Path::new("/tmp/test_history");
let dir = hub.history_dir(root);
let expected_suffix = hex::encode(&Sha256::digest(b"custom.hub")[..4]);
assert_eq!(
dir,
PathBuf::from(format!(
"/tmp/test_history/{}__{}",
hex::encode([0xAA; 16]),
expected_suffix
))
);
}
#[test]
fn test_history_path_sanitization() {
let hub = make_test_hub();
let root = Path::new("/tmp/test_history");
let path = hub.history_path(root, "general chat");
let expected_hash = hex::encode(&Sha256::digest(b"general chat")[..4]);
assert!(path.to_string_lossy().contains("general_chat_"));
assert!(path
.to_string_lossy()
.ends_with(&format!("{}.log", expected_hash)));
}
#[test]
fn test_history_path_empty_room() {
let hub = make_test_hub();
let root = Path::new("/tmp/test_history");
let path = hub.history_path(root, "");
let expected_hash = hex::encode(&Sha256::digest(b"")[..4]);
assert!(path
.to_string_lossy()
.ends_with(&format!("{}.log", expected_hash)));
}
#[test]
fn test_append_and_load_history() {
let tmp = tempfile::tempdir().unwrap();
let history_root = tmp.path().to_path_buf();
let mut hub = make_test_hub();
hub.add_room("general");
let msg = RRCMessage {
kind: "msg".to_string(),
room: Some("general".to_string()),
src: Some(vec![0xDD; 16]),
nick: Some("alice".to_string()),
text: "hello from history".to_string(),
ts: 12345,
mention: false,
};
hub.append_history(&history_root, "general", &msg);
let msg2 = RRCMessage {
kind: "msg".to_string(),
room: Some("general".to_string()),
src: Some(vec![0xEE; 16]),
nick: Some("bob".to_string()),
text: "second message".to_string(),
ts: 12350,
mention: true,
};
hub.append_history(&history_root, "general", &msg2);
let mut hub2 = make_test_hub();
hub2.add_room("general");
hub2.load_history(&history_root);
let msgs = hub2.get_messages("general");
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0].text, "hello from history");
assert_eq!(msgs[1].text, "second message");
assert!(msgs[1].mention);
}
#[test]
fn test_load_history_with_cap() {
let tmp = tempfile::tempdir().unwrap();
let history_root = tmp.path().to_path_buf();
let mut hub = make_test_hub();
hub.add_room("general");
for i in 0..10 {
let msg = RRCMessage {
kind: "msg".to_string(),
room: Some("general".to_string()),
src: Some(vec![0xDD; 16]),
nick: Some("alice".to_string()),
text: format!("msg {}", i),
ts: 1000 + i,
mention: false,
};
hub.append_history(&history_root, "general", &msg);
}
let mut hub2 = make_test_hub();
hub2.add_room("general");
hub2.set_per_room_cap(Some(5));
hub2.load_history(&history_root);
let msgs = hub2.get_messages("general");
assert_eq!(msgs.len(), 5);
assert_eq!(msgs[0].text, "msg 5");
assert_eq!(msgs[4].text, "msg 9");
}
#[test]
fn test_delete_history() {
let tmp = tempfile::tempdir().unwrap();
let history_root = tmp.path().to_path_buf();
let mut hub = make_test_hub();
let msg = RRCMessage {
kind: "msg".to_string(),
room: Some("general".to_string()),
src: None,
nick: None,
text: "test".to_string(),
ts: 1000,
mention: false,
};
hub.append_history(&history_root, "general", &msg);
let path = hub.history_path(&history_root, "general");
assert!(path.is_file());
hub.delete_history(&history_root, "general");
assert!(!path.is_file());
}
#[test]
fn test_append_history_skips_star_room() {
let tmp = tempfile::tempdir().unwrap();
let history_root = tmp.path().to_path_buf();
let mut hub = make_test_hub();
let msg = RRCMessage {
kind: "msg".to_string(),
room: None,
src: None,
nick: None,
text: "test".to_string(),
ts: 1000,
mention: false,
};
hub.append_history(&history_root, "*", &msg);
let dir = hub.history_dir(&history_root);
assert!(!dir.exists());
}
#[test]
fn test_compute_parted_rooms() {
let mut hub = make_test_hub();
hub.add_room("general");
hub.messages.insert("old_room".to_string(), vec![]);
hub.compute_parted_rooms();
assert!(hub.parted_rooms.contains("old_room"));
assert!(!hub.parted_rooms.contains("general"));
}
#[test]
fn test_manager_add_find_remove_hub() {
let tmp = tempfile::tempdir().unwrap();
let mut mgr = RRCManager::new(
tmp.path().to_path_buf(),
vec![0xBB; 16],
Arc::new(|| Some("nick".to_string())),
);
let idx = mgr.add_hub(vec![0xAA; 16], None, Some("TestHub"));
assert_eq!(idx, 0);
assert_eq!(mgr.hubs().len(), 1);
assert_eq!(mgr.hub(0).unwrap().name, "TestHub");
assert!(mgr.find_hub(&[0xAA; 16], None).is_some());
assert!(mgr.find_hub(&[0xFF; 16], None).is_none());
let idx2 = mgr.add_hub(vec![0xAA; 16], None, Some("Dup"));
assert_eq!(idx2, 0);
assert_eq!(mgr.hubs().len(), 1);
assert!(mgr.remove_hub(&[0xAA; 16], None));
assert_eq!(mgr.hubs().len(), 0);
assert!(!mgr.remove_hub(&[0xAA; 16], None));
}
#[test]
fn test_manager_set_active_and_unread() {
let tmp = tempfile::tempdir().unwrap();
let mut mgr = RRCManager::new(
tmp.path().to_path_buf(),
vec![0xBB; 16],
Arc::new(|| Some("nick".to_string())),
);
mgr.add_hub(vec![0xAA; 16], None, None);
mgr.add_hub(vec![0xCC; 16], None, None);
mgr.set_active(Some(&[0xAA; 16]), Some("general"));
assert_eq!(mgr.active_room_for(&[0xAA; 16]), Some("general"));
assert!(mgr.active_room_for(&[0xCC; 16]).is_none());
let hub0 = mgr.hub_mut(0).unwrap();
hub0.add_room("general");
let msg = RRCMessage {
kind: "msg".to_string(),
room: Some("general".to_string()),
src: Some(vec![0xDD; 16]),
nick: Some("alice".to_string()),
text: "hello".to_string(),
ts: 1000,
mention: false,
};
hub0.record_message(msg, false);
assert!(!hub0.unread_rooms.contains("general"));
let msg2 = RRCMessage {
kind: "msg".to_string(),
room: Some("other".to_string()),
src: Some(vec![0xDD; 16]),
nick: Some("alice".to_string()),
text: "hello other".to_string(),
ts: 1001,
mention: false,
};
hub0.record_message(msg2, false);
assert!(hub0.unread_rooms.contains("other"));
assert!(mgr.has_unread());
mgr.set_active(None, None);
assert!(mgr.active_room_for(&[0xAA; 16]).is_none());
}
#[test]
fn test_manager_save_load_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
{
let mut mgr = RRCManager::new(
tmp.path().to_path_buf(),
vec![0xBB; 16],
Arc::new(|| Some("nick".to_string())),
);
let idx = mgr.add_hub(vec![0xAA; 16], None, Some("TestHub"));
let hub = mgr.hub_mut(idx).unwrap();
hub.add_room("general");
hub.add_room("random");
hub.auto_reconnect = true;
hub.nick_override = Some("my_nick".to_string());
hub.ensure_parted_room_buffer("old_room");
mgr.save();
}
let mut mgr2 = RRCManager::new(
tmp.path().to_path_buf(),
vec![0xBB; 16],
Arc::new(|| Some("nick".to_string())),
);
mgr2.load();
assert_eq!(mgr2.hubs().len(), 1);
let hub = mgr2.hub(0).unwrap();
assert_eq!(hub.hub_hash, vec![0xAA; 16]);
assert_eq!(hub.name, "TestHub");
assert!(hub.rooms.contains("general"));
assert!(hub.rooms.contains("random"));
assert!(hub.auto_reconnect);
assert_eq!(hub.nick_override.as_deref(), Some("my_nick"));
assert!(hub.messages.contains_key("old_room"));
assert!(!hub.rooms.contains("old_room"));
}
#[test]
fn test_manager_load_empty() {
let tmp = tempfile::tempdir().unwrap();
let mut mgr = RRCManager::new(
tmp.path().to_path_buf(),
vec![0xBB; 16],
Arc::new(|| Some("nick".to_string())),
);
mgr.load();
assert_eq!(mgr.hubs().len(), 0);
}
#[test]
fn test_manager_load_idempotent() {
let tmp = tempfile::tempdir().unwrap();
let mut mgr = RRCManager::new(
tmp.path().to_path_buf(),
vec![0xBB; 16],
Arc::new(|| Some("nick".to_string())),
);
mgr.add_hub(vec![0xAA; 16], None, None);
mgr.save();
mgr.load();
mgr.load();
assert_eq!(mgr.hubs().len(), 1);
}
#[test]
fn test_manager_shutdown() {
let tmp = tempfile::tempdir().unwrap();
let mut mgr = RRCManager::new(
tmp.path().to_path_buf(),
vec![0xBB; 16],
Arc::new(|| Some("nick".to_string())),
);
mgr.add_hub(vec![0xAA; 16], None, None);
mgr.hub_mut(0).unwrap().status = HubStatus::Connected;
mgr.shutdown();
assert_eq!(mgr.hub(0).unwrap().status, HubStatus::Disconnected);
}
#[test]
fn test_manager_remove_hub_clears_active() {
let tmp = tempfile::tempdir().unwrap();
let mut mgr = RRCManager::new(
tmp.path().to_path_buf(),
vec![0xBB; 16],
Arc::new(|| Some("nick".to_string())),
);
mgr.add_hub(vec![0xAA; 16], None, None);
mgr.set_active(Some(&[0xAA; 16]), Some("general"));
assert!(mgr.active_room_for(&[0xAA; 16]).is_some());
mgr.remove_hub(&[0xAA; 16], None);
assert!(mgr.active_hub_hash.is_none());
assert!(mgr.active_room.is_none());
}
#[test]
fn test_manager_custom_dest_name() {
let tmp = tempfile::tempdir().unwrap();
let mut mgr = RRCManager::new(
tmp.path().to_path_buf(),
vec![0xBB; 16],
Arc::new(|| Some("nick".to_string())),
);
mgr.add_hub(vec![0xAA; 16], None, None);
mgr.add_hub(vec![0xAA; 16], Some("custom.hub"), None);
assert_eq!(mgr.hubs().len(), 2);
assert!(mgr.find_hub(&[0xAA; 16], None).is_some());
assert!(mgr.find_hub(&[0xAA; 16], Some("custom.hub")).is_some());
}
#[test]
fn test_manager_history_per_room_cap_propagates() {
let tmp = tempfile::tempdir().unwrap();
let mut mgr = RRCManager::new(
tmp.path().to_path_buf(),
vec![0xBB; 16],
Arc::new(|| Some("nick".to_string())),
);
mgr.set_history_per_room_cap(Some(100));
mgr.add_hub(vec![0xAA; 16], None, None);
assert_eq!(mgr.hub(0).unwrap().per_room_cap(), Some(100));
mgr.set_history_per_room_cap(Some(200));
assert_eq!(mgr.hub(0).unwrap().per_room_cap(), Some(200));
}
#[test]
fn test_history_roundtrip_with_manager() {
let tmp = tempfile::tempdir().unwrap();
{
let mut mgr = RRCManager::new(
tmp.path().to_path_buf(),
vec![0xBB; 16],
Arc::new(|| Some("nick".to_string())),
);
let idx = mgr.add_hub(vec![0xAA; 16], None, Some("HistoryHub"));
let history_root = mgr.history_root();
let hub = mgr.hub_mut(idx).unwrap();
hub.add_room("general");
let msg = RRCMessage {
kind: "msg".to_string(),
room: Some("general".to_string()),
src: Some(vec![0xDD; 16]),
nick: Some("alice".to_string()),
text: "persisted message".to_string(),
ts: 99999,
mention: false,
};
hub.append_history(&history_root, "general", &msg);
mgr.save();
}
let mut mgr2 = RRCManager::new(
tmp.path().to_path_buf(),
vec![0xBB; 16],
Arc::new(|| Some("nick".to_string())),
);
mgr2.load();
let hub = mgr2.hub(0).unwrap();
let msgs = hub.get_messages("general");
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].text, "persisted message");
assert_eq!(msgs[0].nick.as_deref(), Some("alice"));
}
}