jsdet_core/context.rs
1use std::collections::HashMap;
2
3use crate::observation::Value;
4
5/// Identifies an execution context within a multi-context sandbox.
6///
7/// Chrome extensions have multiple contexts: background service worker,
8/// content scripts (one per tab), popup, options page. Each gets its own
9/// `QuickJS` WASM instance with isolated memory, but they can exchange
10/// messages through the host-mediated message bus.
11#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
12pub struct ContextId(pub String);
13
14impl ContextId {
15 #[must_use]
16 pub fn background() -> Self {
17 Self("background".into())
18 }
19
20 #[must_use]
21 pub fn content_script(tab_id: u32) -> Self {
22 Self(format!("content:{tab_id}"))
23 }
24
25 #[must_use]
26 pub fn popup() -> Self {
27 Self("popup".into())
28 }
29
30 #[must_use]
31 pub fn options() -> Self {
32 Self("options".into())
33 }
34
35 #[must_use]
36 pub fn service_worker() -> Self {
37 Self("service_worker".into())
38 }
39
40 #[must_use]
41 pub fn custom(name: impl Into<String>) -> Self {
42 Self(name.into())
43 }
44}
45
46impl std::fmt::Display for ContextId {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 write!(f, "{}", self.0)
49 }
50}
51
52/// A message passed between execution contexts.
53///
54/// All inter-context communication goes through the host message bus.
55/// This makes every message observable and interceptable.
56#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
57pub struct ContextMessage {
58 pub from: ContextId,
59 pub to: ContextId,
60 pub payload: Value,
61 /// Channel identifier for routing (e.g., port name, message type).
62 pub channel: Option<String>,
63}
64
65/// Maximum total messages retained in the history ring buffer.
66/// Prevents memory exhaustion from adversarial JS flooding `chrome.runtime.sendMessage`.
67const MAX_MESSAGE_HISTORY: usize = 50_000;
68
69/// Maximum pending messages per recipient context queue.
70/// Prevents a single context from monopolizing memory.
71const MAX_QUEUE_PER_CONTEXT: usize = 1_000;
72
73/// The host-side message bus connecting execution contexts.
74///
75/// Messages are queued and delivered when the receiving context
76/// runs its next execution pass. The bus records all messages
77/// for the observation stream, up to a configurable cap.
78#[derive(Debug, Default)]
79pub struct MessageBus {
80 /// Queued messages waiting for delivery, keyed by recipient context.
81 queues: HashMap<ContextId, Vec<ContextMessage>>,
82 /// All messages ever sent, in order. The observation stream.
83 /// Capped at [`MAX_MESSAGE_HISTORY`]; oldest messages are discarded
84 /// when the limit is reached.
85 history: Vec<ContextMessage>,
86}
87
88impl MessageBus {
89 #[must_use]
90 pub fn new() -> Self {
91 Self::default()
92 }
93
94 /// Queue a message for delivery to the target context.
95 ///
96 /// Messages beyond the history or per-queue cap are silently discarded
97 /// to prevent memory exhaustion from adversarial scripts.
98 pub fn send(&mut self, message: ContextMessage) {
99 if self.history.len() < MAX_MESSAGE_HISTORY {
100 self.history.push(message.clone());
101 }
102 let queue = self.queues.entry(message.to.clone()).or_default();
103 if queue.len() < MAX_QUEUE_PER_CONTEXT {
104 queue.push(message);
105 }
106 }
107
108 /// Drain all pending messages for a context.
109 /// Called when the context starts its next execution pass.
110 pub fn receive(&mut self, context: &ContextId) -> Vec<ContextMessage> {
111 self.queues.remove(context).unwrap_or_default()
112 }
113
114 /// Return the full message history for observation.
115 #[must_use]
116 pub fn history(&self) -> &[ContextMessage] {
117 &self.history
118 }
119
120 /// Whether any context has pending messages.
121 #[must_use]
122 pub fn has_pending(&self) -> bool {
123 self.queues.values().any(|q| !q.is_empty())
124 }
125
126 /// Number of messages recorded in the history.
127 #[must_use]
128 pub fn history_len(&self) -> usize {
129 self.history.len()
130 }
131}