use std::collections::HashMap;
use std::sync::Mutex;
#[derive(Debug, Clone)]
pub struct ConsentAsk {
pub cap_id: String,
pub key: String,
pub summary: String,
}
#[async_trait::async_trait]
pub trait ConsentPrompter: Send + Sync {
async fn decide(&self, ask: &ConsentAsk) -> bool;
}
pub struct DenyPrompter;
#[async_trait::async_trait]
impl ConsentPrompter for DenyPrompter {
async fn decide(&self, _ask: &ConsentAsk) -> bool {
false
}
}
#[derive(Default)]
pub struct DecisionCache {
seen: Mutex<HashMap<(String, String), bool>>,
}
impl DecisionCache {
pub fn new() -> Self {
Self {
seen: Mutex::new(HashMap::new()),
}
}
pub async fn decide_cached(&self, prompter: &dyn ConsentPrompter, ask: ConsentAsk) -> bool {
let k = (ask.cap_id.clone(), ask.key.clone());
if let Some(v) = self.seen.lock().unwrap().get(&k).copied() {
return v;
}
let v = prompter.decide(&ask).await;
self.seen.lock().unwrap().insert(k, v);
v
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
struct CountingPrompter {
allow: bool,
calls: AtomicUsize,
}
#[async_trait::async_trait]
impl ConsentPrompter for CountingPrompter {
async fn decide(&self, _ask: &ConsentAsk) -> bool {
self.calls.fetch_add(1, Ordering::SeqCst);
self.allow
}
}
fn ask(key: &str) -> ConsentAsk {
ConsentAsk {
cap_id: "wasi:filesystem".into(),
key: key.into(),
summary: "read".into(),
}
}
#[tokio::test]
async fn cache_remembers_and_prompts_once() {
let cache = DecisionCache::new();
let p = CountingPrompter {
allow: true,
calls: AtomicUsize::new(0),
};
assert!(cache.decide_cached(&p, ask("/a")).await);
assert!(cache.decide_cached(&p, ask("/a")).await); assert_eq!(p.calls.load(Ordering::SeqCst), 1);
assert!(cache.decide_cached(&p, ask("/b")).await); assert_eq!(p.calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn deny_prompter_denies() {
let cache = DecisionCache::new();
assert!(!cache.decide_cached(&DenyPrompter, ask("/x")).await);
}
struct ScriptedPrompter {
decisions: HashMap<String, bool>,
prompts: Mutex<Vec<String>>,
}
#[async_trait::async_trait]
impl ConsentPrompter for ScriptedPrompter {
async fn decide(&self, ask: &ConsentAsk) -> bool {
self.prompts.lock().unwrap().push(ask.key.clone());
self.decisions.get(&ask.key).copied().unwrap_or(false)
}
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn ask_allow_remembered_deny_blocked_and_degrade() {
let p = ScriptedPrompter {
decisions: HashMap::from([("/allow".to_string(), true), ("/deny".to_string(), false)]),
prompts: Mutex::new(Vec::new()),
};
let cache = DecisionCache::new();
assert!(cache.decide_cached(&p, ask("/allow")).await);
assert!(cache.decide_cached(&p, ask("/allow")).await);
assert!(!cache.decide_cached(&p, ask("/deny")).await);
assert!(!cache.decide_cached(&p, ask("/deny")).await);
let prompts = p.prompts.lock().unwrap();
assert_eq!(
prompts.as_slice(),
&["/allow".to_string(), "/deny".to_string()]
);
let deny_cache = DecisionCache::new();
assert!(!deny_cache.decide_cached(&DenyPrompter, ask("/allow")).await);
}
}