1use std::path::Path;
7
8use super::types::ArchiveError;
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 ArchiveError {
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::InvalidArchive(reason) => FfiErrorMessage {
105 code: "INVALID_ARCHIVE",
106 description: format!("invalid archive: {reason}"),
107 context: None,
108 },
109
110 Self::Io(io_err) => FfiErrorMessage {
111 code: "IO_ERROR",
112 description: io_err.to_string(),
113 context: Some(io_err.kind().to_string()),
114 },
115
116 Self::InvalidPermissions { path, mode } => FfiErrorMessage {
117 code: "INVALID_PERMISSIONS",
118 description: format!(
119 "invalid permissions for {}: {mode:#o}",
120 format_path(path, sanitize_paths)
121 ),
122 context: None,
123 },
124
125 Self::SourceNotFound { path } => FfiErrorMessage {
126 code: "SOURCE_NOT_FOUND",
127 description: format!(
128 "source path not found: {}",
129 format_path(path, sanitize_paths)
130 ),
131 context: None,
132 },
133
134 Self::SourceNotAccessible { path } => FfiErrorMessage {
135 code: "SOURCE_NOT_ACCESSIBLE",
136 description: format!(
137 "source path is not accessible: {}",
138 format_path(path, sanitize_paths)
139 ),
140 context: None,
141 },
142
143 Self::OutputExists { path } => FfiErrorMessage {
144 code: "OUTPUT_EXISTS",
145 description: format!(
146 "output file already exists: {}",
147 format_path(path, sanitize_paths)
148 ),
149 context: None,
150 },
151
152 Self::InvalidCompressionLevel { level } => FfiErrorMessage {
153 code: "INVALID_COMPRESSION_LEVEL",
154 description: format!("invalid compression level {level}, must be 1-9"),
155 context: None,
156 },
157
158 Self::UnknownFormat { path } => FfiErrorMessage {
159 code: "UNKNOWN_FORMAT",
160 description: format!(
161 "cannot determine archive format from: {}",
162 format_path(path, sanitize_paths)
163 ),
164 context: None,
165 },
166
167 Self::InvalidConfiguration { reason } => FfiErrorMessage {
168 code: "INVALID_CONFIGURATION",
169 description: format!("invalid configuration: {reason}"),
170 context: None,
171 },
172
173 Self::PartialExtraction { source, .. } => source.to_ffi_message(sanitize_paths),
174 }
175 }
176
177 #[must_use]
181 pub fn error_code(&self) -> &'static str {
182 match self {
183 Self::PathTraversal { .. } => "PATH_TRAVERSAL",
184 Self::SymlinkEscape { .. } => "SYMLINK_ESCAPE",
185 Self::HardlinkEscape { .. } => "HARDLINK_ESCAPE",
186 Self::ZipBomb { .. } => "ZIP_BOMB",
187 Self::QuotaExceeded { .. } => "QUOTA_EXCEEDED",
188 Self::SecurityViolation { .. } => "SECURITY_VIOLATION",
189 Self::InvalidArchive(_) => "INVALID_ARCHIVE",
190 Self::Io(_) => "IO_ERROR",
191 Self::InvalidPermissions { .. } => "INVALID_PERMISSIONS",
192 Self::SourceNotFound { .. } => "SOURCE_NOT_FOUND",
193 Self::SourceNotAccessible { .. } => "SOURCE_NOT_ACCESSIBLE",
194 Self::OutputExists { .. } => "OUTPUT_EXISTS",
195 Self::InvalidCompressionLevel { .. } => "INVALID_COMPRESSION_LEVEL",
196 Self::UnknownFormat { .. } => "UNKNOWN_FORMAT",
197 Self::InvalidConfiguration { .. } => "INVALID_CONFIGURATION",
198 Self::PartialExtraction { source, .. } => source.error_code(),
199 }
200 }
201}
202
203fn format_path(path: &Path, sanitize: bool) -> String {
208 if sanitize {
209 path.file_name()
210 .and_then(|n| n.to_str())
211 .unwrap_or("<unknown>")
212 .to_string()
213 } else {
214 path.display().to_string()
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use std::path::PathBuf;
222
223 #[test]
224 fn test_path_sanitization() {
225 let error = ArchiveError::PathTraversal {
226 path: PathBuf::from("/etc/passwd"),
227 };
228
229 let msg = error.to_ffi_message(false);
231 assert!(msg.description.contains("/etc/passwd"));
232
233 let msg = error.to_ffi_message(true);
235 assert!(msg.description.contains("passwd"));
236 assert!(!msg.description.contains("/etc/"));
237 }
238
239 #[test]
240 fn test_error_codes_match() {
241 let test_cases = vec![
242 (
243 ArchiveError::PathTraversal {
244 path: PathBuf::from("test"),
245 },
246 "PATH_TRAVERSAL",
247 ),
248 (
249 ArchiveError::SymlinkEscape {
250 path: PathBuf::from("test"),
251 },
252 "SYMLINK_ESCAPE",
253 ),
254 (
255 ArchiveError::ZipBomb {
256 compressed: 100,
257 uncompressed: 10000,
258 ratio: 100.0,
259 },
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(false).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 {
285 compressed: 100,
286 uncompressed: 10000,
287 ratio: 100.0,
288 },
289 ArchiveError::QuotaExceeded {
290 resource: QuotaResource::IntegerOverflow,
291 },
292 ArchiveError::SecurityViolation {
293 reason: "test".into(),
294 },
295 ArchiveError::UnknownFormat {
296 path: PathBuf::from("test.rar"),
297 },
298 ArchiveError::InvalidArchive("test".into()),
299 ArchiveError::Io(std::io::Error::other("test")),
300 ArchiveError::InvalidPermissions {
301 path: PathBuf::from("test"),
302 mode: 0o777,
303 },
304 ArchiveError::SourceNotFound {
305 path: PathBuf::from("test"),
306 },
307 ArchiveError::SourceNotAccessible {
308 path: PathBuf::from("test"),
309 },
310 ArchiveError::OutputExists {
311 path: PathBuf::from("test"),
312 },
313 ArchiveError::InvalidCompressionLevel { level: 10 },
314 ArchiveError::UnknownFormat {
315 path: PathBuf::from("test"),
316 },
317 ArchiveError::InvalidConfiguration {
318 reason: "test".into(),
319 },
320 ];
321
322 for error in errors {
323 let code = error.error_code();
324 assert!(!code.is_empty(), "Error code should not be empty");
325
326 let msg = error.to_ffi_message(false);
327 assert_eq!(msg.code, code);
328 assert!(!msg.description.is_empty());
329 }
330 }
331}