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