1use std::path::Path;
7
8use super::types::ExtractionError;
9
10#[derive(Debug, Clone)]
15pub struct FfiErrorMessage {
16 pub code: &'static str,
18
19 pub description: String,
21
22 pub context: Option<String>,
24}
25
26impl ExtractionError {
27 #[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 }
180
181 #[must_use]
185 pub fn error_code(&self) -> &'static str {
186 match self {
187 Self::PathTraversal { .. } => "PATH_TRAVERSAL",
188 Self::SymlinkEscape { .. } => "SYMLINK_ESCAPE",
189 Self::HardlinkEscape { .. } => "HARDLINK_ESCAPE",
190 Self::ZipBomb { .. } => "ZIP_BOMB",
191 Self::QuotaExceeded { .. } => "QUOTA_EXCEEDED",
192 Self::SecurityViolation { .. } => "SECURITY_VIOLATION",
193 Self::UnsupportedFormat => "UNSUPPORTED_FORMAT",
194 Self::InvalidArchive(_) => "INVALID_ARCHIVE",
195 Self::Io(_) => "IO_ERROR",
196 Self::InvalidPermissions { .. } => "INVALID_PERMISSIONS",
197 Self::SourceNotFound { .. } => "SOURCE_NOT_FOUND",
198 Self::SourceNotAccessible { .. } => "SOURCE_NOT_ACCESSIBLE",
199 Self::OutputExists { .. } => "OUTPUT_EXISTS",
200 Self::InvalidCompressionLevel { .. } => "INVALID_COMPRESSION_LEVEL",
201 Self::UnknownFormat { .. } => "UNKNOWN_FORMAT",
202 Self::InvalidConfiguration { .. } => "INVALID_CONFIGURATION",
203 }
204 }
205}
206
207fn format_path(path: &Path, sanitize: bool) -> String {
212 if sanitize {
213 path.file_name()
214 .and_then(|n| n.to_str())
215 .unwrap_or("<unknown>")
216 .to_string()
217 } else {
218 path.display().to_string()
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225 use std::path::PathBuf;
226
227 #[test]
228 fn test_path_sanitization() {
229 let error = ExtractionError::PathTraversal {
230 path: PathBuf::from("/etc/passwd"),
231 };
232
233 let msg = error.to_ffi_message(false);
235 assert!(msg.description.contains("/etc/passwd"));
236
237 let msg = error.to_ffi_message(true);
239 assert!(msg.description.contains("passwd"));
240 assert!(!msg.description.contains("/etc/"));
241 }
242
243 #[test]
244 fn test_error_codes_match() {
245 let test_cases = vec![
246 (
247 ExtractionError::PathTraversal {
248 path: PathBuf::from("test"),
249 },
250 "PATH_TRAVERSAL",
251 ),
252 (
253 ExtractionError::SymlinkEscape {
254 path: PathBuf::from("test"),
255 },
256 "SYMLINK_ESCAPE",
257 ),
258 (
259 ExtractionError::ZipBomb {
260 compressed: 100,
261 uncompressed: 10000,
262 ratio: 100.0,
263 },
264 "ZIP_BOMB",
265 ),
266 ];
267
268 for (error, expected_code) in test_cases {
269 assert_eq!(error.error_code(), expected_code);
270 assert_eq!(error.to_ffi_message(false).code, expected_code);
271 }
272 }
273
274 #[test]
275 fn test_all_error_variants_have_codes() {
276 use super::super::types::QuotaResource;
277
278 let errors = vec![
279 ExtractionError::PathTraversal {
280 path: PathBuf::from("test"),
281 },
282 ExtractionError::SymlinkEscape {
283 path: PathBuf::from("test"),
284 },
285 ExtractionError::HardlinkEscape {
286 path: PathBuf::from("test"),
287 },
288 ExtractionError::ZipBomb {
289 compressed: 100,
290 uncompressed: 10000,
291 ratio: 100.0,
292 },
293 ExtractionError::QuotaExceeded {
294 resource: QuotaResource::IntegerOverflow,
295 },
296 ExtractionError::SecurityViolation {
297 reason: "test".into(),
298 },
299 ExtractionError::UnsupportedFormat,
300 ExtractionError::InvalidArchive("test".into()),
301 ExtractionError::Io(std::io::Error::other("test")),
302 ExtractionError::InvalidPermissions {
303 path: PathBuf::from("test"),
304 mode: 0o777,
305 },
306 ExtractionError::SourceNotFound {
307 path: PathBuf::from("test"),
308 },
309 ExtractionError::SourceNotAccessible {
310 path: PathBuf::from("test"),
311 },
312 ExtractionError::OutputExists {
313 path: PathBuf::from("test"),
314 },
315 ExtractionError::InvalidCompressionLevel { level: 10 },
316 ExtractionError::UnknownFormat {
317 path: PathBuf::from("test"),
318 },
319 ExtractionError::InvalidConfiguration {
320 reason: "test".into(),
321 },
322 ];
323
324 for error in errors {
325 let code = error.error_code();
326 assert!(!code.is_empty(), "Error code should not be empty");
327
328 let msg = error.to_ffi_message(false);
329 assert_eq!(msg.code, code);
330 assert!(!msg.description.is_empty());
331 }
332 }
333}