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