Skip to main content

coding_agent_search/pages/
errors.rs

1//! Centralized error types for the pages export system.
2//!
3//! This module provides user-friendly error types with:
4//! - Clear error messages without technical jargon
5//! - Recovery suggestions for each error type
6//! - Security-conscious design (no secret leakage)
7//!
8//! # Security Considerations
9//!
10//! - Error messages never include passwords or secrets
11//! - Debug output is sanitized
12//! - Timing-safe comparisons where applicable
13
14use std::fmt;
15
16/// Encryption/decryption errors.
17///
18/// These errors are designed to be user-friendly and security-conscious.
19/// They never leak sensitive information like passwords or internal state.
20#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
21pub enum DecryptError {
22    /// Password authentication failed.
23    #[error("The password you entered is incorrect.")]
24    AuthenticationFailed,
25    /// Empty password provided.
26    #[error("Please enter a password.")]
27    EmptyPassword,
28    /// Invalid archive format.
29    #[error("This file is not a valid archive.")]
30    InvalidFormat(String),
31    /// Archive integrity check failed (tampering detected).
32    #[error("The archive appears to be corrupted or tampered with.")]
33    IntegrityCheckFailed,
34    /// Archive version not supported.
35    #[error("This archive requires a newer version of the software (version {0}).")]
36    UnsupportedVersion(u8),
37    /// No matching key slot found.
38    #[error("No matching key slot found for the provided credentials.")]
39    NoMatchingKeySlot,
40    /// Internal cryptographic error.
41    #[error("An error occurred during decryption.")]
42    CryptoError(String),
43}
44
45impl DecryptError {
46    /// Get a user-friendly recovery suggestion for this error.
47    pub fn suggestion(&self) -> &'static str {
48        match self {
49            Self::AuthenticationFailed => {
50                "Double-check your password. Passwords are case-sensitive."
51            }
52            Self::EmptyPassword => "Please enter a password.",
53            Self::InvalidFormat(_) => {
54                "This file may not be a CASS archive, or it may be corrupted."
55            }
56            Self::IntegrityCheckFailed => {
57                "The archive appears to be corrupted. Try downloading it again."
58            }
59            Self::UnsupportedVersion(_) => {
60                "This archive was created with a newer version. Please update CASS."
61            }
62            Self::NoMatchingKeySlot => {
63                "The credentials you provided don't match any key slot in this archive."
64            }
65            Self::CryptoError(_) => {
66                "Please try again. If the problem persists, the archive may be corrupted."
67            }
68        }
69    }
70
71    /// Get a sanitized error message suitable for logging.
72    ///
73    /// This method ensures no sensitive information is included in logs.
74    pub fn log_message(&self) -> String {
75        match self {
76            Self::AuthenticationFailed => "Authentication failed (wrong password)".to_string(),
77            Self::EmptyPassword => "Empty password provided".to_string(),
78            Self::InvalidFormat(detail) => format!("Invalid format: {}", detail),
79            Self::IntegrityCheckFailed => "Integrity check failed".to_string(),
80            Self::UnsupportedVersion(v) => format!("Unsupported version: {}", v),
81            Self::NoMatchingKeySlot => "No matching key slot".to_string(),
82            Self::CryptoError(e) => format!("Crypto error: {}", e),
83        }
84    }
85}
86
87/// Database errors.
88#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
89pub enum DbError {
90    /// Database file is corrupted.
91    #[error("The database appears to be corrupted.")]
92    CorruptDatabase(String),
93    /// Required table is missing.
94    #[error("The archive is missing required data.")]
95    MissingTable(String),
96    /// Query syntax error.
97    #[error("Your search could not be processed.")]
98    InvalidQuery(String),
99    /// Database is locked by another process.
100    #[error("The database is currently in use by another process.")]
101    DatabaseLocked,
102    /// Query returned no results.
103    #[error("No results found.")]
104    NoResults,
105}
106
107impl DbError {
108    /// Get a user-friendly recovery suggestion.
109    pub fn suggestion(&self) -> &'static str {
110        match self {
111            Self::CorruptDatabase(_) => {
112                "The archive may be corrupted. Try downloading it again or use a backup."
113            }
114            Self::MissingTable(_) => "The archive may be incomplete. Try exporting again.",
115            Self::InvalidQuery(_) => {
116                "Try simplifying your search query or removing special characters."
117            }
118            Self::DatabaseLocked => {
119                "Close any other applications that might be using this archive."
120            }
121            Self::NoResults => "Try broadening your search or using different keywords.",
122        }
123    }
124
125    /// Get a sanitized error message suitable for logging.
126    pub fn log_message(&self) -> String {
127        match self {
128            Self::CorruptDatabase(detail) => format!("Corrupt database: {}", detail),
129            Self::MissingTable(table) => format!("Missing table: {}", table),
130            Self::InvalidQuery(detail) => format!("Invalid query: {}", detail),
131            Self::DatabaseLocked => "Database locked".to_string(),
132            Self::NoResults => "No results".to_string(),
133        }
134    }
135}
136
137/// Browser/runtime errors (for web viewer).
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub enum BrowserError {
140    /// Browser doesn't support required features.
141    UnsupportedBrowser(String),
142    /// WebAssembly not available.
143    WasmNotSupported,
144    /// WebCrypto not available.
145    CryptoNotSupported,
146    /// Storage quota exceeded.
147    StorageQuotaExceeded,
148    /// SharedArrayBuffer not available (COI not enabled).
149    SharedArrayBufferNotAvailable,
150}
151
152impl fmt::Display for BrowserError {
153    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154        match self {
155            Self::UnsupportedBrowser(missing) => {
156                write!(
157                    f,
158                    "Your browser doesn't support required features: {}",
159                    missing
160                )
161            }
162            Self::WasmNotSupported => {
163                write!(f, "Your browser doesn't support WebAssembly.")
164            }
165            Self::CryptoNotSupported => {
166                write!(f, "Your browser doesn't support secure cryptography.")
167            }
168            Self::StorageQuotaExceeded => {
169                write!(f, "Not enough storage space available.")
170            }
171            Self::SharedArrayBufferNotAvailable => {
172                write!(f, "Cross-origin isolation is required but not enabled.")
173            }
174        }
175    }
176}
177
178impl std::error::Error for BrowserError {}
179
180impl BrowserError {
181    /// Get a user-friendly recovery suggestion.
182    pub fn suggestion(&self) -> &'static str {
183        match self {
184            Self::UnsupportedBrowser(_) => {
185                "Please use a modern browser like Chrome, Firefox, Edge, or Safari."
186            }
187            Self::WasmNotSupported => "Please update your browser to the latest version.",
188            Self::CryptoNotSupported => "Please use HTTPS or update your browser.",
189            Self::StorageQuotaExceeded => {
190                "Clear some browser storage or use a browser with more available space."
191            }
192            Self::SharedArrayBufferNotAvailable => {
193                "The page must be served with proper cross-origin isolation headers."
194            }
195        }
196    }
197}
198
199/// Network errors.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub enum NetworkError {
202    /// Failed to fetch resource.
203    FetchFailed(String),
204    /// Partial/incomplete download.
205    IncompleteDownload { expected: u64, received: u64 },
206    /// Connection timeout.
207    Timeout,
208    /// Server error.
209    ServerError(u16),
210}
211
212impl fmt::Display for NetworkError {
213    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
214        match self {
215            Self::FetchFailed(_) => {
216                write!(f, "Failed to download the archive.")
217            }
218            Self::IncompleteDownload { .. } => {
219                write!(f, "The download was incomplete.")
220            }
221            Self::Timeout => {
222                write!(f, "The connection timed out.")
223            }
224            Self::ServerError(code) => {
225                write!(f, "The server returned an error ({})", code)
226            }
227        }
228    }
229}
230
231impl std::error::Error for NetworkError {}
232
233impl NetworkError {
234    /// Get a user-friendly recovery suggestion.
235    pub fn suggestion(&self) -> &'static str {
236        match self {
237            Self::FetchFailed(_) => "Check your internet connection and try again.",
238            Self::IncompleteDownload { .. } => {
239                "Try downloading again. If the problem persists, the server may be having issues."
240            }
241            Self::Timeout => "Check your internet connection and try again.",
242            Self::ServerError(code) if *code >= 500 => {
243                "The server is having issues. Please try again later."
244            }
245            Self::ServerError(_) => "Please check the URL and try again.",
246        }
247    }
248}
249
250/// Export errors.
251#[derive(Debug, Clone, PartialEq, Eq)]
252pub enum ExportError {
253    /// No conversations to export.
254    NoConversations,
255    /// Source database error.
256    SourceDatabaseError(String),
257    /// Output directory error.
258    OutputError(String),
259    /// Filter matched nothing.
260    FilterMatchedNothing,
261}
262
263impl fmt::Display for ExportError {
264    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265        match self {
266            Self::NoConversations => {
267                write!(f, "No conversations found to export.")
268            }
269            Self::SourceDatabaseError(_) => {
270                write!(f, "Could not read the source database.")
271            }
272            Self::OutputError(_) => {
273                write!(f, "Could not write to the output location.")
274            }
275            Self::FilterMatchedNothing => {
276                write!(f, "No conversations matched your filter criteria.")
277            }
278        }
279    }
280}
281
282impl std::error::Error for ExportError {}
283
284impl ExportError {
285    /// Get a user-friendly recovery suggestion.
286    pub fn suggestion(&self) -> &'static str {
287        match self {
288            Self::NoConversations => {
289                "Make sure you have some agent sessions recorded before exporting."
290            }
291            Self::SourceDatabaseError(_) => "Check that the CASS database exists and is readable.",
292            Self::OutputError(_) => "Check that you have write permission to the output directory.",
293            Self::FilterMatchedNothing => {
294                "Try broadening your filter criteria or removing some filters."
295            }
296        }
297    }
298}
299
300/// Error code for external reference (e.g., documentation).
301pub trait ErrorCode {
302    /// Get a unique error code for this error type.
303    fn error_code(&self) -> &'static str;
304}
305
306impl ErrorCode for DecryptError {
307    fn error_code(&self) -> &'static str {
308        match self {
309            Self::AuthenticationFailed => "E1001",
310            Self::EmptyPassword => "E1002",
311            Self::InvalidFormat(_) => "E1003",
312            Self::IntegrityCheckFailed => "E1004",
313            Self::UnsupportedVersion(_) => "E1005",
314            Self::NoMatchingKeySlot => "E1006",
315            Self::CryptoError(_) => "E1007",
316        }
317    }
318}
319
320impl ErrorCode for DbError {
321    fn error_code(&self) -> &'static str {
322        match self {
323            Self::CorruptDatabase(_) => "E2001",
324            Self::MissingTable(_) => "E2002",
325            Self::InvalidQuery(_) => "E2003",
326            Self::DatabaseLocked => "E2004",
327            Self::NoResults => "E2005",
328        }
329    }
330}
331
332impl ErrorCode for BrowserError {
333    fn error_code(&self) -> &'static str {
334        match self {
335            Self::UnsupportedBrowser(_) => "E3001",
336            Self::WasmNotSupported => "E3002",
337            Self::CryptoNotSupported => "E3003",
338            Self::StorageQuotaExceeded => "E3004",
339            Self::SharedArrayBufferNotAvailable => "E3005",
340        }
341    }
342}
343
344impl ErrorCode for NetworkError {
345    fn error_code(&self) -> &'static str {
346        match self {
347            Self::FetchFailed(_) => "E4001",
348            Self::IncompleteDownload { .. } => "E4002",
349            Self::Timeout => "E4003",
350            Self::ServerError(_) => "E4004",
351        }
352    }
353}
354
355impl ErrorCode for ExportError {
356    fn error_code(&self) -> &'static str {
357        match self {
358            Self::NoConversations => "E5001",
359            Self::SourceDatabaseError(_) => "E5002",
360            Self::OutputError(_) => "E5003",
361            Self::FilterMatchedNothing => "E5004",
362        }
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn test_decrypt_error_display_is_user_friendly() {
372        let errors = vec![
373            (DecryptError::AuthenticationFailed, "incorrect"),
374            (DecryptError::EmptyPassword, "enter a password"),
375            (
376                DecryptError::InvalidFormat("test".into()),
377                "not a valid archive",
378            ),
379            (DecryptError::IntegrityCheckFailed, "corrupted"),
380            (DecryptError::UnsupportedVersion(99), "newer version"),
381        ];
382
383        for (error, expected_substring) in errors {
384            let message = error.to_string().to_lowercase();
385            assert!(
386                message.contains(expected_substring),
387                "Error {:?} should mention '{}', got: {}",
388                error,
389                expected_substring,
390                message
391            );
392        }
393    }
394
395    #[test]
396    fn test_decrypt_error_display_and_source_are_preserved() {
397        let cases = vec![
398            (
399                DecryptError::AuthenticationFailed,
400                "The password you entered is incorrect.",
401            ),
402            (DecryptError::EmptyPassword, "Please enter a password."),
403            (
404                DecryptError::InvalidFormat("header mismatch".into()),
405                "This file is not a valid archive.",
406            ),
407            (
408                DecryptError::IntegrityCheckFailed,
409                "The archive appears to be corrupted or tampered with.",
410            ),
411            (
412                DecryptError::UnsupportedVersion(99),
413                "This archive requires a newer version of the software (version 99).",
414            ),
415            (
416                DecryptError::NoMatchingKeySlot,
417                "No matching key slot found for the provided credentials.",
418            ),
419            (
420                DecryptError::CryptoError("GCM tag mismatch".into()),
421                "An error occurred during decryption.",
422            ),
423        ];
424
425        for (error, expected_display) in cases {
426            assert_eq!(error.to_string(), expected_display);
427            assert!(std::error::Error::source(&error).is_none());
428        }
429    }
430
431    #[test]
432    fn test_decrypt_error_no_technical_jargon() {
433        let errors = vec![
434            DecryptError::AuthenticationFailed,
435            DecryptError::EmptyPassword,
436            DecryptError::InvalidFormat("header mismatch".into()),
437            DecryptError::IntegrityCheckFailed,
438            DecryptError::UnsupportedVersion(2),
439            DecryptError::CryptoError("GCM tag mismatch".into()),
440        ];
441
442        let jargon = ["GCM", "tag", "nonce", "AEAD", "AES", "cipher", "MAC"];
443
444        for error in errors {
445            let display = error.to_string();
446            for word in jargon {
447                assert!(
448                    !display.contains(word),
449                    "Error {:?} should not contain '{}' in display: {}",
450                    error,
451                    word,
452                    display
453                );
454            }
455        }
456    }
457
458    #[test]
459    fn test_all_errors_have_suggestions() {
460        let decrypt_errors = vec![
461            DecryptError::AuthenticationFailed,
462            DecryptError::EmptyPassword,
463            DecryptError::InvalidFormat("test".into()),
464            DecryptError::IntegrityCheckFailed,
465            DecryptError::UnsupportedVersion(2),
466            DecryptError::NoMatchingKeySlot,
467            DecryptError::CryptoError("test".into()),
468        ];
469
470        for error in decrypt_errors {
471            let suggestion = error.suggestion();
472            assert!(!suggestion.is_empty(), "{:?} has no suggestion", error);
473            assert!(
474                suggestion.ends_with('.') || suggestion.ends_with('!'),
475                "{:?} suggestion should be a complete sentence: {}",
476                error,
477                suggestion
478            );
479        }
480    }
481
482    #[test]
483    fn test_db_error_display_is_user_friendly() {
484        let errors = vec![
485            (DbError::CorruptDatabase("test".into()), "corrupted"),
486            (DbError::MissingTable("messages".into()), "missing"),
487            (DbError::InvalidQuery("syntax error".into()), "search"),
488            (DbError::DatabaseLocked, "in use"),
489            (DbError::NoResults, "no results"),
490        ];
491
492        for (error, expected_substring) in errors {
493            let message = error.to_string().to_lowercase();
494            assert!(
495                message.contains(expected_substring),
496                "Error {:?} should mention '{}', got: {}",
497                error,
498                expected_substring,
499                message
500            );
501        }
502    }
503
504    #[test]
505    fn test_db_error_display_and_source_are_preserved() {
506        let cases = vec![
507            (
508                DbError::CorruptDatabase("page checksum mismatch".into()),
509                "The database appears to be corrupted.",
510            ),
511            (
512                DbError::MissingTable("messages".into()),
513                "The archive is missing required data.",
514            ),
515            (
516                DbError::InvalidQuery("SELECT * FROM sqlite_master".into()),
517                "Your search could not be processed.",
518            ),
519            (
520                DbError::DatabaseLocked,
521                "The database is currently in use by another process.",
522            ),
523            (DbError::NoResults, "No results found."),
524        ];
525
526        for (error, expected_display) in cases {
527            assert_eq!(error.to_string(), expected_display);
528            assert!(std::error::Error::source(&error).is_none());
529        }
530    }
531
532    #[test]
533    fn test_db_error_no_internal_details() {
534        let error = DbError::InvalidQuery("SELECT * FROM sqlite_master WHERE type='table'".into());
535        let display = error.to_string();
536
537        // Should not expose SQL details
538        assert!(
539            !display.contains("sqlite"),
540            "Should not expose sqlite in display: {}",
541            display
542        );
543        assert!(
544            !display.contains("SELECT"),
545            "Should not expose SQL in display: {}",
546            display
547        );
548    }
549
550    #[test]
551    fn test_error_codes_are_unique() {
552        let mut codes = std::collections::HashSet::new();
553
554        let decrypt_errors = vec![
555            DecryptError::AuthenticationFailed,
556            DecryptError::EmptyPassword,
557            DecryptError::InvalidFormat("".into()),
558            DecryptError::IntegrityCheckFailed,
559            DecryptError::UnsupportedVersion(0),
560            DecryptError::NoMatchingKeySlot,
561            DecryptError::CryptoError("".into()),
562        ];
563
564        for error in decrypt_errors {
565            let code = error.error_code();
566            assert!(codes.insert(code), "Duplicate error code: {}", code);
567        }
568
569        let db_errors = vec![
570            DbError::CorruptDatabase("".into()),
571            DbError::MissingTable("".into()),
572            DbError::InvalidQuery("".into()),
573            DbError::DatabaseLocked,
574            DbError::NoResults,
575        ];
576
577        for error in db_errors {
578            let code = error.error_code();
579            assert!(codes.insert(code), "Duplicate error code: {}", code);
580        }
581    }
582
583    #[test]
584    fn test_browser_error_suggestions() {
585        let errors = vec![
586            BrowserError::UnsupportedBrowser("IndexedDB".into()),
587            BrowserError::WasmNotSupported,
588            BrowserError::CryptoNotSupported,
589            BrowserError::StorageQuotaExceeded,
590            BrowserError::SharedArrayBufferNotAvailable,
591        ];
592
593        for error in errors {
594            let suggestion = error.suggestion();
595            assert!(!suggestion.is_empty(), "{:?} has no suggestion", error);
596        }
597    }
598
599    #[test]
600    fn test_network_error_suggestions() {
601        let errors = vec![
602            NetworkError::FetchFailed("connection refused".into()),
603            NetworkError::IncompleteDownload {
604                expected: 1000,
605                received: 500,
606            },
607            NetworkError::Timeout,
608            NetworkError::ServerError(500),
609            NetworkError::ServerError(404),
610        ];
611
612        for error in errors {
613            let suggestion = error.suggestion();
614            assert!(!suggestion.is_empty(), "{:?} has no suggestion", error);
615        }
616    }
617}