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 super::redaction::sanitize_io_error_for_error;
7use super::types::ArchiveError;
8
9/// Error message for FFI consumption.
10///
11/// Contains structured error information that can be converted to
12/// platform-specific error types (Python exceptions, Node.js Error objects).
13#[derive(Debug, Clone)]
14pub struct FfiErrorMessage {
15    /// Error code (e.g., `PATH_TRAVERSAL`, `ZIP_BOMB`)
16    pub code: &'static str,
17
18    /// Human-readable error description
19    pub description: String,
20
21    /// Optional additional context
22    pub context: Option<String>,
23}
24
25impl ArchiveError {
26    /// Formats error for FFI consumption.
27    ///
28    /// Path and I/O-error text is redacted per the shared policy in
29    /// [`super::redaction`] — see [`Self::redacted_path`] and
30    /// [`sanitize_io_error_for_error`] — so this always applies the same
31    /// policy `exarch-python` and `exarch-node` apply directly; there is no
32    /// separate `sanitize_paths` toggle to keep in sync with theirs.
33    ///
34    /// # Examples
35    ///
36    /// ```
37    /// use exarch_core::ArchiveError;
38    /// use std::path::PathBuf;
39    ///
40    /// let error = ArchiveError::PathTraversal {
41    ///     path: PathBuf::from("../../etc/passwd"),
42    /// };
43    ///
44    /// let msg = error.to_ffi_message();
45    /// assert_eq!(msg.code, "PATH_TRAVERSAL");
46    /// // Archive-relative, attacker-authored path: never redacted (#462).
47    /// assert!(msg.description.contains("../../etc/passwd"));
48    /// ```
49    #[must_use]
50    #[allow(clippy::too_many_lines)]
51    pub fn to_ffi_message(&self) -> FfiErrorMessage {
52        let path = self
53            .redacted_path()
54            .unwrap_or_else(|| "<unknown>".to_string());
55        match self {
56            Self::PathTraversal { .. } => FfiErrorMessage {
57                code: "PATH_TRAVERSAL",
58                description: format!("path traversal detected: {path}"),
59                context: None,
60            },
61
62            Self::SymlinkEscape { .. } => FfiErrorMessage {
63                code: "SYMLINK_ESCAPE",
64                description: format!("symlink target outside extraction directory: {path}"),
65                context: None,
66            },
67
68            Self::HardlinkEscape { .. } => FfiErrorMessage {
69                code: "HARDLINK_ESCAPE",
70                description: format!("hardlink target outside extraction directory: {path}"),
71                context: None,
72            },
73
74            Self::ZipBomb {
75                compressed,
76                uncompressed,
77                ratio,
78            } => FfiErrorMessage {
79                code: "ZIP_BOMB",
80                description: format!(
81                    "potential zip bomb: compressed={compressed} bytes, uncompressed={uncompressed} bytes (ratio: {ratio:.2})"
82                ),
83                context: Some(format!("compression ratio: {ratio:.2}x")),
84            },
85
86            Self::QuotaExceeded { resource } => FfiErrorMessage {
87                code: "QUOTA_EXCEEDED",
88                description: resource.to_string(),
89                context: None,
90            },
91
92            Self::SecurityViolation { reason } => FfiErrorMessage {
93                code: "SECURITY_VIOLATION",
94                description: format!("operation denied by security policy: {reason}"),
95                context: None,
96            },
97
98            Self::InvalidArchive(reason) => FfiErrorMessage {
99                code: "INVALID_ARCHIVE",
100                description: format!("invalid archive: {reason}"),
101                context: None,
102            },
103
104            Self::Io(io_err) => FfiErrorMessage {
105                code: "IO_ERROR",
106                description: sanitize_io_error_for_error(io_err),
107                context: Some(io_err.kind().to_string()),
108            },
109
110            Self::InvalidPermissions { mode, .. } => FfiErrorMessage {
111                code: "INVALID_PERMISSIONS",
112                description: format!("invalid permissions for {path}: {mode:#o}"),
113                context: None,
114            },
115
116            Self::SourceNotFound { .. } => FfiErrorMessage {
117                code: "SOURCE_NOT_FOUND",
118                description: format!("source path not found: {path}"),
119                context: None,
120            },
121
122            Self::SourceNotAccessible { .. } => FfiErrorMessage {
123                code: "SOURCE_NOT_ACCESSIBLE",
124                description: format!("source path is not accessible: {path}"),
125                context: None,
126            },
127
128            Self::OutputExists { .. } => FfiErrorMessage {
129                code: "OUTPUT_EXISTS",
130                description: format!("output file already exists: {path}"),
131                context: None,
132            },
133
134            Self::InvalidCompressionLevel { level } => FfiErrorMessage {
135                code: "INVALID_COMPRESSION_LEVEL",
136                description: format!("invalid compression level {level}, must be 1-9"),
137                context: None,
138            },
139
140            Self::UnknownFormat { .. } => FfiErrorMessage {
141                code: "UNKNOWN_FORMAT",
142                description: format!("cannot determine archive format from: {path}"),
143                context: None,
144            },
145
146            Self::InvalidConfiguration { reason } => FfiErrorMessage {
147                code: "INVALID_CONFIGURATION",
148                description: format!("invalid configuration: {reason}"),
149                context: None,
150            },
151
152            Self::PartialExtraction { source, .. } => source.to_ffi_message(),
153        }
154    }
155
156    /// Returns the error code as a static string.
157    ///
158    /// Useful for matching on error types without full message formatting.
159    #[must_use]
160    pub fn error_code(&self) -> &'static str {
161        match self {
162            Self::PathTraversal { .. } => "PATH_TRAVERSAL",
163            Self::SymlinkEscape { .. } => "SYMLINK_ESCAPE",
164            Self::HardlinkEscape { .. } => "HARDLINK_ESCAPE",
165            Self::ZipBomb { .. } => "ZIP_BOMB",
166            Self::QuotaExceeded { .. } => "QUOTA_EXCEEDED",
167            Self::SecurityViolation { .. } => "SECURITY_VIOLATION",
168            Self::InvalidArchive(_) => "INVALID_ARCHIVE",
169            Self::Io(_) => "IO_ERROR",
170            Self::InvalidPermissions { .. } => "INVALID_PERMISSIONS",
171            Self::SourceNotFound { .. } => "SOURCE_NOT_FOUND",
172            Self::SourceNotAccessible { .. } => "SOURCE_NOT_ACCESSIBLE",
173            Self::OutputExists { .. } => "OUTPUT_EXISTS",
174            Self::InvalidCompressionLevel { .. } => "INVALID_COMPRESSION_LEVEL",
175            Self::UnknownFormat { .. } => "UNKNOWN_FORMAT",
176            Self::InvalidConfiguration { .. } => "INVALID_CONFIGURATION",
177            Self::PartialExtraction { source, .. } => source.error_code(),
178        }
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use std::path::PathBuf;
186
187    /// Regression test for #462: `PathTraversal` carries an
188    /// archive-relative, attacker-authored path and must never be redacted,
189    /// unlike a genuinely host-derived path variant (see the test below).
190    #[test]
191    fn test_attacker_path_never_redacted() {
192        let error = ArchiveError::PathTraversal {
193            path: PathBuf::from("../../etc/passwd"),
194        };
195        let msg = error.to_ffi_message();
196        assert!(msg.description.contains("../../etc/passwd"));
197    }
198
199    /// Regression test for #453: `SourceNotFound` carries a host filesystem
200    /// path; in release builds it must be redacted to filename-only. This
201    /// only exercises the release-build branch — the debug-build behavior
202    /// is covered directly in `super::redaction`.
203    #[test]
204    #[cfg(not(debug_assertions))]
205    fn test_host_path_redacted_in_release() {
206        let error = ArchiveError::SourceNotFound {
207            path: PathBuf::from("/srv/secret/app/x.txt"),
208        };
209        let msg = error.to_ffi_message();
210        assert!(msg.description.contains("x.txt"));
211        assert!(!msg.description.contains("/srv/secret"));
212    }
213
214    /// Regression test for #453: `Io` messages must not leak a host path
215    /// embedded in the free-form message text in release builds.
216    #[test]
217    #[cfg(not(debug_assertions))]
218    fn test_io_error_redacted_in_release() {
219        let error = ArchiveError::Io(std::io::Error::new(
220            std::io::ErrorKind::PermissionDenied,
221            "directory is not writable: /srv/secret/app/private-output",
222        ));
223        let msg = error.to_ffi_message();
224        assert!(!msg.description.contains("/srv/secret"));
225    }
226
227    #[test]
228    fn test_error_codes_match() {
229        let test_cases = vec![
230            (
231                ArchiveError::PathTraversal {
232                    path: PathBuf::from("test"),
233                },
234                "PATH_TRAVERSAL",
235            ),
236            (
237                ArchiveError::SymlinkEscape {
238                    path: PathBuf::from("test"),
239                },
240                "SYMLINK_ESCAPE",
241            ),
242            (
243                ArchiveError::ZipBomb {
244                    compressed: 100,
245                    uncompressed: 10000,
246                    ratio: 100.0,
247                },
248                "ZIP_BOMB",
249            ),
250        ];
251
252        for (error, expected_code) in test_cases {
253            assert_eq!(error.error_code(), expected_code);
254            assert_eq!(error.to_ffi_message().code, expected_code);
255        }
256    }
257
258    #[test]
259    fn test_all_error_variants_have_codes() {
260        use super::super::types::QuotaResource;
261
262        let errors = vec![
263            ArchiveError::PathTraversal {
264                path: PathBuf::from("test"),
265            },
266            ArchiveError::SymlinkEscape {
267                path: PathBuf::from("test"),
268            },
269            ArchiveError::HardlinkEscape {
270                path: PathBuf::from("test"),
271            },
272            ArchiveError::ZipBomb {
273                compressed: 100,
274                uncompressed: 10000,
275                ratio: 100.0,
276            },
277            ArchiveError::QuotaExceeded {
278                resource: QuotaResource::IntegerOverflow,
279            },
280            ArchiveError::SecurityViolation {
281                reason: "test".into(),
282            },
283            ArchiveError::UnknownFormat {
284                path: PathBuf::from("test.rar"),
285            },
286            ArchiveError::InvalidArchive("test".into()),
287            ArchiveError::Io(std::io::Error::other("test")),
288            ArchiveError::InvalidPermissions {
289                path: PathBuf::from("test"),
290                mode: 0o777,
291            },
292            ArchiveError::SourceNotFound {
293                path: PathBuf::from("test"),
294            },
295            ArchiveError::SourceNotAccessible {
296                path: PathBuf::from("test"),
297            },
298            ArchiveError::OutputExists {
299                path: PathBuf::from("test"),
300            },
301            ArchiveError::InvalidCompressionLevel { level: 10 },
302            ArchiveError::UnknownFormat {
303                path: PathBuf::from("test"),
304            },
305            ArchiveError::InvalidConfiguration {
306                reason: "test".into(),
307            },
308        ];
309
310        for error in errors {
311            let code = error.error_code();
312            assert!(!code.is_empty(), "Error code should not be empty");
313
314            let msg = error.to_ffi_message();
315            assert_eq!(msg.code, code);
316            assert!(!msg.description.is_empty());
317        }
318    }
319}