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/Cargo.toml +28 -0
- package/README.md +235 -0
- package/build.rs +5 -0
- package/index.d.ts +287 -0
- package/package.json +37 -0
- package/src/config.rs +927 -0
- package/src/error.rs +309 -0
- package/src/lib.rs +428 -0
- package/src/report.rs +176 -0
- package/src/utils.rs +84 -0
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
|
+
}
|