1use std::collections::HashMap;
9use std::sync::Mutex;
10
11#[derive(Debug, Clone)]
12pub struct ConsentAsk {
13 pub cap_id: String,
14 pub key: String,
16 pub summary: String,
17}
18
19#[async_trait::async_trait]
20pub trait ConsentPrompter: Send + Sync {
21 async fn decide(&self, ask: &ConsentAsk) -> bool;
22
23 fn has_channel(&self) -> bool {
34 true
35 }
36}
37
38pub struct DenyPrompter;
40
41#[async_trait::async_trait]
42impl ConsentPrompter for DenyPrompter {
43 async fn decide(&self, _ask: &ConsentAsk) -> bool {
44 false
45 }
46
47 fn has_channel(&self) -> bool {
48 false
49 }
50}
51
52#[derive(Default)]
54pub struct DecisionCache {
55 seen: Mutex<HashMap<(String, String), bool>>,
56}
57
58impl DecisionCache {
59 pub fn new() -> Self {
60 Self {
61 seen: Mutex::new(HashMap::new()),
62 }
63 }
64
65 pub async fn decide_cached(&self, prompter: &dyn ConsentPrompter, ask: ConsentAsk) -> bool {
68 let k = (ask.cap_id.clone(), ask.key.clone());
69 if let Some(v) = self.seen.lock().unwrap().get(&k).copied() {
70 return v;
71 }
72 let v = prompter.decide(&ask).await;
73 self.seen.lock().unwrap().insert(k, v);
74 v
75 }
76}
77
78#[cfg(test)]
79mod tests {
80 use super::*;
81 use std::sync::atomic::{AtomicUsize, Ordering};
82
83 struct CountingPrompter {
84 allow: bool,
85 calls: AtomicUsize,
86 }
87
88 #[async_trait::async_trait]
89 impl ConsentPrompter for CountingPrompter {
90 async fn decide(&self, _ask: &ConsentAsk) -> bool {
91 self.calls.fetch_add(1, Ordering::SeqCst);
92 self.allow
93 }
94 }
95
96 fn ask(key: &str) -> ConsentAsk {
97 ConsentAsk {
98 cap_id: "wasi:filesystem".into(),
99 key: key.into(),
100 summary: "read".into(),
101 }
102 }
103
104 #[tokio::test]
105 async fn cache_remembers_and_prompts_once() {
106 let cache = DecisionCache::new();
107 let p = CountingPrompter {
108 allow: true,
109 calls: AtomicUsize::new(0),
110 };
111 assert!(cache.decide_cached(&p, ask("/a")).await);
112 assert!(cache.decide_cached(&p, ask("/a")).await); assert_eq!(p.calls.load(Ordering::SeqCst), 1);
114 assert!(cache.decide_cached(&p, ask("/b")).await); assert_eq!(p.calls.load(Ordering::SeqCst), 2);
116 }
117
118 #[tokio::test]
119 async fn deny_prompter_denies() {
120 let cache = DecisionCache::new();
121 assert!(!cache.decide_cached(&DenyPrompter, ask("/x")).await);
122 }
123
124 struct ScriptedPrompter {
127 decisions: HashMap<String, bool>,
128 prompts: Mutex<Vec<String>>,
129 }
130
131 #[async_trait::async_trait]
132 impl ConsentPrompter for ScriptedPrompter {
133 async fn decide(&self, ask: &ConsentAsk) -> bool {
134 self.prompts.lock().unwrap().push(ask.key.clone());
135 self.decisions.get(&ask.key).copied().unwrap_or(false)
136 }
137 }
138
139 #[tokio::test]
140 #[allow(clippy::await_holding_lock)]
141 async fn ask_allow_remembered_deny_blocked_and_degrade() {
142 let p = ScriptedPrompter {
144 decisions: HashMap::from([("/allow".to_string(), true), ("/deny".to_string(), false)]),
145 prompts: Mutex::new(Vec::new()),
146 };
147 let cache = DecisionCache::new();
148
149 assert!(cache.decide_cached(&p, ask("/allow")).await);
151 assert!(cache.decide_cached(&p, ask("/allow")).await);
153 assert!(!cache.decide_cached(&p, ask("/deny")).await);
155 assert!(!cache.decide_cached(&p, ask("/deny")).await);
157
158 let prompts = p.prompts.lock().unwrap();
160 assert_eq!(
161 prompts.as_slice(),
162 &["/allow".to_string(), "/deny".to_string()]
163 );
164
165 let deny_cache = DecisionCache::new();
167 assert!(!deny_cache.decide_cached(&DenyPrompter, ask("/allow")).await);
168 }
169}
170
171use std::sync::Arc;
174use std::sync::atomic::{AtomicU64, Ordering};
175use std::time::{Duration, SystemTime, UNIX_EPOCH};
176
177#[derive(Debug, Clone, PartialEq)]
182pub struct PendingConsent {
183 pub id: u64,
184 pub subject: String,
187 pub subject_id: i64,
192 pub cap_id: String,
193 pub key: String,
195 pub summary: String,
196 pub asked_at: i64,
198}
199
200struct Waiting {
201 entry: PendingConsent,
202 answer: tokio::sync::oneshot::Sender<bool>,
203}
204
205pub struct ConsentQueue {
212 next_id: AtomicU64,
213 waiting: Mutex<HashMap<u64, Waiting>>,
214 timeout: Duration,
215}
216
217impl ConsentQueue {
218 pub fn new(timeout: Duration) -> Self {
219 Self {
220 next_id: AtomicU64::new(0),
221 waiting: Mutex::new(HashMap::new()),
222 timeout,
223 }
224 }
225
226 pub fn pending(&self) -> Vec<PendingConsent> {
228 let mut all: Vec<PendingConsent> = self.lock().values().map(|w| w.entry.clone()).collect();
229 all.sort_by_key(|e| e.id);
230 all
231 }
232
233 pub fn resolve(&self, id: u64, allow: bool) -> Option<PendingConsent> {
240 let waiting = self.lock().remove(&id)?;
241 let _ = waiting.answer.send(allow);
244 Some(waiting.entry)
245 }
246
247 pub async fn ask(&self, subject: &str, subject_id: i64, ask: &ConsentAsk) -> bool {
254 let id = self.next_id.fetch_add(1, Ordering::Relaxed) + 1;
255 let (tx, rx) = tokio::sync::oneshot::channel();
256
257 self.lock().insert(
258 id,
259 Waiting {
260 entry: PendingConsent {
261 id,
262 subject: subject.to_string(),
263 subject_id,
264 cap_id: ask.cap_id.clone(),
265 key: ask.key.clone(),
266 summary: ask.summary.clone(),
267 asked_at: now_epoch(),
268 },
269 answer: tx,
270 },
271 );
272
273 match tokio::time::timeout(self.timeout, rx).await {
274 Ok(Ok(decision)) => decision,
275 _ => {
277 self.lock().remove(&id);
278 false
279 }
280 }
281 }
282
283 fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<u64, Waiting>> {
284 self.waiting
285 .lock()
286 .unwrap_or_else(std::sync::PoisonError::into_inner)
287 }
288}
289
290fn now_epoch() -> i64 {
291 SystemTime::now()
292 .duration_since(UNIX_EPOCH)
293 .map_or(0, |d| d.as_secs() as i64)
294}
295
296pub struct QueuePrompter {
302 queue: Arc<ConsentQueue>,
303 subject: String,
304 subject_id: i64,
305}
306
307impl QueuePrompter {
308 pub fn new(queue: Arc<ConsentQueue>, subject: impl Into<String>, subject_id: i64) -> Self {
309 Self {
310 queue,
311 subject: subject.into(),
312 subject_id,
313 }
314 }
315}
316
317#[async_trait::async_trait]
318impl ConsentPrompter for QueuePrompter {
319 async fn decide(&self, ask: &ConsentAsk) -> bool {
320 self.queue.ask(&self.subject, self.subject_id, ask).await
321 }
322
323 fn has_channel(&self) -> bool {
327 true
328 }
329}
330
331#[cfg(test)]
332mod queue_tests {
333 use super::*;
334
335 fn ask(key: &str) -> ConsentAsk {
336 ConsentAsk {
337 cap_id: "wasi:filesystem".into(),
338 key: key.into(),
339 summary: format!("read {key}"),
340 }
341 }
342
343 fn queue() -> Arc<ConsentQueue> {
344 Arc::new(ConsentQueue::new(Duration::from_secs(5)))
345 }
346
347 #[tokio::test]
348 async fn a_question_waits_and_names_who_is_asking() {
349 let queue = queue();
350 let asking = tokio::spawn({
351 let queue = queue.clone();
352 async move { queue.ask("clock", 1, &ask("/data")).await }
353 });
354
355 let pending = wait_for_one(&queue).await;
356 assert_eq!(pending.subject, "clock");
357 assert_eq!(pending.cap_id, "wasi:filesystem");
358 assert_eq!(pending.key, "/data");
359 assert!(pending.asked_at > 1_577_836_800);
360
361 assert!(queue.resolve(pending.id, true).is_some());
362 assert!(asking.await.unwrap(), "allowing must wake the caller");
363 assert!(queue.pending().is_empty());
364 }
365
366 #[tokio::test]
367 async fn denying_wakes_the_caller_with_a_refusal() {
368 let queue = queue();
369 let asking = tokio::spawn({
370 let queue = queue.clone();
371 async move { queue.ask("clock", 1, &ask("/etc")).await }
372 });
373
374 let pending = wait_for_one(&queue).await;
375 queue.resolve(pending.id, false);
376
377 assert!(!asking.await.unwrap());
378 }
379
380 #[tokio::test]
381 async fn answering_a_question_nobody_asked_reports_it() {
382 let queue = queue();
383 assert!(queue.resolve(999, true).is_none());
384 }
385
386 #[tokio::test]
387 async fn a_question_cannot_be_answered_twice() {
388 let queue = queue();
389 let asking = tokio::spawn({
390 let queue = queue.clone();
391 async move { queue.ask("clock", 1, &ask("/data")).await }
392 });
393 let pending = wait_for_one(&queue).await;
394
395 assert!(queue.resolve(pending.id, true).is_some());
396 assert!(
397 queue.resolve(pending.id, false).is_none(),
398 "it is no longer waiting"
399 );
400 assert!(asking.await.unwrap());
401 }
402
403 #[tokio::test]
405 async fn an_unanswered_question_expires_into_a_refusal() {
406 let queue = Arc::new(ConsentQueue::new(Duration::from_millis(50)));
407
408 let decision = queue.ask("clock", 1, &ask("/data")).await;
409
410 assert!(!decision, "expiry denies");
411 assert!(queue.pending().is_empty(), "and stops waiting");
412 }
413
414 #[tokio::test]
415 async fn two_questions_are_answered_independently() {
416 let queue = queue();
417 let first = tokio::spawn({
418 let queue = queue.clone();
419 async move { queue.ask("clock", 1, &ask("/a")).await }
420 });
421 let second = tokio::spawn({
422 let queue = queue.clone();
423 async move { queue.ask("db", 2, &ask("/b")).await }
424 });
425
426 let mut pending = Vec::new();
427 while pending.len() < 2 {
428 tokio::time::sleep(Duration::from_millis(5)).await;
429 pending = queue.pending();
430 }
431 assert_ne!(pending[0].id, pending[1].id);
432
433 let by_key = |k: &str| pending.iter().find(|p| p.key == k).unwrap().id;
434 queue.resolve(by_key("/a"), true);
435 queue.resolve(by_key("/b"), false);
436
437 assert!(first.await.unwrap());
438 assert!(!second.await.unwrap());
439 }
440
441 #[tokio::test]
442 async fn the_prompter_reports_that_a_human_can_be_reached() {
443 let prompter = QueuePrompter::new(queue(), "clock", 1);
444 assert!(prompter.has_channel());
445 }
446
447 async fn wait_for_one(queue: &ConsentQueue) -> PendingConsent {
448 for _ in 0..200 {
449 if let Some(entry) = queue.pending().into_iter().next() {
450 return entry;
451 }
452 tokio::time::sleep(Duration::from_millis(5)).await;
453 }
454 panic!("the question never reached the queue");
455 }
456}