Skip to main content

lcsa_core/
permissions.rs

1use std::collections::HashMap;
2use std::fs::{self, File};
3use std::io::{BufReader, BufWriter};
4use std::path::PathBuf;
5use std::time::{Duration, SystemTime};
6
7use serde::{Deserialize, Serialize};
8
9use crate::error::Error;
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum Capability {
14    ReadClipboardSemantic,
15    ReadSelectionSemantic,
16    ReadFocusSemantic,
17    ReadClipboardContent,
18}
19
20#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum Scope {
23    ForegroundApp,
24    Session,
25    Persistent,
26}
27
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct PermissionRequest {
30    pub capability: Capability,
31    pub scope: Scope,
32    pub reason: String,
33    pub ttl: Option<Duration>,
34}
35
36impl PermissionRequest {
37    pub fn new(capability: Capability, scope: Scope, reason: impl Into<String>) -> Self {
38        Self {
39            capability,
40            scope,
41            reason: reason.into(),
42            ttl: None,
43        }
44    }
45
46    pub fn with_ttl(mut self, ttl: Duration) -> Self {
47        self.ttl = Some(ttl);
48        self
49    }
50}
51
52#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
53pub struct Grant {
54    pub capability: Capability,
55    pub scope: Scope,
56    pub reason: String,
57    #[serde(with = "system_time_serde")]
58    pub granted_at: SystemTime,
59    #[serde(with = "option_system_time_serde")]
60    pub expires_at: Option<SystemTime>,
61}
62
63impl Grant {
64    pub fn is_active_at(&self, now: SystemTime) -> bool {
65        self.expires_at
66            .map(|expires_at| expires_at > now)
67            .unwrap_or(true)
68    }
69}
70
71pub struct PermissionStore {
72    grants: HashMap<Capability, Grant>,
73    persistence: Option<JsonFilePersistence>,
74}
75
76impl Default for PermissionStore {
77    fn default() -> Self {
78        Self {
79            grants: HashMap::new(),
80            persistence: None,
81        }
82    }
83}
84
85impl PermissionStore {
86    pub(crate) fn with_defaults() -> Self {
87        let mut store = Self::default();
88        for capability in [
89            Capability::ReadClipboardSemantic,
90            Capability::ReadSelectionSemantic,
91            Capability::ReadFocusSemantic,
92        ] {
93            store.grant_internal(PermissionRequest::new(
94                capability,
95                Scope::Session,
96                "Structural signals are safe by default",
97            ));
98        }
99        store
100    }
101
102    pub fn with_persistence(path: PathBuf) -> Result<Self, Error> {
103        let persistence = JsonFilePersistence::new(path);
104        let mut store = Self {
105            grants: HashMap::new(),
106            persistence: Some(persistence),
107        };
108
109        // Load existing persistent grants
110        store.load_persistent_grants()?;
111
112        // Add default semantic grants
113        for capability in [
114            Capability::ReadClipboardSemantic,
115            Capability::ReadSelectionSemantic,
116            Capability::ReadFocusSemantic,
117        ] {
118            if !store.grants.contains_key(&capability) {
119                store.grant_internal(PermissionRequest::new(
120                    capability,
121                    Scope::Session,
122                    "Structural signals are safe by default",
123                ));
124            }
125        }
126
127        Ok(store)
128    }
129
130    pub fn default_persistence_path() -> PathBuf {
131        dirs::data_local_dir()
132            .unwrap_or_else(|| PathBuf::from("."))
133            .join("lcsa")
134            .join("permissions.json")
135    }
136
137    fn load_persistent_grants(&mut self) -> Result<(), Error> {
138        if let Some(ref persistence) = self.persistence {
139            match persistence.load() {
140                Ok(grants) => {
141                    let now = SystemTime::now();
142                    for grant in grants {
143                        // Only load grants that are still active and persistent
144                        if grant.scope == Scope::Persistent && grant.is_active_at(now) {
145                            self.grants.insert(grant.capability, grant);
146                        }
147                    }
148                }
149                Err(Error::PersistenceNotFound) => {
150                    // File doesn't exist yet, that's fine
151                }
152                Err(e) => return Err(e),
153            }
154        }
155        Ok(())
156    }
157
158    fn save_persistent_grants(&self) -> Result<(), Error> {
159        if let Some(ref persistence) = self.persistence {
160            let persistent_grants: Vec<&Grant> = self
161                .grants
162                .values()
163                .filter(|g| g.scope == Scope::Persistent)
164                .collect();
165            persistence.save(&persistent_grants)?;
166        }
167        Ok(())
168    }
169
170    pub(crate) fn grant(&mut self, request: PermissionRequest) -> Grant {
171        let grant = self.grant_internal(request);
172
173        // Persist if this is a persistent grant
174        if grant.scope == Scope::Persistent {
175            let _ = self.save_persistent_grants();
176        }
177
178        grant
179    }
180
181    fn grant_internal(&mut self, request: PermissionRequest) -> Grant {
182        let granted_at = SystemTime::now();
183        let expires_at = request.ttl.and_then(|ttl| granted_at.checked_add(ttl));
184
185        let grant = Grant {
186            capability: request.capability,
187            scope: request.scope,
188            reason: request.reason,
189            granted_at,
190            expires_at,
191        };
192
193        self.grants.insert(grant.capability, grant.clone());
194        grant
195    }
196
197    pub(crate) fn is_granted(&self, capability: Capability) -> bool {
198        self.grants
199            .get(&capability)
200            .map(|grant| grant.is_active_at(SystemTime::now()))
201            .unwrap_or(false)
202    }
203
204    pub(crate) fn revoke(&mut self, capability: Capability) -> bool {
205        let existed = self.grants.remove(&capability).is_some();
206        if existed {
207            let _ = self.save_persistent_grants();
208        }
209        existed
210    }
211}
212
213struct JsonFilePersistence {
214    path: PathBuf,
215}
216
217impl JsonFilePersistence {
218    fn new(path: PathBuf) -> Self {
219        Self { path }
220    }
221
222    fn load(&self) -> Result<Vec<Grant>, Error> {
223        if !self.path.exists() {
224            return Err(Error::PersistenceNotFound);
225        }
226
227        let file = File::open(&self.path).map_err(|e| {
228            Error::PersistenceError(format!("failed to open {}: {}", self.path.display(), e))
229        })?;
230        let reader = BufReader::new(file);
231        let grants: Vec<Grant> = serde_json::from_reader(reader).map_err(|e| {
232            Error::PersistenceError(format!("failed to parse {}: {}", self.path.display(), e))
233        })?;
234        Ok(grants)
235    }
236
237    fn save(&self, grants: &[&Grant]) -> Result<(), Error> {
238        // Ensure parent directory exists
239        if let Some(parent) = self.path.parent() {
240            fs::create_dir_all(parent).map_err(|e| {
241                Error::PersistenceError(format!("failed to create dir {}: {}", parent.display(), e))
242            })?;
243        }
244
245        let file = File::create(&self.path).map_err(|e| {
246            Error::PersistenceError(format!("failed to create {}: {}", self.path.display(), e))
247        })?;
248        let writer = BufWriter::new(file);
249        serde_json::to_writer_pretty(writer, &grants).map_err(|e| {
250            Error::PersistenceError(format!("failed to write {}: {}", self.path.display(), e))
251        })?;
252        Ok(())
253    }
254}
255
256mod system_time_serde {
257    use serde::{Deserialize, Deserializer, Serialize, Serializer};
258    use std::time::{Duration, SystemTime, UNIX_EPOCH};
259
260    pub fn serialize<S>(time: &SystemTime, serializer: S) -> Result<S::Ok, S::Error>
261    where
262        S: Serializer,
263    {
264        let duration = time.duration_since(UNIX_EPOCH).unwrap_or(Duration::ZERO);
265        duration.as_secs().serialize(serializer)
266    }
267
268    pub fn deserialize<'de, D>(deserializer: D) -> Result<SystemTime, D::Error>
269    where
270        D: Deserializer<'de>,
271    {
272        let secs = u64::deserialize(deserializer)?;
273        Ok(UNIX_EPOCH + Duration::from_secs(secs))
274    }
275}
276
277mod option_system_time_serde {
278    use serde::{Deserialize, Deserializer, Serialize, Serializer};
279    use std::time::{Duration, SystemTime, UNIX_EPOCH};
280
281    pub fn serialize<S>(time: &Option<SystemTime>, serializer: S) -> Result<S::Ok, S::Error>
282    where
283        S: Serializer,
284    {
285        match time {
286            Some(t) => {
287                let duration = t.duration_since(UNIX_EPOCH).unwrap_or(Duration::ZERO);
288                Some(duration.as_secs()).serialize(serializer)
289            }
290            None => None::<u64>.serialize(serializer),
291        }
292    }
293
294    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<SystemTime>, D::Error>
295    where
296        D: Deserializer<'de>,
297    {
298        let secs: Option<u64> = Option::deserialize(deserializer)?;
299        Ok(secs.map(|s| UNIX_EPOCH + Duration::from_secs(s)))
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use std::time::{Duration, SystemTime};
306
307    use super::*;
308
309    #[test]
310    fn default_store_grants_semantic_clipboard_access() {
311        let store = PermissionStore::with_defaults();
312        assert!(store.is_granted(Capability::ReadClipboardSemantic));
313        assert!(store.is_granted(Capability::ReadSelectionSemantic));
314        assert!(store.is_granted(Capability::ReadFocusSemantic));
315        assert!(!store.is_granted(Capability::ReadClipboardContent));
316    }
317
318    #[test]
319    fn ttl_grant_expires() {
320        let granted_at = SystemTime::UNIX_EPOCH;
321        let grant = Grant {
322            capability: Capability::ReadClipboardContent,
323            scope: Scope::Session,
324            reason: "test".to_string(),
325            granted_at,
326            expires_at: Some(granted_at + Duration::from_secs(5)),
327        };
328
329        assert!(grant.is_active_at(granted_at + Duration::from_secs(4)));
330        assert!(!grant.is_active_at(granted_at + Duration::from_secs(5)));
331    }
332
333    #[test]
334    fn grant_serializes_to_json() {
335        let grant = Grant {
336            capability: Capability::ReadClipboardContent,
337            scope: Scope::Persistent,
338            reason: "test persistence".to_string(),
339            granted_at: SystemTime::UNIX_EPOCH + Duration::from_secs(1000),
340            expires_at: None,
341        };
342
343        let json = serde_json::to_string(&grant).expect("serialize");
344        assert!(json.contains("read_clipboard_content"));
345        assert!(json.contains("persistent"));
346
347        let parsed: Grant = serde_json::from_str(&json).expect("deserialize");
348        assert_eq!(parsed.capability, grant.capability);
349        assert_eq!(parsed.scope, grant.scope);
350    }
351
352    #[test]
353    fn persistence_round_trip() {
354        let temp_dir = std::env::temp_dir().join("lcsa_test");
355        let path = temp_dir.join("test_permissions.json");
356
357        // Clean up from previous runs
358        let _ = std::fs::remove_file(&path);
359
360        // Create store with persistence and add a persistent grant
361        let mut store = PermissionStore::with_persistence(path.clone()).expect("create store");
362        store.grant(PermissionRequest::new(
363            Capability::ReadClipboardContent,
364            Scope::Persistent,
365            "test persistence",
366        ));
367
368        // Create new store and verify grant was loaded
369        let store2 = PermissionStore::with_persistence(path.clone()).expect("reload store");
370        assert!(store2.is_granted(Capability::ReadClipboardContent));
371
372        // Clean up
373        let _ = std::fs::remove_file(&path);
374        let _ = std::fs::remove_dir(&temp_dir);
375    }
376}