exarch-rs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/error.rs ADDED
@@ -0,0 +1,309 @@
1
+ //! Error conversion for Node.js bindings.
2
+
3
+ use exarch_core::ExtractionError as CoreError;
4
+ use exarch_core::QuotaResource as CoreQuotaResource;
5
+ use napi::bindgen_prelude::*;
6
+ use std::path::Path;
7
+
8
+ /// Sanitizes path information for error messages.
9
+ ///
10
+ /// In debug builds, returns the full path for detailed diagnostics.
11
+ /// In release builds, returns only the filename to avoid leaking internal
12
+ /// directory structures to potential attackers.
13
+ #[cfg(debug_assertions)]
14
+ fn sanitize_path_for_error(path: &Path) -> String {
15
+ path.display().to_string()
16
+ }
17
+
18
+ #[cfg(not(debug_assertions))]
19
+ fn sanitize_path_for_error(path: &Path) -> String {
20
+ path.file_name()
21
+ .and_then(|n| n.to_str())
22
+ .unwrap_or("<unknown>")
23
+ .to_string()
24
+ }
25
+
26
+ /// Converts Rust extraction errors to JavaScript exceptions.
27
+ ///
28
+ /// This preserves error context and maps each Rust error variant to a
29
+ /// JavaScript error with a descriptive message prefixed with an error code.
30
+ ///
31
+ /// Error codes enable JavaScript callers to distinguish error types:
32
+ /// - `PATH_TRAVERSAL`: Path traversal attempt detected
33
+ /// - `SYMLINK_ESCAPE`: Symlink points outside extraction directory
34
+ /// - `HARDLINK_ESCAPE`: Hardlink target outside extraction directory
35
+ /// - `ZIP_BOMB`: Potential zip bomb detected
36
+ /// - `INVALID_PERMISSIONS`: File permissions are invalid or unsafe
37
+ /// - `QUOTA_EXCEEDED`: Resource quota exceeded
38
+ /// - `SECURITY_VIOLATION`: Security policy violation
39
+ /// - `UNSUPPORTED_FORMAT`: Archive format not supported
40
+ /// - `INVALID_ARCHIVE`: Archive is corrupted
41
+ /// - `IO_ERROR`: I/O operation failed
42
+ #[allow(clippy::too_many_lines)]
43
+ pub fn convert_error(err: CoreError) -> Error {
44
+ use std::fmt::Write;
45
+ match err {
46
+ CoreError::PathTraversal { path } => {
47
+ let path_str = sanitize_path_for_error(&path);
48
+ let mut msg = String::with_capacity(50 + path_str.len());
49
+ msg.push_str("PATH_TRAVERSAL: path traversal detected: ");
50
+ msg.push_str(&path_str);
51
+ Error::new(Status::GenericFailure, msg)
52
+ }
53
+ CoreError::SymlinkEscape { path } => {
54
+ let path_str = sanitize_path_for_error(&path);
55
+ let mut msg = String::with_capacity(70 + path_str.len());
56
+ msg.push_str("SYMLINK_ESCAPE: symlink target outside extraction directory: ");
57
+ msg.push_str(&path_str);
58
+ Error::new(Status::GenericFailure, msg)
59
+ }
60
+ CoreError::HardlinkEscape { path } => {
61
+ let path_str = sanitize_path_for_error(&path);
62
+ let mut msg = String::with_capacity(70 + path_str.len());
63
+ msg.push_str("HARDLINK_ESCAPE: hardlink target outside extraction directory: ");
64
+ msg.push_str(&path_str);
65
+ Error::new(Status::GenericFailure, msg)
66
+ }
67
+ CoreError::ZipBomb {
68
+ compressed,
69
+ uncompressed,
70
+ ratio,
71
+ } => {
72
+ let mut msg = String::with_capacity(150);
73
+ // Writing to a String never fails
74
+ let _ = write!(
75
+ &mut msg,
76
+ "ZIP_BOMB: potential zip bomb: compressed={compressed} bytes, uncompressed={uncompressed} bytes (ratio: {ratio:.2})"
77
+ );
78
+ Error::new(Status::GenericFailure, msg)
79
+ }
80
+ CoreError::InvalidPermissions { path, mode } => {
81
+ let path_str = sanitize_path_for_error(&path);
82
+ let mut msg = String::with_capacity(60 + path_str.len());
83
+ // Writing to a String never fails
84
+ let _ = write!(
85
+ &mut msg,
86
+ "INVALID_PERMISSIONS: invalid permissions for {path_str}: {mode:#o}"
87
+ );
88
+ Error::new(Status::GenericFailure, msg)
89
+ }
90
+ CoreError::QuotaExceeded { resource } => {
91
+ let mut msg = String::with_capacity(100);
92
+ // Writing to a String never fails
93
+ match resource {
94
+ CoreQuotaResource::FileCount { current, max } => {
95
+ let _ = write!(
96
+ &mut msg,
97
+ "QUOTA_EXCEEDED: quota exceeded: file count ({current} > {max})"
98
+ );
99
+ }
100
+ CoreQuotaResource::TotalSize { current, max } => {
101
+ let _ = write!(
102
+ &mut msg,
103
+ "QUOTA_EXCEEDED: quota exceeded: total size ({current} > {max})"
104
+ );
105
+ }
106
+ CoreQuotaResource::FileSize { size, max } => {
107
+ let _ = write!(
108
+ &mut msg,
109
+ "QUOTA_EXCEEDED: quota exceeded: file size ({size} > {max})"
110
+ );
111
+ }
112
+ CoreQuotaResource::IntegerOverflow => {
113
+ msg.push_str(
114
+ "QUOTA_EXCEEDED: quota exceeded: integer overflow in quota tracking",
115
+ );
116
+ }
117
+ }
118
+ Error::new(Status::GenericFailure, msg)
119
+ }
120
+ CoreError::SecurityViolation { reason } => {
121
+ let mut msg = String::with_capacity(60 + reason.len());
122
+ msg.push_str("SECURITY_VIOLATION: operation denied by security policy: ");
123
+ msg.push_str(&reason);
124
+ Error::new(Status::GenericFailure, msg)
125
+ }
126
+ CoreError::UnsupportedFormat => Error::new(
127
+ Status::GenericFailure,
128
+ "UNSUPPORTED_FORMAT: unsupported archive format",
129
+ ),
130
+ CoreError::InvalidArchive(archive_msg) => {
131
+ let mut msg = String::with_capacity(30 + archive_msg.len());
132
+ msg.push_str("INVALID_ARCHIVE: invalid archive: ");
133
+ msg.push_str(&archive_msg);
134
+ Error::new(Status::GenericFailure, msg)
135
+ }
136
+ CoreError::Io(e) => {
137
+ let e_str = e.to_string();
138
+ let mut msg = String::with_capacity(10 + e_str.len());
139
+ msg.push_str("IO_ERROR: ");
140
+ msg.push_str(&e_str);
141
+ Error::new(Status::GenericFailure, msg)
142
+ }
143
+ }
144
+ }
145
+
146
+ #[cfg(test)]
147
+ #[allow(clippy::unwrap_used, clippy::expect_used)]
148
+ mod tests {
149
+ use super::*;
150
+ use std::path::PathBuf;
151
+
152
+ #[test]
153
+ fn test_path_traversal_conversion() {
154
+ let err = CoreError::PathTraversal {
155
+ path: PathBuf::from("../etc/passwd"),
156
+ };
157
+ let napi_err = convert_error(err);
158
+ let err_str = napi_err.to_string();
159
+ assert!(err_str.contains("PATH_TRAVERSAL"));
160
+ assert!(err_str.contains("path traversal"));
161
+ assert!(err_str.contains("../etc/passwd"));
162
+ }
163
+
164
+ #[test]
165
+ fn test_symlink_escape_conversion() {
166
+ let err = CoreError::SymlinkEscape {
167
+ path: PathBuf::from("/etc/passwd"),
168
+ };
169
+ let napi_err = convert_error(err);
170
+ let err_str = napi_err.to_string();
171
+ assert!(err_str.contains("SYMLINK_ESCAPE"));
172
+ assert!(err_str.contains("symlink target outside"));
173
+ assert!(err_str.contains("/etc/passwd"));
174
+ }
175
+
176
+ #[test]
177
+ fn test_hardlink_escape_conversion() {
178
+ let err = CoreError::HardlinkEscape {
179
+ path: PathBuf::from("/etc/shadow"),
180
+ };
181
+ let napi_err = convert_error(err);
182
+ let err_str = napi_err.to_string();
183
+ assert!(err_str.contains("HARDLINK_ESCAPE"));
184
+ assert!(err_str.contains("hardlink target outside"));
185
+ assert!(err_str.contains("/etc/shadow"));
186
+ }
187
+
188
+ #[test]
189
+ fn test_zip_bomb_conversion() {
190
+ let err = CoreError::ZipBomb {
191
+ compressed: 1000,
192
+ uncompressed: 1_000_000,
193
+ ratio: 1000.0,
194
+ };
195
+ let napi_err = convert_error(err);
196
+ let err_str = napi_err.to_string();
197
+ assert!(err_str.contains("ZIP_BOMB"));
198
+ assert!(err_str.contains("zip bomb"));
199
+ assert!(err_str.contains("1000"));
200
+ }
201
+
202
+ #[test]
203
+ fn test_invalid_permissions_conversion() {
204
+ let err = CoreError::InvalidPermissions {
205
+ path: PathBuf::from("malicious.sh"),
206
+ mode: 0o777,
207
+ };
208
+ let napi_err = convert_error(err);
209
+ let err_str = napi_err.to_string();
210
+ assert!(err_str.contains("INVALID_PERMISSIONS"));
211
+ assert!(err_str.contains("invalid permissions"));
212
+ assert!(err_str.contains("777"));
213
+ assert!(err_str.contains("malicious.sh"));
214
+ }
215
+
216
+ #[test]
217
+ fn test_quota_exceeded_file_count_conversion() {
218
+ let err = CoreError::QuotaExceeded {
219
+ resource: CoreQuotaResource::FileCount {
220
+ current: 11,
221
+ max: 10,
222
+ },
223
+ };
224
+ let napi_err = convert_error(err);
225
+ let err_str = napi_err.to_string();
226
+ assert!(err_str.contains("QUOTA_EXCEEDED"));
227
+ assert!(err_str.contains("file count"));
228
+ }
229
+
230
+ #[test]
231
+ fn test_quota_exceeded_total_size_conversion() {
232
+ let err = CoreError::QuotaExceeded {
233
+ resource: CoreQuotaResource::TotalSize {
234
+ current: 1_000_000,
235
+ max: 500_000,
236
+ },
237
+ };
238
+ let napi_err = convert_error(err);
239
+ let err_str = napi_err.to_string();
240
+ assert!(err_str.contains("QUOTA_EXCEEDED"));
241
+ assert!(err_str.contains("total size"));
242
+ }
243
+
244
+ #[test]
245
+ fn test_quota_exceeded_file_size_conversion() {
246
+ let err = CoreError::QuotaExceeded {
247
+ resource: CoreQuotaResource::FileSize {
248
+ size: 100_000_000,
249
+ max: 50_000_000,
250
+ },
251
+ };
252
+ let napi_err = convert_error(err);
253
+ let err_str = napi_err.to_string();
254
+ assert!(err_str.contains("QUOTA_EXCEEDED"));
255
+ assert!(err_str.contains("file size"));
256
+ }
257
+
258
+ #[test]
259
+ fn test_quota_exceeded_integer_overflow_conversion() {
260
+ let err = CoreError::QuotaExceeded {
261
+ resource: CoreQuotaResource::IntegerOverflow,
262
+ };
263
+ let napi_err = convert_error(err);
264
+ let err_str = napi_err.to_string();
265
+ assert!(err_str.contains("QUOTA_EXCEEDED"));
266
+ assert!(err_str.contains("integer overflow"));
267
+ }
268
+
269
+ #[test]
270
+ fn test_security_violation_conversion() {
271
+ let err = CoreError::SecurityViolation {
272
+ reason: "test violation".to_string(),
273
+ };
274
+ let napi_err = convert_error(err);
275
+ let err_str = napi_err.to_string();
276
+ assert!(err_str.contains("SECURITY_VIOLATION"));
277
+ assert!(err_str.contains("security policy"));
278
+ assert!(err_str.contains("test violation"));
279
+ }
280
+
281
+ #[test]
282
+ fn test_unsupported_format_conversion() {
283
+ let err = CoreError::UnsupportedFormat;
284
+ let napi_err = convert_error(err);
285
+ let err_str = napi_err.to_string();
286
+ assert!(err_str.contains("UNSUPPORTED_FORMAT"));
287
+ assert!(err_str.contains("unsupported archive format"));
288
+ }
289
+
290
+ #[test]
291
+ fn test_invalid_archive_conversion() {
292
+ let err = CoreError::InvalidArchive("corrupted header".to_string());
293
+ let napi_err = convert_error(err);
294
+ let err_str = napi_err.to_string();
295
+ assert!(err_str.contains("INVALID_ARCHIVE"));
296
+ assert!(err_str.contains("invalid archive"));
297
+ assert!(err_str.contains("corrupted header"));
298
+ }
299
+
300
+ #[test]
301
+ fn test_io_error_conversion() {
302
+ let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
303
+ let err = CoreError::Io(io_err);
304
+ let napi_err = convert_error(err);
305
+ let err_str = napi_err.to_string();
306
+ assert!(err_str.contains("IO_ERROR"));
307
+ assert!(err_str.contains("file not found"));
308
+ }
309
+ }