Skip to content

1.1.0 - Security hardening, concurrency improvements, cancellation support

Latest

Choose a tag to compare

@pushpender-singh-ap pushpender-singh-ap released this 05 Oct 13:27
ee8d072

Release Notes - v1.1.0

Release Date: October 5, 2025

Overview

Version 1.1.0 is a major security and stability update that hardens the native implementation on both iOS and Android platforms. This release adds comprehensive protection against concurrency issues, directory traversal attacks, and implements atomic extraction patterns.


Upgrade Command:

npm install react-native-unarchive@^1.1.0
# or
yarn upgrade react-native-unarchive@^1.1.0

# Then reinstall pods
cd ios && pod install

🔒 Security Enhancements

ZIP-SLIP Protection (CVE Prevention)

  • What it is: Protection against directory traversal attacks where malicious archives attempt to write files outside the extraction directory using paths like ../../etc/passwd
  • Implementation:
    • iOS: Pre-extraction validation with isSafePath:withinBaseURL: and post-extraction canonicalization checks
    • Android: Canonicalization validation with isSafeEntryPath() before writing each entry
  • Impact: Archives with malicious paths are rejected with UNSAFE_PATH error

Sandbox Path Enforcement

  • What it is: Output paths are validated to be within app-controlled directories only
  • iOS Allowed Paths:
    • Documents: NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, ...)
    • Caches: NSSearchPathForDirectoriesInDomains(NSCachesDirectory, ...)
    • Temp: NSTemporaryDirectory()
  • Android Allowed Paths:
    • Files: context.filesDir
    • Cache: context.cacheDir
    • External: context.getExternalFilesDir(null)
  • Impact: Prevents writes outside app sandbox; rejects with UNARCHIVE_INVALID_PATH error

Atomic Extraction Pattern

  • What it is: Files are extracted to a temporary directory first, then atomically moved to the final destination
  • Benefits:
    • No partial extraction results visible to the app
    • Automatic cleanup on failure
    • Prevents race conditions from concurrent access to output directory
  • Implementation:
    • iOS: replaceItemAtURL:withItemAtURL: for atomic swap
    • Android: Atomic move with rename-backup fallback

🚀 New Features

Cancellation Support

import { unarchive, cancelUnarchive } from 'react-native-unarchive';

// Start extraction
const promise = unarchive(archivePath, outputPath);

// Cancel if needed
await cancelUnarchive();

// Handle result or cancellation
try {
  await promise;
} catch (error) {
  if (error.code === 'UNARCHIVE_CANCELLED') {
    console.log('User cancelled extraction');
  }
}

Directory Structure Preservation

  • New Field: relativePath added to FileInfo type

  • Purpose: Maintains original archive directory structure

  • Example:

    {
      path: '/app/Documents/extracted/folder1/image.jpg',
      name: 'image.jpg',
      relativePath: 'folder1/image.jpg',  // NEW!
      size: 12345
    }

Comprehensive Error Codes

Error Code Description
UNARCHIVE_BUSY Another extraction is in progress
UNARCHIVE_INVALID_PATH Output path outside app sandbox
UNSAFE_PATH Archive contains malicious paths
UNARCHIVE_CANCELLED Extraction cancelled by user
ATOMIC_REPLACE_ERROR Failed to finalize extraction
FILE_NOT_FOUND Archive file doesn't exist
DIRECTORY_ERROR Can't create extraction directory
EXTRACTION_ERROR General extraction failure
UNSUPPORTED_FORMAT Archive format not supported

🔧 Technical Improvements

Concurrency Safety

iOS:

  • Module-level std::atomic_bool _activeExtraction prevents concurrent operations
  • Per-invocation std::atomic_bool cbInvoked prevents double-callback
  • resolveOnce and rejectOnce helper methods ensure single resolution

Android:

  • Module-level AtomicBoolean activeExtraction prevents concurrent operations
  • Per-invocation AtomicBoolean cbInvoked prevents double-callback
  • Immediate UNARCHIVE_BUSY rejection when already extracting

Thread Safety

iOS:

  • Result payload construction moved to main thread via dispatch_async(dispatch_get_main_queue())
  • Immutable copies made before cross-thread dispatch
  • Per-invocation NSFileManager instead of shared defaultManager

Android:

  • Result payload conversion to WritableNativeArray/Map on Dispatchers.Main
  • POJOs collected on background thread, converted on main thread
  • Single FileOutputStream per entry (prevents file handle leaks)

Debug Diagnostics

iOS (DEBUG builds only):

[Unarchive] Starting CBR extraction: archive.cbr
[Unarchive] Archive contains 125 entries
[Unarchive] Extracting to temp directory: /tmp/.unarchive_temp_...
[Unarchive] Extraction successful, enumerating files...
[Unarchive] Enumerated 125 files
[Unarchive] Performing atomic replacement to final location
[Unarchive] Extraction completed successfully with 125 files

Android (BuildConfig.DEBUG only):

[UnarchiveModule] Starting unarchive: /path/to/archive.cbz -> /path/to/output
[UnarchiveModule] Created temp directory: /path/to/.unarchive_temp_...
[UnarchiveModule] Opened archive successfully
[UnarchiveModule] Archive contains 125 items
[UnarchiveModule] Processing entry: folder/image001.jpg
[UnarchiveModule] Atomic move completed successfully
[UnarchiveModule] Unarchive completed successfully, extracted 125 files

🔄 Migration Guide

Path Changes (IMPORTANT)

Before (v1.0.1):

// This worked but is now rejected
const outputPath = '/sdcard/Download/archives';
await unarchive(archivePath, outputPath);

After (v1.1.0):

import { DocumentDirectoryPath } from '@dr.pogodin/react-native-fs';

// Use app-scoped directories
const outputPath = `${DocumentDirectoryPath}/archives`;
await unarchive(archivePath, outputPath);

Error Handling Changes

Before (v1.0.1):

try {
  await unarchive(archivePath, outputPath);
} catch (error) {
  console.error('Failed:', error.message);
}

After (v1.1.0):

try {
  await unarchive(archivePath, outputPath);
} catch (error) {
  // Check specific error codes
  if (error.code === 'UNARCHIVE_BUSY') {
    console.log('Wait for current extraction to finish');
  } else if (error.code === 'UNARCHIVE_INVALID_PATH') {
    console.log('Use DocumentDirectoryPath or CachesDirectoryPath');
  }
  // ... handle other error codes
}

Concurrency Changes

Before (v1.0.1):

// Multiple extractions could run simultaneously (undefined behavior)
const promise1 = unarchive(archive1, output1);
const promise2 = unarchive(archive2, output2);
await Promise.all([promise1, promise2]);

After (v1.1.0):

// Only one extraction at a time; second will reject with UNARCHIVE_BUSY
try {
  const promise1 = unarchive(archive1, output1);
  const promise2 = unarchive(archive2, output2); // Throws UNARCHIVE_BUSY
  await Promise.all([promise1, promise2]);
} catch (error) {
  if (error.code === 'UNARCHIVE_BUSY') {
    // Queue the second extraction
    await promise1;
    await unarchive(archive2, output2);
  }
}

⚡ Performance Improvements

  • iOS: Reduced file I/O with per-invocation NSFileManager
  • Android: Eliminated file handle leaks with single FileOutputStream per entry
  • Both: Reduced memory usage with temp-dir extraction pattern
  • Both: Minimized main thread blocking with proper thread dispatch

🐛 Bug Fixes

iOS

  • Fixed race condition with concurrent extractions
  • Fixed non-atomic directory replacement
  • Fixed potential bridge crashes from background payload construction
  • Fixed memory leaks in error paths
  • Fixed missing cleanup of temp directories on cancellation

Android

  • Fixed race condition with concurrent extractions
  • Fixed file handle leaks from repeated stream creation
  • Fixed crashes from WritableNativeArray construction off main thread
  • Fixed non-atomic directory replacement
  • Fixed missing cancellation implementation

🔬 Testing Recommendations

After upgrading to v1.1.0, test the following scenarios:

  1. Path Validation:

    // Should reject with UNARCHIVE_INVALID_PATH
    await unarchive(archivePath, '/invalid/path');
  2. Concurrency:

    // Second call should reject with UNARCHIVE_BUSY
    const p1 = unarchive(archive1, output1);
    const p2 = unarchive(archive2, output2);
  3. Cancellation:

    const promise = unarchive(largeArchive, output);
    setTimeout(() => cancelUnarchive(), 1000);
  4. Directory Structure:

    const result = await unarchive(archiveWithFolders, output);
    // Check that relativePath contains folder structure
    console.log(result.files.map(f => f.relativePath));

📦 What's Included in This Release

Changed Files

Native Code:

  • ios/Unarchive.mm - Complete rewrite with security hardening
  • ios/Unarchive.h - Updated interface
  • android/src/main/java/com/unarchive/UnarchiveModule.kt - Complete rewrite with security hardening

TypeScript:

  • src/NativeUnarchive.ts - Added cancelUnarchive(), CancelResult, relativePath field
  • src/index.tsx - Exported cancelUnarchive() and CancelResult type

Documentation:

  • README.md - Comprehensive updates
  • CHANGELOG.md - New file

Example App:

  • example/src/App.tsx - Major UI and functionality updates

🙏 Acknowledgments

This release represents a comprehensive security audit and hardening effort to ensure production-ready quality for the react-native-unarchive package.

📞 Support

For issues or questions:

🔜 What's Next?

Planned for future releases:

  • Comprehensive test suite for both platforms
  • CI/CD integration for automated testing
  • Progress callback support
  • Additional archive format support

What's Changed

  • fix: enforce sandbox validation, atomic replacement & main-thread result payload by @pushpender-singh-ap in #1

New Contributors

Full Changelog: 1.0.1...1.1.0