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/{}/remote.json", project_id))
41    }
42
43    pub fn token_path(&self, project_id: &str) -> PathBuf {
44        self.local_data_dir
45            .join(format!("projects/{}/token.json", project_id))
46    }
47
48    pub fn claim_secret_path(&self, project_id: &str) -> PathBuf {
49        self.local_data_dir
50            .join(format!("projects/{}/claim_secret.json", project_id))
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 load_claim_secret(&self, project_id: &str) -> Result<Option<String>, DomainError> {
111        let path = self.claim_secret_path(project_id);
112        if !path.exists() {
113            return Ok(None);
114        }
115        let content = fs::read_to_string(path)?;
116        let store: ClaimSecretStore = serde_json::from_str(&content)?;
117        Ok(Some(store.claim_secret))
118    }
119
120    pub fn delete_claim_secret(&self, project_id: &str) -> Result<(), DomainError> {
121        let path = self.claim_secret_path(project_id);
122        if path.exists() {
123            fs::remove_file(path)?;
124        }
125        Ok(())
126    }
127
128    pub fn admin_token_path(&self, server_fingerprint: &str) -> PathBuf {
129        self.local_data_dir
130            .join(format!("admins/{}/token.json", server_fingerprint))
131    }
132
133    pub fn save_admin_token(
134        &self,
135        server_fingerprint: &str,
136        token: &str,
137    ) -> Result<(), DomainError> {
138        let path = self.admin_token_path(server_fingerprint);
139        ensure_private_dir(path.parent().unwrap())?;
140        let store = AdminTokenStore {
141            version: 1,
142            server_fingerprint: server_fingerprint.to_string(),
143            token: token.to_string(),
144        };
145        write_private_file(&path, serde_json::to_string_pretty(&store)?.as_bytes())?;
146        Ok(())
147    }
148
149    pub fn load_admin_token(
150        &self,
151        server_fingerprint: &str,
152    ) -> Result<Option<String>, DomainError> {
153        let path = self.admin_token_path(server_fingerprint);
154        if !path.exists() {
155            return Ok(None);
156        }
157        let content = fs::read_to_string(path)?;
158        let store: AdminTokenStore = serde_json::from_str(&content)?;
159        Ok(Some(store.token))
160    }
161
162    pub fn admin_remote_path(&self, server_fingerprint: &str) -> PathBuf {
163        self.local_data_dir
164            .join(format!("admins/{}/remote.json", server_fingerprint))
165    }
166
167    pub fn save_admin_remote(
168        &self,
169        server_fingerprint: &str,
170        remote_url: &str,
171    ) -> Result<(), DomainError> {
172        let path = self.admin_remote_path(server_fingerprint);
173        ensure_private_dir(path.parent().unwrap())?;
174        let config = crate::domain::remote_config::AdminRemoteConfig {
175            version: 1,
176            remote: remote_url.to_string(),
177            server_fingerprint: server_fingerprint.to_string(),
178        };
179        write_private_file(&path, serde_json::to_string_pretty(&config)?.as_bytes())?;
180        Ok(())
181    }
182
183    pub fn load_admin_remote(
184        &self,
185        server_fingerprint: &str,
186    ) -> Result<Option<String>, DomainError> {
187        let path = self.admin_remote_path(server_fingerprint);
188        if !path.exists() {
189            return Ok(None);
190        }
191        let content = fs::read_to_string(path)?;
192        let config: crate::domain::remote_config::AdminRemoteConfig =
193            serde_json::from_str(&content)?;
194        Ok(Some(config.remote))
195    }
196}
197
198fn ensure_private_dir(path: &std::path::Path) -> Result<(), DomainError> {
199    fs::create_dir_all(path)?;
200    #[cfg(unix)]
201    {
202        use std::os::unix::fs::PermissionsExt;
203        fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
204    }
205    Ok(())
206}
207
208fn write_private_file(path: &std::path::Path, content: &[u8]) -> Result<(), DomainError> {
209    #[cfg(unix)]
210    {
211        use std::os::unix::fs::OpenOptionsExt;
212        let mut file = fs::OpenOptions::new()
213            .create(true)
214            .truncate(true)
215            .write(true)
216            .mode(0o600)
217            .open(path)?;
218        file.write_all(content)?;
219        use std::os::unix::fs::PermissionsExt;
220        fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
221        Ok(())
222    }
223
224    #[cfg(not(unix))]
225    {
226        let mut file = fs::OpenOptions::new()
227            .create(true)
228            .truncate(true)
229            .write(true)
230            .open(path)?;
231        file.write_all(content)?;
232        Ok(())
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::domain::remote_config::RemoteMetadata;
240
241    fn test_store() -> RemoteLocalStore {
242        let dir = tempfile::tempdir().unwrap();
243        RemoteLocalStore::new(dir.path().to_path_buf())
244    }
245
246    #[test]
247    fn test_save_and_load_remote_metadata() {
248        let store = test_store();
249        let meta = RemoteMetadata {
250            version: 1,
251            project_id: "kgp_test".into(),
252            remote: "http://localhost:13816".into(),
253            server_key_id: "kgs_abc".into(),
254            server_fingerprint: "fp_abc".into(),
255            local_revision: Some(0),
256            last_pulled_at: None,
257            last_pushed_at: None,
258            last_manifest_hash: None,
259            pending_token_ids: None,
260            pending_accepted_member_ids: None,
261        };
262        store.save_remote_metadata(&meta).unwrap();
263        let loaded = store.load_remote_metadata("kgp_test").unwrap();
264        assert!(loaded.is_some());
265        let loaded = loaded.unwrap();
266        assert_eq!(loaded.project_id, meta.project_id);
267        assert_eq!(loaded.remote, meta.remote);
268        assert_eq!(loaded.server_key_id, meta.server_key_id);
269    }
270
271    #[test]
272    fn test_load_remote_metadata_missing() {
273        let store = test_store();
274        let loaded = store.load_remote_metadata("kgp_missing").unwrap();
275        assert!(loaded.is_none());
276    }
277
278    #[test]
279    fn test_save_and_load_token() {
280        let store = test_store();
281        store.save_token("kgp_test", "my_secret_token").unwrap();
282        let loaded = store.load_token("kgp_test").unwrap();
283        assert_eq!(loaded, Some("my_secret_token".to_string()));
284    }
285
286    #[test]
287    #[cfg(unix)]
288    fn test_token_file_uses_private_permissions() {
289        use std::os::unix::fs::PermissionsExt;
290
291        let dir = tempfile::tempdir().unwrap();
292        let store = RemoteLocalStore::new(dir.path().to_path_buf());
293        store.save_token("kgp_test", "my_secret_token").unwrap();
294
295        let token_path = store.token_path("kgp_test");
296        let project_dir = token_path.parent().unwrap();
297        let dir_mode = fs::metadata(project_dir).unwrap().permissions().mode() & 0o777;
298        let file_mode = fs::metadata(token_path).unwrap().permissions().mode() & 0o777;
299
300        assert_eq!(dir_mode, 0o700);
301        assert_eq!(file_mode, 0o600);
302    }
303
304    #[test]
305    fn test_load_token_missing() {
306        let store = test_store();
307        let loaded = store.load_token("kgp_missing").unwrap();
308        assert!(loaded.is_none());
309    }
310
311    #[test]
312    fn test_save_and_load_admin_remote() {
313        let store = test_store();
314        store
315            .save_admin_remote("kgs_abc", "http://localhost:13816")
316            .unwrap();
317        let loaded = store.load_admin_remote("kgs_abc").unwrap();
318        assert_eq!(loaded, Some("http://localhost:13816".to_string()));
319    }
320
321    #[test]
322    fn test_load_admin_remote_missing() {
323        let store = test_store();
324        let loaded = store.load_admin_remote("kgs_missing").unwrap();
325        assert!(loaded.is_none());
326    }
327
328    #[test]
329    fn test_save_and_load_admin_token() {
330        let store = test_store();
331        store.save_admin_token("kgs_abc", "admin-token").unwrap();
332        let loaded = store.load_admin_token("kgs_abc").unwrap();
333        assert_eq!(loaded, Some("admin-token".to_string()));
334    }
335}