Skip to main content

kagi_sync/infrastructure/
remote_local.rs

1use crate::domain::remote_config::RemoteMetadata;
2use kagi_domain::error::DomainError;
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::io::Write;
6use std::path::PathBuf;
7
8#[derive(Serialize, Deserialize, Debug, Clone)]
9pub struct TokenStore {
10    pub version: u8,
11    pub project_id: String,
12    pub token: String,
13}
14
15#[derive(Serialize, Deserialize, Debug, Clone)]
16pub struct ClaimSecretStore {
17    pub version: u8,
18    pub project_id: String,
19    pub claim_secret: String,
20}
21
22#[derive(Serialize, Deserialize, Debug, Clone)]
23pub struct AdminTokenStore {
24    pub version: u8,
25    pub server_fingerprint: String,
26    pub token: String,
27}
28
29pub struct RemoteLocalStore {
30    local_data_dir: PathBuf,
31}
32
33impl RemoteLocalStore {
34    pub fn new(local_data_dir: PathBuf) -> Self {
35        Self { local_data_dir }
36    }
37
38    pub fn remote_metadata_path(&self, project_id: &str) -> PathBuf {
39        self.local_data_dir
40            .join(format!("projects/{project_id}/remote.json"))
41    }
42
43    pub fn token_path(&self, project_id: &str) -> PathBuf {
44        self.local_data_dir
45            .join(format!("projects/{project_id}/token.json"))
46    }
47
48    pub fn claim_secret_path(&self, project_id: &str) -> PathBuf {
49        self.local_data_dir
50            .join(format!("projects/{project_id}/claim_secret.json"))
51    }
52
53    pub fn save_remote_metadata(&self, meta: &RemoteMetadata) -> Result<(), DomainError> {
54        let path = self.remote_metadata_path(&meta.project_id);
55        ensure_private_dir(path.parent().unwrap())?;
56        write_private_file(&path, serde_json::to_string_pretty(meta)?.as_bytes())?;
57        Ok(())
58    }
59
60    pub fn load_remote_metadata(
61        &self,
62        project_id: &str,
63    ) -> Result<Option<RemoteMetadata>, DomainError> {
64        let path = self.remote_metadata_path(project_id);
65        if !path.exists() {
66            return Ok(None);
67        }
68        let content = fs::read_to_string(path)?;
69        Ok(Some(serde_json::from_str(&content)?))
70    }
71
72    pub fn save_token(&self, project_id: &str, token: &str) -> Result<(), DomainError> {
73        let path = self.token_path(project_id);
74        ensure_private_dir(path.parent().unwrap())?;
75        let store = TokenStore {
76            version: 1,
77            project_id: project_id.to_string(),
78            token: token.to_string(),
79        };
80        write_private_file(&path, serde_json::to_string_pretty(&store)?.as_bytes())?;
81        Ok(())
82    }
83
84    pub fn load_token(&self, project_id: &str) -> Result<Option<String>, DomainError> {
85        let path = self.token_path(project_id);
86        if !path.exists() {
87            return Ok(None);
88        }
89        let content = fs::read_to_string(path)?;
90        let store: TokenStore = serde_json::from_str(&content)?;
91        Ok(Some(store.token))
92    }
93
94    pub fn save_claim_secret(
95        &self,
96        project_id: &str,
97        claim_secret: &str,
98    ) -> Result<(), DomainError> {
99        let path = self.claim_secret_path(project_id);
100        ensure_private_dir(path.parent().unwrap())?;
101        let store = ClaimSecretStore {
102            version: 1,
103            project_id: project_id.to_string(),
104            claim_secret: claim_secret.to_string(),
105        };
106        write_private_file(&path, serde_json::to_string_pretty(&store)?.as_bytes())?;
107        Ok(())
108    }
109
110    pub fn clear_token(&self, project_id: &str) -> Result<(), DomainError> {
111        let path = self.token_path(project_id);
112        if path.exists() {
113            fs::remove_file(path)?;
114        }
115        Ok(())
116    }
117
118    pub fn clear_claim_secret(&self, project_id: &str) -> Result<(), DomainError> {
119        let path = self.claim_secret_path(project_id);
120        if path.exists() {
121            fs::remove_file(path)?;
122        }
123        Ok(())
124    }
125
126    pub fn load_claim_secret(&self, project_id: &str) -> Result<Option<String>, DomainError> {
127        let path = self.claim_secret_path(project_id);
128        if !path.exists() {
129            return Ok(None);
130        }
131        let content = fs::read_to_string(path)?;
132        let store: ClaimSecretStore = serde_json::from_str(&content)?;
133        Ok(Some(store.claim_secret))
134    }
135
136    pub fn delete_claim_secret(&self, project_id: &str) -> Result<(), DomainError> {
137        self.clear_claim_secret(project_id)
138    }
139
140    pub fn clear_project_data(&self, project_id: &str) -> Result<(), DomainError> {
141        self.clear_token(project_id)?;
142        self.clear_claim_secret(project_id)?;
143        let remote_metadata_path = self.remote_metadata_path(project_id);
144        if remote_metadata_path.exists() {
145            fs::remove_file(remote_metadata_path)?;
146        }
147        Ok(())
148    }
149
150    pub fn admin_token_path(&self, server_fingerprint: &str) -> PathBuf {
151        self.local_data_dir
152            .join(format!("admins/{server_fingerprint}/token.json"))
153    }
154
155    pub fn save_admin_token(
156        &self,
157        server_fingerprint: &str,
158        token: &str,
159    ) -> Result<(), DomainError> {
160        let path = self.admin_token_path(server_fingerprint);
161        ensure_private_dir(path.parent().unwrap())?;
162        let store = AdminTokenStore {
163            version: 1,
164            server_fingerprint: server_fingerprint.to_string(),
165            token: token.to_string(),
166        };
167        write_private_file(&path, serde_json::to_string_pretty(&store)?.as_bytes())?;
168        Ok(())
169    }
170
171    pub fn load_admin_token(
172        &self,
173        server_fingerprint: &str,
174    ) -> Result<Option<String>, DomainError> {
175        let path = self.admin_token_path(server_fingerprint);
176        if !path.exists() {
177            return Ok(None);
178        }
179        let content = fs::read_to_string(path)?;
180        let store: AdminTokenStore = serde_json::from_str(&content)?;
181        Ok(Some(store.token))
182    }
183
184    pub fn admin_remote_path(&self, server_fingerprint: &str) -> PathBuf {
185        self.local_data_dir
186            .join(format!("admins/{server_fingerprint}/remote.json"))
187    }
188
189    pub fn save_admin_remote(
190        &self,
191        server_fingerprint: &str,
192        remote_url: &str,
193    ) -> Result<(), DomainError> {
194        let path = self.admin_remote_path(server_fingerprint);
195        ensure_private_dir(path.parent().unwrap())?;
196        let config = crate::domain::remote_config::AdminRemoteConfig {
197            version: 1,
198            remote: remote_url.to_string(),
199            server_fingerprint: server_fingerprint.to_string(),
200        };
201        write_private_file(&path, serde_json::to_string_pretty(&config)?.as_bytes())?;
202        Ok(())
203    }
204
205    pub fn load_admin_remote(
206        &self,
207        server_fingerprint: &str,
208    ) -> Result<Option<String>, DomainError> {
209        let path = self.admin_remote_path(server_fingerprint);
210        if !path.exists() {
211            return Ok(None);
212        }
213        let content = fs::read_to_string(path)?;
214        let config: crate::domain::remote_config::AdminRemoteConfig =
215            serde_json::from_str(&content)?;
216        Ok(Some(config.remote))
217    }
218}
219
220fn ensure_private_dir(path: &std::path::Path) -> Result<(), DomainError> {
221    fs::create_dir_all(path)?;
222    #[cfg(unix)]
223    {
224        use std::os::unix::fs::PermissionsExt;
225        fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
226    }
227    Ok(())
228}
229
230fn write_private_file(path: &std::path::Path, content: &[u8]) -> Result<(), DomainError> {
231    #[cfg(unix)]
232    {
233        use std::os::unix::fs::OpenOptionsExt;
234        let mut file = fs::OpenOptions::new()
235            .create(true)
236            .truncate(true)
237            .write(true)
238            .mode(0o600)
239            .open(path)?;
240        file.write_all(content)?;
241        use std::os::unix::fs::PermissionsExt;
242        fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
243        Ok(())
244    }
245
246    #[cfg(not(unix))]
247    {
248        let mut file = fs::OpenOptions::new()
249            .create(true)
250            .truncate(true)
251            .write(true)
252            .open(path)?;
253        file.write_all(content)?;
254        Ok(())
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use crate::domain::remote_config::RemoteMetadata;
262
263    fn test_store() -> RemoteLocalStore {
264        let dir = tempfile::tempdir().unwrap();
265        RemoteLocalStore::new(dir.path().to_path_buf())
266    }
267
268    #[test]
269    fn test_save_and_load_remote_metadata() {
270        let store = test_store();
271        let meta = RemoteMetadata {
272            version: 1,
273            project_id: "kgp_test".into(),
274            remote: "http://localhost:13816".into(),
275            server_key_id: "kgs_abc".into(),
276            server_fingerprint: "fp_abc".into(),
277            local_revision: Some(0),
278            last_pulled_at: None,
279            last_pushed_at: None,
280            last_manifest_hash: None,
281            pending_token_ids: None,
282            pending_accepted_member_ids: None,
283        };
284        store.save_remote_metadata(&meta).unwrap();
285        let loaded = store.load_remote_metadata("kgp_test").unwrap();
286        assert!(loaded.is_some());
287        let loaded = loaded.unwrap();
288        assert_eq!(loaded.project_id, meta.project_id);
289        assert_eq!(loaded.remote, meta.remote);
290        assert_eq!(loaded.server_key_id, meta.server_key_id);
291    }
292
293    #[test]
294    fn test_load_remote_metadata_missing() {
295        let store = test_store();
296        let loaded = store.load_remote_metadata("kgp_missing").unwrap();
297        assert!(loaded.is_none());
298    }
299
300    #[test]
301    fn test_save_and_load_token() {
302        let store = test_store();
303        store.save_token("kgp_test", "my_secret_token").unwrap();
304        let loaded = store.load_token("kgp_test").unwrap();
305        assert_eq!(loaded, Some("my_secret_token".to_string()));
306    }
307
308    #[test]
309    #[cfg(unix)]
310    fn test_token_file_uses_private_permissions() {
311        use std::os::unix::fs::PermissionsExt;
312
313        let dir = tempfile::tempdir().unwrap();
314        let store = RemoteLocalStore::new(dir.path().to_path_buf());
315        store.save_token("kgp_test", "my_secret_token").unwrap();
316
317        let token_path = store.token_path("kgp_test");
318        let project_dir = token_path.parent().unwrap();
319        let dir_mode = fs::metadata(project_dir).unwrap().permissions().mode() & 0o777;
320        let file_mode = fs::metadata(token_path).unwrap().permissions().mode() & 0o777;
321
322        assert_eq!(dir_mode, 0o700);
323        assert_eq!(file_mode, 0o600);
324    }
325
326    #[test]
327    fn test_load_token_missing() {
328        let store = test_store();
329        let loaded = store.load_token("kgp_missing").unwrap();
330        assert!(loaded.is_none());
331    }
332
333    #[test]
334    fn test_clear_project_data() {
335        let store = test_store();
336        store.save_token("kgp_test", "project-token").unwrap();
337        store.save_claim_secret("kgp_test", "claim-secret").unwrap();
338        assert!(store.load_token("kgp_test").unwrap().is_some());
339        assert!(store.load_claim_secret("kgp_test").unwrap().is_some());
340
341        store.clear_project_data("kgp_test").unwrap();
342        assert!(store.load_token("kgp_test").unwrap().is_none());
343        assert!(store.load_claim_secret("kgp_test").unwrap().is_none());
344    }
345
346    #[test]
347    fn test_save_and_load_admin_remote() {
348        let store = test_store();
349        store
350            .save_admin_remote("kgs_abc", "http://localhost:13816")
351            .unwrap();
352        let loaded = store.load_admin_remote("kgs_abc").unwrap();
353        assert_eq!(loaded, Some("http://localhost:13816".to_string()));
354    }
355
356    #[test]
357    fn test_load_admin_remote_missing() {
358        let store = test_store();
359        let loaded = store.load_admin_remote("kgs_missing").unwrap();
360        assert!(loaded.is_none());
361    }
362
363    #[test]
364    fn test_save_and_load_admin_token() {
365        let store = test_store();
366        store.save_admin_token("kgs_abc", "admin-token").unwrap();
367        let loaded = store.load_admin_token("kgs_abc").unwrap();
368        assert_eq!(loaded, Some("admin-token".to_string()));
369    }
370}