Skip to main content

snip_it/
sync.rs

1//! gRPC sync client for communicating with snip-sync server.
2//!
3//! Handles bidirectional synchronization of snippets with encryption in transit.
4//! Uses TLS for secure communication and AES-256-GCM for snippet encryption.
5//!
6//! # Sync Flow
7//!
8//! 1. Connect to server with TLS
9//! 2. Encrypt local snippets with user's API key
10//! 3. Send encrypted snippets with last sync timestamp
11//! 4. Receive encrypted remote snippets
12//! 5. Decrypt and merge with local storage
13
14use 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; // Total attempts: 1 initial + 3 retries = 4
31const DEFAULT_INITIAL_DELAY_MS: u64 = 100; // Initial backoff before first retry
32const DEFAULT_MAX_DELAY_MS: u64 = 5000; // Cap exponential backoff at 5 seconds
33const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 10;
34const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30;
35
36/// Configuration for gRPC retry behavior with exponential backoff.
37#[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    /// Returns `true` if the gRPC error status code is retryable.
56    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
82/// Retry an async gRPC operation with exponential backoff and jitter.
83macro_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
115/// Add the API key as gRPC `authorization` metadata to a request.
116pub(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
132/// Client for syncing snippets with a remote server.
133///
134/// Wraps a gRPC client with encryption handling for secure sync operations.
135pub struct SyncClient {
136    client: SnippetSyncClient<Channel>,
137    settings: SyncSettings,
138}
139
140impl SyncClient {
141    /// Creates a new sync client connected to the server specified in settings.
142    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    /// Encrypts local snippets, sends them to the server, and decrypts the response.
156    ///
157    /// Snippets that fail encryption/decryption are counted as skipped.
158    /// Handles server-side pagination by fetching all pages before returning.
159    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(), // Auth via gRPC metadata, not body
182            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            // Decrypt server snippets from this page
210            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        // Failure only if ALL server snippets failed to decrypt and none were usable
235        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    /// Manual retry logic for sync requests.
251    ///
252    /// Note: The `retry_grpc!` macro cannot be used here because `self.client.sync()`
253    /// borrows `&mut self`, and the macro requires the operation to be a standalone
254    /// future expression. This method implements the same exponential backoff strategy.
255    /// The request is cloned on retry to avoid re-cloning on every attempt.
256    ///
257    /// `api_key` is passed explicitly rather than read from `request.api_key` so
258    /// callers can leave the body field empty (avoiding leaking the key over the
259    /// wire in the request body) while still authenticating via `authorization`
260    /// metadata.
261    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    /// Checks server health and returns `true` if the server is reachable.
307    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    /// Registers a new device with the server and returns the API key and device ID.
321    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    /// Lists all libraries on the sync server.
347    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            // Server signals end-of-stream with `!has_more` (preferred).
369            // `count < PAGE_LIMIT` is a fallback when the server returns
370            // fewer than the limit (which is the last page by construction).
371            // The `count == 0 && has_more` guard is paranoia against a
372            // buggy server that returns empty pages without setting
373            // `has_more = false` — without it, we'd loop forever.
374            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    /// Creates a new library on the sync server.
383    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    /// Lists all premade libraries available on the server.
415    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    /// Downloads a premade library's content from the server.
431    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    /// Searches premade libraries on the server by query string.
458    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
517/// Encrypts a snippet's sensitive fields (description, command, tags) for sync.
518pub 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
546/// Decrypts a snippet's encrypted fields received from the sync server.
547pub 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
573/// Detects if any server snippets have a device_id that doesn't match the
574/// expected local device_id, indicating a potential conflict from another device.
575pub 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"), // 429 - should be retryable
631        ];
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}