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) = match &evt
231 .params
232 .download_canceled_params_download_complete_params_union
233 {
234 DownloadUnion::DownloadCompleteParams(c) => (
235 c.base_navigation_info.context.inner().to_string(),
236 c.base_navigation_info.url.clone(),
237 "complete".to_string(),
238 c.filepath.clone(),
239 ),
240 DownloadUnion::DownloadCanceledParams(c) => (
241 c.base_navigation_info.context.inner().to_string(),
242 c.base_navigation_info.url.clone(),
243 "canceled".to_string(),
244 None,
245 ),
246 };
247 let mut inner = self.inner.write().await;
248 if let Some(d) = inner
249 .downloads
250 .iter_mut()
251 .rev()
252 .find(|d| d.context == ctx && d.url == url && d.status == "will-begin")
253 {
254 d.status = status;
255 d.filepath = filepath;
256 return;
257 }
258 inner.push_download(CapturedDownload {
259 context: ctx,
260 suggested_filename: String::new(),
261 url,
262 status,
263 filepath,
264 });
265 }
266}
267
268pub fn make_dialog_handler(
271 log: DialogLog,
272) -> impl FnMut(Event) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
273 use rustenium_bidi_definitions::browsing_context::events::BrowsingContextEvent as BCE;
274 move |evt| {
275 let log = log.clone();
276 Box::pin(async move {
277 if let Event::BrowsingContext(bce) = evt {
278 match bce {
279 BCE::UserPromptOpened(e) => log.ingest_opened(&e).await,
280 BCE::UserPromptClosed(e) => log.ingest_closed(&e).await,
281 BCE::DownloadWillBegin(e) => log.ingest_download_begin(&e).await,
282 BCE::DownloadEnd(e) => log.ingest_download_end(&e).await,
283 _ => {}
284 }
285 }
286 })
287 }
288}
289
290pub const DIALOG_EVENTS: &[&str] = &[
292 "browsingContext.userPromptOpened",
293 "browsingContext.userPromptClosed",
294 "browsingContext.downloadWillBegin",
295 "browsingContext.downloadEnd",
296];
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use rustenium_bidi_definitions::browsing_context::events::{
302 UserPromptClosedMethod, UserPromptClosedParams, UserPromptOpenedMethod,
303 UserPromptOpenedParams,
304 };
305 use rustenium_bidi_definitions::browsing_context::types::BrowsingContext;
306
307 fn opened(ctx: &str, kind: UserPromptType, message: &str) -> UserPromptOpened {
308 UserPromptOpened {
309 method: UserPromptOpenedMethod::UserPromptOpened,
310 params: UserPromptOpenedParams {
311 context: BrowsingContext::new(ctx),
312 handler: UserPromptHandlerType::Ignore,
313 message: message.to_string(),
314 r#type: kind,
315 default_value: None,
316 },
317 }
318 }
319
320 fn closed(
321 ctx: &str,
322 kind: UserPromptType,
323 accepted: bool,
324 text: Option<&str>,
325 ) -> UserPromptClosed {
326 UserPromptClosed {
327 method: UserPromptClosedMethod::UserPromptClosed,
328 params: UserPromptClosedParams {
329 context: BrowsingContext::new(ctx),
330 accepted,
331 r#type: kind,
332 user_text: text.map(str::to_string),
333 },
334 }
335 }
336
337 #[tokio::test]
338 async fn captures_alert_message_for_xss_evidence() {
339 let log = DialogLog::new();
340 log.ingest_opened(&opened("ctx-1", UserPromptType::Alert, "1"))
341 .await;
342 let dialogs = log.dialogs().await;
343 assert_eq!(dialogs.len(), 1);
344 assert_eq!(dialogs[0].kind, "alert");
345 assert_eq!(dialogs[0].message, "1");
346 assert_eq!(dialogs[0].handler, "ignore");
347 assert_eq!(dialogs[0].accepted, None);
348 }
349
350 #[tokio::test]
351 async fn close_finalizes_matching_open_dialog() {
352 let log = DialogLog::new();
353 log.ingest_opened(&opened("ctx-1", UserPromptType::Prompt, "name?"))
354 .await;
355 assert_eq!(log.open_dialogs().await.len(), 1);
356 log.ingest_closed(&closed(
357 "ctx-1",
358 UserPromptType::Prompt,
359 true,
360 Some("admin"),
361 ))
362 .await;
363 let dialogs = log.dialogs().await;
364 assert_eq!(dialogs.len(), 1, "close updates, does not append");
365 assert_eq!(dialogs[0].accepted, Some(true));
366 assert_eq!(dialogs[0].user_text.as_deref(), Some("admin"));
367 assert!(log.open_dialogs().await.is_empty());
368 }
369
370 #[tokio::test]
371 async fn close_without_open_pushes_standalone() {
372 let log = DialogLog::new();
373 log.ingest_closed(&closed("ctx-9", UserPromptType::Confirm, false, None))
374 .await;
375 let dialogs = log.dialogs().await;
376 assert_eq!(dialogs.len(), 1);
377 assert_eq!(dialogs[0].accepted, Some(false));
378 }
379
380 #[tokio::test]
381 async fn dialogs_are_bounded() {
382 let log = DialogLog::new();
383 for i in 0..(MAX_ENTRIES + 50) {
384 log.ingest_opened(&opened("ctx", UserPromptType::Alert, &i.to_string()))
385 .await;
386 }
387 assert_eq!(log.dialog_count().await, MAX_ENTRIES);
388 let last = log.last_dialog().await.unwrap();
390 assert_eq!(last.message, (MAX_ENTRIES + 49).to_string());
391 }
392
393 #[test]
394 fn prompt_kind_maps_all_variants() {
395 assert_eq!(prompt_kind(&UserPromptType::Alert), "alert");
396 assert_eq!(prompt_kind(&UserPromptType::Beforeunload), "beforeunload");
397 assert_eq!(prompt_kind(&UserPromptType::Confirm), "confirm");
398 assert_eq!(prompt_kind(&UserPromptType::Prompt), "prompt");
399 }
400
401 #[test]
402 fn handler_maps_all_variants() {
403 assert_eq!(handler_str(&UserPromptHandlerType::Accept), "accept");
404 assert_eq!(handler_str(&UserPromptHandlerType::Dismiss), "dismiss");
405 assert_eq!(handler_str(&UserPromptHandlerType::Ignore), "ignore");
406 assert_eq!(
407 handler_str(&UserPromptHandlerType::DismissAndNotify),
408 "dismiss and notify"
409 );
410 }
411}