kagi_sync/infrastructure/
remote_local.rs1use 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}