Skip to main content

subc_daemon/
operator_confirm.rs

1//! Operator confirmation policy and its blocking provider boundary.
2//!
3//! The forwarding lock precedes the state lock. Providers and deliveries never
4//! acquire the forwarding lock, and neither can hold up the policy timers.
5use std::{
6    collections::{HashMap, VecDeque},
7    sync::{Arc, Mutex},
8    time::Duration,
9};
10
11use subc_protocol::{
12    error_codes,
13    session::{OperatorConfirmReply, OperatorConfirmRequest},
14    ErrorBody, Flags, FrameType, Principal, Priority,
15};
16use tokio::{
17    sync::Notify,
18    time::{timeout_at, Instant},
19};
20
21use crate::{
22    forwarding::{ModuleEndpointId, ModuleRouteKey, RouteBinding},
23    router::RouteCtx,
24    Frame,
25};
26
27/// A blocking provider publishes a withdraw handle as soon as one exists, then
28/// returns only when its prompt has ended. Publishing is allowed to be delayed:
29/// the daemon's timeout also covers setup and a delayed handle is still withdrawn.
30pub trait OperatorProvider: Send + Sync + 'static {
31    fn prompt(
32        &self,
33        text: &str,
34        publish: Box<dyn FnOnce(Arc<dyn OperatorWithdraw>) + Send>,
35    ) -> ProviderResult;
36}
37
38/// Called on a blocking thread, never while a daemon lock is held.
39pub trait OperatorWithdraw: Send + Sync + 'static {
40    fn withdraw(&self, reason: &str);
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum ProviderResult {
45    Approved,
46    Declined,
47    Unavailable,
48    UnsupportedPlatform,
49    NoPresence,
50}
51
52#[derive(Debug, Clone, Copy)]
53pub struct OperatorLimits {
54    pub queue_wait: Duration,
55    pub prompt_timeout: Duration,
56    pub stuck_grace: Duration,
57}
58
59impl Default for OperatorLimits {
60    fn default() -> Self {
61        Self {
62            queue_wait: Duration::from_secs(150),
63            prompt_timeout: Duration::from_secs(120),
64            stuck_grace: Duration::from_secs(10),
65        }
66    }
67}
68
69impl OperatorLimits {
70    fn delivery_budget(self) -> Duration {
71        self.queue_wait + self.prompt_timeout + self.stuck_grace + Duration::from_secs(10)
72    }
73}
74
75struct OsProvider;
76impl OperatorProvider for OsProvider {
77    fn prompt(
78        &self,
79        _: &str,
80        _: Box<dyn FnOnce(Arc<dyn OperatorWithdraw>) + Send>,
81    ) -> ProviderResult {
82        // On Linux the prompt comes from polkit, whose agents may show only the
83        // fixed message of an installed action file, not this request's summary.
84        // A person must see what they approve, so Linux refuses rather than prompts.
85        #[cfg(target_os = "linux")]
86        {
87            ProviderResult::UnsupportedPlatform
88        }
89        #[cfg(not(target_os = "linux"))]
90        {
91            ProviderResult::Unavailable
92        }
93    }
94}
95
96#[derive(Clone, Copy, Debug)]
97pub(crate) struct Outcome {
98    pub code: &'static str,
99    pub reason: &'static str,
100}
101impl Outcome {
102    fn unavailable(reason: &'static str) -> Self {
103        Self {
104            code: error_codes::OPERATOR_PRESENCE_UNAVAILABLE,
105            reason,
106        }
107    }
108    fn declined(reason: &'static str) -> Self {
109        Self {
110            code: error_codes::OPERATOR_DECLINED,
111            reason,
112        }
113    }
114    pub(crate) fn refusal(code: &'static str) -> Self {
115        Self { code, reason: "" }
116    }
117    fn from_provider(result: ProviderResult) -> Self {
118        match result {
119            ProviderResult::Approved => Self {
120                code: "confirmed",
121                reason: "",
122            },
123            ProviderResult::Declined => Self::declined("person"),
124            ProviderResult::Unavailable => Self::unavailable("provider_error"),
125            ProviderResult::UnsupportedPlatform => Self::unavailable("unsupported_platform"),
126            ProviderResult::NoPresence => Self::unavailable("no_presence"),
127        }
128    }
129    pub(crate) fn frame(self, request: &Frame) -> Frame {
130        let (ty, body) = if self.code == "confirmed" {
131            (
132                FrameType::Response,
133                serde_json::to_vec(&OperatorConfirmReply::confirmed()).expect("serializable reply"),
134            )
135        } else {
136            let mut error = ErrorBody::new(self.code, "operator confirmation refused");
137            if !self.reason.is_empty() {
138                error = error.with_detail(serde_json::json!({"reason": self.reason}));
139            }
140            (
141                FrameType::Error,
142                serde_json::to_vec(&error).expect("serializable error"),
143            )
144        };
145        Frame::build_with_version(
146            request.header.ver,
147            ty,
148            Flags::new(false, Priority::Passive, false),
149            0,
150            0,
151            request.header.corr,
152            body,
153        )
154        .expect("bounded control reply")
155    }
156}
157
158pub(crate) fn audit(
159    module_id: &str,
160    summary: &str,
161    principal: &str,
162    outcome: Outcome,
163    wait: Duration,
164    prompt: Duration,
165    prompt_shown: bool,
166) {
167    tracing::info!(target: "subc_daemon::operator_confirm", module_id, summary = ?summary,
168        principal, outcome = outcome.code, reason = outcome.reason,
169        wait_ms = wait.as_millis() as u64, prompt_ms = prompt.as_millis() as u64,
170        prompt_shown, "operator_confirm_audit");
171}
172
173fn principal_label(principal: &Principal) -> String {
174    match principal {
175        Principal::Direct => "direct".into(),
176        Principal::Reserved { module_id } => format!("reserved:{module_id}"),
177        Principal::Unverified => String::new(),
178    }
179}
180
181fn valid_summary(summary: &str) -> bool {
182    let count = summary.chars().count();
183    (1..=200).contains(&count)
184        && !summary.starts_with(char::is_whitespace)
185        && !summary.ends_with(char::is_whitespace)
186        && !summary.chars().any(|c| {
187            c.is_control()
188                || matches!(c,
189            '\u{200b}'..='\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2028}' | '\u{2029}' |
190            '\u{2060}'..='\u{2069}' | '\u{feff}')
191        })
192}
193
194struct Request {
195    id: u64,
196    module_id: String,
197    summary: String,
198    principal: String,
199    text: String,
200    key: ModuleRouteKey,
201    ctx: RouteCtx,
202    frame: Frame,
203    arrived: Instant,
204    delivery_deadline: Instant,
205}
206struct Active {
207    request: Arc<Request>,
208    started: Option<Instant>,
209    handle: Option<Arc<dyn OperatorWithdraw>>,
210    committed: bool,
211    withdrawn: Option<(Instant, &'static str)>,
212    withdraw_started: bool,
213}
214struct State {
215    provider: Arc<dyn OperatorProvider>,
216    limits: OperatorLimits,
217    queue: VecDeque<Arc<Request>>,
218    modules: HashMap<String, u64>,
219    backoff: HashMap<(String, String), Instant>,
220    prompt: Option<Active>,
221    stuck: bool,
222    running: bool,
223    next_id: u64,
224    #[cfg(test)]
225    deliveries: usize,
226}
227
228pub(crate) struct OperatorConfirms {
229    state: Mutex<State>,
230    wake: Notify,
231}
232impl std::fmt::Debug for OperatorConfirms {
233    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234        f.debug_struct("OperatorConfirms").finish_non_exhaustive()
235    }
236}
237impl Default for OperatorConfirms {
238    fn default() -> Self {
239        Self {
240            state: Mutex::new(State {
241                provider: Arc::new(OsProvider),
242                limits: OperatorLimits::default(),
243                queue: VecDeque::new(),
244                modules: HashMap::new(),
245                backoff: HashMap::new(),
246                prompt: None,
247                stuck: false,
248                running: false,
249                next_id: 0,
250                #[cfg(test)]
251                deliveries: 0,
252            }),
253            wake: Notify::new(),
254        }
255    }
256}
257impl OperatorConfirms {
258    fn lock(&self) -> std::sync::MutexGuard<'_, State> {
259        self.state.lock().unwrap_or_else(|p| p.into_inner())
260    }
261    pub(crate) fn configure(&self, provider: Arc<dyn OperatorProvider>, limits: OperatorLimits) {
262        let mut state = self.lock();
263        assert!(
264            !state.running && state.modules.is_empty(),
265            "configure before serving"
266        );
267        state.provider = provider;
268        state.limits = limits;
269    }
270    pub(crate) fn configure_bootstrap(&self, config: &crate::bootstrap::BootstrapConfig) {
271        let provider: Arc<dyn OperatorProvider> = Arc::new(OsProvider);
272        #[cfg(feature = "test-support")]
273        let provider =
274            test_provider::select(provider, config, std::env::current_exe().ok().as_deref());
275        self.configure(
276            provider,
277            OperatorLimits {
278                queue_wait: config.operator_queue_wait,
279                prompt_timeout: config.operator_timeout,
280                stuck_grace: config.operator_stuck_grace,
281            },
282        );
283    }
284
285    /// Called while the forwarding read lock still protects this binding. All
286    /// registration/nonce lookups must have finished before entering this method.
287    pub(crate) fn admit(
288        self: &Arc<Self>,
289        ctx: &RouteCtx,
290        frame: Frame,
291        module_id: String,
292        nonce_proven: bool,
293        request: OperatorConfirmRequest,
294        binding: Option<&RouteBinding>,
295    ) -> Vec<Frame> {
296        let principal = binding
297            .map(|b| principal_label(&b.principal))
298            .unwrap_or_default();
299        let mut state = self.lock();
300        let now = Instant::now();
301        state.backoff.retain(|_, until| *until > now);
302        let refusal = if !nonce_proven
303            || binding.is_none_or(|b| matches!(b.principal, Principal::Unverified))
304        {
305            Some(Outcome::refusal(
306                error_codes::OPERATOR_REQUEST_NOT_PERMITTED,
307            ))
308        } else if !valid_summary(&request.summary) {
309            Some(Outcome::refusal(error_codes::OPERATOR_SUMMARY_INVALID))
310        } else if state
311            .backoff
312            .contains_key(&(module_id.clone(), principal.clone()))
313        {
314            Some(Outcome::declined("backoff"))
315        } else if state.stuck {
316            Some(Outcome::unavailable("provider_stuck"))
317        } else if state.modules.contains_key(&module_id) {
318            Some(Outcome::unavailable("module_limit"))
319        } else if state.queue.len() >= 4 {
320            Some(Outcome::unavailable("queue_full"))
321        } else {
322            None
323        };
324        if let Some(outcome) = refusal {
325            audit(
326                &module_id,
327                &request.summary,
328                &principal,
329                outcome,
330                Duration::ZERO,
331                Duration::ZERO,
332                false,
333            );
334            return vec![outcome.frame(&frame)];
335        }
336        let binding = binding.expect("permission checked");
337        let opener = match &binding.principal {
338            Principal::Direct => "a local program",
339            Principal::Reserved { module_id } => module_id,
340            Principal::Unverified => unreachable!(),
341        };
342        state.next_id += 1;
343        let pending = Arc::new(Request {
344            id: state.next_id,
345            text: format!(
346                "{module_id} asks: {} (requested by {opener})",
347                request.summary
348            ),
349            module_id,
350            summary: request.summary,
351            principal,
352            key: ModuleRouteKey {
353                endpoint: binding.module_endpoint,
354                channel: binding.module_channel,
355            },
356            ctx: ctx.clone(),
357            frame,
358            arrived: now,
359            delivery_deadline: now + state.limits.delivery_budget(),
360        });
361        state.modules.insert(pending.module_id.clone(), pending.id);
362        if state.prompt.is_none() && state.queue.is_empty() {
363            state.prompt = Some(Active {
364                request: pending,
365                started: None,
366                handle: None,
367                committed: false,
368                withdrawn: None,
369                withdraw_started: false,
370            });
371        } else {
372            state.queue.push_back(pending);
373        }
374        if !state.running {
375            state.running = true;
376            tokio::spawn(Arc::clone(self).run());
377        }
378        self.wake.notify_one();
379        Vec::new()
380    }
381
382    fn commit(
383        self: &Arc<Self>,
384        state: &mut State,
385        request: Arc<Request>,
386        outcome: Outcome,
387        started: Option<Instant>,
388        shown: bool,
389    ) {
390        let now = Instant::now();
391        let wait = started.unwrap_or(now).duration_since(request.arrived);
392        let prompt = started.map(|at| now.duration_since(at)).unwrap_or_default();
393        if outcome.reason == "person" {
394            state.backoff.insert(
395                (request.module_id.clone(), request.principal.clone()),
396                now + Duration::from_secs(30),
397            );
398        }
399        audit(
400            &request.module_id,
401            &request.summary,
402            &request.principal,
403            outcome,
404            wait,
405            prompt,
406            shown,
407        );
408        let this = Arc::clone(self);
409        #[cfg(test)]
410        {
411            state.deliveries += 1;
412        }
413        tokio::spawn(async move {
414            let answer = outcome.frame(&request.frame);
415            match timeout_at(request.delivery_deadline, request.ctx.egress.send(answer)).await {
416                Ok(Ok(())) => {}
417                result => {
418                    tracing::warn!(target: "subc_daemon::operator_confirm", module_id = request.module_id, ?result, "operator confirm answer dropped")
419                }
420            }
421            let mut state = this.lock();
422            #[cfg(test)]
423            {
424                state.deliveries -= 1;
425            }
426            if state.modules.get(&request.module_id) == Some(&request.id) {
427                state.modules.remove(&request.module_id);
428            }
429        });
430    }
431
432    fn withdraw_matching(
433        self: &Arc<Self>,
434        predicate: impl Fn(&Request) -> bool,
435        reason: &'static str,
436    ) -> bool {
437        let mut state = self.lock();
438        let mut matched = false;
439        let mut index = 0;
440        while index < state.queue.len() {
441            if predicate(&state.queue[index]) {
442                let request = state.queue.remove(index).expect("queue index");
443                self.commit(&mut state, request, Outcome::declined(reason), None, false);
444                matched = true;
445            } else {
446                index += 1;
447            }
448        }
449        if let Some(active) = state
450            .prompt
451            .as_mut()
452            .filter(|a| !a.committed && predicate(&a.request))
453        {
454            active.committed = true;
455            active.withdrawn = Some((Instant::now(), reason));
456            let (request, started, shown) = (
457                Arc::clone(&active.request),
458                active.started,
459                active.handle.is_some(),
460            );
461            self.commit(
462                &mut state,
463                request,
464                Outcome::declined(reason),
465                started,
466                shown,
467            );
468            matched = true;
469        }
470        self.wake.notify_one();
471        matched
472    }
473    pub(crate) fn route_closed(self: &Arc<Self>, key: ModuleRouteKey) {
474        self.withdraw_matching(|r| r.key == key, "route_closed");
475    }
476    pub(crate) fn module_closed(self: &Arc<Self>, endpoint: ModuleEndpointId) {
477        self.withdraw_matching(|r| r.key.endpoint == endpoint, "module_closed");
478    }
479    pub(crate) fn cancel(self: &Arc<Self>, connection: crate::ConnectionId, corr: u64) -> bool {
480        self.withdraw_matching(
481            |r| r.ctx.connection_id == connection && r.frame.header.corr == corr,
482            "caller_cancelled",
483        )
484    }
485
486    fn publish(self: &Arc<Self>, id: u64, handle: Arc<dyn OperatorWithdraw>) {
487        let mut state = self.lock();
488        if let Some(active) = state.prompt.as_mut().filter(|a| a.request.id == id) {
489            active.handle = Some(Arc::clone(&handle));
490            if let Some((_, reason)) = active.withdrawn {
491                active.withdraw_started = true;
492                tokio::task::spawn_blocking(move || handle.withdraw(reason));
493            }
494        }
495        self.wake.notify_one();
496    }
497    fn returned(self: &Arc<Self>, id: u64, result: ProviderResult) {
498        let mut state = self.lock();
499        let Some(active) = state.prompt.take() else {
500            return;
501        };
502        assert_eq!(active.request.id, id);
503        // A fast late return can beat the prompt task's wake-up. The decision
504        // still requires a withdraw, even though the result is discarded.
505        if !active.withdraw_started {
506            if let (Some(handle), Some((_, reason))) = (active.handle.as_ref(), active.withdrawn) {
507                let handle = Arc::clone(handle);
508                tokio::task::spawn_blocking(move || handle.withdraw(reason));
509            }
510        }
511        if !active.committed {
512            self.commit(
513                &mut state,
514                active.request,
515                Outcome::from_provider(result),
516                active.started,
517                active.handle.is_some(),
518            );
519        } else {
520            tracing::debug!(target: "subc_daemon::operator_confirm", ?result, "late operator provider result discarded");
521        }
522        state.stuck = false;
523        self.wake.notify_one();
524    }
525
526    async fn run(self: Arc<Self>) {
527        loop {
528            let deadline = {
529                let mut state = self.lock();
530                let now = Instant::now();
531                let mut index = 0;
532                while index < state.queue.len() {
533                    if state.queue[index].arrived + state.limits.queue_wait <= now {
534                        let request = state.queue.remove(index).expect("queue index");
535                        self.commit(
536                            &mut state,
537                            request,
538                            Outcome::unavailable("queue_wait"),
539                            None,
540                            false,
541                        );
542                    } else {
543                        index += 1;
544                    }
545                }
546                let timeout = state.limits.prompt_timeout;
547                if let Some(active) = state
548                    .prompt
549                    .as_mut()
550                    .filter(|a| !a.committed && a.started.is_some_and(|at| at + timeout <= now))
551                {
552                    active.committed = true;
553                    active.withdrawn = Some((now, "timeout"));
554                    let (request, started, shown) = (
555                        Arc::clone(&active.request),
556                        active.started,
557                        active.handle.is_some(),
558                    );
559                    self.commit(
560                        &mut state,
561                        request,
562                        Outcome::unavailable("timeout"),
563                        started,
564                        shown,
565                    );
566                }
567                let grace = state.limits.stuck_grace;
568                if !state.stuck
569                    && state.prompt.as_ref().is_some_and(|a| {
570                        a.started.is_some() && a.withdrawn.is_some_and(|(at, _)| at + grace <= now)
571                    })
572                {
573                    state.stuck = true;
574                    tracing::error!(target: "subc_daemon::operator_confirm", "operator provider stuck after withdraw");
575                    while let Some(request) = state.queue.pop_front() {
576                        self.commit(
577                            &mut state,
578                            request,
579                            Outcome::unavailable("provider_stuck"),
580                            None,
581                            false,
582                        );
583                    }
584                }
585                if let Some(active) = state
586                    .prompt
587                    .as_mut()
588                    .filter(|a| !a.withdraw_started && a.withdrawn.is_some() && a.handle.is_some())
589                {
590                    active.withdraw_started = true;
591                    let handle = Arc::clone(active.handle.as_ref().expect("handle checked"));
592                    let reason = active.withdrawn.expect("withdraw checked").1;
593                    tokio::task::spawn_blocking(move || handle.withdraw(reason));
594                }
595                // A reserved prompt cancelled before setup needs no provider call.
596                if state
597                    .prompt
598                    .as_ref()
599                    .is_some_and(|a| a.started.is_none() && a.committed)
600                {
601                    state.prompt = None;
602                }
603                if state.prompt.is_none() {
604                    if let Some(request) = state.queue.pop_front() {
605                        state.prompt = Some(Active {
606                            request,
607                            started: None,
608                            handle: None,
609                            committed: false,
610                            withdrawn: None,
611                            withdraw_started: false,
612                        });
613                    }
614                }
615                if let Some(active) = state.prompt.as_mut().filter(|a| a.started.is_none()) {
616                    active.started = Some(now);
617                    let request = Arc::clone(&active.request);
618                    let provider = Arc::clone(&state.provider);
619                    let this = Arc::clone(&self);
620                    tokio::spawn(async move {
621                        let publish_to = Arc::clone(&this);
622                        let id = request.id;
623                        let result = tokio::task::spawn_blocking(move || {
624                            provider.prompt(
625                                &request.text,
626                                Box::new(move |handle| publish_to.publish(id, handle)),
627                            )
628                        })
629                        .await
630                        .unwrap_or(ProviderResult::Unavailable);
631                        this.returned(id, result);
632                    });
633                }
634                if state.prompt.is_none() && state.queue.is_empty() {
635                    state.running = false;
636                    return;
637                }
638                let mut deadline = state
639                    .queue
640                    .iter()
641                    .map(|r| r.arrived + state.limits.queue_wait)
642                    .min();
643                if let Some(active) = &state.prompt {
644                    let next = if !active.committed {
645                        active.started.map(|at| at + timeout)
646                    } else if !state.stuck {
647                        active.withdrawn.map(|(at, _)| at + grace)
648                    } else {
649                        None
650                    };
651                    if let Some(next) = next {
652                        deadline = Some(deadline.map_or(next, |d| d.min(next)));
653                    }
654                }
655                deadline
656            };
657            if let Some(at) = deadline {
658                tokio::select! { _ = self.wake.notified() => {}, _ = tokio::time::sleep_until(at) => {} }
659            } else {
660                self.wake.notified().await;
661            }
662        }
663    }
664}
665
666#[cfg(feature = "test-support")]
667mod test_provider;
668#[cfg(test)]
669mod tests;