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 store.load_persistent_grants()?;
111
112 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 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 }
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 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 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 let _ = std::fs::remove_file(&path);
359
360 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 let store2 = PermissionStore::with_persistence(path.clone()).expect("reload store");
370 assert!(store2.is_granted(Capability::ReadClipboardContent));
371
372 let _ = std::fs::remove_file(&path);
374 let _ = std::fs::remove_dir(&temp_dir);
375 }
376}