gestura_core_security/
lib.rs1pub mod gdpr;
43pub mod sandbox;
44pub mod secrets;
45pub mod storage;
46
47#[cfg(feature = "security")]
48pub mod encryption;
49
50pub use gdpr::*;
52pub use sandbox::*;
53pub use secrets::SecureStorageSecretProvider;
54pub use storage::{MockSecureStorage, SecureStorage, SecureStorageError};
55
56#[cfg(feature = "security")]
57pub use encryption::{Encryptor, SecureConfigManager};
58
59#[cfg(feature = "security")]
60pub use storage::KeychainStorage;
61
62#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
66pub struct McpToken {
67 pub token: String,
69 pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
71 pub scopes: Vec<String>,
73}
74
75impl McpToken {
76 pub fn new(token: String) -> Self {
78 Self {
79 token,
80 expires_at: None,
81 scopes: Vec::new(),
82 }
83 }
84
85 pub fn with_expiry(token: String, expires_at: chrono::DateTime<chrono::Utc>) -> Self {
87 Self {
88 token,
89 expires_at: Some(expires_at),
90 scopes: Vec::new(),
91 }
92 }
93
94 pub fn with_scopes(mut self, scopes: Vec<String>) -> Self {
96 self.scopes = scopes;
97 self
98 }
99
100 pub fn is_expired(&self) -> bool {
102 self.expires_at
103 .map(|exp| exp < chrono::Utc::now())
104 .unwrap_or(false)
105 }
106
107 pub fn has_scope(&self, scope: &str) -> bool {
109 self.scopes.iter().any(|s| s == scope)
110 }
111}
112
113pub fn keychain_access_disabled() -> bool {
122 let explicitly_disabled = std::env::var_os("GESTURA_DISABLE_KEYCHAIN").is_some()
123 || std::env::var_os("GESTURA_NO_KEYCHAIN").is_some();
124
125 explicitly_disabled || (cfg!(not(test)) && std::env::var_os("CI").is_some())
126}
127
128pub fn create_secure_storage() -> Box<dyn SecureStorage> {
135 #[cfg(all(feature = "security", not(test)))]
136 {
137 if keychain_access_disabled() {
138 Box::new(MockSecureStorage::default())
139 } else {
140 Box::new(KeychainStorage)
141 }
142 }
143 #[cfg(any(not(feature = "security"), test))]
144 {
145 Box::new(MockSecureStorage::default())
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use std::ffi::OsString;
153 use std::sync::{Mutex, OnceLock};
154
155 fn env_lock() -> &'static Mutex<()> {
156 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
157 LOCK.get_or_init(|| Mutex::new(()))
158 }
159
160 struct ScopedEnvVar {
161 key: &'static str,
162 old: Option<OsString>,
163 }
164
165 impl ScopedEnvVar {
166 fn set(key: &'static str, value: &str) -> Self {
167 let old = std::env::var_os(key);
168 unsafe {
169 std::env::set_var(key, value);
170 }
171 Self { key, old }
172 }
173
174 fn unset(key: &'static str) -> Self {
175 let old = std::env::var_os(key);
176 unsafe {
177 std::env::remove_var(key);
178 }
179 Self { key, old }
180 }
181 }
182
183 impl Drop for ScopedEnvVar {
184 fn drop(&mut self) {
185 if let Some(value) = &self.old {
186 unsafe {
187 std::env::set_var(self.key, value);
188 }
189 } else {
190 unsafe {
191 std::env::remove_var(self.key);
192 }
193 }
194 }
195 }
196
197 #[test]
198 fn test_mcp_token_creation() {
199 let token = McpToken::new("test_token".to_string());
200 assert_eq!(token.token, "test_token");
201 assert!(token.expires_at.is_none());
202 assert!(token.scopes.is_empty());
203 }
204
205 #[test]
206 fn test_mcp_token_with_scopes() {
207 let token = McpToken::new("test".to_string())
208 .with_scopes(vec!["read".to_string(), "write".to_string()]);
209 assert!(token.has_scope("read"));
210 assert!(token.has_scope("write"));
211 assert!(!token.has_scope("admin"));
212 }
213
214 #[test]
215 fn test_mcp_token_expiry() {
216 let future = chrono::Utc::now() + chrono::Duration::hours(1);
217 let token = McpToken::with_expiry("test".to_string(), future);
218 assert!(!token.is_expired());
219
220 let past = chrono::Utc::now() - chrono::Duration::hours(1);
221 let expired_token = McpToken::with_expiry("test".to_string(), past);
222 assert!(expired_token.is_expired());
223 }
224
225 #[test]
226 fn keychain_access_disabled_ignores_ci_in_unit_tests() {
227 let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
228 let _ci = ScopedEnvVar::set("CI", "1");
229 let _disabled = ScopedEnvVar::unset("GESTURA_DISABLE_KEYCHAIN");
230 let _no_keychain = ScopedEnvVar::unset("GESTURA_NO_KEYCHAIN");
231
232 assert!(
233 !keychain_access_disabled(),
234 "unit tests should not skip mocked secure storage just because CI is set"
235 );
236 }
237
238 #[test]
239 fn keychain_access_disabled_still_respects_explicit_overrides() {
240 let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
241 let _ci = ScopedEnvVar::unset("CI");
242 let _disabled = ScopedEnvVar::set("GESTURA_DISABLE_KEYCHAIN", "1");
243 let _no_keychain = ScopedEnvVar::unset("GESTURA_NO_KEYCHAIN");
244
245 assert!(keychain_access_disabled());
246 }
247}