Skip to main content

synapse_proxy/
context.rs

1//! Bound context: a generalized key→value bag the proxy injects into forwarded
2//! requests. A permanent base (static ⊕ env, built at startup with env winning)
3//! is overlaid by an optional pushed binding with a TTL; pushed keys win while
4//! live and the overlay reverts on expiry. Single active binding.
5
6use std::collections::HashMap;
7use std::sync::Mutex;
8use std::time::{Duration, Instant};
9
10/// The resolved view handed to transforms.
11#[derive(Debug, Clone, Default)]
12pub struct ResolvedContext {
13    values: HashMap<String, String>,
14}
15
16impl ResolvedContext {
17    pub fn get(&self, key: &str) -> Option<&str> {
18        self.values.get(key).map(String::as_str)
19    }
20    pub fn contains(&self, key: &str) -> bool {
21        self.values.contains_key(key)
22    }
23}
24
25struct Overlay {
26    values: HashMap<String, String>,
27    expires_at: Option<Instant>,
28}
29
30/// Holds the permanent base and an optional pushed overlay.
31pub struct ContextStore {
32    base: HashMap<String, String>,
33    overlay: Mutex<Option<Overlay>>,
34}
35
36impl ContextStore {
37    /// `base` is the merged static ⊕ env map (env precedence applied by the caller).
38    pub fn new(base: HashMap<String, String>) -> Self {
39        Self {
40            base,
41            overlay: Mutex::new(None),
42        }
43    }
44
45    /// Replace the overlay with `values`, expiring after `ttl` (None = no expiry).
46    pub fn push(&self, values: HashMap<String, String>, ttl: Option<Duration>) {
47        let expires_at = ttl.map(|d| Instant::now() + d);
48        *self.overlay.lock().unwrap() = Some(Overlay { values, expires_at });
49    }
50
51    /// Drop the overlay, reverting to base.
52    pub fn clear(&self) {
53        *self.overlay.lock().unwrap() = None;
54    }
55
56    pub fn resolve(&self) -> ResolvedContext {
57        self.resolve_at(Instant::now())
58    }
59
60    /// Base overlaid by a live overlay (overlay keys win). Expired overlay is dropped.
61    pub fn resolve_at(&self, now: Instant) -> ResolvedContext {
62        let mut values = self.base.clone();
63        let mut guard = self.overlay.lock().unwrap();
64        if let Some(o) = guard.as_ref() {
65            if o.expires_at.map(|e| now >= e).unwrap_or(false) {
66                *guard = None; // expired → revert to base
67            } else {
68                for (k, v) in &o.values {
69                    values.insert(k.clone(), v.clone());
70                }
71            }
72        }
73        ResolvedContext { values }
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    fn base() -> HashMap<String, String> {
82        HashMap::from([("org".to_string(), "base-org".to_string())])
83    }
84
85    #[test]
86    fn resolves_base_when_no_overlay() {
87        let s = ContextStore::new(base());
88        let c = s.resolve();
89        assert_eq!(c.get("org"), Some("base-org"));
90        assert_eq!(c.get("missing"), None);
91    }
92
93    #[test]
94    fn live_overlay_overrides_base() {
95        let s = ContextStore::new(base());
96        s.push(
97            HashMap::from([
98                ("org".into(), "pushed".into()),
99                ("workspace".into(), "ws".into()),
100            ]),
101            Some(Duration::from_secs(3600)),
102        );
103        let c = s.resolve();
104        assert_eq!(c.get("org"), Some("pushed")); // overlay wins
105        assert_eq!(c.get("workspace"), Some("ws"));
106    }
107
108    #[test]
109    fn expired_overlay_reverts_to_base() {
110        let s = ContextStore::new(base());
111        let now = Instant::now();
112        s.push(
113            HashMap::from([("org".into(), "pushed".into())]),
114            Some(Duration::from_secs(10)),
115        );
116        // resolve far in the future → overlay expired
117        let c = s.resolve_at(now + Duration::from_secs(20));
118        assert_eq!(c.get("org"), Some("base-org"));
119    }
120
121    #[test]
122    fn clear_drops_overlay() {
123        let s = ContextStore::new(base());
124        s.push(HashMap::from([("org".into(), "pushed".into())]), None);
125        s.clear();
126        assert_eq!(s.resolve().get("org"), Some("base-org"));
127    }
128}