Skip to main content

aimdb_core/remote/
config.rs

1//! Configuration types for AimX remote access
2
3use alloc::string::String;
4use alloc::vec::Vec;
5use hashbrown::HashSet;
6
7use crate::record_id::StringKey;
8
9/// Configuration for AimX remote access
10///
11/// Defines how the remote access layer behaves, including socket path,
12/// security policy and connection / subscription limits.
13#[derive(Debug, Clone)]
14pub struct AimxConfig {
15    /// Path to Unix domain socket
16    pub socket_path: String,
17
18    /// Security policy (read-only or read-write)
19    pub security_policy: SecurityPolicy,
20
21    /// Maximum number of concurrent client connections accepted by the
22    /// supervisor. When the in-flight connection count reaches this
23    /// value, newly-accepted Unix sockets are closed immediately (the
24    /// client sees a closed socket on connect).
25    pub max_connections: usize,
26
27    /// Maximum number of concurrent subscriptions allowed per connection.
28    /// Once a connection holds this many subscriptions, further
29    /// `record.subscribe` calls receive a `too_many_subscriptions` error
30    /// until one is released via `record.unsubscribe`.
31    pub max_subs_per_connection: usize,
32
33    /// Optional authentication token
34    pub auth_token: Option<String>,
35
36    /// File permissions for the socket (Unix only)
37    /// Format: octal mode (e.g., 0o600 for owner-only)
38    pub socket_permissions: Option<u32>,
39}
40
41impl AimxConfig {
42    /// Creates a default UDS configuration
43    ///
44    /// # Defaults
45    /// - Socket path: `/tmp/aimdb.sock`
46    /// - Security policy: Read-only
47    /// - Max connections: 16
48    /// - Max subscriptions per connection: 32
49    /// - No auth token
50    /// - Socket permissions: 0o600 (owner-only)
51    pub fn uds_default() -> Self {
52        Self {
53            socket_path: String::from("/tmp/aimdb.sock"),
54            security_policy: SecurityPolicy::ReadOnly,
55            max_connections: 16,
56            max_subs_per_connection: 32,
57            auth_token: None,
58            socket_permissions: Some(0o600),
59        }
60    }
61
62    /// Sets the socket path
63    pub fn socket_path(mut self, path: impl Into<String>) -> Self {
64        self.socket_path = path.into();
65        self
66    }
67
68    /// Sets the security policy
69    pub fn security_policy(mut self, policy: SecurityPolicy) -> Self {
70        self.security_policy = policy;
71        self
72    }
73
74    /// Sets the maximum number of concurrent connections
75    pub fn max_connections(mut self, max: usize) -> Self {
76        self.max_connections = max;
77        self
78    }
79
80    /// Sets the maximum number of concurrent subscriptions per connection
81    pub fn max_subs_per_connection(mut self, max: usize) -> Self {
82        self.max_subs_per_connection = max;
83        self
84    }
85
86    /// Sets an authentication token
87    pub fn auth_token(mut self, token: impl Into<String>) -> Self {
88        self.auth_token = Some(token.into());
89        self
90    }
91
92    /// Sets the socket file permissions (Unix only)
93    ///
94    /// # Example
95    /// ```rust
96    /// # use aimdb_core::remote::AimxConfig;
97    /// let config = AimxConfig::uds_default()
98    ///     .socket_permissions(0o600); // Owner only (0o660: owner + group)
99    /// ```
100    pub fn socket_permissions(mut self, mode: u32) -> Self {
101        self.socket_permissions = Some(mode);
102        self
103    }
104}
105
106/// Security policy for remote access
107///
108/// Defines which operations are permitted and for which records.
109#[derive(Debug, Clone)]
110pub enum SecurityPolicy {
111    /// Read-only access (list, get, subscribe)
112    ///
113    /// This is the default and recommended policy for most deployments.
114    /// No write operations are permitted.
115    ReadOnly,
116
117    /// Read-write access with explicit per-record opt-in
118    ///
119    /// Write operations (`record.set`) are only allowed for records
120    /// whose RecordKey is in the `writable_records` set.
121    ReadWrite {
122        /// Set of RecordKeys that allow write operations
123        writable_records: HashSet<String>,
124    },
125}
126
127impl SecurityPolicy {
128    /// Creates a read-only policy
129    pub fn read_only() -> Self {
130        Self::ReadOnly
131    }
132
133    /// Creates a read-write policy with no writable records initially
134    pub fn read_write() -> Self {
135        Self::ReadWrite {
136            writable_records: HashSet::new(),
137        }
138    }
139
140    /// Adds a record key to the writable set
141    ///
142    /// Only has effect for ReadWrite policies. Panics if policy is ReadOnly.
143    #[allow(
144        clippy::panic,
145        reason = "documented API contract; a Result instead is a breaking change"
146    )]
147    pub fn allow_write_key(&mut self, key: impl Into<String>) {
148        match self {
149            Self::ReadWrite { writable_records } => {
150                writable_records.insert(key.into());
151            }
152            Self::ReadOnly => {
153                panic!("Cannot allow writes in ReadOnly security policy");
154            }
155        }
156    }
157
158    /// Adds a record key to the writable set (builder pattern)
159    ///
160    /// # Panics
161    /// Panics if called on a ReadOnly policy
162    #[allow(
163        clippy::panic,
164        reason = "documented API contract; a Result instead is a breaking change"
165    )]
166    pub fn with_writable_key(mut self, key: impl Into<String>) -> Self {
167        match self {
168            Self::ReadWrite {
169                ref mut writable_records,
170            } => {
171                writable_records.insert(key.into());
172                self
173            }
174            Self::ReadOnly => {
175                panic!("Cannot allow writes in ReadOnly security policy");
176            }
177        }
178    }
179
180    /// Checks if a record key is writable
181    pub fn is_writable_key(&self, key: &str) -> bool {
182        match self {
183            Self::ReadOnly => false,
184            Self::ReadWrite { writable_records } => writable_records.contains(key),
185        }
186    }
187
188    /// Returns the list of granted permissions
189    pub fn permissions(&self) -> &[&str] {
190        match self {
191            Self::ReadOnly => &["read", "subscribe"],
192            Self::ReadWrite { .. } => &["read", "subscribe", "write"],
193        }
194    }
195
196    /// Returns the list of writable record keys (for ReadWrite policy)
197    pub fn writable_records(&self) -> Vec<String> {
198        match self {
199            Self::ReadOnly => Vec::new(),
200            Self::ReadWrite { writable_records } => writable_records.iter().cloned().collect(),
201        }
202    }
203
204    /// Returns the list of writable record keys as StringKeys
205    pub fn writable_record_keys(&self) -> Vec<StringKey> {
206        match self {
207            Self::ReadOnly => Vec::new(),
208            Self::ReadWrite { writable_records } => writable_records
209                .iter()
210                .map(|s| StringKey::from_dynamic(s.as_str()))
211                .collect(),
212        }
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    #[cfg(feature = "std")]
222    fn test_default_config() {
223        let config = AimxConfig::uds_default();
224        assert_eq!(config.socket_path, "/tmp/aimdb.sock");
225        assert_eq!(config.max_connections, 16);
226        assert_eq!(config.max_subs_per_connection, 32);
227        assert!(matches!(config.security_policy, SecurityPolicy::ReadOnly));
228        assert!(config.auth_token.is_none());
229    }
230
231    #[test]
232    #[cfg(feature = "std")]
233    fn test_config_builder() {
234        let config = AimxConfig::uds_default()
235            .socket_path("/var/run/aimdb.sock")
236            .max_connections(32)
237            .max_subs_per_connection(8)
238            .auth_token("secret-token")
239            .socket_permissions(0o660);
240
241        assert_eq!(config.socket_path, "/var/run/aimdb.sock");
242        assert_eq!(config.max_connections, 32);
243        assert_eq!(config.max_subs_per_connection, 8);
244        assert_eq!(config.auth_token, Some("secret-token".to_string()));
245        assert_eq!(config.socket_permissions, Some(0o660));
246    }
247
248    #[test]
249    fn test_security_policy_read_only() {
250        let policy = SecurityPolicy::read_only();
251        assert!(!policy.is_writable_key("test.record"));
252        assert_eq!(policy.permissions(), &["read", "subscribe"]);
253    }
254
255    #[test]
256    fn test_security_policy_read_write() {
257        let mut policy = SecurityPolicy::read_write();
258        assert!(!policy.is_writable_key("test.record"));
259
260        policy.allow_write_key("test.record");
261        assert!(policy.is_writable_key("test.record"));
262        assert!(!policy.is_writable_key("other.record"));
263        assert_eq!(policy.permissions(), &["read", "subscribe", "write"]);
264    }
265
266    #[test]
267    #[should_panic(expected = "Cannot allow writes in ReadOnly security policy")]
268    fn test_security_policy_read_only_panic() {
269        let mut policy = SecurityPolicy::read_only();
270        policy.allow_write_key("test.record");
271    }
272
273    #[test]
274    #[should_panic(expected = "Cannot allow writes in ReadOnly security policy")]
275    fn test_security_policy_read_only_builder_panic() {
276        let _policy = SecurityPolicy::read_only().with_writable_key("test.record");
277    }
278
279    #[test]
280    fn test_security_policy_builder() {
281        let policy = SecurityPolicy::read_write()
282            .with_writable_key("sensor.temperature")
283            .with_writable_key("config.app");
284
285        assert!(policy.is_writable_key("sensor.temperature"));
286        assert!(policy.is_writable_key("config.app"));
287        assert!(!policy.is_writable_key("other.record"));
288    }
289}