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