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;
fn has_channel(&self) -> bool {
true
}
}
pub struct DenyPrompter;
#[async_trait::async_trait]
impl ConsentPrompter for DenyPrompter {
async fn decide(&self, _ask: &ConsentAsk) -> bool {
false
}
fn has_channel(&self) -> 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);
}
}
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, PartialEq)]
pub struct PendingConsent {
pub id: u64,
pub subject: String,
pub subject_id: i64,
pub cap_id: String,
pub key: String,
pub summary: String,
pub asked_at: i64,
}
struct Waiting {
entry: PendingConsent,
answer: tokio::sync::oneshot::Sender<bool>,
}
pub struct ConsentQueue {
next_id: AtomicU64,
waiting: Mutex<HashMap<u64, Waiting>>,
timeout: Duration,
}
impl ConsentQueue {
pub fn new(timeout: Duration) -> Self {
Self {
next_id: AtomicU64::new(0),
waiting: Mutex::new(HashMap::new()),
timeout,
}
}
pub fn pending(&self) -> Vec<PendingConsent> {
let mut all: Vec<PendingConsent> = self.lock().values().map(|w| w.entry.clone()).collect();
all.sort_by_key(|e| e.id);
all
}
pub fn resolve(&self, id: u64, allow: bool) -> Option<PendingConsent> {
let waiting = self.lock().remove(&id)?;
let _ = waiting.answer.send(allow);
Some(waiting.entry)
}
pub async fn ask(&self, subject: &str, subject_id: i64, ask: &ConsentAsk) -> bool {
let id = self.next_id.fetch_add(1, Ordering::Relaxed) + 1;
let (tx, rx) = tokio::sync::oneshot::channel();
self.lock().insert(
id,
Waiting {
entry: PendingConsent {
id,
subject: subject.to_string(),
subject_id,
cap_id: ask.cap_id.clone(),
key: ask.key.clone(),
summary: ask.summary.clone(),
asked_at: now_epoch(),
},
answer: tx,
},
);
match tokio::time::timeout(self.timeout, rx).await {
Ok(Ok(decision)) => decision,
_ => {
self.lock().remove(&id);
false
}
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<u64, Waiting>> {
self.waiting
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
fn now_epoch() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64)
}
pub struct QueuePrompter {
queue: Arc<ConsentQueue>,
subject: String,
subject_id: i64,
}
impl QueuePrompter {
pub fn new(queue: Arc<ConsentQueue>, subject: impl Into<String>, subject_id: i64) -> Self {
Self {
queue,
subject: subject.into(),
subject_id,
}
}
}
#[async_trait::async_trait]
impl ConsentPrompter for QueuePrompter {
async fn decide(&self, ask: &ConsentAsk) -> bool {
self.queue.ask(&self.subject, self.subject_id, ask).await
}
fn has_channel(&self) -> bool {
true
}
}
#[cfg(test)]
mod queue_tests {
use super::*;
fn ask(key: &str) -> ConsentAsk {
ConsentAsk {
cap_id: "wasi:filesystem".into(),
key: key.into(),
summary: format!("read {key}"),
}
}
fn queue() -> Arc<ConsentQueue> {
Arc::new(ConsentQueue::new(Duration::from_secs(5)))
}
#[tokio::test]
async fn a_question_waits_and_names_who_is_asking() {
let queue = queue();
let asking = tokio::spawn({
let queue = queue.clone();
async move { queue.ask("clock", 1, &ask("/data")).await }
});
let pending = wait_for_one(&queue).await;
assert_eq!(pending.subject, "clock");
assert_eq!(pending.cap_id, "wasi:filesystem");
assert_eq!(pending.key, "/data");
assert!(pending.asked_at > 1_577_836_800);
assert!(queue.resolve(pending.id, true).is_some());
assert!(asking.await.unwrap(), "allowing must wake the caller");
assert!(queue.pending().is_empty());
}
#[tokio::test]
async fn denying_wakes_the_caller_with_a_refusal() {
let queue = queue();
let asking = tokio::spawn({
let queue = queue.clone();
async move { queue.ask("clock", 1, &ask("/etc")).await }
});
let pending = wait_for_one(&queue).await;
queue.resolve(pending.id, false);
assert!(!asking.await.unwrap());
}
#[tokio::test]
async fn answering_a_question_nobody_asked_reports_it() {
let queue = queue();
assert!(queue.resolve(999, true).is_none());
}
#[tokio::test]
async fn a_question_cannot_be_answered_twice() {
let queue = queue();
let asking = tokio::spawn({
let queue = queue.clone();
async move { queue.ask("clock", 1, &ask("/data")).await }
});
let pending = wait_for_one(&queue).await;
assert!(queue.resolve(pending.id, true).is_some());
assert!(
queue.resolve(pending.id, false).is_none(),
"it is no longer waiting"
);
assert!(asking.await.unwrap());
}
#[tokio::test]
async fn an_unanswered_question_expires_into_a_refusal() {
let queue = Arc::new(ConsentQueue::new(Duration::from_millis(50)));
let decision = queue.ask("clock", 1, &ask("/data")).await;
assert!(!decision, "expiry denies");
assert!(queue.pending().is_empty(), "and stops waiting");
}
#[tokio::test]
async fn two_questions_are_answered_independently() {
let queue = queue();
let first = tokio::spawn({
let queue = queue.clone();
async move { queue.ask("clock", 1, &ask("/a")).await }
});
let second = tokio::spawn({
let queue = queue.clone();
async move { queue.ask("db", 2, &ask("/b")).await }
});
let mut pending = Vec::new();
while pending.len() < 2 {
tokio::time::sleep(Duration::from_millis(5)).await;
pending = queue.pending();
}
assert_ne!(pending[0].id, pending[1].id);
let by_key = |k: &str| pending.iter().find(|p| p.key == k).unwrap().id;
queue.resolve(by_key("/a"), true);
queue.resolve(by_key("/b"), false);
assert!(first.await.unwrap());
assert!(!second.await.unwrap());
}
#[tokio::test]
async fn the_prompter_reports_that_a_human_can_be_reached() {
let prompter = QueuePrompter::new(queue(), "clock", 1);
assert!(prompter.has_channel());
}
async fn wait_for_one(queue: &ConsentQueue) -> PendingConsent {
for _ in 0..200 {
if let Some(entry) = queue.pending().into_iter().next() {
return entry;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
panic!("the question never reached the queue");
}
}