Skip to main content

exarch_core/error/
messages.rs

1//! FFI error message formatting.
2//!
3//! Provides consistent error messages across Python and Node.js bindings
4//! while allowing platform-specific customization.
5
6use std::path::Path;
7
8use super::types::ExtractionError;
9
10/// Error message for FFI consumption.
11///
12/// Contains structured error information that can be converted to
13/// platform-specific error types (Python exceptions, Node.js Error objects).
14#[derive(Debug, Clone)]
15pub struct FfiErrorMessage {
16    /// Error code (e.g., `PATH_TRAVERSAL`, `ZIP_BOMB`)
17    pub code: &'static str,
18
19    /// Human-readable error description
20    pub description: String,
21
22    /// Optional additional context
23    pub context: Option<String>,
24}
25
26impl ExtractionError {
27    /// Formats error for FFI consumption.
28    ///
29    /// # Arguments
30    ///
31    /// * `sanitize_paths` - If true, only show filename (not full path) for
32    ///   security. Should be `false` in development, `true` in production
33    ///   Node.js builds.
34    ///
35    /// # Examples
36    ///
37    /// ```
38    /// use exarch_core::ExtractionError;
39    /// use std::path::PathBuf;
40    ///
41    /// let error = ExtractionError::PathTraversal {
42    ///     path: PathBuf::from("/etc/passwd"),
43    /// };
44    ///
45    /// let msg = error.to_ffi_message(true);
46    /// assert_eq!(msg.code, "PATH_TRAVERSAL");
47    /// assert!(msg.description.contains("passwd")); // Only filename shown
48    /// ```
49    #[must_use]
50    #[allow(clippy::too_many_lines)]
51    pub fn to_ffi_message(&self, sanitize_paths: bool) -> FfiErrorMessage {
52        match self {
53            Self::PathTraversal { path } => FfiErrorMessage {
54                code: "PATH_TRAVERSAL",
55                description: format!(
56                    "path traversal detected: {}",
57                    format_path(path, sanitize_paths)
58                ),
59                context: None,
60            },
61
62            Self::SymlinkEscape { path } => FfiErrorMessage {
63                code: "SYMLINK_ESCAPE",
64                description: format!(
65                    "symlink target outside extraction directory: {}",
66                    format_path(path, sanitize_paths)
67                ),
68                context: None,
69            },
70
71            Self::HardlinkEscape { path } => FfiErrorMessage {
72                code: "HARDLINK_ESCAPE",
73                description: format!(
74                    "hardlink target outside extraction directory: {}",
75                    format_path(path, sanitize_paths)
76                ),
77                context: None,
78            },
79
80            Self::ZipBomb {
81                compressed,
82                uncompressed,
83                ratio,
84            } => FfiErrorMessage {
85                code: "ZIP_BOMB",
86                description: format!(
87                    "potential zip bomb: compressed={compressed} bytes, uncompressed={uncompressed} bytes (ratio: {ratio:.2})"
88                ),
89                context: Some(format!("compression ratio: {ratio:.2}x")),
90            },
91
92            Self::QuotaExceeded { resource } => FfiErrorMessage {
93                code: "QUOTA_EXCEEDED",
94                description: resource.to_string(),
95                context: None,
96            },
97
98            Self::SecurityViolation { reason } => FfiErrorMessage {
99                code: "SECURITY_VIOLATION",
100                description: format!("operation denied by security policy: {reason}"),
101                context: None,
102            },
103
104            Self::UnsupportedFormat => FfiErrorMessage {
105                code: "UNSUPPORTED_FORMAT",
106                description: "unsupported archive format".into(),
107                context: None,
108            },
109
110            Self::InvalidArchive(reason) => FfiErrorMessage {
111                code: "INVALID_ARCHIVE",
112                description: format!("invalid archive: {reason}"),
113                context: None,
114            },
115
116            Self::Io(io_err) => FfiErrorMessage {
117                code: "IO_ERROR",
118                description: io_err.to_string(),
119                context: Some(io_err.kind().to_string()),
120            },
121
122            Self::InvalidPermissions { path, mode } => FfiErrorMessage {
123                code: "INVALID_PERMISSIONS",
124                description: format!(
125                    "invalid permissions for {}: {mode:#o}",
126                    format_path(path, sanitize_paths)
127                ),
128                context: None,
129            },
130
131            Self::SourceNotFound { path } => FfiErrorMessage {
132                code: "SOURCE_NOT_FOUND",
133                description: format!(
134                    "source path not found: {}",
135                    format_path(path, sanitize_paths)
136                ),
137                context: None,
138            },
139
140            Self::SourceNotAccessible { path } => FfiErrorMessage {
141                code: "SOURCE_NOT_ACCESSIBLE",
142                description: format!(
143                    "source path is not accessible: {}",
144                    format_path(path, sanitize_paths)
145                ),
146                context: None,
147            },
148
149            Self::OutputExists { path } => FfiErrorMessage {
150                code: "OUTPUT_EXISTS",
151                description: format!(
152                    "output file already exists: {}",
153                    format_path(path, sanitize_paths)
154                ),
155                context: None,
156            },
157
158            Self::InvalidCompressionLevel { level } => FfiErrorMessage {
159                code: "INVALID_COMPRESSION_LEVEL",
160                description: format!("invalid compression level {level}, must be 1-9"),
161                context: None,
162            },
163
164            Self::UnknownFormat { path } => FfiErrorMessage {
165                code: "UNKNOWN_FORMAT",
166                description: format!(
167                    "cannot determine archive format from: {}",
168                    format_path(path, sanitize_paths)
169                ),
170                context: None,
171            },
172
173            Self::InvalidConfiguration { reason } => FfiErrorMessage {
174                code: "INVALID_CONFIGURATION",
175                description: format!("invalid configuration: {reason}"),
176                context: None,
177            },
178
179            Self::PartialExtraction { source, .. } => source.to_ffi_message(sanitize_paths),
180        }
181    }
182
183    /// Returns the error code as a static string.
184    ///
185    /// Useful for matching on error types without full message formatting.
186    #[must_use]
187    pub fn error_code(&self) -> &'static str {
188        match self {
189            Self::PathTraversal { .. } => "PATH_TRAVERSAL",
190            Self::SymlinkEscape { .. } => "SYMLINK_ESCAPE",
191            Self::HardlinkEscape { .. } => "HARDLINK_ESCAPE",
192            Self::ZipBomb { .. } => "ZIP_BOMB",
193            Self::QuotaExceeded { .. } => "QUOTA_EXCEEDED",
194            Self::SecurityViolation { .. } => "SECURITY_VIOLATION",
195            Self::UnsupportedFormat => "UNSUPPORTED_FORMAT",
196            Self::InvalidArchive(_) => "INVALID_ARCHIVE",
197            Self::Io(_) => "IO_ERROR",
198            Self::InvalidPermissions { .. } => "INVALID_PERMISSIONS",
199            Self::SourceNotFound { .. } => "SOURCE_NOT_FOUND",
200            Self::SourceNotAccessible { .. } => "SOURCE_NOT_ACCESSIBLE",
201            Self::OutputExists { .. } => "OUTPUT_EXISTS",
202            Self::InvalidCompressionLevel { .. } => "INVALID_COMPRESSION_LEVEL",
203            Self::UnknownFormat { .. } => "UNKNOWN_FORMAT",
204            Self::InvalidConfiguration { .. } => "INVALID_CONFIGURATION",
205            Self::PartialExtraction { source, .. } => source.error_code(),
206        }
207    }
208}
209
210/// Formats a path for error messages.
211///
212/// If `sanitize` is true, only returns the filename (for production).
213/// If `sanitize` is false, returns the full path (for development).
214fn format_path(path: &Path, sanitize: bool) -> String {
215    if sanitize {
216        path.file_name()
217            .and_then(|n| n.to_str())
218            .unwrap_or("<unknown>")
219            .to_string()
220    } else {
221        path.display().to_string()
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use std::path::PathBuf;
229
230    #[test]
231    fn test_path_sanitization() {
232        let error = ExtractionError::PathTraversal {
233            path: PathBuf::from("/etc/passwd"),
234        };
235
236        // Development: full path
237        let msg = error.to_ffi_message(false);
238        assert!(msg.description.contains("/etc/passwd"));
239
240        // Production: filename only
241        let msg = error.to_ffi_message(true);
242        assert!(msg.description.contains("passwd"));
243        assert!(!msg.description.contains("/etc/"));
244    }
245
246    #[test]
247    fn test_error_codes_match() {
248        let test_cases = vec![
249            (
250                ExtractionError::PathTraversal {
251                    path: PathBuf::from("test"),
252                },
253                "PATH_TRAVERSAL",
254            ),
255            (
256                ExtractionError::SymlinkEscape {
257                    path: PathBuf::from("test"),
258                },
259                "SYMLINK_ESCAPE",
260            ),
261            (
262                ExtractionError::ZipBomb {
263                    compressed: 100,
264                    uncompressed: 10000,
265                    ratio: 100.0,
266                },
267                "ZIP_BOMB",
268            ),
269        ];
270
271        for (error, expected_code) in test_cases {
272            assert_eq!(error.error_code(), expected_code);
273            assert_eq!(error.to_ffi_message(false).code, expected_code);
274        }
275    }
276
277    #[test]
278    fn test_all_error_variants_have_codes() {
279        use super::super::types::QuotaResource;
280
281        let errors = vec![
282            ExtractionError::PathTraversal {
283                path: PathBuf::from("test"),
284            },
285            ExtractionError::SymlinkEscape {
286                path: PathBuf::from("test"),
287            },
288            ExtractionError::HardlinkEscape {
289                path: PathBuf::from("test"),
290            },
291            ExtractionError::ZipBomb {
292                compressed: 100,
293                uncompressed: 10000,
294                ratio: 100.0,
295            },
296            ExtractionError::QuotaExceeded {
297                resource: QuotaResource::IntegerOverflow,
298            },
299            ExtractionError::SecurityViolation {
300                reason: "test".into(),
301            },
302            ExtractionError::UnsupportedFormat,
303            ExtractionError::InvalidArchive("test".into()),
304            ExtractionError::Io(std::io::Error::other("test")),
305            ExtractionError::InvalidPermissions {
306                path: PathBuf::from("test"),
307                mode: 0o777,
308            },
309            ExtractionError::SourceNotFound {
310                path: PathBuf::from("test"),
311            },
312            ExtractionError::SourceNotAccessible {
313                path: PathBuf::from("test"),
314            },
315            ExtractionError::OutputExists {
316                path: PathBuf::from("test"),
317            },
318            ExtractionError::InvalidCompressionLevel { level: 10 },
319            ExtractionError::UnknownFormat {
320                path: PathBuf::from("test"),
321            },
322            ExtractionError::InvalidConfiguration {
323                reason: "test".into(),
324            },
325        ];
326
327        for error in errors {
328            let code = error.error_code();
329            assert!(!code.is_empty(), "Error code should not be empty");
330
331            let msg = error.to_ffi_message(false);
332            assert_eq!(msg.code, code);
333            assert!(!msg.description.is_empty());
334        }
335    }
336}