1use std::sync::Arc;
24use tokio::sync::RwLock;
25
26use rustenium_bidi_definitions::browsing_context::events::{
27 DownloadEnd, DownloadWillBegin, UserPromptClosed, UserPromptOpened,
28};
29use rustenium_bidi_definitions::browsing_context::types::{
30 DownloadCanceledParamsDownloadCompleteParamsUnion as DownloadUnion, UserPromptType,
31};
32use rustenium_bidi_definitions::session::types::UserPromptHandlerType;
33use rustenium_bidi_definitions::Event;
34
35const MAX_ENTRIES: usize = 1000;
40
41#[derive(Debug, Clone, PartialEq, serde::Serialize)]
43pub struct CapturedDialog {
44 pub context: String,
46 pub kind: String,
48 pub message: String,
50 pub default_value: Option<String>,
52 pub handler: String,
55 pub accepted: Option<bool>,
58 pub user_text: Option<String>,
60}
61
62#[derive(Debug, Clone, PartialEq, serde::Serialize)]
64pub struct CapturedDownload {
65 pub context: String,
67 pub suggested_filename: String,
69 pub url: String,
71 pub status: String,
73 pub filepath: Option<String>,
75}
76
77#[derive(Default)]
78struct Inner {
79 dialogs: Vec<CapturedDialog>,
80 downloads: Vec<CapturedDownload>,
81}
82
83impl Inner {
84 fn push_dialog(&mut self, d: CapturedDialog) {
85 self.dialogs.push(d);
86 if self.dialogs.len() > MAX_ENTRIES {
87 let overflow = self.dialogs.len() - MAX_ENTRIES;
88 self.dialogs.drain(0..overflow);
89 }
90 }
91
92 fn push_download(&mut self, d: CapturedDownload) {
93 self.downloads.push(d);
94 if self.downloads.len() > MAX_ENTRIES {
95 let overflow = self.downloads.len() - MAX_ENTRIES;
96 self.downloads.drain(0..overflow);
97 }
98 }
99}
100
101#[derive(Clone, Default)]
103pub struct DialogLog {
104 inner: Arc<RwLock<Inner>>,
105}
106
107fn prompt_kind(t: &UserPromptType) -> &'static str {
109 match t {
110 UserPromptType::Alert => "alert",
111 UserPromptType::Beforeunload => "beforeunload",
112 UserPromptType::Confirm => "confirm",
113 UserPromptType::Prompt => "prompt",
114 }
115}
116
117fn handler_str(h: &UserPromptHandlerType) -> &'static str {
119 match h {
120 UserPromptHandlerType::Accept => "accept",
121 UserPromptHandlerType::Dismiss => "dismiss",
122 UserPromptHandlerType::Ignore => "ignore",
123 UserPromptHandlerType::DismissAndNotify => "dismiss and notify",
124 }
125}
126
127impl DialogLog {
128 pub fn new() -> Self {
129 Self::default()
130 }
131
132 pub async fn dialogs(&self) -> Vec<CapturedDialog> {
134 self.inner.read().await.dialogs.clone()
135 }
136
137 pub async fn downloads(&self) -> Vec<CapturedDownload> {
139 self.inner.read().await.downloads.clone()
140 }
141
142 pub async fn open_dialogs(&self) -> Vec<CapturedDialog> {
145 self.inner
146 .read()
147 .await
148 .dialogs
149 .iter()
150 .filter(|d| d.accepted.is_none())
151 .cloned()
152 .collect()
153 }
154
155 pub async fn last_dialog(&self) -> Option<CapturedDialog> {
157 self.inner.read().await.dialogs.last().cloned()
158 }
159
160 pub async fn dialog_count(&self) -> usize {
162 self.inner.read().await.dialogs.len()
163 }
164
165 pub async fn clear(&self) {
167 let mut inner = self.inner.write().await;
168 inner.dialogs.clear();
169 inner.downloads.clear();
170 }
171
172 pub async fn ingest_opened(&self, evt: &UserPromptOpened) {
174 let p = &evt.params;
175 let dialog = CapturedDialog {
176 context: p.context.inner().to_string(),
177 kind: prompt_kind(&p.r#type).to_string(),
178 message: p.message.clone(),
179 default_value: p.default_value.clone(),
180 handler: handler_str(&p.handler).to_string(),
181 accepted: None,
182 user_text: None,
183 };
184 self.inner.write().await.push_dialog(dialog);
185 }
186
187 pub async fn ingest_closed(&self, evt: &UserPromptClosed) {
191 let p = &evt.params;
192 let ctx = p.context.inner().to_string();
193 let mut inner = self.inner.write().await;
194 if let Some(d) = inner
195 .dialogs
196 .iter_mut()
197 .rev()
198 .find(|d| d.context == ctx && d.accepted.is_none())
199 {
200 d.accepted = Some(p.accepted);
201 d.user_text = p.user_text.clone();
202 return;
203 }
204 inner.push_dialog(CapturedDialog {
205 context: ctx,
206 kind: prompt_kind(&p.r#type).to_string(),
207 message: String::new(),
208 default_value: None,
209 handler: String::new(),
210 accepted: Some(p.accepted),
211 user_text: p.user_text.clone(),
212 });
213 }
214
215 pub async fn ingest_download_begin(&self, evt: &DownloadWillBegin) {
217 let p = &evt.params;
218 self.inner.write().await.push_download(CapturedDownload {
219 context: p.base_navigation_info.context.inner().to_string(),
220 suggested_filename: p.suggested_filename.clone(),
221 url: p.base_navigation_info.url.clone(),
222 status: "will-begin".to_string(),
223 filepath: None,
224 });
225 }
226
227 pub async fn ingest_download_end(&self, evt: &DownloadEnd) {
230 let (ctx, url, status, filepath) =
231 match &evt.params.download_canceled_params_download_complete_params_union {
232 DownloadUnion::DownloadCompleteParams(c) => (
233 c.base_navigation_info.context.inner().to_string(),
234 c.base_navigation_info.url.clone(),
235 "complete".to_string(),
236 c.filepath.clone(),
237 ),
238 DownloadUnion::DownloadCanceledParams(c) => (
239 c.base_navigation_info.context.inner().to_string(),
240 c.base_navigation_info.url.clone(),
241 "canceled".to_string(),
242 None,
243 ),
244 };
245 let mut inner = self.inner.write().await;
246 if let Some(d) = inner
247 .downloads
248 .iter_mut()
249 .rev()
250 .find(|d| d.context == ctx && d.url == url && d.status == "will-begin")
251 {
252 d.status = status;
253 d.filepath = filepath;
254 return;
255 }
256 inner.push_download(CapturedDownload {
257 context: ctx,
258 suggested_filename: String::new(),
259 url,
260 status,
261 filepath,
262 });
263 }
264}
265
266pub fn make_dialog_handler(
269 log: DialogLog,
270) -> impl FnMut(Event) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
271 use rustenium_bidi_definitions::browsing_context::events::BrowsingContextEvent as BCE;
272 move |evt| {
273 let log = log.clone();
274 Box::pin(async move {
275 if let Event::BrowsingContext(bce) = evt {
276 match bce {
277 BCE::UserPromptOpened(e) => log.ingest_opened(&e).await,
278 BCE::UserPromptClosed(e) => log.ingest_closed(&e).await,
279 BCE::DownloadWillBegin(e) => log.ingest_download_begin(&e).await,
280 BCE::DownloadEnd(e) => log.ingest_download_end(&e).await,
281 _ => {}
282 }
283 }
284 })
285 }
286}
287
288pub const DIALOG_EVENTS: &[&str] = &[
290 "browsingContext.userPromptOpened",
291 "browsingContext.userPromptClosed",
292 "browsingContext.downloadWillBegin",
293 "browsingContext.downloadEnd",
294];
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use rustenium_bidi_definitions::browsing_context::events::{
300 UserPromptClosedMethod, UserPromptClosedParams, UserPromptOpenedMethod,
301 UserPromptOpenedParams,
302 };
303 use rustenium_bidi_definitions::browsing_context::types::BrowsingContext;
304
305 fn opened(ctx: &str, kind: UserPromptType, message: &str) -> UserPromptOpened {
306 UserPromptOpened {
307 method: UserPromptOpenedMethod::UserPromptOpened,
308 params: UserPromptOpenedParams {
309 context: BrowsingContext::new(ctx),
310 handler: UserPromptHandlerType::Ignore,
311 message: message.to_string(),
312 r#type: kind,
313 default_value: None,
314 },
315 }
316 }
317
318 fn closed(ctx: &str, kind: UserPromptType, accepted: bool, text: Option<&str>) -> UserPromptClosed {
319 UserPromptClosed {
320 method: UserPromptClosedMethod::UserPromptClosed,
321 params: UserPromptClosedParams {
322 context: BrowsingContext::new(ctx),
323 accepted,
324 r#type: kind,
325 user_text: text.map(str::to_string),
326 },
327 }
328 }
329
330 #[tokio::test]
331 async fn captures_alert_message_for_xss_evidence() {
332 let log = DialogLog::new();
333 log.ingest_opened(&opened("ctx-1", UserPromptType::Alert, "1"))
334 .await;
335 let dialogs = log.dialogs().await;
336 assert_eq!(dialogs.len(), 1);
337 assert_eq!(dialogs[0].kind, "alert");
338 assert_eq!(dialogs[0].message, "1");
339 assert_eq!(dialogs[0].handler, "ignore");
340 assert_eq!(dialogs[0].accepted, None);
341 }
342
343 #[tokio::test]
344 async fn close_finalizes_matching_open_dialog() {
345 let log = DialogLog::new();
346 log.ingest_opened(&opened("ctx-1", UserPromptType::Prompt, "name?"))
347 .await;
348 assert_eq!(log.open_dialogs().await.len(), 1);
349 log.ingest_closed(&closed("ctx-1", UserPromptType::Prompt, true, Some("admin")))
350 .await;
351 let dialogs = log.dialogs().await;
352 assert_eq!(dialogs.len(), 1, "close updates, does not append");
353 assert_eq!(dialogs[0].accepted, Some(true));
354 assert_eq!(dialogs[0].user_text.as_deref(), Some("admin"));
355 assert!(log.open_dialogs().await.is_empty());
356 }
357
358 #[tokio::test]
359 async fn close_without_open_pushes_standalone() {
360 let log = DialogLog::new();
361 log.ingest_closed(&closed("ctx-9", UserPromptType::Confirm, false, None))
362 .await;
363 let dialogs = log.dialogs().await;
364 assert_eq!(dialogs.len(), 1);
365 assert_eq!(dialogs[0].accepted, Some(false));
366 }
367
368 #[tokio::test]
369 async fn dialogs_are_bounded() {
370 let log = DialogLog::new();
371 for i in 0..(MAX_ENTRIES + 50) {
372 log.ingest_opened(&opened("ctx", UserPromptType::Alert, &i.to_string()))
373 .await;
374 }
375 assert_eq!(log.dialog_count().await, MAX_ENTRIES);
376 let last = log.last_dialog().await.unwrap();
378 assert_eq!(last.message, (MAX_ENTRIES + 49).to_string());
379 }
380
381 #[test]
382 fn prompt_kind_maps_all_variants() {
383 assert_eq!(prompt_kind(&UserPromptType::Alert), "alert");
384 assert_eq!(prompt_kind(&UserPromptType::Beforeunload), "beforeunload");
385 assert_eq!(prompt_kind(&UserPromptType::Confirm), "confirm");
386 assert_eq!(prompt_kind(&UserPromptType::Prompt), "prompt");
387 }
388
389 #[test]
390 fn handler_maps_all_variants() {
391 assert_eq!(handler_str(&UserPromptHandlerType::Accept), "accept");
392 assert_eq!(handler_str(&UserPromptHandlerType::Dismiss), "dismiss");
393 assert_eq!(handler_str(&UserPromptHandlerType::Ignore), "ignore");
394 assert_eq!(
395 handler_str(&UserPromptHandlerType::DismissAndNotify),
396 "dismiss and notify"
397 );
398 }
399}