1use super::redaction::sanitize_io_error_for_error;
7use super::types::ArchiveError;
8
9#[derive(Debug, Clone)]
14pub struct FfiErrorMessage {
15 pub code: &'static str,
17
18 pub description: String,
20
21 pub context: Option<String>,
23}
24
25impl ArchiveError {
26 #[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 #[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 #[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 #[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 #[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}