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 Self::PartialExtraction { source, .. } => source.to_ffi_message(sanitize_paths),
180 }
181 }
182
183 #[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
210fn 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 let msg = error.to_ffi_message(false);
238 assert!(msg.description.contains("/etc/passwd"));
239
240 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}