Skip to main content

m4a_agent/
resolver.rs

1//! Which session is this process? One question, asked of ordered sources (a host's agents
2//! directory, the CLI's active sessions, a provider hook, the OS attestation of the local mail
3//! node...). Sources only name the session; identity, nick and credentials are the
4//! [`IdentityStore`]'s business, so they cannot disagree about them.
5
6use crate::error::{AgentError, Result};
7use crate::identity::{IdentityStore, SessionIdentity};
8use crate::backend::BackendKind;
9
10/// What a source knows about the current session.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct ResolvedSession {
13    /// The one session id every other part of the client uses.
14    pub session_id: String,
15    /// The tier-1 session id (the local mail node's attested id), when the source knows it.
16    pub local_session: Option<String>,
17    /// Which source answered (for logs and errors).
18    pub source: &'static str,
19}
20
21pub trait SessionResolver: Send + Sync {
22    fn name(&self) -> &'static str;
23    /// `Ok(None)`: this source does not know the session. `Err`: it should know and failed.
24    fn resolve(&self) -> Result<Option<ResolvedSession>>;
25}
26
27/// A source that always answers the same (CLI flag, test).
28pub struct FixedResolver(pub ResolvedSession);
29
30impl SessionResolver for FixedResolver {
31    fn name(&self) -> &'static str {
32        self.0.source
33    }
34    fn resolve(&self) -> Result<Option<ResolvedSession>> {
35        Ok(Some(self.0.clone()))
36    }
37}
38
39/// Ordered sources. The first that knows wins; a lower source that knows something DIFFERENT is an
40/// error, so two paths can never silently produce two identities for one process.
41#[derive(Default)]
42pub struct ResolverChain {
43    sources: Vec<Box<dyn SessionResolver>>,
44}
45
46impl ResolverChain {
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    pub fn with(mut self, r: impl SessionResolver + 'static) -> Self {
52        self.sources.push(Box::new(r));
53        self
54    }
55
56    pub fn resolve(&self) -> Result<ResolvedSession> {
57        let mut first: Option<ResolvedSession> = None;
58        for s in &self.sources {
59            let Some(found) = s.resolve()? else { continue };
60            match &first {
61                None => first = Some(found),
62                Some(f) if f.session_id != found.session_id => {
63                    return Err(AgentError::Identity(format!("sources disagree about the session: {} says one id, {} another", f.source, found.source)));
64                }
65                Some(_) => {
66                    // Same session: the tier-1 link may come from a lower source.
67                    if let (Some(f), Some(l)) = (first.as_mut(), found.local_session) {
68                        match &f.local_session {
69                            None => f.local_session = Some(l),
70                            Some(have) if *have != l => return Err(AgentError::Identity("sources disagree about the local session".into())),
71                            Some(_) => {}
72                        }
73                    }
74                }
75            }
76        }
77        first.ok_or_else(|| AgentError::Identity("no source knows which session this is".into()))
78    }
79
80    /// Resolve the session, then its identity for `tier` on `server_ref`, with the tier-1 link
81    /// recorded. The one entry point a host uses.
82    pub fn identity(&self, ids: &IdentityStore, tier: BackendKind, server_ref: &str) -> Result<SessionIdentity> {
83        let r = self.resolve()?;
84        let mut id = ids.resolve(&r.session_id, tier, server_ref)?;
85        if let Some(l) = &r.local_session {
86            ids.bind_local(&mut id, l)?;
87        }
88        Ok(id)
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::vault::MemoryVault;
96    use std::sync::Arc;
97
98    struct Nothing;
99    impl SessionResolver for Nothing {
100        fn name(&self) -> &'static str {
101            "nothing"
102        }
103        fn resolve(&self) -> Result<Option<ResolvedSession>> {
104            Ok(None)
105        }
106    }
107    fn fixed(id: &str, local: Option<&str>, source: &'static str) -> FixedResolver {
108        FixedResolver(ResolvedSession { session_id: id.into(), local_session: local.map(Into::into), source })
109    }
110
111    #[test]
112    fn first_source_wins_and_lower_ones_can_add_the_local_link() {
113        let c = ResolverChain::new().with(Nothing).with(fixed("s1", None, "agents-dir")).with(fixed("s1", Some("l1"), "attestation"));
114        let r = c.resolve().unwrap();
115        assert_eq!((r.session_id.as_str(), r.local_session.as_deref(), r.source), ("s1", Some("l1"), "agents-dir"));
116        let ids = IdentityStore::new(Arc::new(MemoryVault::new()));
117        let id = c.identity(&ids, BackendKind::Server, "https://p.example").unwrap();
118        assert_eq!(id.local_session.as_deref(), Some("l1"));
119    }
120
121    #[test]
122    fn disagreeing_sources_and_no_source_are_errors() {
123        assert!(ResolverChain::new().with(fixed("s1", None, "a")).with(fixed("s2", None, "b")).resolve().is_err());
124        assert!(ResolverChain::new().with(fixed("s1", Some("l1"), "a")).with(fixed("s1", Some("l2"), "b")).resolve().is_err());
125        assert!(ResolverChain::new().with(Nothing).resolve().is_err());
126    }
127}