use std::sync::Arc;
use std::time::Duration;
use dashmap::DashMap;
use freenet_stdlib::prelude::{ClientResponse, UserInputRequest};
use tokio::sync::{broadcast, oneshot};
pub(crate) const USER_INPUT_TIMEOUT: Duration = Duration::from_secs(60);
const MAX_MESSAGE_LEN: usize = 2048;
const MAX_LABEL_LEN: usize = 64;
const MAX_LABELS: usize = 10;
pub(crate) const MAX_IDENTITY_HASH_CHARS: usize = 256;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CallerIdentity {
None,
WebApp(String),
}
pub trait UserInputPrompter: Send + Sync {
fn prompt(
&self,
request: &UserInputRequest<'static>,
delegate_key: &str,
caller: CallerIdentity,
) -> impl std::future::Future<Output = Option<(usize, ClientResponse<'static>)>> + Send;
}
pub(crate) struct PendingPrompt {
pub message: String,
pub labels: Vec<String>,
pub delegate_key: String,
pub caller: CallerIdentity,
pub response_tx: oneshot::Sender<usize>,
}
pub(crate) type PendingPrompts = Arc<DashMap<String, PendingPrompt>>;
static PENDING_PROMPTS: std::sync::OnceLock<PendingPrompts> = std::sync::OnceLock::new();
pub(crate) fn pending_prompts() -> PendingPrompts {
PENDING_PROMPTS
.get_or_init(|| Arc::new(DashMap::new()))
.clone()
}
const MAX_PENDING_PROMPTS: usize = 32;
#[derive(Clone, Debug)]
pub(crate) struct PromptSnapshot {
pub nonce: String,
pub message: String,
pub labels: Vec<String>,
pub delegate_key: String,
pub caller: CallerIdentity,
}
#[derive(Clone, Debug)]
pub(crate) enum PromptEvent {
Added(PromptSnapshot),
Removed { nonce: String },
}
const PROMPT_EVENT_CAPACITY: usize = 128;
static PROMPT_EVENTS: std::sync::OnceLock<broadcast::Sender<PromptEvent>> =
std::sync::OnceLock::new();
pub(crate) fn prompt_events() -> broadcast::Sender<PromptEvent> {
PROMPT_EVENTS
.get_or_init(|| broadcast::channel(PROMPT_EVENT_CAPACITY).0)
.clone()
}
pub(crate) fn emit_prompt_event(event: PromptEvent) {
let event = match event {
PromptEvent::Removed { nonce } => PromptEvent::Removed {
nonce: cap_identity_chars(&nonce),
},
other => other,
};
drop(prompt_events().send(event));
}
pub struct DashboardPrompter {
pending: PendingPrompts,
}
impl DashboardPrompter {
pub fn new(pending: PendingPrompts) -> Self {
Self { pending }
}
}
impl UserInputPrompter for DashboardPrompter {
async fn prompt(
&self,
request: &UserInputRequest<'static>,
delegate_key: &str,
caller: CallerIdentity,
) -> Option<(usize, ClientResponse<'static>)> {
if request.responses.is_empty() {
tracing::warn!("RequestUserInput has no response options");
return None;
}
if self.pending.len() >= MAX_PENDING_PROMPTS {
tracing::warn!(
max = MAX_PENDING_PROMPTS,
"Too many pending permission prompts, auto-denying"
);
return None;
}
let message = parse_message(request);
let labels = parse_button_labels(request);
let nonce = generate_nonce();
let (tx, rx) = oneshot::channel();
let stored_delegate_key = cap_identity_chars(delegate_key);
let stored_caller = match caller {
CallerIdentity::None => CallerIdentity::None,
CallerIdentity::WebApp(hash) => CallerIdentity::WebApp(cap_identity_chars(&hash)),
};
self.pending.insert(
nonce.clone(),
PendingPrompt {
message: message.clone(),
labels: labels.clone(),
delegate_key: stored_delegate_key.clone(),
caller: stored_caller.clone(),
response_tx: tx,
},
);
emit_prompt_event(PromptEvent::Added(PromptSnapshot {
nonce: nonce.clone(),
message,
labels,
delegate_key: stored_delegate_key,
caller: stored_caller,
}));
tracing::debug!(
request_id = request.request_id,
"Permission prompt created, waiting for user response via dashboard"
);
let result = tokio::time::timeout(USER_INPUT_TIMEOUT, rx).await;
let was_present = self.pending.remove(&nonce).is_some();
if was_present {
emit_prompt_event(PromptEvent::Removed {
nonce: nonce.clone(),
});
}
match result {
Ok(Ok(idx)) if idx < request.responses.len() => {
let response = request.responses[idx].clone().into_owned();
Some((idx, response))
}
Ok(Ok(_)) => {
tracing::warn!(nonce = %nonce, "Invalid response index from dashboard");
None
}
Ok(Err(_)) => {
tracing::debug!(nonce = %nonce, "Permission prompt channel closed");
None
}
Err(_) => {
tracing::warn!(nonce = %nonce, "Permission prompt timed out after 60s");
None
}
}
}
}
fn cap_identity_chars(s: &str) -> String {
s.chars().take(MAX_IDENTITY_HASH_CHARS).collect()
}
fn generate_nonce() -> String {
use crate::config::GlobalRng;
let a = GlobalRng::random_u64();
let b = GlobalRng::random_u64();
format!("{a:016x}{b:016x}")
}
pub(crate) fn parse_message(request: &UserInputRequest<'_>) -> String {
let bytes = request.message.bytes();
let raw = if let Ok(json_str) = serde_json::from_slice::<String>(bytes) {
json_str
} else {
String::from_utf8(bytes.to_vec())
.unwrap_or_else(|_| "A delegate is requesting permission.".to_string())
};
raw.chars()
.take(MAX_MESSAGE_LEN)
.filter(|c| !c.is_control() || *c == '\n')
.collect()
}
pub(crate) fn parse_button_labels(request: &UserInputRequest<'_>) -> Vec<String> {
request
.responses
.iter()
.take(MAX_LABELS)
.enumerate()
.map(|(i, r)| {
let label =
String::from_utf8((**r).to_vec()).unwrap_or_else(|_| format!("Option {}", i + 1));
label
.chars()
.take(MAX_LABEL_LEN)
.filter(|c| !c.is_control())
.collect()
})
.collect()
}
pub struct AutoApprovePrompter;
impl UserInputPrompter for AutoApprovePrompter {
async fn prompt(
&self,
request: &UserInputRequest<'static>,
_delegate_key: &str,
_caller: CallerIdentity,
) -> Option<(usize, ClientResponse<'static>)> {
request
.responses
.first()
.map(|r| (0, r.clone().into_owned()))
}
}
#[allow(dead_code)]
pub struct AutoDenyPrompter;
impl UserInputPrompter for AutoDenyPrompter {
async fn prompt(
&self,
_request: &UserInputRequest<'static>,
_delegate_key: &str,
_caller: CallerIdentity,
) -> Option<(usize, ClientResponse<'static>)> {
None
}
}
#[cfg(test)]
pub(crate) fn make_test_request(message: &str, responses: Vec<&str>) -> UserInputRequest<'static> {
use freenet_stdlib::prelude::NotificationMessage;
let msg = NotificationMessage::try_from(&serde_json::Value::String(message.to_string()))
.expect("valid JSON");
UserInputRequest {
request_id: 1,
message: msg,
responses: responses
.into_iter()
.map(|r| ClientResponse::new(r.as_bytes().to_vec()))
.collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn webapp(s: &str) -> CallerIdentity {
CallerIdentity::WebApp(s.to_string())
}
#[tokio::test]
async fn test_auto_approve_returns_first_response() {
let req = make_test_request("Allow this?", vec!["Allow", "Deny"]);
let result = AutoApprovePrompter
.prompt(&req, "dkey", webapp("cid"))
.await;
let (idx, response) = result.unwrap();
assert_eq!(idx, 0);
assert_eq!(&*response, b"Allow");
}
#[tokio::test]
async fn test_auto_approve_empty_responses() {
let req = make_test_request("Allow this?", vec![]);
let result = AutoApprovePrompter
.prompt(&req, "dkey", webapp("cid"))
.await;
assert!(result.is_none());
}
#[tokio::test]
async fn test_auto_deny_always_returns_none() {
let req = make_test_request("Allow this?", vec!["Allow", "Deny"]);
let result = AutoDenyPrompter.prompt(&req, "dkey", webapp("cid")).await;
assert!(result.is_none());
}
#[test]
fn test_parse_button_labels() {
let req = make_test_request("msg", vec!["Allow Once", "Always Allow", "Deny"]);
let labels = parse_button_labels(&req);
assert_eq!(labels, vec!["Allow Once", "Always Allow", "Deny"]);
}
#[test]
fn test_parse_message_json_encoded() {
let req = make_test_request("Hello world", vec![]);
let msg = parse_message(&req);
assert_eq!(msg, "Hello world");
}
#[test]
fn test_parse_message_json_with_quotes() {
use freenet_stdlib::prelude::NotificationMessage;
let json_val = serde_json::Value::String("Test with \"quotes\"".to_string());
let msg = NotificationMessage::try_from(&json_val).unwrap();
let req = UserInputRequest {
request_id: 1,
message: msg,
responses: vec![],
};
let parsed = parse_message(&req);
assert_eq!(parsed, "Test with \"quotes\"");
}
#[tokio::test]
async fn test_dashboard_prompter_max_pending() {
let pending: PendingPrompts = Arc::new(DashMap::new());
let prompter = DashboardPrompter::new(pending.clone());
for i in 0..MAX_PENDING_PROMPTS {
let (tx, _rx) = oneshot::channel();
pending.insert(
format!("nonce_{i}"),
PendingPrompt {
message: "test".to_string(),
labels: vec!["OK".to_string()],
delegate_key: String::new(),
caller: CallerIdentity::None,
response_tx: tx,
},
);
}
let req = make_test_request("Over limit", vec!["Allow"]);
let result = prompter.prompt(&req, "dkey", webapp("cid")).await;
assert!(result.is_none());
}
#[test]
fn test_nonce_is_32_hex_chars() {
let nonce = generate_nonce();
assert_eq!(nonce.len(), 32);
assert!(nonce.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_parse_message_strips_control_chars() {
use freenet_stdlib::prelude::NotificationMessage;
let json_val = serde_json::Value::String("Hello\x00\x07world".to_string());
let msg = NotificationMessage::try_from(&json_val).unwrap();
let req = UserInputRequest {
request_id: 1,
message: msg,
responses: vec![],
};
let parsed = parse_message(&req);
assert_eq!(parsed, "Helloworld");
}
#[test]
fn test_parse_button_labels_invalid_utf8() {
use freenet_stdlib::prelude::NotificationMessage;
let req = UserInputRequest {
request_id: 1,
message: NotificationMessage::try_from(&serde_json::Value::String("msg".to_string()))
.unwrap(),
responses: vec![
ClientResponse::new(b"Valid".to_vec()),
ClientResponse::new(vec![0xFF, 0xFE]),
],
};
let labels = parse_button_labels(&req);
assert_eq!(labels, vec!["Valid", "Option 2"]);
}
#[test]
fn test_parse_message_raw_utf8() {
use freenet_stdlib::prelude::NotificationMessage;
let raw_msg =
NotificationMessage::try_from(&serde_json::Value::String("Raw message".to_string()))
.unwrap();
let req = UserInputRequest {
request_id: 1,
message: raw_msg,
responses: vec![],
};
let msg = parse_message(&req);
assert_eq!(msg, "Raw message");
}
#[tokio::test]
async fn test_auto_approve_with_three_responses() {
let req = make_test_request("Allow?", vec!["Allow Once", "Always Allow", "Deny"]);
let result = AutoApprovePrompter
.prompt(&req, "dkey", webapp("cid"))
.await;
let (idx, response) = result.unwrap();
assert_eq!(idx, 0);
assert_eq!(&*response, b"Allow Once");
}
#[tokio::test]
async fn test_auto_deny_with_multiple_responses() {
let req = make_test_request("Allow?", vec!["Allow Once", "Always Allow", "Deny"]);
let result = AutoDenyPrompter.prompt(&req, "dkey", webapp("cid")).await;
assert!(result.is_none());
}
#[tokio::test]
async fn test_dashboard_prompter_populates_webapp_caller() {
let pending: PendingPrompts = Arc::new(DashMap::new());
let prompter = DashboardPrompter::new(pending.clone());
let req = make_test_request("Approve?", vec!["Allow", "Deny"]);
let pending_clone = pending.clone();
let handle = tokio::spawn(async move {
prompter
.prompt(&req, "DLGKEY123", webapp("CONTRACT456"))
.await
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let entry = pending_clone
.iter()
.next()
.expect("prompt should be registered");
assert_eq!(entry.value().delegate_key, "DLGKEY123");
assert_eq!(
entry.value().caller,
CallerIdentity::WebApp("CONTRACT456".to_string())
);
let nonce = entry.key().clone();
drop(entry);
let (_, prompt) = pending_clone.remove(&nonce).unwrap();
prompt.response_tx.send(1).unwrap();
let _ = handle.await.unwrap();
}
#[tokio::test]
async fn test_dashboard_prompter_records_none_caller() {
let pending: PendingPrompts = Arc::new(DashMap::new());
let prompter = DashboardPrompter::new(pending.clone());
let req = make_test_request("Approve?", vec!["Allow"]);
let pending_clone = pending.clone();
let handle =
tokio::spawn(
async move { prompter.prompt(&req, "DLGKEY", CallerIdentity::None).await },
);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let entry = pending_clone
.iter()
.next()
.expect("prompt should be registered");
assert_eq!(entry.value().delegate_key, "DLGKEY");
assert_eq!(entry.value().caller, CallerIdentity::None);
let nonce = entry.key().clone();
drop(entry);
let (_, prompt) = pending_clone.remove(&nonce).unwrap();
prompt.response_tx.send(0).unwrap();
let _ = handle.await.unwrap();
}
#[tokio::test]
async fn test_dashboard_prompter_happy_path() {
let pending: PendingPrompts = Arc::new(DashMap::new());
let prompter = DashboardPrompter::new(pending.clone());
let req = make_test_request("Allow signing?", vec!["Allow", "Deny"]);
let pending_clone = pending.clone();
let handle =
tokio::spawn(async move { prompter.prompt(&req, "dkey", webapp("cid")).await });
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let nonce = pending_clone
.iter()
.next()
.expect("should have a pending prompt")
.key()
.clone();
let (_, prompt) = pending_clone.remove(&nonce).unwrap();
prompt.response_tx.send(0).unwrap();
let result = handle.await.unwrap();
let (idx, response) = result.unwrap();
assert_eq!(idx, 0);
assert_eq!(&*response, b"Allow");
}
#[tokio::test]
async fn test_prompt_added_emitted_and_external_remove_skips_cleanup_removed() {
let mut rx = prompt_events().subscribe();
let pending: PendingPrompts = Arc::new(DashMap::new());
let prompter = DashboardPrompter::new(pending.clone());
let req = make_test_request("lifecycle?", vec!["Allow", "Deny"]);
let pending_clone = pending.clone();
let handle =
tokio::spawn(async move { prompter.prompt(&req, "dkey-lc", webapp("cid-lc")).await });
let mut added_nonce: Option<String> = None;
for _ in 0..50 {
match tokio::time::timeout(Duration::from_millis(200), rx.recv()).await {
Ok(Ok(PromptEvent::Added(snap))) if snap.delegate_key == "dkey-lc" => {
added_nonce = Some(snap.nonce);
break;
}
Ok(_) => continue, Err(_) => break, }
}
let nonce = added_nonce.expect("PromptEvent::Added with our delegate_key");
let (_, prompt) = pending_clone.remove(&nonce).unwrap();
prompt.response_tx.send(0).unwrap();
let _ = handle.await.unwrap();
let mut saw_removed = false;
for _ in 0..50 {
match tokio::time::timeout(Duration::from_millis(200), rx.recv()).await {
Ok(Ok(PromptEvent::Removed { nonce: n })) if n == nonce => {
saw_removed = true;
break;
}
Ok(_) => continue,
Err(_) => break,
}
}
assert!(
!saw_removed,
"this test exercises the manual-remove path; \
Removed should fire only from the prompter's own cleanup \
(timeout) or the HTTP respond handler (covered elsewhere)"
);
}
#[tokio::test(start_paused = true)]
async fn test_prompt_timeout_emits_removed() {
let mut rx = prompt_events().subscribe();
let pending: PendingPrompts = Arc::new(DashMap::new());
let prompter = DashboardPrompter::new(pending.clone());
let req = make_test_request("timeout?", vec!["Allow"]);
let handle = tokio::spawn(async move {
prompter
.prompt(&req, "dkey-timeout", webapp("cid-timeout"))
.await
});
let mut our_nonce: Option<String> = None;
for _ in 0..50 {
tokio::task::yield_now().await;
match rx.try_recv() {
Ok(PromptEvent::Added(snap)) if snap.delegate_key == "dkey-timeout" => {
our_nonce = Some(snap.nonce);
break;
}
Ok(_) => continue,
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {
tokio::time::sleep(Duration::from_millis(10)).await;
}
Err(_) => break,
}
}
let nonce = our_nonce.expect("Added event for the timeout test");
tokio::time::advance(USER_INPUT_TIMEOUT + Duration::from_secs(1)).await;
let _ = handle.await.unwrap();
let mut saw_removed = false;
for _ in 0..50 {
match rx.try_recv() {
Ok(PromptEvent::Removed { nonce: n }) if n == nonce => {
saw_removed = true;
break;
}
Ok(_) => continue,
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {
tokio::time::sleep(Duration::from_millis(10)).await;
}
Err(_) => break,
}
}
assert!(
saw_removed,
"prompter timeout must emit PromptEvent::Removed"
);
}
}