1use crate::config::SyncSettings;
15use crate::encryption;
16use crate::error::{SnipError, SnipResult};
17use crate::proto::PremadeLibrary;
18use crate::proto::snippet_sync_client::SnippetSyncClient;
19use crate::proto::{
20 CreateLibraryRequest, GetPremadeLibraryRequest, HealthRequest, Library, ListLibrariesRequest,
21 ListPremadeLibrariesRequest, RegisterRequest, SearchPremadeLibrariesRequest, SyncRequest,
22};
23use std::sync::atomic::{AtomicU32, Ordering};
24use std::time::Duration;
25use tonic::Code;
26use tonic::transport::{Channel, ClientTlsConfig, Endpoint, Uri};
27
28static JITTER_COUNTER: AtomicU32 = AtomicU32::new(0);
29
30const DEFAULT_MAX_RETRIES: u32 = 3; const DEFAULT_INITIAL_DELAY_MS: u64 = 100; const DEFAULT_MAX_DELAY_MS: u64 = 5000; const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 10;
34const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30;
35
36#[derive(Debug, Clone)]
38pub struct SyncRetryConfig {
39 pub max_retries: u32,
40 pub initial_delay_ms: u64,
41 pub max_delay_ms: u64,
42}
43
44impl Default for SyncRetryConfig {
45 fn default() -> Self {
46 Self {
47 max_retries: DEFAULT_MAX_RETRIES,
48 initial_delay_ms: DEFAULT_INITIAL_DELAY_MS,
49 max_delay_ms: DEFAULT_MAX_DELAY_MS,
50 }
51 }
52}
53
54impl SyncRetryConfig {
55 pub fn is_retryable_grpc_error(status: &tonic::Status) -> bool {
57 !matches!(
58 status.code(),
59 Code::InvalidArgument
60 | Code::NotFound
61 | Code::AlreadyExists
62 | Code::PermissionDenied
63 | Code::Unauthenticated
64 )
65 }
66}
67
68fn default_retry_config() -> SyncRetryConfig {
69 SyncRetryConfig::default()
70}
71
72fn retry_jitter_multiplier() -> f64 {
73 let counter = JITTER_COUNTER.fetch_add(1, Ordering::Relaxed);
74 let nanos = std::time::SystemTime::now()
75 .duration_since(std::time::UNIX_EPOCH)
76 .map(|d| d.subsec_nanos())
77 .unwrap_or(0);
78 let combined = nanos.wrapping_add(counter.wrapping_mul(31));
79 0.5 + ((combined as u64 % 1000) as f64 / 1000.0)
80}
81
82macro_rules! retry_grpc {
84 ($op:expr, $name:expr) => {{
85 let config = default_retry_config();
86 let mut delay_ms = config.initial_delay_ms;
87 let mut attempt = 0u32;
88 loop {
89 match $op.await {
90 Ok(val) => break Ok(val),
91 Err(e) => {
92 if !SyncRetryConfig::is_retryable_grpc_error(&e)
93 || attempt >= config.max_retries
94 {
95 break Err(SnipError::runtime_error($name, Some(&e.to_string())));
96 }
97 let actual_delay = (delay_ms as f64 * retry_jitter_multiplier()) as u64;
98 tracing::warn!(
99 "{} failed (attempt {}/{}): {}. Retrying in {}ms...",
100 $name,
101 attempt + 1,
102 config.max_retries + 1,
103 e,
104 actual_delay
105 );
106 tokio::time::sleep(Duration::from_millis(actual_delay)).await;
107 delay_ms = (delay_ms * 2).min(config.max_delay_ms);
108 attempt += 1;
109 }
110 }
111 }
112 }};
113}
114
115pub(crate) fn add_api_key_metadata<T>(request: &mut tonic::Request<T>, api_key: &str) {
117 debug_assert!(!api_key.is_empty(), "api_key must not be empty");
118 if !api_key.is_empty()
119 && let Ok(val) = format!("Bearer {api_key}").parse()
120 {
121 request.metadata_mut().insert("authorization", val);
122 }
123}
124
125#[derive(serde::Serialize, serde::Deserialize)]
126struct EncryptedSnippetData {
127 description: String,
128 command: String,
129 tags: Vec<String>,
130}
131
132pub struct SyncClient {
136 client: SnippetSyncClient<Channel>,
137 settings: SyncSettings,
138}
139
140impl SyncClient {
141 pub async fn create(settings: SyncSettings) -> SnipResult<Self> {
143 let server_url = settings.server_url.clone();
144
145 let channel = create_tls_channel(&server_url)
146 .await
147 .map_err(|e| SnipError::runtime_error("Failed to connect", Some(&e.to_string())))?;
148
149 Ok(Self {
150 client: SnippetSyncClient::new(channel),
151 settings,
152 })
153 }
154
155 pub async fn sync_encrypted(
160 &mut self,
161 local_snippets: Vec<crate::proto::Snippet>,
162 last_sync: i64,
163 library_id: &str,
164 ) -> SnipResult<crate::proto::SyncResponse> {
165 let api_key = self.settings.api_key.clone();
166
167 let mut encrypted_snippets = Vec::new();
168 let mut encrypt_failed_ids = Vec::new();
169
170 for s in &local_snippets {
171 match encrypt_snippet(&api_key, s) {
172 Ok(es) => encrypted_snippets.push(es),
173 Err(e) => {
174 encrypt_failed_ids.push(s.id.clone());
175 tracing::warn!("Failed to encrypt snippet {}: {}", s.id, e);
176 }
177 }
178 }
179
180 let mut request = SyncRequest {
181 api_key: String::new(), local_snippets: Vec::new(),
183 last_sync_timestamp: last_sync,
184 library_id: library_id.to_string(),
185 limit: self.settings.sync_limit_value(),
186 offset: 0,
187 };
188
189 let local_snippets_for_push = encrypted_snippets.clone();
190 let encrypt_failed_count = encrypt_failed_ids.len();
191 let mut all_server_snippets = Vec::new();
192 let mut all_skipped_ids = encrypt_failed_ids;
193 let mut server_decrypt_failed_count = 0usize;
194 let mut final_timestamp;
195 let mut final_message;
196 let mut final_total_count;
197 let mut offset = 0;
198
199 loop {
200 if offset == 0 {
201 request.local_snippets = local_snippets_for_push.clone();
202 } else {
203 request.local_snippets.clear();
204 }
205 request.offset = offset;
206
207 let mut response = self.sync_with_retry(request.clone(), &api_key).await?;
208
209 for s in &response.snippets {
211 match decrypt_snippet(&api_key, s) {
212 Ok(ds) => all_server_snippets.push(ds),
213 Err(e) => {
214 server_decrypt_failed_count += 1;
215 all_skipped_ids.push(s.id.clone());
216 tracing::warn!("Failed to decrypt snippet {}: {}", s.id, e);
217 }
218 }
219 }
220
221 final_timestamp = response.server_timestamp;
222 final_message = std::mem::take(&mut response.message);
223 final_total_count = response.total_count;
224
225 if !response.has_more || response.snippets.is_empty() {
226 break;
227 }
228
229 offset = offset.saturating_add(response.snippets.len() as i32);
230 }
231
232 let total_skipped = all_skipped_ids.len();
233 let all_skipped_local = encrypt_failed_count > 0 && encrypted_snippets.is_empty();
234 let all_skipped_server = server_decrypt_failed_count > 0 && all_server_snippets.is_empty();
236 let overall_success = !(all_skipped_local || all_skipped_server);
237
238 Ok(crate::proto::SyncResponse {
239 success: overall_success,
240 message: final_message,
241 snippets: all_server_snippets,
242 server_timestamp: final_timestamp,
243 skipped_count: total_skipped as i32,
244 skipped_ids: all_skipped_ids,
245 has_more: false,
246 total_count: final_total_count,
247 })
248 }
249
250 async fn sync_with_retry(
262 &mut self,
263 request: SyncRequest,
264 api_key: &str,
265 ) -> SnipResult<crate::proto::SyncResponse> {
266 let config = default_retry_config();
267 let mut delay_ms = config.initial_delay_ms;
268 let mut attempt = 0;
269 loop {
270 let mut grpc_req = tonic::Request::new(request.clone());
271 add_api_key_metadata(&mut grpc_req, api_key);
272 match self.client.sync(grpc_req).await {
273 Ok(response) => return Ok(response.into_inner()),
274 Err(e) => {
275 if !SyncRetryConfig::is_retryable_grpc_error(&e)
276 || attempt >= config.max_retries
277 {
278 return Err(SnipError::runtime_error(
279 "Sync request",
280 Some(&e.to_string()),
281 ));
282 }
283 let is_rate_limited = e.code() == Code::ResourceExhausted;
284 tracing::warn!(
285 "Sync request failed (attempt {}/{}): {}. Retrying in {}ms...",
286 attempt + 1,
287 config.max_retries + 1,
288 e,
289 delay_ms
290 );
291 let actual_delay = (delay_ms as f64 * retry_jitter_multiplier()) as u64;
292 tokio::time::sleep(Duration::from_millis(actual_delay)).await;
293 let backoff_multiplier = if is_rate_limited { 4.0 } else { 2.0 };
294 let max_delay = if is_rate_limited {
295 120_000u64
296 } else {
297 config.max_delay_ms
298 };
299 delay_ms = ((delay_ms as f64 * backoff_multiplier) as u64).min(max_delay);
300 attempt += 1;
301 }
302 }
303 }
304 }
305
306 pub async fn health_check(&mut self) -> SnipResult<bool> {
308 match retry_grpc!(
309 self.client.health(tonic::Request::new(HealthRequest {})),
310 "Health check"
311 ) {
312 Ok(response) => Ok(response.into_inner().healthy),
313 Err(e) => {
314 tracing::debug!(error = %e, "Health check failed");
315 Ok(false)
316 }
317 }
318 }
319
320 pub async fn register(server_url: String) -> SnipResult<(String, String)> {
322 let channel = create_tls_channel(&server_url)
323 .await
324 .map_err(|e| SnipError::runtime_error("Failed to connect", Some(&e.to_string())))?;
325
326 let mut client = SnippetSyncClient::new(channel);
327
328 let response = retry_grpc!(
329 client.register(tonic::Request::new(RegisterRequest {
330 device_id: String::new(),
331 })),
332 "Register request"
333 )?;
334
335 let response = response.into_inner();
336 if response.success {
337 Ok((response.api_key, response.device_id))
338 } else {
339 Err(SnipError::runtime_error(
340 "Registration failed",
341 Some(&response.message),
342 ))
343 }
344 }
345
346 pub async fn list_libraries(&mut self) -> SnipResult<Vec<Library>> {
348 let api_key = self.settings.api_key.clone();
349 let mut all_libraries = Vec::new();
350 let mut offset = 0i32;
351 const PAGE_LIMIT: i32 = 50;
352 loop {
353 let response = retry_grpc!(
354 async {
355 let mut req = tonic::Request::new(ListLibrariesRequest {
356 api_key: api_key.clone(),
357 limit: PAGE_LIMIT,
358 offset,
359 });
360 add_api_key_metadata(&mut req, &api_key);
361 self.client.list_libraries(req).await
362 },
363 "List libraries"
364 )?;
365 let inner = response.into_inner();
366 let count = inner.libraries.len() as i32;
367 all_libraries.extend(inner.libraries);
368 if !inner.has_more || count < PAGE_LIMIT || count == 0 {
375 break;
376 }
377 offset = offset.saturating_add(count);
378 }
379 Ok(all_libraries)
380 }
381
382 pub async fn create_library(&mut self, name: &str) -> SnipResult<Library> {
384 let api_key = self.settings.api_key.clone();
385 let name_str = name.to_string();
386 let response = retry_grpc!(
387 async {
388 let mut req = tonic::Request::new(CreateLibraryRequest {
389 api_key: api_key.clone(),
390 name: name_str.clone(),
391 });
392 add_api_key_metadata(&mut req, &api_key);
393 self.client.create_library(req).await
394 },
395 "Create library"
396 )?;
397
398 let response = response.into_inner();
399 if response.success {
400 Ok(Library {
401 id: response.library_id,
402 name: name_str,
403 created_at: chrono::Utc::now().timestamp(),
404 snippet_count: 0,
405 })
406 } else {
407 Err(SnipError::runtime_error(
408 "Failed to create library",
409 Some(&response.message),
410 ))
411 }
412 }
413
414 pub async fn list_premade_libraries(&mut self) -> SnipResult<Vec<PremadeLibrary>> {
416 let api_key = self.settings.api_key.clone();
417 let response = retry_grpc!(
418 async {
419 let mut req = tonic::Request::new(ListPremadeLibrariesRequest {
420 api_key: api_key.clone(),
421 });
422 add_api_key_metadata(&mut req, &api_key);
423 self.client.list_premade_libraries(req).await
424 },
425 "List premade libraries"
426 )?;
427 Ok(response.into_inner().libraries)
428 }
429
430 pub async fn get_premade_library(&mut self, filename: &str) -> SnipResult<String> {
432 let api_key = self.settings.api_key.clone();
433 let filename_str = filename.to_string();
434 let response = retry_grpc!(
435 async {
436 let mut req = tonic::Request::new(GetPremadeLibraryRequest {
437 api_key: api_key.clone(),
438 filename: filename_str.clone(),
439 });
440 add_api_key_metadata(&mut req, &api_key);
441 self.client.get_premade_library(req).await
442 },
443 "Get premade library"
444 )?;
445
446 let response = response.into_inner();
447 if response.success {
448 Ok(response.content)
449 } else {
450 Err(SnipError::runtime_error(
451 "Failed to get premade library",
452 Some(&response.message),
453 ))
454 }
455 }
456
457 pub async fn search_premade_libraries(
459 &mut self,
460 query: &str,
461 ) -> SnipResult<Vec<PremadeLibrary>> {
462 let api_key = self.settings.api_key.clone();
463 let query_str = query.to_string();
464 let response = retry_grpc!(
465 async {
466 let mut req = tonic::Request::new(SearchPremadeLibrariesRequest {
467 api_key: api_key.clone(),
468 query: query_str.clone(),
469 });
470 add_api_key_metadata(&mut req, &api_key);
471 self.client.search_premade_libraries(req).await
472 },
473 "Search premade libraries"
474 )?;
475 Ok(response.into_inner().libraries)
476 }
477}
478
479async fn create_tls_channel(
480 server_url: &str,
481) -> Result<Channel, Box<dyn std::error::Error + Send + Sync>> {
482 let uri: Uri = server_url.parse()?;
483 let scheme = uri.scheme_str().unwrap_or("https").to_ascii_lowercase();
484 let host = if scheme == "https" {
485 Some(uri.host().ok_or("No host in URI")?.to_string())
486 } else {
487 None
488 };
489
490 let connect_timeout_secs = std::env::var("SNP_SYNC_CONNECT_TIMEOUT")
491 .ok()
492 .and_then(|v| v.parse().ok())
493 .unwrap_or(DEFAULT_CONNECT_TIMEOUT_SECS);
494 let request_timeout_secs = std::env::var("SNP_SYNC_REQUEST_TIMEOUT")
495 .ok()
496 .and_then(|v| v.parse().ok())
497 .unwrap_or(DEFAULT_REQUEST_TIMEOUT_SECS);
498
499 let endpoint = Endpoint::new(uri)?
500 .connect_timeout(Duration::from_secs(connect_timeout_secs))
501 .timeout(Duration::from_secs(request_timeout_secs));
502
503 let endpoint = if let Some(host) = host {
504 let tls_config = ClientTlsConfig::new()
505 .with_enabled_roots()
506 .domain_name(host)
507 .assume_http2(true);
508 endpoint.tls_config(tls_config)?
509 } else {
510 endpoint
511 };
512
513 let channel = endpoint.connect().await?;
514 Ok(channel)
515}
516
517pub fn encrypt_snippet(
519 api_key: &str,
520 snippet: &crate::proto::Snippet,
521) -> SnipResult<crate::proto::Snippet> {
522 let data = EncryptedSnippetData {
523 description: snippet.description.clone(),
524 command: snippet.command.clone(),
525 tags: snippet.tags.clone(),
526 };
527
528 let json = serde_json::to_string(&data)
529 .map_err(|e| SnipError::runtime_error("Serialize snippet data", Some(&e.to_string())))?;
530
531 let encrypted = encryption::encrypt(api_key, &json)?;
532
533 Ok(crate::proto::Snippet {
534 id: snippet.id.clone(),
535 description: String::new(),
536 command: encrypted,
537 tags: vec![],
538 created_at: snippet.created_at,
539 updated_at: snippet.updated_at,
540 device_id: snippet.device_id.clone(),
541 deleted: snippet.deleted,
542 encrypted: true,
543 })
544}
545
546pub fn decrypt_snippet(
548 api_key: &str,
549 snippet: &crate::proto::Snippet,
550) -> SnipResult<crate::proto::Snippet> {
551 if !snippet.encrypted {
552 return Ok(snippet.clone());
553 }
554
555 let decrypted = encryption::decrypt(api_key, &snippet.command)?;
556
557 let data: EncryptedSnippetData = serde_json::from_str(&decrypted)
558 .map_err(|e| SnipError::runtime_error("Deserialize snippet data", Some(&e.to_string())))?;
559
560 Ok(crate::proto::Snippet {
561 id: snippet.id.clone(),
562 description: data.description,
563 command: data.command,
564 tags: data.tags,
565 created_at: snippet.created_at,
566 updated_at: snippet.updated_at,
567 device_id: snippet.device_id.clone(),
568 deleted: snippet.deleted,
569 encrypted: false,
570 })
571}
572
573pub fn detect_device_conflict(
576 server_snippets: &[crate::proto::Snippet],
577 expected_device_id: &str,
578) -> Vec<String> {
579 if expected_device_id.is_empty() {
580 return Vec::new();
581 }
582 let mut conflicting_ids = Vec::new();
583 for s in server_snippets {
584 if !s.device_id.is_empty() && s.device_id != expected_device_id {
585 tracing::warn!(
586 "Device conflict detected: snippet {} has device_id '{}', expected '{}'",
587 s.id,
588 s.device_id,
589 expected_device_id
590 );
591 conflicting_ids.push(s.id.clone());
592 }
593 }
594 conflicting_ids
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600
601 #[test]
602 fn test_default_retry_config() {
603 let config = SyncRetryConfig::default();
604 assert_eq!(config.max_retries, 3);
605 assert_eq!(config.initial_delay_ms, 100);
606 assert_eq!(config.max_delay_ms, 5000);
607 }
608
609 #[test]
610 fn test_non_retryable_errors() {
611 let non_retryable = [
612 tonic::Status::invalid_argument("test"),
613 tonic::Status::not_found("test"),
614 tonic::Status::already_exists("test"),
615 tonic::Status::permission_denied("test"),
616 tonic::Status::unauthenticated("test"),
617 ];
618 for status in &non_retryable {
619 assert!(
620 !SyncRetryConfig::is_retryable_grpc_error(status),
621 "Expected {:?} to be non-retryable",
622 status.code()
623 );
624 }
625
626 let retryable = [
627 tonic::Status::internal("test"),
628 tonic::Status::unavailable("test"),
629 tonic::Status::deadline_exceeded("test"),
630 tonic::Status::resource_exhausted("rate limited"), ];
632 for status in &retryable {
633 assert!(
634 SyncRetryConfig::is_retryable_grpc_error(status),
635 "Expected {:?} to be retryable",
636 status.code()
637 );
638 }
639 }
640
641 #[test]
642 fn test_detect_device_conflict_empty_device_id() {
643 let snippets = vec![crate::proto::Snippet {
644 id: "1".to_string(),
645 description: String::new(),
646 command: String::new(),
647 tags: vec![],
648 created_at: 0,
649 updated_at: 0,
650 device_id: "other-device".to_string(),
651 deleted: false,
652 encrypted: false,
653 }];
654 assert!(detect_device_conflict(&snippets, "").is_empty());
655 }
656
657 #[test]
658 fn test_detect_device_conflict_no_conflict() {
659 let snippets = vec![crate::proto::Snippet {
660 id: "1".to_string(),
661 description: String::new(),
662 command: String::new(),
663 tags: vec![],
664 created_at: 0,
665 updated_at: 0,
666 device_id: "device-a".to_string(),
667 deleted: false,
668 encrypted: false,
669 }];
670 assert!(detect_device_conflict(&snippets, "device-a").is_empty());
671 }
672
673 #[test]
674 fn test_detect_device_conflict_with_mismatch() {
675 let snippets = vec![crate::proto::Snippet {
676 id: "1".to_string(),
677 description: String::new(),
678 command: String::new(),
679 tags: vec![],
680 created_at: 0,
681 updated_at: 0,
682 device_id: "device-b".to_string(),
683 deleted: false,
684 encrypted: false,
685 }];
686 let conflicts = detect_device_conflict(&snippets, "device-a");
687 assert_eq!(conflicts, vec!["1".to_string()]);
688 }
689
690 #[test]
691 fn test_encrypt_decrypt_snippet_roundtrip() {
692 let api_key = "test-api-key-for-encryption";
693 let snippet = crate::proto::Snippet {
694 id: "test-id".to_string(),
695 description: "Test Description".to_string(),
696 command: "echo hello world".to_string(),
697 tags: vec!["bash".to_string(), "test".to_string()],
698 created_at: 1000,
699 updated_at: 2000,
700 device_id: "device-1".to_string(),
701 deleted: false,
702 encrypted: false,
703 };
704
705 let encrypted = encrypt_snippet(api_key, &snippet).unwrap();
706 assert!(encrypted.encrypted);
707 assert_eq!(encrypted.id, "test-id");
708 assert_eq!(encrypted.description, "");
709 assert!(encrypted.tags.is_empty());
710 assert!(!encrypted.command.is_empty());
711 assert_ne!(encrypted.command, "echo hello world");
712
713 let decrypted = decrypt_snippet(api_key, &encrypted).unwrap();
714 assert!(!decrypted.encrypted);
715 assert_eq!(decrypted.description, "Test Description");
716 assert_eq!(decrypted.command, "echo hello world");
717 assert_eq!(decrypted.tags, vec!["bash", "test"]);
718 assert_eq!(decrypted.created_at, 1000);
719 assert_eq!(decrypted.updated_at, 2000);
720 assert_eq!(decrypted.device_id, "device-1");
721 }
722
723 #[test]
724 fn test_decrypt_non_encrypted_passthrough() {
725 let api_key = "test-api-key";
726 let snippet = crate::proto::Snippet {
727 id: "test-id".to_string(),
728 description: "desc".to_string(),
729 command: "cmd".to_string(),
730 tags: vec![],
731 created_at: 0,
732 updated_at: 0,
733 device_id: "device".to_string(),
734 deleted: false,
735 encrypted: false,
736 };
737
738 let result = decrypt_snippet(api_key, &snippet).unwrap();
739 assert_eq!(result.description, "desc");
740 assert_eq!(result.command, "cmd");
741 }
742
743 #[test]
744 fn test_decrypt_wrong_key_fails() {
745 let snippet = crate::proto::Snippet {
746 id: "test-id".to_string(),
747 description: "desc".to_string(),
748 command: "cmd".to_string(),
749 tags: vec![],
750 created_at: 0,
751 updated_at: 0,
752 device_id: "device".to_string(),
753 deleted: false,
754 encrypted: false,
755 };
756
757 let encrypted = encrypt_snippet("correct-key", &snippet).unwrap();
758 let result = decrypt_snippet("wrong-key", &encrypted);
759 assert!(result.is_err());
760 }
761
762 #[test]
763 fn test_encrypt_decrypt_with_special_characters() {
764 let api_key = "test-key-special-chars";
765 let snippet = crate::proto::Snippet {
766 id: "id".to_string(),
767 description: "Unicode: 你好世界 🌍".to_string(),
768 command: "echo 'hello \"world\"' && echo $HOME".to_string(),
769 tags: vec!["tag with spaces".to_string()],
770 created_at: 0,
771 updated_at: 0,
772 device_id: "device".to_string(),
773 deleted: false,
774 encrypted: false,
775 };
776
777 let encrypted = encrypt_snippet(api_key, &snippet).unwrap();
778 let decrypted = decrypt_snippet(api_key, &encrypted).unwrap();
779 assert_eq!(decrypted.description, "Unicode: 你好世界 🌍");
780 assert_eq!(decrypted.command, "echo 'hello \"world\"' && echo $HOME");
781 assert_eq!(decrypted.tags, vec!["tag with spaces"]);
782 }
783}