exarch-rs 0.3.1 → 0.4.1
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/README.md +39 -5
- package/native/exarch-rs.darwin-arm64.node +0 -0
- package/native/exarch-rs.darwin-x64.node +0 -0
- package/native/exarch-rs.linux-arm64-gnu.node +0 -0
- package/native/exarch-rs.linux-x64-gnu.node +0 -0
- package/native/exarch-rs.win32-x64-msvc.node +0 -0
- package/package.json +1 -1
- package/src/config.rs +22 -0
- package/src/error.rs +146 -10
- package/src/lib.rs +178 -17
- package/tests/security-config.test.js +13 -0
package/README.md
CHANGED
|
@@ -161,19 +161,51 @@ interface ExtractionReport {
|
|
|
161
161
|
}
|
|
162
162
|
```
|
|
163
163
|
|
|
164
|
+
### `extractArchiveWithProgress(archivePath, outputDir, config?, progress?)`
|
|
165
|
+
|
|
166
|
+
Async extraction with an optional progress callback.
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
import { extractArchiveWithProgress } from 'exarch-rs';
|
|
170
|
+
|
|
171
|
+
const result = await extractArchiveWithProgress(
|
|
172
|
+
'archive.tar.gz',
|
|
173
|
+
'/output/path',
|
|
174
|
+
undefined, // SecurityConfig or undefined
|
|
175
|
+
(path, total, current, bytesWritten) => {
|
|
176
|
+
console.log(`[${current}/${total}] ${path} (${bytesWritten} bytes)`);
|
|
177
|
+
}
|
|
178
|
+
);
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**Parameters:**
|
|
182
|
+
|
|
183
|
+
| Name | Type | Description |
|
|
184
|
+
|------|------|-------------|
|
|
185
|
+
| `archivePath` | `string` | Path to the archive file |
|
|
186
|
+
| `outputDir` | `string` | Directory where files will be extracted |
|
|
187
|
+
| `config` | `SecurityConfig \| undefined` | Optional security configuration |
|
|
188
|
+
| `progress` | `(path: string, total: bigint, current: bigint, bytesWritten: bigint) => void \| undefined` | Optional progress callback |
|
|
189
|
+
|
|
190
|
+
**Returns:** `Promise<ExtractionReport>`
|
|
191
|
+
|
|
164
192
|
### `SecurityConfig`
|
|
165
193
|
|
|
166
194
|
Builder-style security configuration.
|
|
167
195
|
|
|
168
196
|
```typescript
|
|
169
197
|
const config = new SecurityConfig()
|
|
170
|
-
.maxFileSize(bytes)
|
|
171
|
-
.maxTotalSize(bytes)
|
|
172
|
-
.maxFileCount(count)
|
|
173
|
-
.maxCompressionRatio(n)
|
|
174
|
-
.
|
|
198
|
+
.maxFileSize(bytes) // Max size per file
|
|
199
|
+
.maxTotalSize(bytes) // Max total extraction size
|
|
200
|
+
.maxFileCount(count) // Max number of files
|
|
201
|
+
.maxCompressionRatio(n) // Max compression ratio (zip bomb detection)
|
|
202
|
+
.allowedExtensions([".txt", ".md"]) // Restrict to a set of extensions
|
|
203
|
+
.bannedPathComponents(["__MACOSX"]) // Skip these path components
|
|
204
|
+
.setAllowSolidArchives(true); // Allow solid 7z archives (default: false)
|
|
175
205
|
```
|
|
176
206
|
|
|
207
|
+
**Getters:** `allowSymlinks`, `allowHardlinks`, `allowAbsolutePaths`, `allowWorldWritable`, `allowSolidArchives` — each returns the corresponding boolean policy value.
|
|
208
|
+
|
|
177
209
|
## Security Features
|
|
178
210
|
|
|
179
211
|
The library provides built-in protection against:
|
|
@@ -203,6 +235,8 @@ The library provides built-in protection against:
|
|
|
203
235
|
|
|
204
236
|
**Note:** 7z creation is not yet supported. Solid and encrypted 7z archives are rejected for security reasons. Unix symlinks inside 7z archives are reported as regular files (sevenz-rust2 API limitation).
|
|
205
237
|
|
|
238
|
+
**Note:** Since v0.4.0, partial extraction failures return the `ExtractionReport` accumulated up to the failure point without the inner error text being duplicated, and the report is now correctly delivered across the FFI boundary instead of being dropped early.
|
|
239
|
+
|
|
206
240
|
## Comparison with tar-fs
|
|
207
241
|
|
|
208
242
|
```javascript
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
package/src/config.rs
CHANGED
|
@@ -313,6 +313,12 @@ impl SecurityConfig {
|
|
|
313
313
|
self.inner.allowed.world_writable
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
+
/// Whether solid 7z archives are allowed.
|
|
317
|
+
#[napi(getter)]
|
|
318
|
+
pub fn get_allow_solid_archives(&self) -> bool {
|
|
319
|
+
self.inner.allow_solid_archives
|
|
320
|
+
}
|
|
321
|
+
|
|
316
322
|
/// List of allowed file extensions.
|
|
317
323
|
///
|
|
318
324
|
/// Note: This getter clones the underlying data. For performance-critical
|
|
@@ -829,6 +835,22 @@ mod tests {
|
|
|
829
835
|
);
|
|
830
836
|
}
|
|
831
837
|
|
|
838
|
+
#[test]
|
|
839
|
+
fn test_allow_solid_archives_default_and_round_trip() {
|
|
840
|
+
let config = SecurityConfig::new();
|
|
841
|
+
assert!(
|
|
842
|
+
!config.get_allow_solid_archives(),
|
|
843
|
+
"allow_solid_archives should default to false"
|
|
844
|
+
);
|
|
845
|
+
|
|
846
|
+
let mut config = SecurityConfig::new();
|
|
847
|
+
config.set_allow_solid_archives(Some(true));
|
|
848
|
+
assert!(
|
|
849
|
+
config.get_allow_solid_archives(),
|
|
850
|
+
"allow_solid_archives getter should reflect setter value"
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
|
|
832
854
|
#[test]
|
|
833
855
|
fn test_allowed_extensions_getter_after_add() {
|
|
834
856
|
let mut config = SecurityConfig::new();
|
package/src/error.rs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
//! Error conversion for Node.js bindings.
|
|
2
2
|
|
|
3
|
-
use exarch_core::
|
|
3
|
+
use exarch_core::ArchiveError as CoreError;
|
|
4
4
|
use exarch_core::QuotaResource as CoreQuotaResource;
|
|
5
5
|
use napi::bindgen_prelude::*;
|
|
6
6
|
use std::path::Path;
|
|
@@ -123,10 +123,6 @@ pub fn convert_error(err: CoreError) -> Error {
|
|
|
123
123
|
msg.push_str(&reason);
|
|
124
124
|
Error::new(Status::GenericFailure, msg)
|
|
125
125
|
}
|
|
126
|
-
CoreError::UnsupportedFormat => Error::new(
|
|
127
|
-
Status::GenericFailure,
|
|
128
|
-
"UNSUPPORTED_FORMAT: unsupported archive format",
|
|
129
|
-
),
|
|
130
126
|
CoreError::InvalidArchive(archive_msg) => {
|
|
131
127
|
let mut msg = String::with_capacity(30 + archive_msg.len());
|
|
132
128
|
msg.push_str("INVALID_ARCHIVE: invalid archive: ");
|
|
@@ -182,7 +178,21 @@ pub fn convert_error(err: CoreError) -> Error {
|
|
|
182
178
|
msg.push_str(&reason);
|
|
183
179
|
Error::new(Status::GenericFailure, msg)
|
|
184
180
|
}
|
|
185
|
-
CoreError::PartialExtraction { source,
|
|
181
|
+
CoreError::PartialExtraction { source, report } => {
|
|
182
|
+
// Preserve the specific error code (SYMLINK_ESCAPE, QUOTA_EXCEEDED, etc.) from
|
|
183
|
+
// the inner source so JavaScript callers can distinguish the error type. The
|
|
184
|
+
// partial-extraction report fields from #210 are appended for caller
|
|
185
|
+
// inspection.
|
|
186
|
+
let inner = convert_error(*source);
|
|
187
|
+
let mut msg = String::with_capacity(inner.reason.len() + 64);
|
|
188
|
+
msg.push_str(&inner.reason);
|
|
189
|
+
let _ = write!(
|
|
190
|
+
&mut msg,
|
|
191
|
+
" | filesExtracted={}, bytesWritten={}",
|
|
192
|
+
report.files_extracted, report.bytes_written
|
|
193
|
+
);
|
|
194
|
+
Error::new(Status::GenericFailure, msg)
|
|
195
|
+
}
|
|
186
196
|
}
|
|
187
197
|
}
|
|
188
198
|
|
|
@@ -322,12 +332,14 @@ mod tests {
|
|
|
322
332
|
}
|
|
323
333
|
|
|
324
334
|
#[test]
|
|
325
|
-
fn
|
|
326
|
-
let err = CoreError::
|
|
335
|
+
fn test_unknown_format_conversion() {
|
|
336
|
+
let err = CoreError::UnknownFormat {
|
|
337
|
+
path: std::path::PathBuf::from("archive.rar"),
|
|
338
|
+
};
|
|
327
339
|
let napi_err = convert_error(err);
|
|
328
340
|
let err_str = napi_err.to_string();
|
|
329
|
-
assert!(err_str.contains("
|
|
330
|
-
assert!(err_str.contains("
|
|
341
|
+
assert!(err_str.contains("UNKNOWN_FORMAT"));
|
|
342
|
+
assert!(err_str.contains("cannot determine archive format"));
|
|
331
343
|
}
|
|
332
344
|
|
|
333
345
|
#[test]
|
|
@@ -349,4 +361,128 @@ mod tests {
|
|
|
349
361
|
assert!(err_str.contains("IO_ERROR"));
|
|
350
362
|
assert!(err_str.contains("file not found"));
|
|
351
363
|
}
|
|
364
|
+
|
|
365
|
+
/// Regression test for #251 + #210: `convert_error` must preserve the
|
|
366
|
+
/// specific error code from the inner source (e.g. `QUOTA_EXCEEDED`,
|
|
367
|
+
/// not `PARTIAL_EXTRACTION`), while still appending `filesExtracted`
|
|
368
|
+
/// and `bytesWritten` for caller inspection.
|
|
369
|
+
#[test]
|
|
370
|
+
fn test_partial_extraction_preserves_specific_code_with_report() {
|
|
371
|
+
use exarch_core::ExtractionReport;
|
|
372
|
+
use exarch_core::QuotaResource;
|
|
373
|
+
|
|
374
|
+
let report = ExtractionReport {
|
|
375
|
+
files_extracted: 3,
|
|
376
|
+
bytes_written: 1024,
|
|
377
|
+
..ExtractionReport::default()
|
|
378
|
+
};
|
|
379
|
+
let source = CoreError::QuotaExceeded {
|
|
380
|
+
resource: QuotaResource::FileCount { current: 4, max: 3 },
|
|
381
|
+
};
|
|
382
|
+
let err = CoreError::PartialExtraction {
|
|
383
|
+
source: Box::new(source),
|
|
384
|
+
report,
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
let napi_err = convert_error(err);
|
|
388
|
+
let msg = napi_err.reason.clone();
|
|
389
|
+
// Must carry the specific error code, not the generic PARTIAL_EXTRACTION
|
|
390
|
+
// prefix.
|
|
391
|
+
assert!(
|
|
392
|
+
msg.starts_with("QUOTA_EXCEEDED"),
|
|
393
|
+
"message must start with QUOTA_EXCEEDED, got: {msg}"
|
|
394
|
+
);
|
|
395
|
+
assert!(
|
|
396
|
+
msg.contains("filesExtracted=3"),
|
|
397
|
+
"message must contain filesExtracted=3, got: {msg}"
|
|
398
|
+
);
|
|
399
|
+
assert!(
|
|
400
|
+
msg.contains("bytesWritten=1024"),
|
|
401
|
+
"message must contain bytesWritten=1024, got: {msg}"
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Regression tests for #251: security error variants must also preserve
|
|
406
|
+
// their specific error code and carry the #210 report fields.
|
|
407
|
+
//
|
|
408
|
+
// Helper that asserts the produced error reason starts with the given code
|
|
409
|
+
// and contains the expected report fields.
|
|
410
|
+
fn assert_partial_node_report(napi_err: &Error, expected_code: &str, files: usize, bytes: u64) {
|
|
411
|
+
let msg = &napi_err.reason;
|
|
412
|
+
assert!(
|
|
413
|
+
msg.starts_with(expected_code),
|
|
414
|
+
"message must start with {expected_code}, got: {msg}"
|
|
415
|
+
);
|
|
416
|
+
assert!(
|
|
417
|
+
msg.contains(&format!("filesExtracted={files}")),
|
|
418
|
+
"message must contain filesExtracted={files}, got: {msg}"
|
|
419
|
+
);
|
|
420
|
+
assert!(
|
|
421
|
+
msg.contains(&format!("bytesWritten={bytes}")),
|
|
422
|
+
"message must contain bytesWritten={bytes}, got: {msg}"
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
#[test]
|
|
427
|
+
fn test_partial_extraction_symlink_escape_preserves_code_and_report() {
|
|
428
|
+
use exarch_core::ExtractionReport;
|
|
429
|
+
|
|
430
|
+
let report = ExtractionReport {
|
|
431
|
+
files_extracted: 2,
|
|
432
|
+
bytes_written: 512,
|
|
433
|
+
..ExtractionReport::default()
|
|
434
|
+
};
|
|
435
|
+
let source = CoreError::SymlinkEscape {
|
|
436
|
+
path: PathBuf::from("/etc/passwd"),
|
|
437
|
+
};
|
|
438
|
+
let err = CoreError::PartialExtraction {
|
|
439
|
+
source: Box::new(source),
|
|
440
|
+
report,
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
let napi_err = convert_error(err);
|
|
444
|
+
assert_partial_node_report(&napi_err, "SYMLINK_ESCAPE", 2, 512);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
#[test]
|
|
448
|
+
fn test_partial_extraction_hardlink_escape_preserves_code_and_report() {
|
|
449
|
+
use exarch_core::ExtractionReport;
|
|
450
|
+
|
|
451
|
+
let report = ExtractionReport {
|
|
452
|
+
files_extracted: 2,
|
|
453
|
+
bytes_written: 512,
|
|
454
|
+
..ExtractionReport::default()
|
|
455
|
+
};
|
|
456
|
+
let source = CoreError::HardlinkEscape {
|
|
457
|
+
path: PathBuf::from("/etc/shadow"),
|
|
458
|
+
};
|
|
459
|
+
let err = CoreError::PartialExtraction {
|
|
460
|
+
source: Box::new(source),
|
|
461
|
+
report,
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
let napi_err = convert_error(err);
|
|
465
|
+
assert_partial_node_report(&napi_err, "HARDLINK_ESCAPE", 2, 512);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
#[test]
|
|
469
|
+
fn test_partial_extraction_security_violation_preserves_code_and_report() {
|
|
470
|
+
use exarch_core::ExtractionReport;
|
|
471
|
+
|
|
472
|
+
let report = ExtractionReport {
|
|
473
|
+
files_extracted: 2,
|
|
474
|
+
bytes_written: 512,
|
|
475
|
+
..ExtractionReport::default()
|
|
476
|
+
};
|
|
477
|
+
let source = CoreError::SecurityViolation {
|
|
478
|
+
reason: "test policy violation".to_string(),
|
|
479
|
+
};
|
|
480
|
+
let err = CoreError::PartialExtraction {
|
|
481
|
+
source: Box::new(source),
|
|
482
|
+
report,
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
let napi_err = convert_error(err);
|
|
486
|
+
assert_partial_node_report(&napi_err, "SECURITY_VIOLATION", 2, 512);
|
|
487
|
+
}
|
|
352
488
|
}
|
package/src/lib.rs
CHANGED
|
@@ -44,6 +44,8 @@
|
|
|
44
44
|
#![allow(clippy::trailing_empty_array)]
|
|
45
45
|
|
|
46
46
|
use napi::bindgen_prelude::*;
|
|
47
|
+
use napi::threadsafe_function::ThreadsafeFunction;
|
|
48
|
+
use napi::threadsafe_function::ThreadsafeFunctionCallMode;
|
|
47
49
|
use napi_derive::napi;
|
|
48
50
|
|
|
49
51
|
mod config;
|
|
@@ -150,11 +152,16 @@ pub async fn extract_archive(
|
|
|
150
152
|
// For maximum security with untrusted archives, use extractArchiveSync()
|
|
151
153
|
// or ensure exclusive file access (e.g., flock) during extraction.
|
|
152
154
|
let report = tokio::task::spawn_blocking(move || {
|
|
153
|
-
|
|
155
|
+
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
156
|
+
exarch_core::extract_archive(&archive_path, &output_dir, &config_owned)
|
|
157
|
+
.map_err(convert_error)
|
|
158
|
+
}))
|
|
159
|
+
.map_err(|_| Error::from_reason("Internal panic during archive extraction"))
|
|
160
|
+
.flatten()
|
|
154
161
|
})
|
|
155
162
|
.await
|
|
156
|
-
.map_err(|e| Error::from_reason(format!("task
|
|
157
|
-
.
|
|
163
|
+
.map_err(|e| Error::from_reason(format!("task join error: {e}")))
|
|
164
|
+
.flatten()?;
|
|
158
165
|
|
|
159
166
|
Ok(ExtractionReport::from(report))
|
|
160
167
|
}
|
|
@@ -264,12 +271,17 @@ pub async fn create_archive(
|
|
|
264
271
|
config.map(|c| c.as_core().clone()).unwrap_or_default();
|
|
265
272
|
|
|
266
273
|
let report = tokio::task::spawn_blocking(move || {
|
|
267
|
-
|
|
268
|
-
|
|
274
|
+
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
275
|
+
let sources_refs: Vec<&str> = sources.iter().map(String::as_str).collect();
|
|
276
|
+
exarch_core::create_archive(&output_path, &sources_refs, &config_owned)
|
|
277
|
+
.map_err(convert_error)
|
|
278
|
+
}))
|
|
279
|
+
.map_err(|_| Error::from_reason("Internal panic during archive creation"))
|
|
280
|
+
.flatten()
|
|
269
281
|
})
|
|
270
282
|
.await
|
|
271
|
-
.map_err(|e| Error::from_reason(format!("task
|
|
272
|
-
.
|
|
283
|
+
.map_err(|e| Error::from_reason(format!("task join error: {e}")))
|
|
284
|
+
.flatten()?;
|
|
273
285
|
|
|
274
286
|
Ok(CreationReport::from(report))
|
|
275
287
|
}
|
|
@@ -364,11 +376,15 @@ pub async fn list_archive(
|
|
|
364
376
|
config.map(|c| c.as_core().clone()).unwrap_or_default();
|
|
365
377
|
|
|
366
378
|
let manifest = tokio::task::spawn_blocking(move || {
|
|
367
|
-
|
|
379
|
+
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
380
|
+
exarch_core::list_archive(&archive_path, &config_owned).map_err(convert_error)
|
|
381
|
+
}))
|
|
382
|
+
.map_err(|_| Error::from_reason("Internal panic during archive listing"))
|
|
383
|
+
.flatten()
|
|
368
384
|
})
|
|
369
385
|
.await
|
|
370
|
-
.map_err(|e| Error::from_reason(format!("task
|
|
371
|
-
.
|
|
386
|
+
.map_err(|e| Error::from_reason(format!("task join error: {e}")))
|
|
387
|
+
.flatten()?;
|
|
372
388
|
|
|
373
389
|
Ok(ArchiveManifest::from(manifest))
|
|
374
390
|
}
|
|
@@ -461,11 +477,15 @@ pub async fn verify_archive(
|
|
|
461
477
|
config.map(|c| c.as_core().clone()).unwrap_or_default();
|
|
462
478
|
|
|
463
479
|
let report = tokio::task::spawn_blocking(move || {
|
|
464
|
-
|
|
480
|
+
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
481
|
+
exarch_core::verify_archive(&archive_path, &config_owned).map_err(convert_error)
|
|
482
|
+
}))
|
|
483
|
+
.map_err(|_| Error::from_reason("Internal panic during archive verification"))
|
|
484
|
+
.flatten()
|
|
465
485
|
})
|
|
466
486
|
.await
|
|
467
|
-
.map_err(|e| Error::from_reason(format!("task
|
|
468
|
-
.
|
|
487
|
+
.map_err(|e| Error::from_reason(format!("task join error: {e}")))
|
|
488
|
+
.flatten()?;
|
|
469
489
|
|
|
470
490
|
Ok(VerificationReport::from(report))
|
|
471
491
|
}
|
|
@@ -517,10 +537,151 @@ pub fn verify_archive_sync(
|
|
|
517
537
|
Ok(VerificationReport::from(report))
|
|
518
538
|
}
|
|
519
539
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
540
|
+
/// Extract an archive to the specified directory with a progress callback
|
|
541
|
+
/// (async).
|
|
542
|
+
///
|
|
543
|
+
/// The `progress` callback is called once per entry with
|
|
544
|
+
/// `(path, total, current, bytesWritten)` where:
|
|
545
|
+
/// - `path` — entry path inside the archive
|
|
546
|
+
/// - `total` — total number of entries as `bigint` (0 for TAR-family formats
|
|
547
|
+
/// because the entry count is unknown until the stream is fully read)
|
|
548
|
+
/// - `current` — 1-based index of the current entry as `bigint`
|
|
549
|
+
/// - `bytesWritten` — cumulative bytes written to disk so far as `bigint`
|
|
550
|
+
/// (always 0 during extraction because the core library does not emit
|
|
551
|
+
/// byte-level progress events for extraction; only entry-level events fire)
|
|
552
|
+
///
|
|
553
|
+
/// Extraction runs on the tokio blocking thread pool. The progress callback is
|
|
554
|
+
/// dispatched back to the JavaScript thread via a threadsafe function.
|
|
555
|
+
///
|
|
556
|
+
/// # Arguments
|
|
557
|
+
///
|
|
558
|
+
/// * `archive_path` - Path to the archive file
|
|
559
|
+
/// * `output_dir` - Directory where files will be extracted
|
|
560
|
+
/// * `config` - Optional `SecurityConfig` (uses secure defaults if omitted)
|
|
561
|
+
/// * `progress` - Optional progress callback `(path: string, total: bigint,
|
|
562
|
+
/// current: bigint, bytesWritten: bigint) => void`
|
|
563
|
+
///
|
|
564
|
+
/// # Returns
|
|
565
|
+
///
|
|
566
|
+
/// Promise resolving to `ExtractionReport` with extraction statistics
|
|
567
|
+
///
|
|
568
|
+
/// # Errors
|
|
569
|
+
///
|
|
570
|
+
/// Returns error for security violations or I/O errors. Error messages are
|
|
571
|
+
/// prefixed with error codes for discrimination in JavaScript. See
|
|
572
|
+
/// `extractArchive` for the full list of error codes.
|
|
573
|
+
///
|
|
574
|
+
/// # Examples
|
|
575
|
+
///
|
|
576
|
+
/// ```javascript
|
|
577
|
+
/// const report = await extractArchiveWithProgress(
|
|
578
|
+
/// 'archive.tar.gz',
|
|
579
|
+
/// '/tmp/output',
|
|
580
|
+
/// null,
|
|
581
|
+
/// (path, total, current, bytesWritten) => {
|
|
582
|
+
/// console.log(`${current}/${total}: ${path}`);
|
|
583
|
+
/// },
|
|
584
|
+
/// );
|
|
585
|
+
/// console.log(`Extracted ${report.filesExtracted} files`);
|
|
586
|
+
/// ```
|
|
587
|
+
#[napi]
|
|
588
|
+
#[allow(clippy::needless_pass_by_value, clippy::trailing_empty_array)]
|
|
589
|
+
pub async fn extract_archive_with_progress(
|
|
590
|
+
archive_path: String,
|
|
591
|
+
output_dir: String,
|
|
592
|
+
config: Option<&SecurityConfig>,
|
|
593
|
+
progress: Option<ThreadsafeFunction<(String, i64, i64, i64)>>,
|
|
594
|
+
) -> Result<ExtractionReport> {
|
|
595
|
+
validate_path(&archive_path)?;
|
|
596
|
+
validate_path(&output_dir)?;
|
|
597
|
+
|
|
598
|
+
let config_owned: exarch_core::SecurityConfig =
|
|
599
|
+
config.map(|c| c.as_core().clone()).unwrap_or_default();
|
|
600
|
+
|
|
601
|
+
let report = tokio::task::spawn_blocking(move || {
|
|
602
|
+
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
603
|
+
run_extract_with_optional_progress(&archive_path, &output_dir, &config_owned, progress)
|
|
604
|
+
.map_err(convert_error)
|
|
605
|
+
}))
|
|
606
|
+
.map_err(|_| Error::from_reason("Internal panic during archive extraction with progress"))
|
|
607
|
+
.flatten()
|
|
608
|
+
})
|
|
609
|
+
.await
|
|
610
|
+
.map_err(|e| Error::from_reason(format!("task join error: {e}")))
|
|
611
|
+
.flatten()?;
|
|
612
|
+
|
|
613
|
+
Ok(ExtractionReport::from(report))
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/// Runs `extract_archive_with_progress` routing to the JS callback when present
|
|
617
|
+
/// or to [`exarch_core::NoopProgress`] when absent.
|
|
618
|
+
fn run_extract_with_optional_progress(
|
|
619
|
+
archive_path: &str,
|
|
620
|
+
output_dir: &str,
|
|
621
|
+
config: &exarch_core::SecurityConfig,
|
|
622
|
+
progress: Option<ThreadsafeFunction<(String, i64, i64, i64)>>,
|
|
623
|
+
) -> exarch_core::Result<exarch_core::ExtractionReport> {
|
|
624
|
+
progress.map_or_else(
|
|
625
|
+
|| {
|
|
626
|
+
let mut noop = exarch_core::NoopProgress;
|
|
627
|
+
exarch_core::extract_archive_with_progress(archive_path, output_dir, config, &mut noop)
|
|
628
|
+
},
|
|
629
|
+
|tsfn| {
|
|
630
|
+
let mut callback = NodeProgressAdapter::new(tsfn);
|
|
631
|
+
exarch_core::extract_archive_with_progress(
|
|
632
|
+
archive_path,
|
|
633
|
+
output_dir,
|
|
634
|
+
config,
|
|
635
|
+
&mut callback,
|
|
636
|
+
)
|
|
637
|
+
},
|
|
638
|
+
)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/// Adapter that calls a JavaScript progress callback from a Rust worker thread.
|
|
642
|
+
///
|
|
643
|
+
/// The JavaScript callback receives `(path: string, total: number, current:
|
|
644
|
+
/// number, bytesWritten: number)` where `bytesWritten` is the number of bytes
|
|
645
|
+
/// written **for the current entry so far** (starts at 0 when the entry begins,
|
|
646
|
+
/// grows as chunks are flushed to disk; always 0 during extraction because the
|
|
647
|
+
/// core library does not emit byte-level progress events for extraction).
|
|
648
|
+
struct NodeProgressAdapter {
|
|
649
|
+
tsfn: ThreadsafeFunction<(String, i64, i64, i64)>,
|
|
650
|
+
current_entry_bytes: i64,
|
|
651
|
+
total: usize,
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
impl NodeProgressAdapter {
|
|
655
|
+
fn new(tsfn: ThreadsafeFunction<(String, i64, i64, i64)>) -> Self {
|
|
656
|
+
Self {
|
|
657
|
+
tsfn,
|
|
658
|
+
current_entry_bytes: 0,
|
|
659
|
+
total: 0,
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
impl exarch_core::ProgressCallback for NodeProgressAdapter {
|
|
665
|
+
fn on_entry_start(&mut self, path: &std::path::Path, total: usize, current: usize) {
|
|
666
|
+
self.current_entry_bytes = 0;
|
|
667
|
+
self.total = total;
|
|
668
|
+
let path_str = path.to_string_lossy().into_owned();
|
|
669
|
+
let total_i64 = i64::try_from(total).unwrap_or(i64::MAX);
|
|
670
|
+
let current_i64 = i64::try_from(current).unwrap_or(i64::MAX);
|
|
671
|
+
self.tsfn.call(
|
|
672
|
+
Ok((path_str, total_i64, current_i64, self.current_entry_bytes)),
|
|
673
|
+
ThreadsafeFunctionCallMode::NonBlocking,
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
fn on_bytes_written(&mut self, bytes: u64) {
|
|
678
|
+
self.current_entry_bytes = self.current_entry_bytes.saturating_add(bytes.cast_signed());
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
fn on_entry_complete(&mut self, _path: &std::path::Path) {}
|
|
682
|
+
|
|
683
|
+
fn on_complete(&mut self) {}
|
|
684
|
+
}
|
|
524
685
|
|
|
525
686
|
#[cfg(test)]
|
|
526
687
|
#[allow(
|
|
@@ -112,6 +112,19 @@ describe('SecurityConfig', () => {
|
|
|
112
112
|
assert.strictEqual(config.allowWorldWritable, true);
|
|
113
113
|
});
|
|
114
114
|
|
|
115
|
+
it('should default allowSolidArchives to false', () => {
|
|
116
|
+
const config = new SecurityConfig();
|
|
117
|
+
|
|
118
|
+
assert.strictEqual(config.allowSolidArchives, false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should set allow solid archives', () => {
|
|
122
|
+
const config = new SecurityConfig();
|
|
123
|
+
config.setAllowSolidArchives(true);
|
|
124
|
+
|
|
125
|
+
assert.strictEqual(config.allowSolidArchives, true);
|
|
126
|
+
});
|
|
127
|
+
|
|
115
128
|
it('should set preserve permissions', () => {
|
|
116
129
|
const config = new SecurityConfig();
|
|
117
130
|
config.setPreservePermissions(true);
|