//! Signal channel via signal-cli daemon HTTP/JSON-RPC. //! //! Connects to a running `signal-cli daemon --http `. //! Listens for messages via SSE at `/api/v1/events` and sends via //! JSON-RPC at `/api/v1/rpc`. use std::num::NonZeroUsize; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use async_trait::async_trait; use futures::StreamExt; use lru::LruCache; use reqwest::Client; use serde::Deserialize; use tokio::sync::RwLock; use uuid::Uuid; use crate::bootstrap::ironclaw_base_dir; use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate}; use crate::config::SignalConfig; use crate::error::ChannelError; use crate::pairing::PairingStore; const GROUP_TARGET_PREFIX: &str = "group:"; const SIGNAL_HEALTH_ENDPOINT: &str = "/api/v1/check"; const MAX_SSE_BUFFER_SIZE: usize = 1024 * 1024; const MAX_SSE_EVENT_SIZE: usize = 256 * 1024; const MAX_HTTP_RESPONSE_SIZE: usize = 10 * 1024 * 1024; const MAX_REPLY_TARGETS: usize = 10000; const MAX_ERROR_LOG_BODY: usize = 1024; const REPLY_TARGETS_CAP: NonZeroUsize = NonZeroUsize::new(MAX_REPLY_TARGETS).unwrap(); // safety: 10000 is nonzero /// Recipient classification for outbound messages. #[derive(Debug, Clone, PartialEq, Eq)] enum RecipientTarget { Direct(String), Group(String), } // ── signal-cli SSE event JSON shapes ──────────────────────────── #[derive(Debug, Deserialize)] struct SseEnvelope { #[serde(default)] envelope: Option, } #[derive(Debug, Deserialize)] struct Envelope { #[serde(default)] source: Option, #[serde(rename = "sourceNumber", default)] source_number: Option, #[serde(rename = "sourceName", default)] source_name: Option, #[serde(rename = "sourceUuid", default)] source_uuid: Option, #[serde(rename = "dataMessage", default)] data_message: Option, #[serde(rename = "storyMessage", default)] story_message: Option, #[serde(default)] timestamp: Option, } #[derive(Debug, Deserialize)] struct DataMessage { #[serde(default)] message: Option, #[serde(default)] timestamp: Option, #[serde(rename = "groupInfo", default)] group_info: Option, #[serde(default)] attachments: Option>, } #[derive(Debug, Deserialize)] struct GroupInfo { #[serde(rename = "groupId", default)] group_id: Option, } /// Signal channel using signal-cli daemon's native JSON-RPC + SSE API. pub struct SignalChannel { config: SignalConfig, client: Client, /// LRU cache of reply targets per incoming message, used by `respond()`. /// Bounded to `MAX_REPLY_TARGETS` entries; least-recently-used entries /// are evicted automatically when the cache is full. reply_targets: Arc>>, /// Debug mode for verbose tool output (toggled via /debug command). debug_mode: Arc, } impl SignalChannel { /// Create a new Signal channel with normalized config and fresh client/cache. pub fn new(config: SignalConfig) -> Result { let mut config = config; config.http_url = config.http_url.trim_end_matches('/').to_string(); let client = Client::builder() .connect_timeout(Duration::from_secs(10)) .build() .map_err(|e| ChannelError::Http(e.to_string()))?; let cap = REPLY_TARGETS_CAP; let reply_targets = Arc::new(RwLock::new(LruCache::new(cap))); let debug_mode = Arc::new(AtomicBool::new(false)); Ok(Self::from_parts(config, client, reply_targets, debug_mode)) } /// Construct a SignalChannel from pre-validated parts. /// /// Used by [`new()`][Self::new] after normalization and by [`sse_listener`] /// to ensure both code paths use the same constructor. fn from_parts( config: SignalConfig, client: Client, reply_targets: Arc>>, debug_mode: Arc, ) -> Self { Self { config, client, reply_targets, debug_mode, } } fn is_debug(&self) -> bool { self.debug_mode.load(Ordering::Relaxed) } fn toggle_debug(&self) -> bool { let current = self.debug_mode.load(Ordering::Relaxed); self.debug_mode.store(!current, Ordering::Relaxed); !current } /// Effective sender: prefer `sourceNumber` (E.164), fall back to `source` /// (UUID for privacy-enabled users). fn sender(envelope: &Envelope) -> Option { envelope .source_number .as_deref() .or(envelope.source.as_deref()) .map(String::from) } /// Normalize an allowlist entry to the bare identifier. /// /// Strips the `uuid:` prefix if present, so `uuid:` and `` both /// match against a bare UUID sender. fn normalize_allow_entry(entry: &str) -> &str { entry.strip_prefix("uuid:").unwrap_or(entry) } /// Check whether a sender is in the allowed users list. fn is_sender_allowed(&self, sender: &str) -> bool { if self.config.allow_from.is_empty() { return false; } self.config.allow_from.iter().any(|entry| { entry == "*" || Self::normalize_allow_entry(entry) == Self::normalize_allow_entry(sender) }) } /// Check if sender is allowed via config allow_from OR pairing store. fn is_sender_allowed_with_pairing(&self, sender: &str) -> bool { if self.is_sender_allowed(sender) { return true; } let store = PairingStore::new(); if let Ok(allowed) = store.read_allow_from("signal") { return allowed.iter().any(|entry| entry == "*" || entry == sender); } false } /// Handle pairing request for unapproved sender. /// Returns Ok(true) if message should be allowed (was already paired), /// Ok(false) if message was blocked but pairing request was processed. fn handle_pairing_request(&self, sender: &str, source_name: Option<&str>) -> Result { let store = PairingStore::new(); let meta = serde_json::json!({ "sender": sender, "name": source_name, }); match store.upsert_request("signal", sender, Some(meta)) { Ok(result) => { tracing::info!( sender = %sender, code = %result.code, "Signal: pairing request upserted" ); if result.created { let message = format!( "To pair with this bot, run: `ironclaw pairing approve signal {}`", result.code ); let http_url = self.config.http_url.clone(); let account = self.config.account.clone(); let sender_owned = sender.to_string(); let message_owned = message.clone(); tokio::spawn(async move { if let Err(e) = Self::send_pairing_reply_async( &http_url, &account, &sender_owned, &message_owned, ) .await { tracing::error!(sender = %sender_owned, error = %e, "Signal: failed to send pairing reply"); } }); } Ok(false) } Err(e) => { tracing::error!(sender = %sender, error = %e, "Signal: pairing upsert failed"); Err(()) } } } /// Send a pairing reply message to the sender (async helper for spawned task). async fn send_pairing_reply_async( http_url: &str, account: &str, recipient: &str, message: &str, ) -> Result<(), ChannelError> { let client = Client::builder() .connect_timeout(Duration::from_secs(10)) .build() .map_err(|e| ChannelError::Http(e.to_string()))?; let target = Self::parse_recipient_target(recipient); let params = Self::build_rpc_params_static(http_url, account, &target, Some(message), None); let url = format!("{}/api/v1/rpc", http_url); let id = Uuid::new_v4().to_string(); let body = serde_json::json!({ "jsonrpc": "2.0", "method": "send", "params": params, "id": id, }); let resp = client .post(&url) .timeout(Duration::from_secs(30)) .header("Content-Type", "application/json") .json(&body) .send() .await .map_err(|e| ChannelError::SendFailed { name: "signal".to_string(), reason: format!("RPC request failed to {}: {e}", Self::redact_url(&url)), })?; let status = resp.status(); let is_success = status.is_success(); if status.as_u16() == 201 { return Ok(()); } if !is_success { let bytes = resp.bytes().await.unwrap_or_default(); let truncated_len = bytes.len().min(MAX_ERROR_LOG_BODY); let truncated_body = String::from_utf8_lossy(&bytes[..truncated_len]); return Err(ChannelError::SendFailed { name: "signal".to_string(), reason: format!("HTTP error {}: {}", status.as_u16(), truncated_body), }); } Ok(()) } /// Get effective group allow_from list (inherits from allow_from if empty). fn effective_group_allow_from(&self) -> &[String] { if self.config.group_allow_from.is_empty() { &self.config.allow_from } else { &self.config.group_allow_from } } /// Check whether a group is in the allowed groups list. /// /// - Empty list — deny all groups (DMs only, secure by default). /// - `*` — allow all groups. /// - Specific IDs — allow only those groups. fn is_group_allowed(&self, group_id: &str) -> bool { if self.config.allow_from_groups.is_empty() { return false; } self.config .allow_from_groups .iter() .any(|entry| entry == "*" || entry == group_id) } /// Check whether a sender is allowed for group messages. fn is_group_sender_allowed(&self, sender: &str) -> bool { let effective_list = self.effective_group_allow_from(); if effective_list.is_empty() { return false; } effective_list.iter().any(|entry| { entry == "*" || Self::normalize_allow_entry(entry) == Self::normalize_allow_entry(sender) }) } /// Redact credentials from a URL for safe logging. /// /// Replaces any embedded username/password with `**REDACTED**` and returns /// the sanitised string. Returns `""` when parsing fails. pub fn redact_url(url: &str) -> String { reqwest::Url::parse(url) .map(|mut u| { if u.password().is_some() || !u.username().is_empty() { let _ = u.set_username("**REDACTED**"); let _ = u.set_password(None); } u.to_string() }) .unwrap_or_else(|_| "".to_string()) } fn is_e164(recipient: &str) -> bool { let Some(number) = recipient.strip_prefix('+') else { return false; }; (7..=15).contains(&number.len()) && number.chars().all(|c| c.is_ascii_digit()) } /// Check whether a string is a valid UUID (signal-cli uses these for /// privacy-enabled users who have opted out of sharing their phone number). fn is_uuid(s: &str) -> bool { Uuid::parse_str(s).is_ok() } /// Generate a deterministic UUID from an identifier (phone number or group ID). /// /// This ensures that the same phone number or group always produces the same UUID, /// allowing conversation history to persist across gateway restarts. fn thread_id_from_identifier(identifier: &str) -> String { // Use a stable, deterministic UUID v5 derived from the identifier. // This avoids relying on `DefaultHasher` implementation details and // provides a full 128 bits of entropy. Uuid::new_v5(&Uuid::NAMESPACE_URL, identifier.as_bytes()).to_string() } fn parse_recipient_target(recipient: &str) -> RecipientTarget { if let Some(group_id) = recipient.strip_prefix(GROUP_TARGET_PREFIX) { return RecipientTarget::Group(group_id.to_string()); } if Self::is_e164(recipient) || Self::is_uuid(recipient) { RecipientTarget::Direct(recipient.to_string()) } else { RecipientTarget::Group(recipient.to_string()) } } /// Determine the reply target: group id (prefixed) or the sender's identifier. fn reply_target(data_msg: &DataMessage, sender: &str) -> String { if let Some(group_id) = data_msg .group_info .as_ref() .and_then(|g| g.group_id.as_deref()) { format!("{GROUP_TARGET_PREFIX}{group_id}") } else { sender.to_string() } } /// Send a JSON-RPC request to signal-cli daemon. async fn rpc_request( &self, method: &str, params: serde_json::Value, ) -> Result, ChannelError> { let url = format!("{}/api/v1/rpc", self.config.http_url); let id = Uuid::new_v4().to_string(); let body = serde_json::json!({ "jsonrpc": "2.0", "method": method, "params": params, "id": id, }); let resp = self .client .post(&url) .timeout(Duration::from_secs(30)) .header("Content-Type", "application/json") .json(&body) .send() .await .map_err(|e| ChannelError::SendFailed { name: "signal".to_string(), reason: format!("RPC request failed to {}: {e}", Self::redact_url(&url)), })?; // 201 = success with no body (e.g. typing indicators). if resp.status().as_u16() == 201 { return Ok(None); } // Reject obviously oversized responses before buffering. if let Some(len) = resp.content_length() && len as usize > MAX_HTTP_RESPONSE_SIZE { return Err(ChannelError::SendFailed { name: "signal".to_string(), reason: format!( "RPC response Content-Length too large: {} bytes (max {})", len, MAX_HTTP_RESPONSE_SIZE ), }); } let status = resp.status(); let mut stream = resp.bytes_stream(); let mut total_bytes = 0usize; let mut body = Vec::new(); while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(|e| ChannelError::SendFailed { name: "signal".to_string(), reason: format!("Failed to read RPC response: {e}"), })?; let chunk_len = chunk.len(); total_bytes += chunk_len; if total_bytes > MAX_HTTP_RESPONSE_SIZE { return Err(ChannelError::SendFailed { name: "signal".to_string(), reason: format!( "RPC response too large: {} bytes (max {})", total_bytes, MAX_HTTP_RESPONSE_SIZE ), }); } body.extend_from_slice(&chunk); } let bytes = body; if bytes.is_empty() { return Ok(None); } // Check for non-success HTTP status codes before parsing as JSON. if !status.is_success() { let truncated_len = std::cmp::min(bytes.len(), 512); let truncated_body = String::from_utf8_lossy(&bytes[..truncated_len]); return Err(ChannelError::SendFailed { name: "signal".to_string(), reason: format!("HTTP error {}: {}", status.as_u16(), truncated_body), }); } let parsed: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| ChannelError::SendFailed { name: "signal".to_string(), reason: format!("Invalid RPC response JSON: {e}"), })?; if let Some(err) = parsed.get("error") { let code = err.get("code").and_then(|c| c.as_i64()).unwrap_or(-1); let msg = err .get("message") .and_then(|m| m.as_str()) .unwrap_or("unknown"); return Err(ChannelError::SendFailed { name: "signal".to_string(), reason: format!("Signal RPC error {code}: {msg}"), }); } Ok(parsed.get("result").cloned()) } /// Build JSON-RPC params for a send/typing call. fn build_rpc_params( &self, target: &RecipientTarget, message: Option<&str>, attachments: Option<&[String]>, ) -> serde_json::Value { match target { RecipientTarget::Direct(id) => { let mut params = serde_json::json!({ "recipient": [id], "account": &self.config.account, }); if let Some(msg) = message { params["message"] = serde_json::Value::String(msg.to_string()); } if let Some(attachments) = attachments && !attachments.is_empty() { params["attachments"] = serde_json::Value::Array( attachments .iter() .map(|s| serde_json::Value::String(s.clone())) .collect(), ); } params } RecipientTarget::Group(group_id) => { let mut params = serde_json::json!({ "groupId": group_id, "account": &self.config.account, }); if let Some(msg) = message { params["message"] = serde_json::Value::String(msg.to_string()); } if let Some(attachments) = attachments && !attachments.is_empty() { params["attachments"] = serde_json::Value::Array( attachments .iter() .map(|s| serde_json::Value::String(s.clone())) .collect(), ); } params } } } /// Validate that attachment paths are safe and within the sandbox. /// Uses the shared path validation logic from path_utils to ensure: /// - No path traversal attacks (../, URL-encoded, null bytes) /// - Paths are canonicalized and symlinks resolved /// - All paths are within ~/.ironclaw/ sandbox fn validate_attachment_paths(paths: &[String]) -> Result<(), ChannelError> { // Get the sandbox base directory (same as MessageTool uses) let base_dir = ironclaw_base_dir(); for path in paths { crate::tools::builtin::path_utils::validate_path(path, Some(&base_dir)).map_err( |e| { ChannelError::InvalidMessage(format!( "Attachment path must be within {}: {}", base_dir.display(), e )) }, )?; } Ok(()) } /// Send a message with attachments (if any). /// Combines text and attachments into a single RPC call when both are present. async fn send_with_attachments( &self, target: &RecipientTarget, content: &str, attachments: &[String], ) -> Result<(), ChannelError> { Self::validate_attachment_paths(attachments)?; if attachments.is_empty() { let params = self.build_rpc_params(target, Some(content), None); self.rpc_request("send", params).await?; } else if content.is_empty() { // Attachments only - send all in a single call with no message text let params = self.build_rpc_params(target, None, Some(attachments)); self.rpc_request("send", params).await?; } else { // Both text and attachments - send in a single RPC call let params = self.build_rpc_params(target, Some(content), Some(attachments)); self.rpc_request("send", params).await?; } Ok(()) } /// Build JSON-RPC params for a send/typing call (static version). fn build_rpc_params_static( _http_url: &str, account: &str, target: &RecipientTarget, message: Option<&str>, attachments: Option<&[String]>, ) -> serde_json::Value { match target { RecipientTarget::Direct(id) => { let mut params = serde_json::json!({ "recipient": [id], "account": account, }); if let Some(msg) = message { params["message"] = serde_json::Value::String(msg.to_string()); } if let Some(attachments) = attachments && !attachments.is_empty() { params["attachments"] = serde_json::Value::Array( attachments .iter() .map(|s| serde_json::Value::String(s.clone())) .collect(), ); } params } RecipientTarget::Group(group_id) => { let mut params = serde_json::json!({ "groupId": group_id, "account": account, }); if let Some(msg) = message { params["message"] = serde_json::Value::String(msg.to_string()); } if let Some(attachments) = attachments && !attachments.is_empty() { params["attachments"] = serde_json::Value::Array( attachments .iter() .map(|s| serde_json::Value::String(s.clone())) .collect(), ); } params } } } /// Process a single SSE envelope, returning an `IncomingMessage` if valid. fn process_envelope(&self, envelope: &Envelope) -> Option<(IncomingMessage, String)> { // Skip story messages when configured. if self.config.ignore_stories && envelope.story_message.is_some() { tracing::debug!("Signal: dropping story message"); return None; } let data_msg = envelope.data_message.as_ref()?; // Skip attachment-only messages when configured. let has_attachments = data_msg.attachments.as_ref().is_some_and(|a| !a.is_empty()); let has_message_text = data_msg.message.as_ref().is_some_and(|m| !m.is_empty()); if self.config.ignore_attachments && has_attachments && !has_message_text { tracing::debug!("Signal: dropping attachment-only message"); return None; } // Use message text, or fall back to "[Attachment]" for attachment-only messages // when ignore_attachments is false. This ensures attachment-only messages are // still processed when the user wants them (rather than always being dropped). let text = data_msg .message .as_deref() .filter(|t| !t.is_empty()) .map(String::from) .or_else(|| { if has_attachments { Some("[Attachment]".to_string()) } else { None } })?; let sender = Self::sender(envelope)?; // Log sender info including UUID if available tracing::debug!( sender = %sender, uuid = ?envelope.source_uuid, "Signal: received message" ); // Check if this is a group message let is_group = data_msg .group_info .as_ref() .and_then(|g| g.group_id.as_deref()) .is_some(); // Apply group policy first (before DM policy for group messages) if is_group { match self.config.group_policy.as_str() { "disabled" => { tracing::debug!("Signal: group messages disabled, dropping"); return None; } "open" => { // For "open" policy, check group allowlist but not sender allowlist if let Some(group_id) = data_msg .group_info .as_ref() .and_then(|g| g.group_id.as_deref()) && !self.is_group_allowed(group_id) { tracing::debug!( group_id = %group_id, "Signal: group not in allow_from_groups, dropping" ); return None; } } "allowlist" => { // Default to allowlist - check group AND sender if let Some(group_id) = data_msg .group_info .as_ref() .and_then(|g| g.group_id.as_deref()) { if !self.is_group_allowed(group_id) { tracing::debug!( group_id = %group_id, "Signal: group not in allow_from_groups, dropping" ); return None; } // Also check sender is allowed for group if !self.is_group_sender_allowed(&sender) { tracing::debug!( sender = %sender, group_id = %group_id, "Signal: sender not in group_allow_from, dropping" ); return None; } } } _ => {} } } else { // DM message - apply DM policy match self.config.dm_policy.as_str() { "open" => {} "pairing" => { // Pairing policy: check allow_from + pairing store if !self.is_sender_allowed_with_pairing(&sender) { // Handle pairing request - this will create a request and send reply if new match self.handle_pairing_request(&sender, envelope.source_name.as_deref()) { Ok(_) => { // Pairing request processed (new or existing), drop the message return None; } Err(()) => { // Error processing pairing, drop message return None; } } } } "allowlist" => { // Default: check allow_from list if !self.is_sender_allowed(&sender) { tracing::debug!(sender = %sender, "Signal: sender not in allow_from, dropping"); return None; } } _ => {} } } let target = Self::reply_target(data_msg, &sender); let timestamp = data_msg .timestamp .or(envelope.timestamp) .unwrap_or_else(|| { u64::try_from( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis(), ) .unwrap_or(u64::MAX) }); // Build metadata with signal-specific routing info. let sender_uuid = envelope.source_uuid.as_deref(); let metadata = serde_json::json!({ "signal_sender": &sender, "signal_sender_uuid": sender_uuid, "signal_target": &target, "signal_timestamp": timestamp, }); let mut msg = IncomingMessage::new("signal", &sender, text).with_metadata(metadata); // Use sourceName as display name if available. if let Some(ref name) = envelope.source_name && !name.is_empty() { msg = msg.with_user_name(name); } // Use a deterministic UUID as thread_id for all conversations. // This ensures DMs and groups continue the same thread AND work with // maybe_hydrate_thread, enabling conversation history persistence. // Priority: source_uuid > generated UUID from phone/group if data_msg.group_info.is_some() { // For groups, use the group ID to generate a deterministic UUID msg = msg.with_thread(Self::thread_id_from_identifier(&target)); } else if let Some(ref uuid) = envelope.source_uuid { // Privacy mode users already have a UUID msg = msg.with_thread(uuid.clone()); } else { // For regular DMs, generate a deterministic UUID from the phone number msg = msg.with_thread(Self::thread_id_from_identifier(&sender)); } Some((msg, target)) } } #[async_trait] impl Channel for SignalChannel { fn name(&self) -> &str { "signal" } async fn start(&self) -> Result { let (tx, rx) = tokio::sync::mpsc::channel(256); let config = self.config.clone(); let client = self.client.clone(); let reply_targets = Arc::clone(&self.reply_targets); let debug_mode = Arc::clone(&self.debug_mode); tokio::spawn(async move { if let Err(e) = sse_listener(config, client, tx, reply_targets, debug_mode).await { tracing::error!("Signal SSE listener exited with error: {e}"); } }); // Log the URL with credentials redacted (if any). let safe_url = Self::redact_url(&self.config.http_url); tracing::info!( url = %safe_url, "Signal channel started" ); Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx))) } async fn respond( &self, msg: &IncomingMessage, response: OutgoingResponse, ) -> Result<(), ChannelError> { // Resolve reply target from stored metadata. let target_str = { let targets = self.reply_targets.read().await; targets.peek(&msg.id).cloned() } .or_else(|| { // Fall back to metadata if not in the map. msg.metadata .get("signal_target") .and_then(|v| v.as_str()) .map(String::from) }) .unwrap_or_else(|| msg.user_id.clone()); let target = Self::parse_recipient_target(&target_str); // Use shared helper for sending with attachments (includes validation) let result = self .send_with_attachments(&target, &response.content, &response.attachments) .await; // Clean up stored target regardless of success or failure. self.reply_targets.write().await.pop(&msg.id); result } async fn send_status( &self, status: StatusUpdate, metadata: &serde_json::Value, ) -> Result<(), ChannelError> { // Send typing indicator for thinking status. if matches!(status, StatusUpdate::Thinking(_)) && let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str()) { let target = Self::parse_recipient_target(target_str); let params = self.build_rpc_params(&target, None, None); let _ = self.rpc_request("sendTyping", params).await; } // Send approval prompt to user if let StatusUpdate::ApprovalNeeded { request_id, tool_name, description: _, parameters, allow_always, } = &status && let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str()) { let params_json = serde_json::to_string_pretty(parameters).unwrap_or_default(); let always_line = if *allow_always { format!( "\n• `always` or `a` - Approve and auto-approve future {} requests", tool_name ) } else { String::new() }; let message = format!( "⚠️ *Approval Required*\n\n\ *Request ID:* `{}`\n\ *Tool:* {}\n\ *Parameters:*\n```\n{}\n```\n\n\ Reply with:\n\ • `yes` or `y` - Approve this request{}\n\ • `no` or `n` - Deny", request_id, tool_name, params_json, always_line ); self.send_status_message(target_str, &message).await; } // Filter out well-known UX/terminal status messages to avoid redundant updates. let should_forward_status = |msg: &str| { let normalized = msg.trim(); !normalized.eq_ignore_ascii_case("done") && !normalized.eq_ignore_ascii_case("awaiting approval") && !normalized.eq_ignore_ascii_case("rejected") }; // Filter/send status messages if let StatusUpdate::Status(msg) = &status && let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str()) && should_forward_status(msg) { self.send_status_message(target_str, msg).await; } // Send tool result previews to user (debug mode only) if self.is_debug() && let StatusUpdate::ToolResult { name, preview } = &status && let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str()) { let truncated = if preview.chars().count() > 500 { let s: String = preview.chars().take(500).collect(); format!("{s}...") } else { preview.clone() }; let message = format!("Tool '{}' result:\n{}", name, truncated); self.send_status_message(target_str, &message).await; } // Send tool started notification (debug mode only) if self.is_debug() && let StatusUpdate::ToolStarted { name } = &status && let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str()) { let message = format!("\u{25CB} Running tool: {}", name); self.send_status_message(target_str, &message).await; } // Send tool completed notification (debug mode only) if self.is_debug() && let StatusUpdate::ToolCompleted { name, success, .. } = &status && let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str()) { let (icon, color) = if *success { ("\u{25CF}", "success") } else { ("\u{2717}", "failed") }; let message = format!("{} Tool '{}' completed ({})", icon, name, color); self.send_status_message(target_str, &message).await; } // Send job started notification (sandbox jobs) if let StatusUpdate::JobStarted { job_id, title, browse_url, } = &status && let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str()) { let message = format!( "\u{1F680} Job started: {}\nID: {}\nURL: {}", title, job_id, browse_url ); self.send_status_message(target_str, &message).await; } // Send auth required notification if let StatusUpdate::AuthRequired { extension_name, instructions, auth_url, setup_url, } = &status && let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str()) { let mut message = format!("\u{1F512} Authentication required for: {}", extension_name); if let Some(instr) = instructions { message.push_str(&format!("\n\n{}", instr)); } if let Some(url) = auth_url { message.push_str(&format!("\n\nAuth URL: {}", url)); } if let Some(url) = setup_url { message.push_str(&format!("\nSetup URL: {}", url)); } self.send_status_message(target_str, &message).await; } // Send auth completed notification if let StatusUpdate::AuthCompleted { extension_name, success, message: msg, } = &status && let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str()) { let icon = if *success { "\u{2705}" } else { "\u{274C}" }; let mut message = format!( "{} Authentication {} for {}", icon, if *success { "completed" } else { "failed" }, extension_name ); if !msg.is_empty() { message.push_str(&format!("\n{}", msg)); } self.send_status_message(target_str, &message).await; } Ok(()) } async fn broadcast( &self, user_id: &str, response: OutgoingResponse, ) -> Result<(), ChannelError> { let target = Self::parse_recipient_target(user_id); // Use shared helper for sending with attachments (includes validation) self.send_with_attachments(&target, &response.content, &response.attachments) .await } async fn health_check(&self) -> Result<(), ChannelError> { let url = format!("{}{}", self.config.http_url, SIGNAL_HEALTH_ENDPOINT); let resp = self .client .get(&url) .timeout(Duration::from_secs(10)) .send() .await .map_err(|e| ChannelError::HealthCheckFailed { name: format!("signal ({}): {e}", Self::redact_url(&url)), })?; if resp.status().is_success() { Ok(()) } else { Err(ChannelError::HealthCheckFailed { name: format!("signal: HTTP {}", resp.status()), }) } } fn conversation_context( &self, metadata: &serde_json::Value, ) -> std::collections::HashMap { use std::collections::HashMap; let mut ctx = HashMap::new(); if let Some(sender) = metadata.get("signal_sender").and_then(|v| v.as_str()) { ctx.insert("sender".to_string(), sender.to_string()); } if let Some(sender_uuid) = metadata.get("signal_sender_uuid").and_then(|v| v.as_str()) { ctx.insert("sender_uuid".to_string(), sender_uuid.to_string()); } if let Some(target) = metadata.get("signal_target").and_then(|v| v.as_str()) && target.starts_with("group:") { ctx.insert("group".to_string(), target.to_string()); } ctx } } impl SignalChannel { async fn send_status_message(&self, target: &str, message: &str) { let target = Self::parse_recipient_target(target); let params = self.build_rpc_params(&target, Some(message), None); if let Err(e) = self.rpc_request("send", params).await { tracing::warn!("Signal: failed to send status message: {}", e); } } } /// Long-running SSE listener that reconnects with exponential backoff. async fn sse_listener( config: SignalConfig, client: Client, tx: tokio::sync::mpsc::Sender, reply_targets: Arc>>, debug_mode: Arc, ) -> Result<(), ChannelError> { let channel = SignalChannel::from_parts( config, client, Arc::clone(&reply_targets), Arc::clone(&debug_mode), ); let mut url = reqwest::Url::parse(&format!("{}/api/v1/events", channel.config.http_url)) .map_err(|e| ChannelError::StartupFailed { name: "signal".to_string(), reason: format!("Invalid SSE URL: {e}"), })?; url.query_pairs_mut() .append_pair("account", &channel.config.account); let mut retry_delay = Duration::from_secs(2); let max_delay = Duration::from_secs(60); loop { let resp = channel .client .get(url.clone()) .header("Accept", "text/event-stream") .send() .await; let resp = match resp { Ok(r) if r.status().is_success() => r, Ok(r) => { let status = r.status(); let mut stream = r.bytes_stream(); let mut bytes = Vec::new(); let mut collected = 0usize; while let Some(chunk) = stream.next().await { let chunk = chunk.unwrap_or_default(); let remaining = MAX_ERROR_LOG_BODY.saturating_sub(collected); if remaining == 0 { break; } bytes.extend_from_slice(&chunk[..chunk.len().min(remaining)]); collected = bytes.len(); if collected >= MAX_ERROR_LOG_BODY { break; } } let body = String::from_utf8_lossy(&bytes); tracing::warn!("Signal SSE returned {status}: {body}"); tokio::time::sleep(retry_delay).await; retry_delay = (retry_delay * 2).min(max_delay); continue; } Err(e) => { let safe_url = SignalChannel::redact_url(url.as_str()); tracing::warn!("Signal SSE connect error to {safe_url}: {e}, retrying..."); tokio::time::sleep(retry_delay).await; retry_delay = (retry_delay * 2).min(max_delay); continue; } }; // Connection succeeded — reset backoff. retry_delay = Duration::from_secs(2); tracing::info!("Signal SSE connected"); let mut bytes_stream = resp.bytes_stream(); let mut buffer = String::with_capacity(8192); let mut current_data = String::with_capacity(4096); // Holds trailing bytes from the previous chunk that form an incomplete // multi-byte UTF-8 sequence. At most 3 bytes (the longest incomplete // leading sequence for a 4-byte character). let mut utf8_carry: Vec = Vec::with_capacity(4); while let Some(chunk) = bytes_stream.next().await { let chunk = match chunk { Ok(c) => c, Err(e) => { tracing::debug!("Signal SSE chunk error, reconnecting: {e}"); break; } }; // Prepend any leftover bytes from the previous chunk. let decode_buf = if utf8_carry.is_empty() { chunk.to_vec() } else { let mut combined = std::mem::take(&mut utf8_carry); combined.extend_from_slice(&chunk); combined }; // Decode as much valid UTF-8 as possible, carrying over any // incomplete trailing sequence to the next iteration. let (valid_len, carry_start) = match std::str::from_utf8(&decode_buf) { Ok(_) => (decode_buf.len(), decode_buf.len()), Err(e) => { let valid_up_to = e.valid_up_to(); match e.error_len() { Some(bad_len) => { // Genuinely invalid byte sequence (not just incomplete). // Skip the bad byte(s) and keep going with what we have. tracing::debug!( "Signal SSE invalid UTF-8 byte at offset {valid_up_to}, \ skipping" ); // Advance past the bad byte(s); remaining data (if any) // will be carried over to the next chunk. (valid_up_to, valid_up_to + bad_len) } None => { // Incomplete multi-byte sequence at the end – carry it over. (valid_up_to, valid_up_to) } } } }; use std::borrow::Cow; debug_assert!( std::str::from_utf8(&decode_buf[..valid_len]).is_ok(), "valid_len {} should be a valid UTF-8 boundary (buffer len: {})", valid_len, decode_buf.len() ); let text: Cow = match std::str::from_utf8(&decode_buf[..valid_len]) { Ok(s) => Cow::Borrowed(s), Err(_) => { tracing::warn!( "Signal SSE: unexpected invalid UTF-8 boundary at valid_len {}, \ falling back to lossy conversion", valid_len ); Cow::Owned(String::from_utf8_lossy(&decode_buf[..valid_len]).into_owned()) } }; if buffer.len() + text.len() > MAX_SSE_BUFFER_SIZE { tracing::warn!( "Signal SSE buffer overflow, resetting: buffer_len={} text_len={} max={}", buffer.len(), text.len(), MAX_SSE_BUFFER_SIZE ); buffer.clear(); utf8_carry.clear(); current_data.clear(); continue; } buffer.push_str(&text); // Preserve any trailing incomplete bytes for the next chunk. if carry_start < decode_buf.len() { utf8_carry.extend_from_slice(&decode_buf[carry_start..]); } while let Some(newline_pos) = buffer.find('\n') { let line = buffer[..newline_pos].trim_end_matches('\r').to_string(); buffer.drain(..=newline_pos); // Skip SSE comments (keepalive). if line.starts_with(':') { continue; } if line.is_empty() { // Empty line = event boundary, dispatch accumulated data. if !current_data.is_empty() { match serde_json::from_str::(¤t_data) { Ok(sse) => { if let Some(ref envelope) = sse.envelope && let Some((msg, target)) = channel.process_envelope(envelope) { // Handle /debug command locally (same as REPL). let content_lower = msg.content.trim().to_lowercase(); if content_lower == "/debug" { let new_state = channel.toggle_debug(); let response = if new_state { "Debug mode enabled. Tool execution will be shown in chat." } else { "Debug mode disabled. Tool execution will be hidden from chat." }; let reply_params = channel.build_rpc_params( &SignalChannel::parse_recipient_target(&target), Some(response), None, ); let _ = channel.rpc_request("send", reply_params).await; // Don't send the /debug command to the agent. continue; } // Store reply target for respond(). // LruCache automatically evicts the // least-recently-used entry when full. { let mut targets = reply_targets.write().await; targets.put(msg.id, target); } if tx.send(msg).await.is_err() { tracing::debug!("Signal SSE: receiver dropped, exiting"); return Ok(()); } } } Err(e) => { tracing::debug!("Signal SSE parse skip: {e}"); } } current_data.clear(); } } else if let Some(data) = line.strip_prefix("data:") { if current_data.len() + data.len() > MAX_SSE_EVENT_SIZE { tracing::warn!("Signal SSE event too large, dropping"); current_data.clear(); continue; } if !current_data.is_empty() { current_data.push('\n'); } current_data.push_str(data.trim_start()); } // Ignore "event:", "id:", "retry:" lines. } } // Process any trailing data before reconnect. if !current_data.is_empty() && let Ok(sse) = serde_json::from_str::(¤t_data) && let Some(ref envelope) = sse.envelope && let Some((msg, target)) = channel.process_envelope(envelope) { reply_targets.write().await.put(msg.id, target); let _ = tx.send(msg).await; } tracing::debug!("Signal SSE stream ended, reconnecting with backoff..."); tokio::time::sleep(retry_delay).await; retry_delay = std::cmp::min(retry_delay * 2, max_delay); } } #[cfg(test)] mod tests { use super::*; fn make_config() -> SignalConfig { SignalConfig { http_url: "http://127.0.0.1:8686".to_string(), account: "+1234567890".to_string(), allow_from: vec!["+1111111111".to_string()], allow_from_groups: vec![], dm_policy: "allowlist".to_string(), group_policy: "disabled".to_string(), group_allow_from: vec![], ignore_attachments: false, ignore_stories: false, } } /// Create a config that allows a specific group (and all senders). fn make_config_with_allowed_group(group_id: &str) -> SignalConfig { SignalConfig { http_url: "http://127.0.0.1:8686".to_string(), account: "+1234567890".to_string(), allow_from: vec!["*".to_string()], allow_from_groups: vec![group_id.to_string()], dm_policy: "allowlist".to_string(), group_policy: "allowlist".to_string(), group_allow_from: vec![], ignore_attachments: true, ignore_stories: true, } } fn make_channel() -> Result { SignalChannel::new(make_config()) } fn make_channel_with_allowed_group(group_id: &str) -> Result { SignalChannel::new(make_config_with_allowed_group(group_id)) } fn make_envelope(source_number: Option<&str>, message: Option<&str>) -> Envelope { Envelope { source: source_number.map(String::from), source_number: source_number.map(String::from), source_name: None, source_uuid: None, data_message: message.map(|m| DataMessage { message: Some(m.to_string()), timestamp: Some(1_700_000_000_000), group_info: None, attachments: None, }), story_message: None, timestamp: Some(1_700_000_000_000), } } #[test] fn creates_with_correct_fields() -> Result<(), ChannelError> { let ch = make_channel()?; assert_eq!(ch.config.http_url, "http://127.0.0.1:8686"); assert_eq!(ch.config.account, "+1234567890"); assert_eq!(ch.config.allow_from.len(), 1); assert!(ch.config.allow_from_groups.is_empty()); assert!(!ch.config.ignore_attachments); assert!(!ch.config.ignore_stories); Ok(()) } #[test] fn strips_trailing_slash() -> Result<(), ChannelError> { let mut config = make_config(); config.http_url = "http://127.0.0.1:8686/".to_string(); let ch = SignalChannel::new(config)?; assert_eq!(ch.config.http_url, "http://127.0.0.1:8686"); Ok(()) } #[test] fn debug_mode_disabled_by_default() -> Result<(), ChannelError> { let ch = make_channel()?; assert!(!ch.is_debug()); Ok(()) } #[test] fn debug_mode_toggle() -> Result<(), ChannelError> { let ch = make_channel()?; // Initially disabled assert!(!ch.is_debug()); // Toggle on let new_state = ch.toggle_debug(); assert!(new_state); assert!(ch.is_debug()); // Toggle off let new_state = ch.toggle_debug(); assert!(!new_state); assert!(!ch.is_debug()); Ok(()) } #[test] fn debug_mode_persists_across_toggles() -> Result<(), ChannelError> { let ch = make_channel()?; // Multiple toggles ch.toggle_debug(); assert!(ch.is_debug()); ch.toggle_debug(); assert!(!ch.is_debug()); ch.toggle_debug(); assert!(ch.is_debug()); ch.toggle_debug(); assert!(!ch.is_debug()); Ok(()) } #[test] fn wildcard_allows_anyone() -> Result<(), ChannelError> { let mut config = make_config(); config.allow_from = vec!["*".to_string()]; let ch = SignalChannel::new(config)?; assert!(ch.is_sender_allowed("+9999999999")); Ok(()) } #[test] fn specific_sender_allowed() -> Result<(), ChannelError> { let ch = make_channel()?; assert!(ch.is_sender_allowed("+1111111111")); Ok(()) } #[test] fn unknown_sender_denied() -> Result<(), ChannelError> { let ch = make_channel()?; assert!(!ch.is_sender_allowed("+9999999999")); Ok(()) } #[test] fn empty_allowlist_denies_all() -> Result<(), ChannelError> { let mut config = make_config(); config.allow_from = vec![]; let ch = SignalChannel::new(config)?; assert!(!ch.is_sender_allowed("+1111111111")); Ok(()) } #[test] fn uuid_prefix_in_allowlist() -> Result<(), ChannelError> { let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; let mut config = make_config(); config.allow_from = vec![format!("uuid:{uuid}")]; let ch = SignalChannel::new(config)?; assert!(ch.is_sender_allowed(uuid)); // Should not match phone numbers. assert!(!ch.is_sender_allowed("+1111111111")); Ok(()) } #[test] fn bare_uuid_in_allowlist() -> Result<(), ChannelError> { let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; let mut config = make_config(); config.allow_from = vec![uuid.to_string()]; let ch = SignalChannel::new(config)?; assert!(ch.is_sender_allowed(uuid)); Ok(()) } #[test] fn group_allowlist_filtering() -> Result<(), ChannelError> { let mut config = make_config(); config.allow_from = vec!["*".to_string()]; config.allow_from_groups = vec!["group123".to_string()]; let ch = SignalChannel::new(config)?; assert!(ch.is_group_allowed("group123")); assert!(!ch.is_group_allowed("other_group")); Ok(()) } #[test] fn group_allowlist_wildcard() -> Result<(), ChannelError> { let mut config = make_config(); config.allow_from_groups = vec!["*".to_string()]; let ch = SignalChannel::new(config)?; assert!(ch.is_group_allowed("any_group")); Ok(()) } #[test] fn group_allowlist_empty_denies_all() -> Result<(), ChannelError> { let mut config = make_config(); config.allow_from_groups = vec![]; let ch = SignalChannel::new(config)?; assert!(!ch.is_group_allowed("any_group")); Ok(()) } #[test] fn name_returns_signal() -> Result<(), ChannelError> { let ch = make_channel()?; assert_eq!(ch.name(), "signal"); Ok(()) } #[test] fn process_envelope_dm_accepted_with_empty_allow_from_groups() -> Result<(), ChannelError> { // Empty allow_from_groups = DMs only. DMs should be accepted. let ch = make_channel()?; let env = make_envelope(Some("+1111111111"), Some("Hello!")); assert!(ch.process_envelope(&env).is_some()); Ok(()) } #[test] fn process_envelope_group_denied_with_empty_allow_from_groups() -> Result<(), ChannelError> { // Empty allow_from_groups = DMs only. Group messages should be denied. let mut config = make_config(); config.allow_from = vec!["*".to_string()]; let ch = SignalChannel::new(config)?; let env = Envelope { source: Some("+1111111111".to_string()), source_number: Some("+1111111111".to_string()), source_name: None, source_uuid: None, data_message: Some(DataMessage { message: Some("hi".to_string()), timestamp: Some(1000), group_info: Some(GroupInfo { group_id: Some("group123".to_string()), }), attachments: None, }), story_message: None, timestamp: Some(1000), }; assert!(ch.process_envelope(&env).is_none()); Ok(()) } #[test] fn process_envelope_group_accepted_when_in_allow_from_groups() -> Result<(), ChannelError> { let ch = make_channel_with_allowed_group("group123")?; let env = Envelope { source: Some("+1111111111".to_string()), source_number: Some("+1111111111".to_string()), source_name: None, source_uuid: None, data_message: Some(DataMessage { message: Some("hi".to_string()), timestamp: Some(1000), group_info: Some(GroupInfo { group_id: Some("group123".to_string()), }), attachments: None, }), story_message: None, timestamp: Some(1000), }; assert!(ch.process_envelope(&env).is_some()); // Different group should be denied. let env2 = Envelope { source: Some("+1111111111".to_string()), source_number: Some("+1111111111".to_string()), source_name: None, source_uuid: None, data_message: Some(DataMessage { message: Some("hi".to_string()), timestamp: Some(1000), group_info: Some(GroupInfo { group_id: Some("other_group".to_string()), }), attachments: None, }), story_message: None, timestamp: Some(1000), }; assert!(ch.process_envelope(&env2).is_none()); Ok(()) } #[test] fn reply_target_dm() { let dm = DataMessage { message: Some("hi".to_string()), timestamp: Some(1000), group_info: None, attachments: None, }; assert_eq!( SignalChannel::reply_target(&dm, "+1111111111"), "+1111111111" ); } #[test] fn reply_target_group() { let group = DataMessage { message: Some("hi".to_string()), timestamp: Some(1000), group_info: Some(GroupInfo { group_id: Some("group123".to_string()), }), attachments: None, }; assert_eq!( SignalChannel::reply_target(&group, "+1111111111"), "group:group123" ); } #[test] fn parse_recipient_target_e164_is_direct() { assert_eq!( SignalChannel::parse_recipient_target("+1234567890"), RecipientTarget::Direct("+1234567890".to_string()) ); } #[test] fn parse_recipient_target_prefixed_group_is_group() { assert_eq!( SignalChannel::parse_recipient_target("group:abc123"), RecipientTarget::Group("abc123".to_string()) ); } #[test] fn parse_recipient_target_uuid_is_direct() { let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; assert_eq!( SignalChannel::parse_recipient_target(uuid), RecipientTarget::Direct(uuid.to_string()) ); } #[test] fn parse_recipient_target_non_e164_plus_is_group() { assert_eq!( SignalChannel::parse_recipient_target("+abc123"), RecipientTarget::Group("+abc123".to_string()) ); } #[test] fn is_uuid_valid() { assert!(SignalChannel::is_uuid( "a1b2c3d4-e5f6-7890-abcd-ef1234567890" )); assert!(SignalChannel::is_uuid( "00000000-0000-0000-0000-000000000000" )); } #[test] fn is_uuid_invalid() { assert!(!SignalChannel::is_uuid("+1234567890")); assert!(!SignalChannel::is_uuid("not-a-uuid")); assert!(!SignalChannel::is_uuid("group:abc123")); assert!(!SignalChannel::is_uuid("")); } #[test] fn thread_id_from_identifier_is_deterministic() { let id1 = SignalChannel::thread_id_from_identifier("+1234567890"); let id2 = SignalChannel::thread_id_from_identifier("+1234567890"); assert_eq!(id1, id2, "same input should produce same UUID"); } #[test] fn thread_id_from_identifier_is_valid_uuid() { let id = SignalChannel::thread_id_from_identifier("+1234567890"); assert!(Uuid::parse_str(&id).is_ok(), "should be a valid UUID"); } #[test] fn thread_id_from_identifier_different_inputs() { let id1 = SignalChannel::thread_id_from_identifier("+1234567890"); let id2 = SignalChannel::thread_id_from_identifier("+9876543210"); assert_ne!(id1, id2, "different inputs should produce different UUIDs"); } #[test] fn sender_prefers_source_number() { let env = Envelope { source: Some("uuid-123".to_string()), source_number: Some("+1111111111".to_string()), source_name: None, source_uuid: None, data_message: None, story_message: None, timestamp: Some(1000), }; assert_eq!(SignalChannel::sender(&env), Some("+1111111111".to_string())); } #[test] fn sender_falls_back_to_source() { let env = Envelope { source: Some("a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string()), source_number: None, source_name: None, source_uuid: None, data_message: None, story_message: None, timestamp: Some(1000), }; assert_eq!( SignalChannel::sender(&env), Some("a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string()) ); } #[test] fn sender_none_when_both_missing() { let env = Envelope { source: None, source_number: None, source_name: None, source_uuid: None, data_message: None, story_message: None, timestamp: None, }; assert_eq!(SignalChannel::sender(&env), None); } #[test] fn process_envelope_valid_dm() -> Result<(), ChannelError> { let ch = make_channel()?; let env = make_envelope(Some("+1111111111"), Some("Hello!")); let (msg, target) = ch.process_envelope(&env).unwrap(); assert_eq!(msg.content, "Hello!"); assert_eq!(msg.user_id, "+1111111111"); assert_eq!(msg.channel, "signal"); assert_eq!(target, "+1111111111"); Ok(()) } #[test] fn process_envelope_denied_sender() -> Result<(), ChannelError> { let ch = make_channel()?; let env = make_envelope(Some("+9999999999"), Some("Hello!")); assert!(ch.process_envelope(&env).is_none()); Ok(()) } #[test] fn process_envelope_empty_message() -> Result<(), ChannelError> { let ch = make_channel()?; let env = make_envelope(Some("+1111111111"), Some("")); assert!(ch.process_envelope(&env).is_none()); Ok(()) } #[test] fn process_envelope_no_data_message() -> Result<(), ChannelError> { let ch = make_channel()?; let env = make_envelope(Some("+1111111111"), None); assert!(ch.process_envelope(&env).is_none()); Ok(()) } #[test] fn process_envelope_skips_stories() -> Result<(), ChannelError> { let mut config = make_config(); config.allow_from = vec!["*".to_string()]; config.ignore_stories = true; let ch = SignalChannel::new(config)?; let mut env = make_envelope(Some("+1111111111"), Some("story text")); env.story_message = Some(serde_json::json!({})); assert!(ch.process_envelope(&env).is_none()); Ok(()) } #[test] fn process_envelope_skips_attachment_only() -> Result<(), ChannelError> { let mut config = make_config(); config.allow_from = vec!["*".to_string()]; config.ignore_attachments = true; let ch = SignalChannel::new(config)?; let env = Envelope { source: Some("+1111111111".to_string()), source_number: Some("+1111111111".to_string()), source_name: None, source_uuid: None, data_message: Some(DataMessage { message: None, timestamp: Some(1_700_000_000_000), group_info: None, attachments: Some(vec![serde_json::json!({"contentType": "image/png"})]), }), story_message: None, timestamp: Some(1_700_000_000_000), }; assert!(ch.process_envelope(&env).is_none()); Ok(()) } #[test] fn process_envelope_uuid_sender_dm() -> Result<(), ChannelError> { let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; let mut config = make_config(); config.allow_from = vec!["*".to_string()]; let ch = SignalChannel::new(config)?; let env = Envelope { source: Some(uuid.to_string()), source_number: None, source_name: Some("Privacy User".to_string()), source_uuid: None, data_message: Some(DataMessage { message: Some("Hello from privacy user".to_string()), timestamp: Some(1_700_000_000_000), group_info: None, attachments: None, }), story_message: None, timestamp: Some(1_700_000_000_000), }; let (msg, target) = ch.process_envelope(&env).unwrap(); assert_eq!(msg.user_id, uuid); assert_eq!(msg.user_name.as_deref(), Some("Privacy User")); assert_eq!(msg.content, "Hello from privacy user"); assert_eq!(target, uuid); // Verify reply routing: UUID sender in DM should route as Direct. let parsed = SignalChannel::parse_recipient_target(&target); assert_eq!(parsed, RecipientTarget::Direct(uuid.to_string())); Ok(()) } #[test] fn process_envelope_uuid_sender_in_group() -> Result<(), ChannelError> { let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; let mut config = make_config_with_allowed_group("testgroup"); config.ignore_attachments = false; config.ignore_stories = false; let ch = SignalChannel::new(config)?; let env = Envelope { source: Some(uuid.to_string()), source_number: None, source_name: None, source_uuid: None, data_message: Some(DataMessage { message: Some("Group msg from privacy user".to_string()), timestamp: Some(1_700_000_000_000), group_info: Some(GroupInfo { group_id: Some("testgroup".to_string()), }), attachments: None, }), story_message: None, timestamp: Some(1_700_000_000_000), }; let (msg, target) = ch.process_envelope(&env).unwrap(); assert_eq!(msg.user_id, uuid); assert_eq!(target, "group:testgroup"); // Groups now use deterministic UUID derived from group ID let expected_thread_id = SignalChannel::thread_id_from_identifier("group:testgroup"); assert_eq!(msg.thread_id, Some(expected_thread_id)); // Verify reply routing: group message should still route as Group. let parsed = SignalChannel::parse_recipient_target(&target); assert_eq!(parsed, RecipientTarget::Group("testgroup".to_string())); Ok(()) } #[test] fn process_envelope_group_not_in_allow_from_groups() -> Result<(), ChannelError> { let mut config = make_config(); config.allow_from = vec!["*".to_string()]; config.allow_from_groups = vec!["allowed_group".to_string()]; let ch = SignalChannel::new(config)?; let env = Envelope { source: Some("+1111111111".to_string()), source_number: Some("+1111111111".to_string()), source_name: None, source_uuid: None, data_message: Some(DataMessage { message: Some("Hi".to_string()), timestamp: Some(1_700_000_000_000), group_info: Some(GroupInfo { group_id: Some("other_group".to_string()), }), attachments: None, }), story_message: None, timestamp: Some(1_700_000_000_000), }; assert!(ch.process_envelope(&env).is_none()); Ok(()) } #[test] fn sse_envelope_deserializes() { let json = r#"{ "envelope": { "source": "+1111111111", "sourceNumber": "+1111111111", "sourceName": "Test User", "timestamp": 1700000000000, "dataMessage": { "message": "Hello Signal!", "timestamp": 1700000000000 } } }"#; let sse: SseEnvelope = serde_json::from_str(json).unwrap(); let env = sse.envelope.unwrap(); assert_eq!(env.source_number.as_deref(), Some("+1111111111")); assert_eq!(env.source_name.as_deref(), Some("Test User")); let dm = env.data_message.unwrap(); assert_eq!(dm.message.as_deref(), Some("Hello Signal!")); } #[test] fn sse_envelope_deserializes_group() { let json = r#"{ "envelope": { "sourceNumber": "+2222222222", "dataMessage": { "message": "Group msg", "groupInfo": { "groupId": "abc123" } } } }"#; let sse: SseEnvelope = serde_json::from_str(json).unwrap(); let env = sse.envelope.unwrap(); let dm = env.data_message.unwrap(); assert_eq!( dm.group_info.as_ref().unwrap().group_id.as_deref(), Some("abc123") ); } #[test] fn envelope_defaults() { let json = r#"{}"#; let env: Envelope = serde_json::from_str(json).unwrap(); assert!(env.source.is_none()); assert!(env.source_number.is_none()); assert!(env.source_name.is_none()); assert!(env.data_message.is_none()); assert!(env.story_message.is_none()); assert!(env.timestamp.is_none()); } #[test] fn normalize_allow_entry_strips_uuid_prefix() { assert_eq!( SignalChannel::normalize_allow_entry("uuid:abc-123"), "abc-123" ); assert_eq!( SignalChannel::normalize_allow_entry("+1234567890"), "+1234567890" ); assert_eq!(SignalChannel::normalize_allow_entry("*"), "*"); } // ── build_rpc_params tests ────────────────────────────────────── #[test] fn build_rpc_params_direct_with_message() -> Result<(), ChannelError> { let ch = make_channel()?; let target = RecipientTarget::Direct("+5555555555".to_string()); let params = ch.build_rpc_params(&target, Some("Hello!"), None); assert_eq!(params["recipient"], serde_json::json!(["+5555555555"])); assert_eq!(params["account"], "+1234567890"); assert_eq!(params["message"], "Hello!"); // Direct targets must NOT include groupId. assert!(params.get("groupId").is_none()); Ok(()) } #[test] fn build_rpc_params_direct_without_message() -> Result<(), ChannelError> { let ch = make_channel()?; let target = RecipientTarget::Direct("+5555555555".to_string()); let params = ch.build_rpc_params(&target, None, None); assert_eq!(params["recipient"], serde_json::json!(["+5555555555"])); assert_eq!(params["account"], "+1234567890"); // No message key should be present for typing indicators. assert!(params.get("message").is_none()); Ok(()) } #[test] fn build_rpc_params_group_with_message() -> Result<(), ChannelError> { let ch = make_channel()?; let target = RecipientTarget::Group("abc123".to_string()); let params = ch.build_rpc_params(&target, Some("Group msg"), None); assert_eq!(params["groupId"], "abc123"); assert_eq!(params["account"], "+1234567890"); assert_eq!(params["message"], "Group msg"); // Group targets must NOT include recipient. assert!(params.get("recipient").is_none()); Ok(()) } #[test] fn build_rpc_params_group_without_message() -> Result<(), ChannelError> { let ch = make_channel()?; let target = RecipientTarget::Group("abc123".to_string()); let params = ch.build_rpc_params(&target, None, None); assert_eq!(params["groupId"], "abc123"); assert_eq!(params["account"], "+1234567890"); assert!(params.get("message").is_none()); Ok(()) } #[test] fn build_rpc_params_uuid_direct_target() -> Result<(), ChannelError> { let ch = make_channel()?; let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; let target = RecipientTarget::Direct(uuid.to_string()); let params = ch.build_rpc_params(&target, Some("hi"), None); assert_eq!(params["recipient"], serde_json::json!([uuid])); Ok(()) } // ── build_rpc_params with attachments tests ───────────────────────── #[test] fn build_rpc_params_with_attachments() -> Result<(), ChannelError> { let ch = make_channel()?; let target = RecipientTarget::Direct("+5555555555".to_string()); let attachments = vec!["/path/to/image.png".to_string()]; let params = ch.build_rpc_params(&target, Some("Check this!"), Some(&attachments)); assert_eq!(params["recipient"], serde_json::json!(["+5555555555"])); assert_eq!(params["message"], "Check this!"); assert_eq!( params["attachments"], serde_json::json!(["/path/to/image.png"]) ); Ok(()) } #[test] fn build_rpc_params_with_multiple_attachments() -> Result<(), ChannelError> { let ch = make_channel()?; let target = RecipientTarget::Direct("+5555555555".to_string()); let attachments = vec![ "/path/to/image.png".to_string(), "/path/to/document.pdf".to_string(), ]; let params = ch.build_rpc_params(&target, Some("Files attached"), Some(&attachments)); assert_eq!( params["attachments"], serde_json::json!(["/path/to/image.png", "/path/to/document.pdf"]) ); Ok(()) } #[test] fn build_rpc_params_with_attachments_no_message() -> Result<(), ChannelError> { let ch = make_channel()?; let target = RecipientTarget::Direct("+5555555555".to_string()); let attachments = vec!["/path/to/image.png".to_string()]; let params = ch.build_rpc_params(&target, None, Some(&attachments)); assert!(params.get("message").is_none()); assert_eq!( params["attachments"], serde_json::json!(["/path/to/image.png"]) ); Ok(()) } #[test] fn build_rpc_params_group_with_attachments() -> Result<(), ChannelError> { let ch = make_channel()?; let target = RecipientTarget::Group("abc123".to_string()); let attachments = vec!["/path/to/photo.jpg".to_string()]; let params = ch.build_rpc_params(&target, Some("Group photo"), Some(&attachments)); assert_eq!(params["groupId"], "abc123"); assert_eq!(params["message"], "Group photo"); assert_eq!( params["attachments"], serde_json::json!(["/path/to/photo.jpg"]) ); Ok(()) } // ── OutgoingResponse attachment tests ───────────────────────────── #[test] fn outgoing_response_with_attachments() { let response = OutgoingResponse::text("Hello with file") .with_attachments(vec!["/path/to/file.png".to_string()]); assert_eq!(response.content, "Hello with file"); assert!( response .attachments .contains(&"/path/to/file.png".to_string()) ); } #[test] fn outgoing_response_text_empty_attachments() { let response = OutgoingResponse::text("Hello"); assert_eq!(response.content, "Hello"); assert!(response.attachments.is_empty()); } // ── metadata assertion tests ──────────────────────────────────── #[test] fn process_envelope_metadata_has_signal_fields() -> Result<(), ChannelError> { let ch = make_channel()?; let env = make_envelope(Some("+1111111111"), Some("Hello!")); let (msg, _) = ch.process_envelope(&env).unwrap(); assert_eq!(msg.metadata["signal_sender"], "+1111111111"); assert_eq!(msg.metadata["signal_target"], "+1111111111"); assert_eq!(msg.metadata["signal_timestamp"], 1_700_000_000_000_u64); Ok(()) } #[test] fn process_envelope_metadata_group_target() -> Result<(), ChannelError> { let mut config = make_config(); config.allow_from = vec!["*".to_string()]; config.allow_from_groups = vec!["*".to_string()]; config.group_policy = "allowlist".to_string(); let ch = SignalChannel::new(config)?; let env = Envelope { source: Some("+2222222222".to_string()), source_number: Some("+2222222222".to_string()), source_name: None, source_uuid: None, data_message: Some(DataMessage { message: Some("In the group".to_string()), timestamp: Some(1_700_000_000_000), group_info: Some(GroupInfo { group_id: Some("mygroup".to_string()), }), attachments: None, }), story_message: None, timestamp: Some(1_700_000_000_000), }; let (msg, _) = ch.process_envelope(&env).unwrap(); assert_eq!(msg.metadata["signal_target"], "group:mygroup"); assert_eq!(msg.metadata["signal_sender"], "+2222222222"); Ok(()) } // ── attachment-with-text tests ────────────────────────────────── #[test] fn process_envelope_attachment_with_text_not_skipped() -> Result<(), ChannelError> { // Even with ignore_attachments=true, messages that have BOTH text // and attachments should be processed (only attachment-only are skipped). let mut config = make_config(); config.allow_from = vec!["*".to_string()]; config.ignore_attachments = true; let ch = SignalChannel::new(config)?; let env = Envelope { source: Some("+1111111111".to_string()), source_number: Some("+1111111111".to_string()), source_name: None, source_uuid: None, data_message: Some(DataMessage { message: Some("Check this out".to_string()), timestamp: Some(1_700_000_000_000), group_info: None, attachments: Some(vec![serde_json::json!({"contentType": "image/png"})]), }), story_message: None, timestamp: Some(1_700_000_000_000), }; let result = ch.process_envelope(&env); assert!( result.is_some(), "Message with text + attachment should not be skipped" ); let (msg, _) = result.unwrap(); assert_eq!(msg.content, "Check this out"); Ok(()) } #[test] fn process_envelope_attachment_only_not_skipped_when_ignore_disabled() -> Result<(), ChannelError> { // With ignore_attachments=false, attachment-only messages should be // processed with the "[Attachment]" placeholder text. let mut config = make_config(); config.allow_from = vec!["*".to_string()]; config.ignore_attachments = false; let ch = SignalChannel::new(config)?; let env = Envelope { source: Some("+1111111111".to_string()), source_number: Some("+1111111111".to_string()), source_name: None, source_uuid: None, data_message: Some(DataMessage { message: None, timestamp: Some(1_700_000_000_000), group_info: None, attachments: Some(vec![serde_json::json!({"contentType": "image/png"})]), }), story_message: None, timestamp: Some(1_700_000_000_000), }; // With ignore_attachments=false, attachment-only messages are now // processed with a placeholder "[Attachment]" text. let result = ch.process_envelope(&env); assert!( result.is_some(), "Attachment-only should be processed when ignore_attachments=false" ); let (msg, _) = result.unwrap(); assert_eq!(msg.content, "[Attachment]"); Ok(()) } // ── source_name / display name tests ──────────────────────────── #[test] fn process_envelope_source_name_sets_user_name() -> Result<(), ChannelError> { let mut config = make_config(); config.allow_from = vec!["*".to_string()]; let ch = SignalChannel::new(config)?; let env = Envelope { source: Some("+3333333333".to_string()), source_number: Some("+3333333333".to_string()), source_name: Some("Alice".to_string()), source_uuid: None, data_message: Some(DataMessage { message: Some("Hey".to_string()), timestamp: Some(1_700_000_000_000), group_info: None, attachments: None, }), story_message: None, timestamp: Some(1_700_000_000_000), }; let (msg, _) = ch.process_envelope(&env).unwrap(); assert_eq!(msg.user_name.as_deref(), Some("Alice")); Ok(()) } #[test] fn process_envelope_empty_source_name_not_set() -> Result<(), ChannelError> { let mut config = make_config(); config.allow_from = vec!["*".to_string()]; let ch = SignalChannel::new(config)?; let env = Envelope { source: Some("+3333333333".to_string()), source_number: Some("+3333333333".to_string()), source_name: Some("".to_string()), source_uuid: None, data_message: Some(DataMessage { message: Some("Hey".to_string()), timestamp: Some(1_700_000_000_000), group_info: None, attachments: None, }), story_message: None, timestamp: Some(1_700_000_000_000), }; let (msg, _) = ch.process_envelope(&env).unwrap(); assert!( msg.user_name.is_none(), "Empty source_name should not set user_name" ); Ok(()) } #[test] fn process_envelope_no_source_name_not_set() -> Result<(), ChannelError> { let ch = make_channel()?; let env = make_envelope(Some("+1111111111"), Some("hi")); let (msg, _) = ch.process_envelope(&env).unwrap(); assert!(msg.user_name.is_none()); Ok(()) } // ── thread_id tests ───────────────────────────────────────────────────────────────── #[test] fn process_envelope_dm_sets_thread_id_to_uuid() -> Result<(), ChannelError> { let ch = make_channel()?; let env = make_envelope(Some("+1111111111"), Some("DM")); let (msg, _) = ch.process_envelope(&env).unwrap(); // DMs now set thread_id to a deterministic UUID derived from phone number let expected_thread_id = SignalChannel::thread_id_from_identifier("+1111111111"); assert_eq!( msg.thread_id, Some(expected_thread_id), "DMs should set thread_id to UUID" ); Ok(()) } #[test] fn process_envelope_group_sets_thread_id_to_uuid() -> Result<(), ChannelError> { let mut config = make_config(); config.allow_from = vec!["*".to_string()]; config.allow_from_groups = vec!["*".to_string()]; config.group_policy = "allowlist".to_string(); let ch = SignalChannel::new(config)?; let env = Envelope { source: Some("+1111111111".to_string()), source_number: Some("+1111111111".to_string()), source_name: None, source_uuid: None, data_message: Some(DataMessage { message: Some("Group msg".to_string()), timestamp: Some(1_700_000_000_000), group_info: Some(GroupInfo { group_id: Some("grp999".to_string()), }), attachments: None, }), story_message: None, timestamp: Some(1_700_000_000_000), }; let (msg, _) = ch.process_envelope(&env).unwrap(); // Groups now set thread_id to a deterministic UUID derived from group ID let expected_thread_id = SignalChannel::thread_id_from_identifier("group:grp999"); assert_eq!( msg.thread_id, Some(expected_thread_id), "Groups should set thread_id to UUID" ); Ok(()) } // ── timestamp edge cases ──────────────────────────────────────── #[test] fn process_envelope_uses_data_message_timestamp() -> Result<(), ChannelError> { let mut config = make_config(); config.allow_from = vec!["*".to_string()]; let ch = SignalChannel::new(config)?; let env = Envelope { source: Some("+1111111111".to_string()), source_number: Some("+1111111111".to_string()), source_name: None, source_uuid: None, data_message: Some(DataMessage { message: Some("hi".to_string()), timestamp: Some(9999), group_info: None, attachments: None, }), story_message: None, timestamp: Some(1111), }; let (msg, _) = ch.process_envelope(&env).unwrap(); // data_message timestamp takes priority. assert_eq!(msg.metadata["signal_timestamp"], 9999); Ok(()) } #[test] fn process_envelope_falls_back_to_envelope_timestamp() -> Result<(), ChannelError> { let mut config = make_config(); config.allow_from = vec!["*".to_string()]; let ch = SignalChannel::new(config)?; let env = Envelope { source: Some("+1111111111".to_string()), source_number: Some("+1111111111".to_string()), source_name: None, source_uuid: None, data_message: Some(DataMessage { message: Some("hi".to_string()), timestamp: None, group_info: None, attachments: None, }), story_message: None, timestamp: Some(7777), }; let (msg, _) = ch.process_envelope(&env).unwrap(); assert_eq!(msg.metadata["signal_timestamp"], 7777); Ok(()) } #[test] fn process_envelope_generates_timestamp_when_missing() -> Result<(), ChannelError> { let mut config = make_config(); config.allow_from = vec!["*".to_string()]; let ch = SignalChannel::new(config)?; let env = Envelope { source: Some("+1111111111".to_string()), source_number: Some("+1111111111".to_string()), source_name: None, source_uuid: None, data_message: Some(DataMessage { message: Some("hi".to_string()), timestamp: None, group_info: None, attachments: None, }), story_message: None, timestamp: None, }; let (msg, _) = ch.process_envelope(&env).unwrap(); // Should generate a timestamp (current time in millis), just verify it's positive. let ts = msg.metadata["signal_timestamp"].as_u64().unwrap(); assert!(ts > 0, "Generated timestamp should be positive"); Ok(()) } // ── SSE envelope deserialization edge cases ───────────────────── #[test] fn sse_envelope_missing_envelope_field() { let json = r#"{"account": "+1234567890"}"#; let sse: SseEnvelope = serde_json::from_str(json).unwrap(); assert!(sse.envelope.is_none()); } #[test] fn sse_envelope_with_story_message() { let json = r#"{ "envelope": { "sourceNumber": "+1111111111", "storyMessage": {"allowsReplies": true}, "dataMessage": { "message": "story text" } } }"#; let sse: SseEnvelope = serde_json::from_str(json).unwrap(); let env = sse.envelope.unwrap(); assert!(env.story_message.is_some()); assert!(env.data_message.is_some()); } #[test] fn sse_envelope_with_attachments() { let json = r#"{ "envelope": { "sourceNumber": "+1111111111", "dataMessage": { "message": "See attached", "attachments": [ {"contentType": "image/jpeg", "filename": "photo.jpg"}, {"contentType": "application/pdf"} ] } } }"#; let sse: SseEnvelope = serde_json::from_str(json).unwrap(); let dm = sse.envelope.unwrap().data_message.unwrap(); let attachments = dm.attachments.unwrap(); assert_eq!(attachments.len(), 2); } // ── is_e164 tests ─────────────────────────────────────────────── #[test] fn is_e164_valid_numbers() { assert!(SignalChannel::is_e164("+12345678901")); assert!(SignalChannel::is_e164("+1234567")); // min 7 digits after + assert!(SignalChannel::is_e164("+123456789012345")); // max 15 digits } #[test] fn is_e164_invalid_numbers() { assert!(!SignalChannel::is_e164("12345678901")); // no + assert!(!SignalChannel::is_e164("+1")); // too short (1 digit) assert!(!SignalChannel::is_e164("+1234567890123456")); // too long (16 digits) assert!(!SignalChannel::is_e164("+abc123")); // non-digit assert!(!SignalChannel::is_e164("")); // empty assert!(!SignalChannel::is_e164("+")); // plus only } // ── config edge cases ─────────────────────────────────────────── #[test] fn multiple_allow_from() -> Result<(), ChannelError> { let mut config = make_config(); config.allow_from = vec![ "+1111111111".to_string(), "+2222222222".to_string(), "a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(), ]; let ch = SignalChannel::new(config)?; assert!(ch.is_sender_allowed("+1111111111")); assert!(ch.is_sender_allowed("+2222222222")); assert!(ch.is_sender_allowed("a1b2c3d4-e5f6-7890-abcd-ef1234567890")); assert!(!ch.is_sender_allowed("+9999999999")); Ok(()) } #[test] fn multiple_allow_from_groups() -> Result<(), ChannelError> { let mut config = make_config(); config.allow_from_groups = vec!["group_a".to_string(), "group_b".to_string()]; let ch = SignalChannel::new(config)?; assert!(ch.is_group_allowed("group_a")); assert!(ch.is_group_allowed("group_b")); assert!(!ch.is_group_allowed("group_c")); Ok(()) } #[test] fn uuid_prefix_normalization_in_allowlist() -> Result<(), ChannelError> { let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; let mut config = make_config(); config.allow_from = vec![format!("uuid:{uuid}"), "+1111111111".to_string()]; let ch = SignalChannel::new(config)?; // uuid:-prefixed entry should match bare UUID sender. assert!(ch.is_sender_allowed(uuid)); // Phone numbers still work alongside UUID entries. assert!(ch.is_sender_allowed("+1111111111")); // Non-matching should fail. assert!(!ch.is_sender_allowed("+9999999999")); Ok(()) } // ── stories behavior tests ────────────────────────────────────── #[test] fn process_envelope_stories_not_skipped_when_disabled() -> Result<(), ChannelError> { // With ignore_stories=false, story messages with a data_message // should still be processed. let mut config = make_config(); config.allow_from = vec!["*".to_string()]; config.ignore_stories = false; let ch = SignalChannel::new(config)?; let env = Envelope { source: Some("+1111111111".to_string()), source_number: Some("+1111111111".to_string()), source_name: None, source_uuid: None, data_message: Some(DataMessage { message: Some("story with text".to_string()), timestamp: Some(1_700_000_000_000), group_info: None, attachments: None, }), story_message: Some(serde_json::json!({})), timestamp: Some(1_700_000_000_000), }; let result = ch.process_envelope(&env); assert!( result.is_some(), "Stories should not be skipped when ignore_stories=false" ); Ok(()) } // ── trailing slash variations ─────────────────────────────────── #[test] fn strips_multiple_trailing_slashes() -> Result<(), ChannelError> { let mut config = make_config(); config.http_url = "http://127.0.0.1:8686///".to_string(); let ch = SignalChannel::new(config)?; assert_eq!(ch.config.http_url, "http://127.0.0.1:8686"); Ok(()) } #[test] fn preserves_url_without_trailing_slash() -> Result<(), ChannelError> { let config = make_config(); let ch = SignalChannel::new(config)?; assert_eq!(ch.config.http_url, "http://127.0.0.1:8686"); Ok(()) } // ── attachment path validation ─────────────────────────────────── #[test] fn validate_attachment_paths_rejects_double_dot() { let paths = vec!["../etc/passwd".to_string()]; let result = SignalChannel::validate_attachment_paths(&paths); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains("forbidden") || err.contains("sandbox")); } #[test] fn validate_attachment_paths_accepts_normal_paths() { use std::fs; // Create test files in sandbox let base_dir = crate::bootstrap::ironclaw_base_dir(); // Create sandbox directory if it doesn't exist (needed for CI) let _ = fs::create_dir_all(&base_dir); let temp_dir = tempfile::tempdir_in(&base_dir).unwrap(); let file1 = temp_dir.path().join("file.txt"); let file2 = temp_dir.path().join("report.pdf"); fs::write(&file1, "test").unwrap(); fs::write(&file2, "test").unwrap(); let paths = vec![ file1.to_string_lossy().to_string(), file2.to_string_lossy().to_string(), ]; let result = SignalChannel::validate_attachment_paths(&paths); assert!(result.is_ok()); } #[test] fn validate_attachment_paths_rejects_nested_traversal() { let paths = vec!["foo/../bar/../../secret.txt".to_string()]; let result = SignalChannel::validate_attachment_paths(&paths); assert!(result.is_err()); } #[test] fn validate_attachment_paths_empty_ok() { let paths: Vec = vec![]; let result = SignalChannel::validate_attachment_paths(&paths); assert!(result.is_ok()); } #[test] fn validate_attachment_paths_rejects_path_outside_sandbox() { let paths = vec!["/tmp/evil.txt".to_string()]; let result = SignalChannel::validate_attachment_paths(&paths); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains("sandbox")); } #[test] fn validate_attachment_paths_rejects_url_encoded_traversal() { let paths = vec!["%2e%2e%2fetc/passwd".to_string()]; let result = SignalChannel::validate_attachment_paths(&paths); assert!(result.is_err()); } #[test] fn validate_attachment_paths_rejects_null_byte() { let paths = vec!["file\0.txt".to_string()]; let result = SignalChannel::validate_attachment_paths(&paths); assert!(result.is_err()); } // ── conversation context ─────────────────────────────────────────── #[test] fn conversation_context_extracts_sender() { let ch = SignalChannel::new(make_config()).unwrap(); let metadata = serde_json::json!({ "signal_sender": "+1234567890", "signal_sender_uuid": "uuid-123", "signal_target": "+0987654321" }); let ctx = ch.conversation_context(&metadata); assert_eq!(ctx.get("sender"), Some(&"+1234567890".to_string())); assert_eq!(ctx.get("sender_uuid"), Some(&"uuid-123".to_string())); assert!(!ctx.contains_key("group")); } #[test] fn conversation_context_extracts_group() { let ch = SignalChannel::new(make_config()).unwrap(); let metadata = serde_json::json!({ "signal_sender": "+1234567890", "signal_target": "group:mygroup" }); let ctx = ch.conversation_context(&metadata); assert_eq!(ctx.get("sender"), Some(&"+1234567890".to_string())); assert_eq!(ctx.get("group"), Some(&"group:mygroup".to_string())); } #[test] fn conversation_context_empty_for_unknown_channel() { let ch = SignalChannel::new(make_config()).unwrap(); let metadata = serde_json::json!({ "unknown_key": "value" }); let ctx = ch.conversation_context(&metadata); assert!(ctx.is_empty()); } }