1use super::redaction::sanitize_io_error_for_error;
7use super::types::ArchiveError;
8use super::types::PolicySetting;
9
10#[derive(Debug, Clone)]
18#[non_exhaustive]
19pub struct FfiErrorMessage {
20 pub code: &'static str,
22
23 pub description: String,
25
26 pub context: Option<String>,
28
29 pub relaxable_by: Option<PolicySetting>,
32}
33
34impl FfiErrorMessage {
35 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 #[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 #[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 #[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 #[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 #[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}