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/lib.rs
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
//! Node.js bindings for exarch-core.
|
|
2
|
+
//!
|
|
3
|
+
//! This crate provides a JavaScript/TypeScript API for secure archive
|
|
4
|
+
//! extraction with built-in protection against path traversal, zip bombs,
|
|
5
|
+
//! symlink attacks, and other common vulnerabilities.
|
|
6
|
+
//!
|
|
7
|
+
//! # Installation
|
|
8
|
+
//!
|
|
9
|
+
//! ```bash
|
|
10
|
+
//! npm install @exarch/node
|
|
11
|
+
//! ```
|
|
12
|
+
//!
|
|
13
|
+
//! # Quick Start
|
|
14
|
+
//!
|
|
15
|
+
//! ```javascript
|
|
16
|
+
//! const { extractArchive, SecurityConfig } = require('@exarch/node');
|
|
17
|
+
//!
|
|
18
|
+
//! // Use secure defaults
|
|
19
|
+
//! const report = await extractArchive('archive.tar.gz', '/tmp/output');
|
|
20
|
+
//! console.log(`Extracted ${report.filesExtracted} files`);
|
|
21
|
+
//!
|
|
22
|
+
//! // Customize security settings
|
|
23
|
+
//! const config = new SecurityConfig()
|
|
24
|
+
//! .maxFileSize(100 * 1024 * 1024)
|
|
25
|
+
//! .allowSymlinks(true);
|
|
26
|
+
//! const report = await extractArchive('archive.tar.gz', '/tmp/output', config);
|
|
27
|
+
//! ```
|
|
28
|
+
//!
|
|
29
|
+
//! # Security
|
|
30
|
+
//!
|
|
31
|
+
//! This library uses a secure-by-default approach. All potentially dangerous
|
|
32
|
+
//! features are disabled by default and must be explicitly enabled. See
|
|
33
|
+
//! `SecurityConfig` for configuration options.
|
|
34
|
+
//!
|
|
35
|
+
//! # Repository
|
|
36
|
+
//!
|
|
37
|
+
//! <https://github.com/rabax/exarch>
|
|
38
|
+
//!
|
|
39
|
+
//! # License
|
|
40
|
+
//!
|
|
41
|
+
//! MIT OR Apache-2.0
|
|
42
|
+
|
|
43
|
+
use napi::bindgen_prelude::*;
|
|
44
|
+
use napi_derive::napi;
|
|
45
|
+
|
|
46
|
+
mod config;
|
|
47
|
+
mod error;
|
|
48
|
+
mod report;
|
|
49
|
+
mod utils;
|
|
50
|
+
|
|
51
|
+
use config::SecurityConfig;
|
|
52
|
+
use error::convert_error;
|
|
53
|
+
use report::ExtractionReport;
|
|
54
|
+
use utils::validate_path;
|
|
55
|
+
|
|
56
|
+
/// Extract an archive to the specified directory (async).
|
|
57
|
+
///
|
|
58
|
+
/// This function provides secure archive extraction with configurable
|
|
59
|
+
/// security policies. By default, it uses a restrictive security
|
|
60
|
+
/// configuration that blocks symlinks, hardlinks, absolute paths, and
|
|
61
|
+
/// enforces resource quotas.
|
|
62
|
+
///
|
|
63
|
+
/// # Security Considerations
|
|
64
|
+
///
|
|
65
|
+
/// ## Thread Safety and TOCTOU
|
|
66
|
+
///
|
|
67
|
+
/// The extraction runs on a libuv thread pool worker thread. This creates
|
|
68
|
+
/// a Time-Of-Check-Time-Of-Use (TOCTOU) race condition where the archive
|
|
69
|
+
/// file could be modified between validation and extraction. This is an
|
|
70
|
+
/// accepted tradeoff for async performance. For untrusted archives, ensure
|
|
71
|
+
/// exclusive access to the archive file during extraction.
|
|
72
|
+
///
|
|
73
|
+
/// ## Input Validation
|
|
74
|
+
///
|
|
75
|
+
/// - Paths containing null bytes are rejected (security)
|
|
76
|
+
/// - Paths exceeding 4096 bytes are rejected (`DoS` prevention)
|
|
77
|
+
/// - All validation happens at the Node.js boundary before calling core library
|
|
78
|
+
///
|
|
79
|
+
/// # Arguments
|
|
80
|
+
///
|
|
81
|
+
/// * `archive_path` - Path to the archive file
|
|
82
|
+
/// * `output_dir` - Directory where files will be extracted
|
|
83
|
+
/// * `config` - Optional `SecurityConfig` (uses secure defaults if omitted)
|
|
84
|
+
///
|
|
85
|
+
/// # Returns
|
|
86
|
+
///
|
|
87
|
+
/// Promise resolving to `ExtractionReport` with extraction statistics
|
|
88
|
+
///
|
|
89
|
+
/// # Errors
|
|
90
|
+
///
|
|
91
|
+
/// Returns error for security violations or I/O errors. Error messages are
|
|
92
|
+
/// prefixed with error codes for discrimination in JavaScript:
|
|
93
|
+
///
|
|
94
|
+
/// - `PATH_TRAVERSAL`: Path traversal attempt detected
|
|
95
|
+
/// - `SYMLINK_ESCAPE`: Symlink points outside extraction directory
|
|
96
|
+
/// - `HARDLINK_ESCAPE`: Hardlink target outside extraction directory
|
|
97
|
+
/// - `ZIP_BOMB`: Potential zip bomb detected
|
|
98
|
+
/// - `INVALID_PERMISSIONS`: File permissions are invalid or unsafe
|
|
99
|
+
/// - `QUOTA_EXCEEDED`: Resource quota exceeded
|
|
100
|
+
/// - `SECURITY_VIOLATION`: Security policy violation
|
|
101
|
+
/// - `UNSUPPORTED_FORMAT`: Archive format not supported
|
|
102
|
+
/// - `INVALID_ARCHIVE`: Archive is corrupted
|
|
103
|
+
/// - `IO_ERROR`: I/O operation failed
|
|
104
|
+
///
|
|
105
|
+
/// # Examples
|
|
106
|
+
///
|
|
107
|
+
/// ```javascript
|
|
108
|
+
/// // Use secure defaults
|
|
109
|
+
/// const report = await extractArchive('archive.tar.gz', '/tmp/output');
|
|
110
|
+
/// console.log(`Extracted ${report.filesExtracted} files`);
|
|
111
|
+
///
|
|
112
|
+
/// // Customize security settings
|
|
113
|
+
/// const config = new SecurityConfig().maxFileSize(100 * 1024 * 1024);
|
|
114
|
+
/// const report = await extractArchive('archive.tar.gz', '/tmp/output', config);
|
|
115
|
+
/// ```
|
|
116
|
+
#[napi]
|
|
117
|
+
#[allow(clippy::needless_pass_by_value, clippy::trailing_empty_array)]
|
|
118
|
+
pub async fn extract_archive(
|
|
119
|
+
archive_path: String,
|
|
120
|
+
output_dir: String,
|
|
121
|
+
config: Option<&SecurityConfig>,
|
|
122
|
+
) -> Result<ExtractionReport> {
|
|
123
|
+
// Validate paths at boundary
|
|
124
|
+
// NOTE: Defense-in-depth - paths are validated here and again in core
|
|
125
|
+
// library. This boundary validation catches issues early and provides
|
|
126
|
+
// better error messages for Node.js users.
|
|
127
|
+
validate_path(&archive_path)?;
|
|
128
|
+
validate_path(&output_dir)?;
|
|
129
|
+
|
|
130
|
+
// Get config reference or use default
|
|
131
|
+
let default_config = exarch_core::SecurityConfig::default();
|
|
132
|
+
let config_ref = config.map_or(&default_config, |c| c.as_core());
|
|
133
|
+
|
|
134
|
+
// Use Arc to share config across thread boundary without cloning
|
|
135
|
+
let config_arc = std::sync::Arc::new(config_ref.clone());
|
|
136
|
+
|
|
137
|
+
// Run extraction on tokio thread pool
|
|
138
|
+
//
|
|
139
|
+
// NAPI-RS with tokio_rt feature uses tokio runtime for async operations.
|
|
140
|
+
// spawn_blocking is required because archive extraction does blocking I/O.
|
|
141
|
+
// This moves the work to tokio's blocking thread pool rather than
|
|
142
|
+
// blocking the Node.js event loop.
|
|
143
|
+
//
|
|
144
|
+
// NOTE: TOCTOU race condition - archive contents can change between
|
|
145
|
+
// validation and extraction. This is an accepted limitation for async I/O.
|
|
146
|
+
// For maximum security with untrusted archives, use extractArchiveSync()
|
|
147
|
+
// or ensure exclusive file access (e.g., flock) during extraction.
|
|
148
|
+
let report = tokio::task::spawn_blocking(move || {
|
|
149
|
+
exarch_core::extract_archive(&archive_path, &output_dir, &config_arc)
|
|
150
|
+
})
|
|
151
|
+
.await
|
|
152
|
+
.map_err(|e| Error::from_reason(format!("task execution failed: {e}")))?
|
|
153
|
+
.map_err(convert_error)?;
|
|
154
|
+
|
|
155
|
+
Ok(ExtractionReport::from(report))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/// Extract an archive to the specified directory (sync).
|
|
159
|
+
///
|
|
160
|
+
/// Synchronous version of `extractArchive`. Blocks the event loop until
|
|
161
|
+
/// extraction completes. Prefer the async version for most use cases.
|
|
162
|
+
///
|
|
163
|
+
/// # Arguments
|
|
164
|
+
///
|
|
165
|
+
/// * `archive_path` - Path to the archive file
|
|
166
|
+
/// * `output_dir` - Directory where files will be extracted
|
|
167
|
+
/// * `config` - Optional `SecurityConfig` (uses secure defaults if omitted)
|
|
168
|
+
///
|
|
169
|
+
/// # Returns
|
|
170
|
+
///
|
|
171
|
+
/// `ExtractionReport` with extraction statistics
|
|
172
|
+
///
|
|
173
|
+
/// # Errors
|
|
174
|
+
///
|
|
175
|
+
/// Returns error for security violations or I/O errors. See `extract_archive`
|
|
176
|
+
/// for error code documentation.
|
|
177
|
+
///
|
|
178
|
+
/// # Examples
|
|
179
|
+
///
|
|
180
|
+
/// ```javascript
|
|
181
|
+
/// // Use secure defaults
|
|
182
|
+
/// const report = extractArchiveSync('archive.tar.gz', '/tmp/output');
|
|
183
|
+
/// console.log(`Extracted ${report.filesExtracted} files`);
|
|
184
|
+
///
|
|
185
|
+
/// // Customize security settings
|
|
186
|
+
/// const config = new SecurityConfig().maxFileSize(100 * 1024 * 1024);
|
|
187
|
+
/// const report = extractArchiveSync('archive.tar.gz', '/tmp/output', config);
|
|
188
|
+
/// ```
|
|
189
|
+
#[napi]
|
|
190
|
+
#[allow(clippy::needless_pass_by_value)]
|
|
191
|
+
pub fn extract_archive_sync(
|
|
192
|
+
archive_path: String,
|
|
193
|
+
output_dir: String,
|
|
194
|
+
config: Option<&SecurityConfig>,
|
|
195
|
+
) -> Result<ExtractionReport> {
|
|
196
|
+
// Validate paths at boundary
|
|
197
|
+
// NOTE: Defense-in-depth - paths are validated here and again in core
|
|
198
|
+
// library. This boundary validation catches issues early and provides
|
|
199
|
+
// better error messages for Node.js users.
|
|
200
|
+
validate_path(&archive_path)?;
|
|
201
|
+
validate_path(&output_dir)?;
|
|
202
|
+
|
|
203
|
+
// Get config reference or use default
|
|
204
|
+
let default_config = exarch_core::SecurityConfig::default();
|
|
205
|
+
let config_ref = config.map_or(&default_config, |c| c.as_core());
|
|
206
|
+
|
|
207
|
+
// Run extraction synchronously
|
|
208
|
+
let report = exarch_core::extract_archive(&archive_path, &output_dir, config_ref)
|
|
209
|
+
.map_err(convert_error)?;
|
|
210
|
+
|
|
211
|
+
Ok(ExtractionReport::from(report))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
#[cfg(test)]
|
|
215
|
+
#[allow(
|
|
216
|
+
clippy::unwrap_used,
|
|
217
|
+
clippy::expect_used,
|
|
218
|
+
clippy::uninlined_format_args,
|
|
219
|
+
clippy::manual_string_new
|
|
220
|
+
)]
|
|
221
|
+
mod tests {
|
|
222
|
+
use super::*;
|
|
223
|
+
|
|
224
|
+
#[test]
|
|
225
|
+
fn test_module_exports_functions() {
|
|
226
|
+
// This test just ensures the module compiles and exports the expected
|
|
227
|
+
// functions. Runtime tests would require actual archive files.
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// CR-004: Path validation tests
|
|
231
|
+
#[tokio::test]
|
|
232
|
+
async fn test_extract_archive_rejects_null_byte_in_archive_path() {
|
|
233
|
+
let result = extract_archive(
|
|
234
|
+
"/tmp/test\0malicious.tar".to_string(),
|
|
235
|
+
"/tmp/output".to_string(),
|
|
236
|
+
None,
|
|
237
|
+
)
|
|
238
|
+
.await;
|
|
239
|
+
|
|
240
|
+
assert!(result.is_err(), "should reject null bytes in archive path");
|
|
241
|
+
assert!(
|
|
242
|
+
result.unwrap_err().to_string().contains("null bytes"),
|
|
243
|
+
"error message should mention null bytes"
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
#[tokio::test]
|
|
248
|
+
async fn test_extract_archive_rejects_null_byte_in_output_dir() {
|
|
249
|
+
let result = extract_archive(
|
|
250
|
+
"/tmp/test.tar".to_string(),
|
|
251
|
+
"/tmp/output\0malicious".to_string(),
|
|
252
|
+
None,
|
|
253
|
+
)
|
|
254
|
+
.await;
|
|
255
|
+
|
|
256
|
+
assert!(result.is_err(), "should reject null bytes in output path");
|
|
257
|
+
assert!(
|
|
258
|
+
result.unwrap_err().to_string().contains("null bytes"),
|
|
259
|
+
"error message should mention null bytes"
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
#[tokio::test]
|
|
264
|
+
async fn test_extract_archive_rejects_excessively_long_archive_path() {
|
|
265
|
+
let long_path = "x".repeat(5000);
|
|
266
|
+
let result = extract_archive(long_path, "/tmp/output".to_string(), None).await;
|
|
267
|
+
|
|
268
|
+
assert!(result.is_err(), "should reject excessively long paths");
|
|
269
|
+
assert!(
|
|
270
|
+
result.unwrap_err().to_string().contains("maximum length"),
|
|
271
|
+
"error message should mention length limit"
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
#[tokio::test]
|
|
276
|
+
async fn test_extract_archive_rejects_excessively_long_output_dir() {
|
|
277
|
+
let long_path = "x".repeat(5000);
|
|
278
|
+
let result = extract_archive("/tmp/test.tar".to_string(), long_path, None).await;
|
|
279
|
+
|
|
280
|
+
assert!(result.is_err(), "should reject excessively long paths");
|
|
281
|
+
assert!(
|
|
282
|
+
result.unwrap_err().to_string().contains("maximum length"),
|
|
283
|
+
"error message should mention length limit"
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
#[tokio::test]
|
|
288
|
+
async fn test_extract_archive_accepts_empty_paths() {
|
|
289
|
+
// Empty paths should be accepted at boundary validation
|
|
290
|
+
// Core library will handle actual path validation
|
|
291
|
+
let result = extract_archive("".to_string(), "".to_string(), None).await;
|
|
292
|
+
|
|
293
|
+
// If it fails, ensure it's not a boundary path validation error
|
|
294
|
+
// (empty paths pass boundary validation; core handles semantic validation)
|
|
295
|
+
if let Err(e) = result {
|
|
296
|
+
let err_msg = e.to_string();
|
|
297
|
+
assert!(
|
|
298
|
+
!err_msg.contains("null bytes") && !err_msg.contains("maximum length"),
|
|
299
|
+
"should not fail on boundary path validation, got: {}",
|
|
300
|
+
err_msg
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
// If it succeeds, boundary validation passed (which is what we're
|
|
304
|
+
// testing)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
#[test]
|
|
308
|
+
fn test_extract_archive_sync_rejects_null_byte_in_archive_path() {
|
|
309
|
+
let result = extract_archive_sync(
|
|
310
|
+
"/tmp/test\0malicious.tar".to_string(),
|
|
311
|
+
"/tmp/output".to_string(),
|
|
312
|
+
None,
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
assert!(result.is_err(), "should reject null bytes in archive path");
|
|
316
|
+
assert!(
|
|
317
|
+
result.unwrap_err().to_string().contains("null bytes"),
|
|
318
|
+
"error message should mention null bytes"
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
#[test]
|
|
323
|
+
fn test_extract_archive_sync_rejects_null_byte_in_output_dir() {
|
|
324
|
+
let result = extract_archive_sync(
|
|
325
|
+
"/tmp/test.tar".to_string(),
|
|
326
|
+
"/tmp/output\0malicious".to_string(),
|
|
327
|
+
None,
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
assert!(result.is_err(), "should reject null bytes in output path");
|
|
331
|
+
assert!(
|
|
332
|
+
result.unwrap_err().to_string().contains("null bytes"),
|
|
333
|
+
"error message should mention null bytes"
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
#[test]
|
|
338
|
+
fn test_extract_archive_sync_rejects_excessively_long_archive_path() {
|
|
339
|
+
let long_path = "x".repeat(5000);
|
|
340
|
+
let result = extract_archive_sync(long_path, "/tmp/output".to_string(), None);
|
|
341
|
+
|
|
342
|
+
assert!(result.is_err(), "should reject excessively long paths");
|
|
343
|
+
assert!(
|
|
344
|
+
result.unwrap_err().to_string().contains("maximum length"),
|
|
345
|
+
"error message should mention length limit"
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
#[test]
|
|
350
|
+
fn test_extract_archive_sync_rejects_excessively_long_output_dir() {
|
|
351
|
+
let long_path = "x".repeat(5000);
|
|
352
|
+
let result = extract_archive_sync("/tmp/test.tar".to_string(), long_path, None);
|
|
353
|
+
|
|
354
|
+
assert!(result.is_err(), "should reject excessively long paths");
|
|
355
|
+
assert!(
|
|
356
|
+
result.unwrap_err().to_string().contains("maximum length"),
|
|
357
|
+
"error message should mention length limit"
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
#[test]
|
|
362
|
+
fn test_extract_archive_sync_accepts_valid_paths() {
|
|
363
|
+
// Test that valid paths pass boundary validation
|
|
364
|
+
// The actual extraction may succeed (empty archive) or fail (file not found)
|
|
365
|
+
// but should NOT fail due to path validation
|
|
366
|
+
let result = extract_archive_sync(
|
|
367
|
+
"/tmp/valid_test_path.tar".to_string(),
|
|
368
|
+
"/tmp/valid_output_path".to_string(),
|
|
369
|
+
None,
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
// If it fails, ensure it's not a path validation error
|
|
373
|
+
if let Err(e) = result {
|
|
374
|
+
let err_msg = e.to_string();
|
|
375
|
+
assert!(
|
|
376
|
+
!err_msg.contains("null bytes") && !err_msg.contains("maximum length"),
|
|
377
|
+
"should not fail on path validation, got: {}",
|
|
378
|
+
err_msg
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
// If it succeeds, path validation passed (which is what we're testing)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
#[test]
|
|
385
|
+
fn test_extract_archive_sync_accepts_relative_paths() {
|
|
386
|
+
// Test that valid relative paths pass boundary validation
|
|
387
|
+
let result = extract_archive_sync(
|
|
388
|
+
"relative_test.tar".to_string(),
|
|
389
|
+
"relative_output".to_string(),
|
|
390
|
+
None,
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
// If it fails, ensure it's not a path validation error
|
|
394
|
+
if let Err(e) = result {
|
|
395
|
+
let err_msg = e.to_string();
|
|
396
|
+
assert!(
|
|
397
|
+
!err_msg.contains("null bytes") && !err_msg.contains("maximum length"),
|
|
398
|
+
"should not fail on path validation for relative paths, got: {}",
|
|
399
|
+
err_msg
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
// If it succeeds, path validation passed (which is what we're testing)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
#[test]
|
|
406
|
+
fn test_extract_archive_sync_with_custom_config() {
|
|
407
|
+
let mut config = SecurityConfig::new();
|
|
408
|
+
config.max_file_size(1_000_000).unwrap();
|
|
409
|
+
|
|
410
|
+
// Test that valid paths pass boundary validation with custom config
|
|
411
|
+
let result = extract_archive_sync(
|
|
412
|
+
"custom_test.tar".to_string(),
|
|
413
|
+
"custom_output".to_string(),
|
|
414
|
+
Some(&config),
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
// If it fails, ensure it's not a path validation error
|
|
418
|
+
if let Err(e) = result {
|
|
419
|
+
let err_msg = e.to_string();
|
|
420
|
+
assert!(
|
|
421
|
+
!err_msg.contains("null bytes") && !err_msg.contains("maximum length"),
|
|
422
|
+
"should not fail on path validation, got: {}",
|
|
423
|
+
err_msg
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
// If it succeeds, path validation passed (which is what we're testing)
|
|
427
|
+
}
|
|
428
|
+
}
|
package/src/report.rs
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
//! Node.js bindings for `ExtractionReport`.
|
|
2
|
+
|
|
3
|
+
use exarch_core::ExtractionReport as CoreReport;
|
|
4
|
+
use napi_derive::napi;
|
|
5
|
+
|
|
6
|
+
/// Report of an archive extraction operation.
|
|
7
|
+
///
|
|
8
|
+
/// Contains statistics and metadata about the extraction process.
|
|
9
|
+
#[napi(object)]
|
|
10
|
+
#[derive(Debug, Clone)]
|
|
11
|
+
pub struct ExtractionReport {
|
|
12
|
+
/// Number of files successfully extracted.
|
|
13
|
+
pub files_extracted: u32,
|
|
14
|
+
/// Number of directories created.
|
|
15
|
+
pub directories_created: u32,
|
|
16
|
+
/// Number of symlinks created.
|
|
17
|
+
pub symlinks_created: u32,
|
|
18
|
+
/// Total bytes written to disk.
|
|
19
|
+
pub bytes_written: i64,
|
|
20
|
+
/// Extraction duration in milliseconds.
|
|
21
|
+
pub duration_ms: i64,
|
|
22
|
+
/// Number of files skipped due to security checks.
|
|
23
|
+
pub files_skipped: u32,
|
|
24
|
+
/// List of warning messages.
|
|
25
|
+
pub warnings: Vec<String>,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
impl From<CoreReport> for ExtractionReport {
|
|
29
|
+
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
|
|
30
|
+
fn from(report: CoreReport) -> Self {
|
|
31
|
+
// Use saturating conversions to prevent silent wraparound on overflow
|
|
32
|
+
// This ensures audit trails remain accurate even for very large extractions
|
|
33
|
+
Self {
|
|
34
|
+
files_extracted: report.files_extracted.min(u32::MAX as usize) as u32,
|
|
35
|
+
directories_created: report.directories_created.min(u32::MAX as usize) as u32,
|
|
36
|
+
symlinks_created: report.symlinks_created.min(u32::MAX as usize) as u32,
|
|
37
|
+
bytes_written: report.bytes_written.min(i64::MAX as u64) as i64,
|
|
38
|
+
duration_ms: report.duration.as_millis().min(i64::MAX as u128) as i64,
|
|
39
|
+
files_skipped: report.files_skipped.min(u32::MAX as usize) as u32,
|
|
40
|
+
warnings: report.warnings,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#[cfg(test)]
|
|
46
|
+
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
|
47
|
+
mod tests {
|
|
48
|
+
use super::*;
|
|
49
|
+
use std::time::Duration;
|
|
50
|
+
|
|
51
|
+
#[test]
|
|
52
|
+
fn test_extraction_report_conversion() {
|
|
53
|
+
let mut core_report = CoreReport::new();
|
|
54
|
+
core_report.files_extracted = 10;
|
|
55
|
+
core_report.directories_created = 5;
|
|
56
|
+
core_report.symlinks_created = 2;
|
|
57
|
+
core_report.bytes_written = 1024;
|
|
58
|
+
core_report.duration = Duration::from_millis(500);
|
|
59
|
+
core_report.files_skipped = 1;
|
|
60
|
+
core_report.add_warning("Test warning".to_string());
|
|
61
|
+
|
|
62
|
+
let report = ExtractionReport::from(core_report);
|
|
63
|
+
|
|
64
|
+
assert_eq!(report.files_extracted, 10);
|
|
65
|
+
assert_eq!(report.directories_created, 5);
|
|
66
|
+
assert_eq!(report.symlinks_created, 2);
|
|
67
|
+
assert_eq!(report.bytes_written, 1024);
|
|
68
|
+
assert_eq!(report.duration_ms, 500);
|
|
69
|
+
assert_eq!(report.files_skipped, 1);
|
|
70
|
+
assert_eq!(report.warnings.len(), 1);
|
|
71
|
+
assert_eq!(report.warnings[0], "Test warning");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#[test]
|
|
75
|
+
fn test_extraction_report_zero_values() {
|
|
76
|
+
let core_report = CoreReport::new();
|
|
77
|
+
let report = ExtractionReport::from(core_report);
|
|
78
|
+
|
|
79
|
+
assert_eq!(report.files_extracted, 0);
|
|
80
|
+
assert_eq!(report.directories_created, 0);
|
|
81
|
+
assert_eq!(report.symlinks_created, 0);
|
|
82
|
+
assert_eq!(report.bytes_written, 0);
|
|
83
|
+
assert_eq!(report.files_skipped, 0);
|
|
84
|
+
assert_eq!(report.warnings.len(), 0);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#[test]
|
|
88
|
+
fn test_extraction_report_large_values() {
|
|
89
|
+
let mut core_report = CoreReport::new();
|
|
90
|
+
core_report.files_extracted = 100_000;
|
|
91
|
+
core_report.directories_created = 50_000;
|
|
92
|
+
core_report.bytes_written = 10_000_000_000; // 10 GB
|
|
93
|
+
core_report.duration = Duration::from_secs(3600); // 1 hour
|
|
94
|
+
|
|
95
|
+
let report = ExtractionReport::from(core_report);
|
|
96
|
+
|
|
97
|
+
assert_eq!(report.files_extracted, 100_000);
|
|
98
|
+
assert_eq!(report.bytes_written, 10_000_000_000);
|
|
99
|
+
assert_eq!(report.duration_ms, 3_600_000);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#[test]
|
|
103
|
+
fn test_extraction_report_multiple_warnings() {
|
|
104
|
+
let mut core_report = CoreReport::new();
|
|
105
|
+
core_report.add_warning("Warning 1".to_string());
|
|
106
|
+
core_report.add_warning("Warning 2".to_string());
|
|
107
|
+
core_report.add_warning("Warning 3".to_string());
|
|
108
|
+
|
|
109
|
+
let report = ExtractionReport::from(core_report);
|
|
110
|
+
|
|
111
|
+
assert_eq!(report.warnings.len(), 3);
|
|
112
|
+
assert_eq!(report.warnings[0], "Warning 1");
|
|
113
|
+
assert_eq!(report.warnings[1], "Warning 2");
|
|
114
|
+
assert_eq!(report.warnings[2], "Warning 3");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#[test]
|
|
118
|
+
fn test_extraction_report_duration_hours() {
|
|
119
|
+
let mut core_report = CoreReport::new();
|
|
120
|
+
core_report.duration = Duration::from_secs(7200); // 2 hours
|
|
121
|
+
|
|
122
|
+
let report = ExtractionReport::from(core_report);
|
|
123
|
+
|
|
124
|
+
assert_eq!(
|
|
125
|
+
report.duration_ms, 7_200_000,
|
|
126
|
+
"2 hours should be 7,200,000 milliseconds"
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#[test]
|
|
131
|
+
fn test_extraction_report_duration_zero() {
|
|
132
|
+
let mut core_report = CoreReport::new();
|
|
133
|
+
core_report.duration = Duration::from_secs(0);
|
|
134
|
+
|
|
135
|
+
let report = ExtractionReport::from(core_report);
|
|
136
|
+
|
|
137
|
+
assert_eq!(report.duration_ms, 0, "zero duration should be 0 ms");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#[test]
|
|
141
|
+
fn test_extraction_report_duration_microseconds() {
|
|
142
|
+
let mut core_report = CoreReport::new();
|
|
143
|
+
core_report.duration = Duration::from_micros(1500); // 1.5 ms
|
|
144
|
+
|
|
145
|
+
let report = ExtractionReport::from(core_report);
|
|
146
|
+
|
|
147
|
+
assert_eq!(
|
|
148
|
+
report.duration_ms, 1,
|
|
149
|
+
"1500 microseconds should round to 1 millisecond"
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
#[test]
|
|
154
|
+
fn test_extraction_report_warnings_order_preserved() {
|
|
155
|
+
let mut core_report = CoreReport::new();
|
|
156
|
+
core_report.add_warning("First warning".to_string());
|
|
157
|
+
core_report.add_warning("Second warning".to_string());
|
|
158
|
+
core_report.add_warning("Third warning".to_string());
|
|
159
|
+
|
|
160
|
+
let report = ExtractionReport::from(core_report);
|
|
161
|
+
|
|
162
|
+
assert_eq!(report.warnings.len(), 3, "should have 3 warnings");
|
|
163
|
+
assert_eq!(
|
|
164
|
+
report.warnings[0], "First warning",
|
|
165
|
+
"first warning should be at index 0"
|
|
166
|
+
);
|
|
167
|
+
assert_eq!(
|
|
168
|
+
report.warnings[1], "Second warning",
|
|
169
|
+
"second warning should be at index 1"
|
|
170
|
+
);
|
|
171
|
+
assert_eq!(
|
|
172
|
+
report.warnings[2], "Third warning",
|
|
173
|
+
"third warning should be at index 2"
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
package/src/utils.rs
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
//! Utility functions for Node.js bindings.
|
|
2
|
+
|
|
3
|
+
use napi::bindgen_prelude::*;
|
|
4
|
+
|
|
5
|
+
/// Maximum path length in bytes (Linux/macOS `PATH_MAX` is typically 4096)
|
|
6
|
+
const MAX_PATH_LENGTH: usize = 4096;
|
|
7
|
+
|
|
8
|
+
/// Validates a path string for security issues.
|
|
9
|
+
///
|
|
10
|
+
/// Rejects:
|
|
11
|
+
/// - Paths containing null bytes (potential injection attacks)
|
|
12
|
+
/// - Paths exceeding `MAX_PATH_LENGTH` bytes (`DoS` prevention)
|
|
13
|
+
///
|
|
14
|
+
/// # Errors
|
|
15
|
+
///
|
|
16
|
+
/// Returns error if path contains null bytes or exceeds maximum length.
|
|
17
|
+
pub fn validate_path(path: &str) -> Result<()> {
|
|
18
|
+
// Use constant-time null byte check to prevent timing side-channel attacks
|
|
19
|
+
// The fold operation processes every byte regardless of when null is found
|
|
20
|
+
let has_null = path.bytes().fold(false, |acc, b| acc | (b == 0));
|
|
21
|
+
|
|
22
|
+
if has_null {
|
|
23
|
+
return Err(Error::from_reason(
|
|
24
|
+
"path contains null bytes - potential security issue",
|
|
25
|
+
));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if path.len() > MAX_PATH_LENGTH {
|
|
29
|
+
// Pre-allocate string to avoid multiple allocations
|
|
30
|
+
use std::fmt::Write;
|
|
31
|
+
let mut msg = String::with_capacity(80);
|
|
32
|
+
// Writing to a String never fails
|
|
33
|
+
let _ = write!(
|
|
34
|
+
&mut msg,
|
|
35
|
+
"path exceeds maximum length of {MAX_PATH_LENGTH} bytes (got {} bytes)",
|
|
36
|
+
path.len()
|
|
37
|
+
);
|
|
38
|
+
return Err(Error::from_reason(msg));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
Ok(())
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#[cfg(test)]
|
|
45
|
+
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
|
46
|
+
mod tests {
|
|
47
|
+
use super::*;
|
|
48
|
+
|
|
49
|
+
#[test]
|
|
50
|
+
fn test_validate_path_accepts_normal() {
|
|
51
|
+
assert!(
|
|
52
|
+
validate_path("/tmp/test.tar.gz").is_ok(),
|
|
53
|
+
"absolute paths should be accepted"
|
|
54
|
+
);
|
|
55
|
+
assert!(
|
|
56
|
+
validate_path("relative/path.tar").is_ok(),
|
|
57
|
+
"relative paths should be accepted"
|
|
58
|
+
);
|
|
59
|
+
// Empty path is valid - callers may provide empty strings for defaults
|
|
60
|
+
// or optional parameters. Core library handles empty path validation.
|
|
61
|
+
assert!(validate_path("").is_ok(), "empty paths should be accepted");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#[test]
|
|
65
|
+
fn test_validate_path_rejects_null_bytes() {
|
|
66
|
+
let result = validate_path("/tmp/test\0malicious");
|
|
67
|
+
assert!(result.is_err());
|
|
68
|
+
assert!(result.unwrap_err().to_string().contains("null bytes"));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
#[test]
|
|
72
|
+
fn test_validate_path_rejects_too_long() {
|
|
73
|
+
let long_path = "x".repeat(MAX_PATH_LENGTH + 1);
|
|
74
|
+
let result = validate_path(&long_path);
|
|
75
|
+
assert!(result.is_err());
|
|
76
|
+
assert!(result.unwrap_err().to_string().contains("maximum length"));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#[test]
|
|
80
|
+
fn test_validate_path_accepts_max_length() {
|
|
81
|
+
let max_path = "x".repeat(MAX_PATH_LENGTH);
|
|
82
|
+
assert!(validate_path(&max_path).is_ok());
|
|
83
|
+
}
|
|
84
|
+
}
|