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/{}/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}