use std::sync::Arc;
use tokio::sync::RwLock;
use rustenium_bidi_definitions::browsing_context::events::{
DownloadEnd, DownloadWillBegin, UserPromptClosed, UserPromptOpened,
};
use rustenium_bidi_definitions::browsing_context::types::{
DownloadCanceledParamsDownloadCompleteParamsUnion as DownloadUnion, UserPromptType,
};
use rustenium_bidi_definitions::session::types::UserPromptHandlerType;
use rustenium_bidi_definitions::Event;
const MAX_ENTRIES: usize = 1000;
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct CapturedDialog {
pub context: String,
pub kind: String,
pub message: String,
pub default_value: Option<String>,
pub handler: String,
pub accepted: Option<bool>,
pub user_text: Option<String>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct CapturedDownload {
pub context: String,
pub suggested_filename: String,
pub url: String,
pub status: String,
pub filepath: Option<String>,
}
#[derive(Default)]
struct Inner {
dialogs: Vec<CapturedDialog>,
downloads: Vec<CapturedDownload>,
}
impl Inner {
fn push_dialog(&mut self, d: CapturedDialog) {
self.dialogs.push(d);
if self.dialogs.len() > MAX_ENTRIES {
let overflow = self.dialogs.len() - MAX_ENTRIES;
self.dialogs.drain(0..overflow);
}
}
fn push_download(&mut self, d: CapturedDownload) {
self.downloads.push(d);
if self.downloads.len() > MAX_ENTRIES {
let overflow = self.downloads.len() - MAX_ENTRIES;
self.downloads.drain(0..overflow);
}
}
}
#[derive(Clone, Default)]
pub struct DialogLog {
inner: Arc<RwLock<Inner>>,
}
fn prompt_kind(t: &UserPromptType) -> &'static str {
match t {
UserPromptType::Alert => "alert",
UserPromptType::Beforeunload => "beforeunload",
UserPromptType::Confirm => "confirm",
UserPromptType::Prompt => "prompt",
}
}
fn handler_str(h: &UserPromptHandlerType) -> &'static str {
match h {
UserPromptHandlerType::Accept => "accept",
UserPromptHandlerType::Dismiss => "dismiss",
UserPromptHandlerType::Ignore => "ignore",
UserPromptHandlerType::DismissAndNotify => "dismiss and notify",
}
}
impl DialogLog {
pub fn new() -> Self {
Self::default()
}
pub async fn dialogs(&self) -> Vec<CapturedDialog> {
self.inner.read().await.dialogs.clone()
}
pub async fn downloads(&self) -> Vec<CapturedDownload> {
self.inner.read().await.downloads.clone()
}
pub async fn open_dialogs(&self) -> Vec<CapturedDialog> {
self.inner
.read()
.await
.dialogs
.iter()
.filter(|d| d.accepted.is_none())
.cloned()
.collect()
}
pub async fn last_dialog(&self) -> Option<CapturedDialog> {
self.inner.read().await.dialogs.last().cloned()
}
pub async fn dialog_count(&self) -> usize {
self.inner.read().await.dialogs.len()
}
pub async fn clear(&self) {
let mut inner = self.inner.write().await;
inner.dialogs.clear();
inner.downloads.clear();
}
pub async fn ingest_opened(&self, evt: &UserPromptOpened) {
let p = &evt.params;
let dialog = CapturedDialog {
context: p.context.inner().to_string(),
kind: prompt_kind(&p.r#type).to_string(),
message: p.message.clone(),
default_value: p.default_value.clone(),
handler: handler_str(&p.handler).to_string(),
accepted: None,
user_text: None,
};
self.inner.write().await.push_dialog(dialog);
}
pub async fn ingest_closed(&self, evt: &UserPromptClosed) {
let p = &evt.params;
let ctx = p.context.inner().to_string();
let mut inner = self.inner.write().await;
if let Some(d) = inner
.dialogs
.iter_mut()
.rev()
.find(|d| d.context == ctx && d.accepted.is_none())
{
d.accepted = Some(p.accepted);
d.user_text = p.user_text.clone();
return;
}
inner.push_dialog(CapturedDialog {
context: ctx,
kind: prompt_kind(&p.r#type).to_string(),
message: String::new(),
default_value: None,
handler: String::new(),
accepted: Some(p.accepted),
user_text: p.user_text.clone(),
});
}
pub async fn ingest_download_begin(&self, evt: &DownloadWillBegin) {
let p = &evt.params;
self.inner.write().await.push_download(CapturedDownload {
context: p.base_navigation_info.context.inner().to_string(),
suggested_filename: p.suggested_filename.clone(),
url: p.base_navigation_info.url.clone(),
status: "will-begin".to_string(),
filepath: None,
});
}
pub async fn ingest_download_end(&self, evt: &DownloadEnd) {
let (ctx, url, status, filepath) = match &evt
.params
.download_canceled_params_download_complete_params_union
{
DownloadUnion::DownloadCompleteParams(c) => (
c.base_navigation_info.context.inner().to_string(),
c.base_navigation_info.url.clone(),
"complete".to_string(),
c.filepath.clone(),
),
DownloadUnion::DownloadCanceledParams(c) => (
c.base_navigation_info.context.inner().to_string(),
c.base_navigation_info.url.clone(),
"canceled".to_string(),
None,
),
};
let mut inner = self.inner.write().await;
if let Some(d) = inner
.downloads
.iter_mut()
.rev()
.find(|d| d.context == ctx && d.url == url && d.status == "will-begin")
{
d.status = status;
d.filepath = filepath;
return;
}
inner.push_download(CapturedDownload {
context: ctx,
suggested_filename: String::new(),
url,
status,
filepath,
});
}
}
pub fn make_dialog_handler(
log: DialogLog,
) -> impl FnMut(Event) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
use rustenium_bidi_definitions::browsing_context::events::BrowsingContextEvent as BCE;
move |evt| {
let log = log.clone();
Box::pin(async move {
if let Event::BrowsingContext(bce) = evt {
match bce {
BCE::UserPromptOpened(e) => log.ingest_opened(&e).await,
BCE::UserPromptClosed(e) => log.ingest_closed(&e).await,
BCE::DownloadWillBegin(e) => log.ingest_download_begin(&e).await,
BCE::DownloadEnd(e) => log.ingest_download_end(&e).await,
_ => {}
}
}
})
}
}
pub const DIALOG_EVENTS: &[&str] = &[
"browsingContext.userPromptOpened",
"browsingContext.userPromptClosed",
"browsingContext.downloadWillBegin",
"browsingContext.downloadEnd",
];
#[cfg(test)]
mod tests {
use super::*;
use rustenium_bidi_definitions::browsing_context::events::{
UserPromptClosedMethod, UserPromptClosedParams, UserPromptOpenedMethod,
UserPromptOpenedParams,
};
use rustenium_bidi_definitions::browsing_context::types::BrowsingContext;
fn opened(ctx: &str, kind: UserPromptType, message: &str) -> UserPromptOpened {
UserPromptOpened {
method: UserPromptOpenedMethod::UserPromptOpened,
params: UserPromptOpenedParams {
context: BrowsingContext::new(ctx),
handler: UserPromptHandlerType::Ignore,
message: message.to_string(),
r#type: kind,
default_value: None,
},
}
}
fn closed(
ctx: &str,
kind: UserPromptType,
accepted: bool,
text: Option<&str>,
) -> UserPromptClosed {
UserPromptClosed {
method: UserPromptClosedMethod::UserPromptClosed,
params: UserPromptClosedParams {
context: BrowsingContext::new(ctx),
accepted,
r#type: kind,
user_text: text.map(str::to_string),
},
}
}
#[tokio::test]
async fn captures_alert_message_for_xss_evidence() {
let log = DialogLog::new();
log.ingest_opened(&opened("ctx-1", UserPromptType::Alert, "1"))
.await;
let dialogs = log.dialogs().await;
assert_eq!(dialogs.len(), 1);
assert_eq!(dialogs[0].kind, "alert");
assert_eq!(dialogs[0].message, "1");
assert_eq!(dialogs[0].handler, "ignore");
assert_eq!(dialogs[0].accepted, None);
}
#[tokio::test]
async fn close_finalizes_matching_open_dialog() {
let log = DialogLog::new();
log.ingest_opened(&opened("ctx-1", UserPromptType::Prompt, "name?"))
.await;
assert_eq!(log.open_dialogs().await.len(), 1);
log.ingest_closed(&closed(
"ctx-1",
UserPromptType::Prompt,
true,
Some("admin"),
))
.await;
let dialogs = log.dialogs().await;
assert_eq!(dialogs.len(), 1, "close updates, does not append");
assert_eq!(dialogs[0].accepted, Some(true));
assert_eq!(dialogs[0].user_text.as_deref(), Some("admin"));
assert!(log.open_dialogs().await.is_empty());
}
#[tokio::test]
async fn close_without_open_pushes_standalone() {
let log = DialogLog::new();
log.ingest_closed(&closed("ctx-9", UserPromptType::Confirm, false, None))
.await;
let dialogs = log.dialogs().await;
assert_eq!(dialogs.len(), 1);
assert_eq!(dialogs[0].accepted, Some(false));
}
#[tokio::test]
async fn dialogs_are_bounded() {
let log = DialogLog::new();
for i in 0..(MAX_ENTRIES + 50) {
log.ingest_opened(&opened("ctx", UserPromptType::Alert, &i.to_string()))
.await;
}
assert_eq!(log.dialog_count().await, MAX_ENTRIES);
let last = log.last_dialog().await.unwrap();
assert_eq!(last.message, (MAX_ENTRIES + 49).to_string());
}
#[test]
fn prompt_kind_maps_all_variants() {
assert_eq!(prompt_kind(&UserPromptType::Alert), "alert");
assert_eq!(prompt_kind(&UserPromptType::Beforeunload), "beforeunload");
assert_eq!(prompt_kind(&UserPromptType::Confirm), "confirm");
assert_eq!(prompt_kind(&UserPromptType::Prompt), "prompt");
}
#[test]
fn handler_maps_all_variants() {
assert_eq!(handler_str(&UserPromptHandlerType::Accept), "accept");
assert_eq!(handler_str(&UserPromptHandlerType::Dismiss), "dismiss");
assert_eq!(handler_str(&UserPromptHandlerType::Ignore), "ignore");
assert_eq!(
handler_str(&UserPromptHandlerType::DismissAndNotify),
"dismiss and notify"
);
}
}