1use std::fmt;
15
16#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
21pub enum DecryptError {
22 #[error("The password you entered is incorrect.")]
24 AuthenticationFailed,
25 #[error("Please enter a password.")]
27 EmptyPassword,
28 #[error("This file is not a valid archive.")]
30 InvalidFormat(String),
31 #[error("The archive appears to be corrupted or tampered with.")]
33 IntegrityCheckFailed,
34 #[error("This archive requires a newer version of the software (version {0}).")]
36 UnsupportedVersion(u8),
37 #[error("No matching key slot found for the provided credentials.")]
39 NoMatchingKeySlot,
40 #[error("An error occurred during decryption.")]
42 CryptoError(String),
43}
44
45impl DecryptError {
46 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 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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
89pub enum DbError {
90 #[error("The database appears to be corrupted.")]
92 CorruptDatabase(String),
93 #[error("The archive is missing required data.")]
95 MissingTable(String),
96 #[error("Your search could not be processed.")]
98 InvalidQuery(String),
99 #[error("The database is currently in use by another process.")]
101 DatabaseLocked,
102 #[error("No results found.")]
104 NoResults,
105}
106
107impl DbError {
108 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 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#[derive(Debug, Clone, PartialEq, Eq)]
139pub enum BrowserError {
140 UnsupportedBrowser(String),
142 WasmNotSupported,
144 CryptoNotSupported,
146 StorageQuotaExceeded,
148 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 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#[derive(Debug, Clone, PartialEq, Eq)]
201pub enum NetworkError {
202 FetchFailed(String),
204 IncompleteDownload { expected: u64, received: u64 },
206 Timeout,
208 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 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#[derive(Debug, Clone, PartialEq, Eq)]
252pub enum ExportError {
253 NoConversations,
255 SourceDatabaseError(String),
257 OutputError(String),
259 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 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
300pub trait ErrorCode {
302 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 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}