jsdet-core 0.1.0

Core WASM-sandboxed JavaScript detonation engine
Documentation
use std::collections::HashMap;

use crate::observation::Value;

/// Identifies an execution context within a multi-context sandbox.
///
/// Chrome extensions have multiple contexts: background service worker,
/// content scripts (one per tab), popup, options page. Each gets its own
/// `QuickJS` WASM instance with isolated memory, but they can exchange
/// messages through the host-mediated message bus.
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct ContextId(pub String);

impl ContextId {
    #[must_use]
    pub fn background() -> Self {
        Self("background".into())
    }

    #[must_use]
    pub fn content_script(tab_id: u32) -> Self {
        Self(format!("content:{tab_id}"))
    }

    #[must_use]
    pub fn popup() -> Self {
        Self("popup".into())
    }

    #[must_use]
    pub fn options() -> Self {
        Self("options".into())
    }

    #[must_use]
    pub fn service_worker() -> Self {
        Self("service_worker".into())
    }

    #[must_use]
    pub fn custom(name: impl Into<String>) -> Self {
        Self(name.into())
    }
}

impl std::fmt::Display for ContextId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

/// A message passed between execution contexts.
///
/// All inter-context communication goes through the host message bus.
/// This makes every message observable and interceptable.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ContextMessage {
    pub from: ContextId,
    pub to: ContextId,
    pub payload: Value,
    /// Channel identifier for routing (e.g., port name, message type).
    pub channel: Option<String>,
}

/// Maximum total messages retained in the history ring buffer.
/// Prevents memory exhaustion from adversarial JS flooding `chrome.runtime.sendMessage`.
const MAX_MESSAGE_HISTORY: usize = 50_000;

/// Maximum pending messages per recipient context queue.
/// Prevents a single context from monopolizing memory.
const MAX_QUEUE_PER_CONTEXT: usize = 1_000;

/// The host-side message bus connecting execution contexts.
///
/// Messages are queued and delivered when the receiving context
/// runs its next execution pass. The bus records all messages
/// for the observation stream, up to a configurable cap.
#[derive(Debug, Default)]
pub struct MessageBus {
    /// Queued messages waiting for delivery, keyed by recipient context.
    queues: HashMap<ContextId, Vec<ContextMessage>>,
    /// All messages ever sent, in order. The observation stream.
    /// Capped at [`MAX_MESSAGE_HISTORY`]; oldest messages are discarded
    /// when the limit is reached.
    history: Vec<ContextMessage>,
}

impl MessageBus {
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Queue a message for delivery to the target context.
    ///
    /// Messages beyond the history or per-queue cap are silently discarded
    /// to prevent memory exhaustion from adversarial scripts.
    pub fn send(&mut self, message: ContextMessage) {
        if self.history.len() < MAX_MESSAGE_HISTORY {
            self.history.push(message.clone());
        }
        let queue = self.queues.entry(message.to.clone()).or_default();
        if queue.len() < MAX_QUEUE_PER_CONTEXT {
            queue.push(message);
        }
    }

    /// Drain all pending messages for a context.
    /// Called when the context starts its next execution pass.
    pub fn receive(&mut self, context: &ContextId) -> Vec<ContextMessage> {
        self.queues.remove(context).unwrap_or_default()
    }

    /// Return the full message history for observation.
    #[must_use]
    pub fn history(&self) -> &[ContextMessage] {
        &self.history
    }

    /// Whether any context has pending messages.
    #[must_use]
    pub fn has_pending(&self) -> bool {
        self.queues.values().any(|q| !q.is_empty())
    }

    /// Number of messages recorded in the history.
    #[must_use]
    pub fn history_len(&self) -> usize {
        self.history.len()
    }
}