diff --git a/.gitignore b/.gitignore index 67f3212..ba28e01 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,5 @@ android/generated # React Native Nitro Modules nitrogen/ + +.kotlin/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..93d921f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,172 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.1.0] - (2025-10-05) Security Hardening, Concurrency Improvements, Cancellation Support + +### Added + +#### Cross-Platform Features + +- **Cancellation Support**: Added `cancelUnarchive()` method for both iOS and Android to stop ongoing extractions +- **Directory Structure Preservation**: Added `relativePath` field to `FileInfo` to maintain original archive directory structure +- **Comprehensive Error Codes**: Added specific error codes for different failure scenarios: + - `UNARCHIVE_BUSY`: Another extraction is already in progress + - `UNARCHIVE_INVALID_PATH`: Output path is outside allowed app directories + - `UNSAFE_PATH`: Archive contains path traversal attempts (ZIP-SLIP attack) + - `UNARCHIVE_CANCELLED`: Extraction was cancelled by user + - `ATOMIC_REPLACE_ERROR`: Failed to finalize extraction atomically + - `FILE_NOT_FOUND`: Archive file does not exist + - `DIRECTORY_ERROR`: Failed to create extraction directory + - `EXTRACTION_ERROR`: Archive extraction failed + - `UNSUPPORTED_FORMAT`: Archive format not supported + +#### iOS-Specific Improvements + +- **Sandbox Path Validation**: Output paths are now validated to be within app sandbox (Documents/Caches/tmp) +- **Atomic Directory Replacement**: Uses `replaceItemAtURL:withItemAtURL:` for atomic final directory swap +- **Main Thread Payload Construction**: Result payloads are now built on main thread for bridge safety +- **Cooperative Cancellation**: Checks cancellation flag at multiple points during extraction +- **Debug Diagnostics**: Added comprehensive debug logging (enabled in DEBUG builds only) + - Extraction progress tracking + - File count reporting + - Path validation warnings + - Temp directory tracking + +#### Android-Specific Improvements + +- **Sandbox Path Validation**: Output paths validated against `filesDir`, `cacheDir`, and `externalFilesDir` +- **Atomic Move with Fallback**: Implements atomic move with rename-backup fallback strategy +- **Single FileOutputStream per Entry**: Prevents file handle leaks and race conditions +- **Main Thread Payload Conversion**: WritableNativeArray/Map converted on Dispatchers.Main +- **Cooperative Cancellation**: Job tracking with `isActive` checks throughout extraction +- **Debug Diagnostics**: Added comprehensive debug logging (gated by BuildConfig.DEBUG) + +### Security + +#### Cross-Platform Security Enhancements + +- **ZIP-SLIP Protection**: All archive entries validated before extraction to prevent directory traversal attacks +- **Path Canonicalization**: Prevents malicious paths with `..` or symlinks from escaping extraction directory +- **Sandbox Enforcement**: + - iOS: Only allows extraction to Documents, Caches, or tmp directories + - Android: Only allows extraction to app-scoped directories (filesDir, cacheDir, externalFilesDir) +- **Temp Directory Extraction**: Files extracted to temporary directory first, then atomically moved +- **Post-Extraction Validation** (iOS CBZ): Additional verification that no extracted files escaped temp directory + +### Changed + +#### iOS Changes + +- Replaced `RCT_EXPORT_MODULE()` with proper TurboModule initialization +- Added atomic single-callback guards (`resolveOnce`/`rejectOnce`) to prevent race conditions +- Added module-level concurrency guard using `std::atomic_bool` (one extraction at a time) +- Extraction now uses per-invocation `NSFileManager` instead of `defaultManager` +- Moved from direct output extraction to temp-dir-then-atomic-move pattern +- Enhanced error reporting with partial file diagnostics in debug builds +- Removed inheritance from `RCTEventEmitter` in favor of TurboModule-only design + +#### Android Changes + +- Added module-level concurrency guard using `AtomicBoolean` (one extraction at a time) +- Added per-invocation single-callback guard to prevent race conditions +- Moved from direct output extraction to temp-dir-then-atomic-move pattern +- Improved error handling with structured error information +- Enhanced extraction to recursively enumerate files and preserve relative paths +- Added `BuildConfig.DEBUG` gating for all debug logs + +### Fixed + +#### iOS Fixes + +- Fixed race condition where multiple concurrent extractions could occur +- Fixed non-atomic final directory replacement (was remove-then-move, now atomic) +- Fixed potential bridge thread-safety issue with background payload construction +- Fixed memory leaks in error paths by ensuring temp directory cleanup +- Fixed zip-slip vulnerability by validating all entry paths before extraction +- Fixed missing relative path information in extracted file metadata + +#### Android Fixes + +- Fixed race condition with multiple concurrent extraction attempts +- Fixed file handle leaks from repeated FileOutputStream creation per chunk +- Fixed WritableNativeArray construction off main thread +- Fixed zip-slip vulnerability with canonicalFile path validation +- Fixed non-atomic directory replacement with atomic move + fallback +- Fixed missing cancellation support +- Fixed missing relative path information in extracted file metadata + +### Documentation + +- Updated README.md with: + - New `cancelUnarchive()` API documentation + - Comprehensive error handling examples with error codes + - Security features documentation (ZIP-SLIP, sandbox enforcement) + - Concurrency behavior documentation + - Atomic extraction pattern documentation + - Directory structure preservation examples + - Debug logging documentation + - Platform-specific path requirements for Android and iOS +- Updated example app with: + - Cancellation button + - Error code handling + - Share file functionality + - Export to Downloads feature + - Improved path selection for sandbox compliance + +### Performance + +- Reduced I/O overhead by using single FileOutputStream per entry (Android) +- Improved extraction speed with optimized path validation +- Reduced memory usage by extracting to temp directory and moving atomically +- Minimized main thread blocking by building payloads on appropriate threads + +### Breaking Changes + +**None** - This release is backward compatible, but behavior changes may affect apps: + +1. **Path Restrictions**: Output paths must now be within app sandbox: + - iOS: Documents, Caches, or tmp directories only + - Android: filesDir, cacheDir, or externalFilesDir only + - **Migration**: Update code to use app-scoped paths (e.g., `DocumentDirectoryPath` from react-native-fs) + +2. **Concurrency**: Only one extraction can run at a time per module instance + - Concurrent extraction attempts will immediately fail with `UNARCHIVE_BUSY` + - **Migration**: Queue extraction requests or wait for completion before starting new ones + +3. **Error Codes**: Errors now include specific `code` property for programmatic handling + - **Migration**: Update error handlers to check `error.code` instead of `error.message` + +## [1.0.1] - (2025-09-30) Initial Release + +### Added + +- Initial release of react-native-unarchive +- Support for RAR (CBR) and ZIP (CBZ) archive extraction +- Cross-platform support for iOS and Android +- TurboModule implementation for React Native 0.80+ +- TypeScript support with full type definitions +- Basic error handling +- File metadata (path, name, size) for extracted files + +### iOS Features + +- UnrarKit integration for RAR/CBR files +- SSZipArchive integration for ZIP/CBZ files +- File enumeration with size information + +### Android Features + +- SevenZip JBinding integration for archive extraction +- Kotlin coroutines for async operations +- Stream-based extraction for memory efficiency + +--- + +## Version History + +- **1.1.0** (2025-10-05): Security hardening, concurrency improvements, cancellation support +- **1.0.1** (2025-09-30): Initial release with basic extraction functionality diff --git a/README.md b/README.md index 54dafcd..a9c01a0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ An Archive Extraction Library for React Native Projects. -For React Native developers that need to extract RAR, ZIP CBR and CBZ files in their apps, this package is a useful resource. It supports React Native's new Turbo Module architecture and was created in Kotlin and Objective-C++. +For React Native developers that need to extract RAR, ZIP, CBR and CBZ files in their apps, this package is a useful resource. It supports React Native's new Turbo Module architecture and was created in Kotlin and Objective-C++. With this package, users can quickly and easily extract RAR, ZIP, CBR and CBZ archives and other compressed files using their device's native capabilities. Using this package, multiple archive formats can be processed, and it is simple to integrate into existing projects. @@ -16,6 +16,11 @@ If you want to provide your React Native app the ability to read and extract RAR - **Document picker integration**: Easy integration with document pickers - **File system compatibility**: Works with React Native File System libraries - **Turbo Module**: Built using React Native's new architecture +- **Thread-safe**: Prevents concurrent extractions with busy-state checking +- **Atomic extraction**: Safe extraction with atomic directory replacement +- **Security hardened**: ZIP-SLIP protection prevents directory traversal attacks +- **Relative path preservation**: Maintains directory structure from archives +- **Debug diagnostics**: Comprehensive logging in debug builds ## Installation @@ -35,8 +40,9 @@ cd ios && pod install ### React Native Version Compatibility -- React Native 0.76+ +- React Native 0.80+ - New Architecture (Turbo Modules) supported +- iOS min version 16.0+ ## Usage @@ -49,6 +55,7 @@ import { DocumentDirectoryPath } from '@dr.pogodin/react-native-fs'; const extractArchive = async () => { try { const archivePath = '/path/to/your/archive.cbr'; // or .cbz + // IMPORTANT: outputPath must be within app sandbox (Documents/Caches/tmp) const outputPath = `${DocumentDirectoryPath}/extracted`; const result: UnarchiveResult = await unarchive(archivePath, outputPath); @@ -60,6 +67,7 @@ const extractArchive = async () => { // Access individual files result.files.forEach((file) => { console.log(`File: ${file.name}, Size: ${file.size}, Path: ${file.path}`); + console.log(` Relative path in archive: ${file.relativePath}`); }); } catch (error) { console.error('Extraction failed:', error); @@ -174,6 +182,41 @@ Extracts the specified archive file to the given output directory. const result = await unarchive('/path/to/archive.cbr', '/path/to/output'); ``` +#### `cancelUnarchive(): Promise` + +Cancels an ongoing extraction operation. + +**Returns:** + +- `Promise`: Promise that resolves when cancellation is complete + +**Example:** + +```typescript +import { Platform } from 'react-native'; +import { unarchive, cancelUnarchive } from 'react-native-unarchive'; + +// Start extraction +const extractionPromise = unarchive(archivePath, outputPath); + +try { + const result = await cancelUnarchive(); + console.log('Cancelled:', result.cancelled); +} catch (error) { + console.error('Cancellation failed:', error); +} + +// Handle extraction result or cancellation +try { + const result = await extractionPromise; + console.log('Extraction completed'); +} catch (error) { + if (error.code === 'UNARCHIVE_CANCELLED') { + console.log('Extraction was cancelled by user'); + } +} +``` + ### Types #### `FileInfo` @@ -182,9 +225,10 @@ Represents information about an extracted file. ```typescript interface FileInfo { - path: string; // Full path to the extracted file - name: string; // Original filename - size: number; // File size in bytes + path: string; // Full path to the extracted file + name: string; // Filename (basename) + relativePath: string; // Relative path within the archive (preserves directory structure) + size: number; // File size in bytes } ``` @@ -199,20 +243,15 @@ interface UnarchiveResult { } ``` -## Platform-Specific Notes +#### `CancelResult` -### Android +Contains the result of a cancellation operation. -- Uses 7-Zip-JBinding library for extraction -- Supports content URIs (requires copying to accessible location first) -- Optimized for large archives with stream processing - -### iOS - -- Uses UnrarKit for CBR/RAR files -- Uses SSZipArchive for CBZ/ZIP files -- Full file system access within app sandbox -- Memory-efficient extraction process +```typescript +interface CancelResult { + cancelled: boolean; // Always true when cancellation succeeds +} +``` ## File System Integration @@ -223,12 +262,13 @@ This library works well with popular React Native file system libraries: ```typescript import { DocumentDirectoryPath, - ExternalStorageDirectoryPath, + CachesDirectoryPath, + TemporaryDirectoryPath, exists, readDir, } from '@dr.pogodin/react-native-fs'; -// Check if extraction directory exists +// IMPORTANT: Use app-scoped directories only const outputDir = `${DocumentDirectoryPath}/comics`; if (!(await exists(outputDir))) { // Directory will be created automatically by unarchive @@ -238,6 +278,65 @@ if (!(await exists(outputDir))) { const extractedFiles = await readDir(outputDir); ``` +## Security and Path Requirements + +### Sandbox Path Enforcement + +For security, the library enforces that extraction only occurs within app-scoped directories: + +**iOS Allowed Paths:** +- Documents directory: `NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, ...)` +- Caches directory: `NSSearchPathForDirectoriesInDomains(NSCachesDirectory, ...)` +- Temporary directory: `NSTemporaryDirectory()` + +In React Native with `@dr.pogodin/react-native-fs`: +```typescript +import { DocumentDirectoryPath, CachesDirectoryPath, TemporaryDirectoryPath } from '@dr.pogodin/react-native-fs'; + +// ✅ Valid paths +const validPaths = [ + `${DocumentDirectoryPath}/extracted`, + `${CachesDirectoryPath}/archives`, + `${TemporaryDirectoryPath}/temp-extract`, +]; + +// ❌ Invalid paths (will reject with UNARCHIVE_INVALID_PATH) +const invalidPaths = [ + '/var/mobile/Containers/Shared', // Outside sandbox + '/Users/shared/archives', // Absolute path outside app +]; +``` + +**Android Allowed Paths:** +- App files directory: `context.filesDir` +- App cache directory: `context.cacheDir` +- External files directory: `context.getExternalFilesDir(null)` + +In React Native with `@dr.pogodin/react-native-fs`: +```typescript +import { DocumentDirectoryPath, CachesDirectoryPath, ExternalStorageDirectoryPath } from '@dr.pogodin/react-native-fs'; + +// ✅ Valid paths +const validPaths = [ + `${DocumentDirectoryPath}/extracted`, // App internal files + `${CachesDirectoryPath}/archives`, // App cache + `${ExternalStorageDirectoryPath}/Android/data/YOUR_PACKAGE/files`, // App external files +]; + +// ❌ Invalid paths (will reject with UNARCHIVE_INVALID_PATH) +const invalidPaths = [ + '/sdcard/Download/archives', // Shared storage (Android 11+) + ExternalStorageDirectoryPath, // Root of external storage +]; +``` + +### Why Sandbox Enforcement? + +1. **Security**: Prevents directory traversal attacks and unauthorized file access +2. **Privacy**: Ensures files are only written to app-controlled locations +3. **Compliance**: Follows platform security guidelines (iOS App Sandbox, Android Scoped Storage) +4. **Predictability**: Guarantees cleanup when app is uninstalled + ## Error Handling The library provides detailed error information for common scenarios: @@ -246,20 +345,93 @@ The library provides detailed error information for common scenarios: try { const result = await unarchive(archivePath, outputPath); } catch (error) { - if (error.message.includes('File not found')) { + if (error.code === 'UNARCHIVE_BUSY') { + console.log('Another extraction is in progress. Please wait and try again.'); + } else if (error.code === 'UNARCHIVE_INVALID_PATH') { + console.log('Output path must be within app sandbox (Documents/Caches/tmp)'); + } else if (error.code === 'FILE_NOT_FOUND') { console.log('Archive file does not exist'); - } else if (error.message.includes('Permission denied')) { - console.log('Insufficient permissions to access file'); - } else if (error.message.includes('Unsupported format')) { + } else if (error.code === 'DIRECTORY_ERROR') { + console.log('Failed to create extraction directory'); + } else if (error.code === 'UNSUPPORTED_FORMAT') { console.log('Archive format not supported'); - } else if (error.message.includes('Extraction failed')) { + } else if (error.code === 'EXTRACTION_ERROR') { console.log('Failed to extract archive contents'); + // In debug builds, error may include partialFilesCount and partialFilesList + if (__DEV__ && error.userInfo) { + console.log('Partial files extracted:', error.userInfo.partialFilesCount); + console.log('Temp path:', error.userInfo.tempPath); + } + } else if (error.code === 'UNSAFE_PATH') { + console.log('Archive contains unsafe paths (potential ZIP-SLIP attack)'); + } else if (error.code === 'UNARCHIVE_CANCELLED') { + console.log('Extraction was cancelled by user'); + } else if (error.code === 'ATOMIC_REPLACE_ERROR') { + console.log('Failed to finalize extraction'); } else { console.log('Unknown error:', error.message); } } ``` +### Concurrency + +The library implements a busy-state check to prevent concurrent extractions: + +- Only one extraction operation can run at a time per module instance +- If you attempt to start a new extraction while one is in progress, you'll receive an `UNARCHIVE_BUSY` error immediately +- This prevents I/O saturation and ensures predictable behavior +- After an extraction completes (successfully or with error), the module is ready for the next operation + +### Atomic Extraction + +For data safety and consistency: + +- Files are extracted to a temporary directory first +- On success, the temporary directory is atomically moved to the final output location +- On failure, the temporary directory is automatically cleaned up +- This ensures you never see partial extraction results in the output directory +- The output directory only appears when extraction is fully complete + +### Security + +The library includes protection against directory traversal attacks: + +- **ZIP-SLIP Protection**: All archive entries are validated before extraction +- **Path Canonicalization**: Entries with `..` or absolute paths that attempt to escape the extraction directory are rejected +- **Sandbox Enforcement**: Files are verified to remain within the intended extraction directory +- Archives containing malicious paths will be rejected with an `UNSAFE_PATH` error + +### Directory Structure Preservation + +The library preserves the original directory structure from archives: + +- `relativePath` field in `FileInfo` shows the file's path within the archive +- Nested directories are maintained in the extraction output +- Duplicate basenames in different directories are handled correctly +- Example: An archive with `folder1/image.jpg` and `folder2/image.jpg` will extract both files to their respective directories + +### Debug Logging + +In debug builds, comprehensive logging is enabled: + +```typescript +// Debug logs appear in Metro/Xcode console +// Example output: +// [Unarchive] Starting CBR extraction: archive.cbr +// [Unarchive] Archive contains 125 entries +// [Unarchive] Extraction successful, enumerating files... +// [Unarchive] Enumerated 125 files +// [Unarchive] Extraction completed successfully with 125 files +``` + +Debug features include: +- Extraction progress logging +- Error details with file counts +- Path validation warnings + +In release builds, logging is automatically disabled to reduce overhead. + ## Troubleshooting ### Common Issues @@ -339,14 +511,3 @@ For issues and questions: 1. Check the troubleshooting section above 2. Search existing GitHub issues 3. Create a new issue with detailed reproduction steps - -## Changelog - -### 1.0.0 - -- Initial release -- Support for RAR and ZIP extraction -- Support for CBR and CBZ extraction -- Cross-platform Android and iOS support -- TypeScript definitions -- Document picker integration examples diff --git a/android/src/main/java/com/unarchive/UnarchiveModule.kt b/android/src/main/java/com/unarchive/UnarchiveModule.kt index 264161e..9fe0a2a 100644 --- a/android/src/main/java/com/unarchive/UnarchiveModule.kt +++ b/android/src/main/java/com/unarchive/UnarchiveModule.kt @@ -1,5 +1,6 @@ package com.unarchive +import android.util.Log import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.Promise import com.facebook.react.bridge.WritableArray @@ -15,8 +16,15 @@ import net.sf.sevenzipjbinding.simple.ISimpleInArchiveItem import java.io.File import java.io.FileOutputStream import java.io.RandomAccessFile +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -24,6 +32,12 @@ import kotlinx.coroutines.withContext class UnarchiveModule(reactContext: ReactApplicationContext) : NativeUnarchiveSpec(reactContext) { + // Module-level concurrency guard + private val activeExtraction = AtomicBoolean(false) + + // Track active job for cancellation + private val currentJobRef = AtomicReference(null) + override fun getName(): String { return NAME } @@ -34,34 +48,211 @@ class UnarchiveModule(reactContext: ReactApplicationContext) : return a * b } + // Enforce allowed output roots + private fun isPathAllowed(path: String): Boolean { + try { + val file = File(path).canonicalFile + val canonicalPath = file.path + + val allowedRoots = listOf( + reactApplicationContext.filesDir.canonicalPath, + reactApplicationContext.cacheDir.canonicalPath, + reactApplicationContext.getExternalFilesDir(null)?.canonicalPath + ).filterNotNull() + + return allowedRoots.any { root -> + canonicalPath.startsWith(root) + } + } catch (e: Exception) { + return false + } + } + + // Zip-slip sanitization per entry + private fun isSafeEntryPath(entryPath: String, tempDir: File): File? { + try { + val destFile = File(tempDir, entryPath).canonicalFile + val tempDirCanonical = tempDir.canonicalPath + File.separator + + if (!destFile.path.startsWith(tempDirCanonical)) { + return null + } + return destFile + } catch (e: Exception) { + return null + } + } + + // Atomic move with fallback + private fun atomicMoveOrFallback(source: File, dest: File): Boolean { + try { + // Try atomic move first + try { + Files.move(source.toPath(), dest.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + return true + } catch (e: UnsupportedOperationException) { + // Atomic move not supported, use fallback + debugLog("Atomic move not supported, using fallback strategy") + } catch (e: Exception) { + debugLog("Atomic move failed: ${e.message}, trying fallback") + } + + // Fallback: rename-with-backup strategy + val backupFile = if (dest.exists()) { + File(dest.parentFile, "${dest.name}.backup.${UUID.randomUUID()}") + } else null + + try { + // Backup existing destination if it exists + if (backupFile != null && dest.exists()) { + if (!dest.renameTo(backupFile)) { + return false + } + } + + // Move temp to destination + if (!source.renameTo(dest)) { + // Restore backup on failure + if (backupFile != null && backupFile.exists()) { + backupFile.renameTo(dest) + } + return false + } + + // Delete backup on success + backupFile?.delete() + return true + } catch (e: Exception) { + // Attempt to restore backup on any error + if (backupFile != null && backupFile.exists()) { + try { + backupFile.renameTo(dest) + } catch (restoreError: Exception) { + Log.e(TAG, "Failed to restore backup: ${restoreError.message}") + } + } + throw e + } + } catch (e: Exception) { + Log.e(TAG, "Move operation failed: ${e.message}", e) + return false + } + } + override fun unarchive(archivePath: String, outputPath: String, promise: Promise) { - CoroutineScope(Dispatchers.IO).launch { + // Immediate busy rejection + if (!activeExtraction.compareAndSet(false, true)) { + promise.reject("UNARCHIVE_BUSY", "Another unarchive operation is already in progress") + return + } + + // Enforce allowed output roots + if (!isPathAllowed(outputPath)) { + activeExtraction.set(false) + promise.reject("UNARCHIVE_INVALID_PATH", "Output path is outside allowed app directories: $outputPath") + return + } + + debugLog("Starting unarchive: $archivePath -> $outputPath") + + // Single-callback guard per invocation + val cbInvoked = AtomicBoolean(false) + + fun resolveOnce(result: WritableMap) { + if (cbInvoked.compareAndSet(false, true)) { + CoroutineScope(Dispatchers.Main).launch { + promise.resolve(result) + } + } + } + + fun rejectOnce(code: String, message: String, error: Throwable? = null, userInfo: WritableMap? = null) { + if (cbInvoked.compareAndSet(false, true)) { + CoroutineScope(Dispatchers.Main).launch { + if (userInfo != null) { + promise.reject(code, message, error, userInfo) + } else { + promise.reject(code, message, error) + } + } + } + } + + // Store job for cancellation + val job = CoroutineScope(Dispatchers.IO).launch { + var tempDir: File? = null + var randomAccessFile: RandomAccessFile? = null + var inStream: RandomAccessFileInStream? = null + var inArchive: IInArchive? = null + + // Collect data as POJOs for main-thread conversion + data class ExtractedFileInfo(val path: String, val name: String, val relativePath: String, val size: Long) + val extractedFilesList = mutableListOf() + try { val archiveFile = File(archivePath) if (!archiveFile.exists()) { - promise.reject("FILE_NOT_FOUND", "Archive file does not exist: $archivePath") + rejectOnce("FILE_NOT_FOUND", "Archive file does not exist: $archivePath") return@launch } + // Check for cancellation + if (!isActive) { + rejectOnce("UNARCHIVE_CANCELLED", "Unarchive operation cancelled by user") + return@launch + } + + // Create temp directory for extraction val outputDir = File(outputPath) - if (!outputDir.exists()) { - outputDir.mkdirs() + val tempDirName = "unarchive_temp_${UUID.randomUUID()}_${System.currentTimeMillis()}" + tempDir = File(outputDir.parentFile, tempDirName) + + if (!tempDir.mkdirs()) { + rejectOnce("TEMP_DIR_CREATION_FAILED", "Failed to create temporary directory: ${tempDir.absolutePath}") + return@launch } - val randomAccessFile = RandomAccessFile(archiveFile, "r") - val inStream = RandomAccessFileInStream(randomAccessFile) - val inArchive: IInArchive = SevenZip.openInArchive(null, inStream) + debugLog("Created temp directory: ${tempDir.absolutePath}") + + randomAccessFile = RandomAccessFile(archiveFile, "r") + inStream = RandomAccessFileInStream(randomAccessFile) + inArchive = SevenZip.openInArchive(null, inStream) + + debugLog("Opened archive successfully") - val extractedFiles = WritableNativeArray() - try { val simpleInArchive = inArchive.simpleInterface val items = simpleInArchive.archiveItems + + debugLog("Archive contains ${items.size} items") + + // Check for cancellation + if (!isActive) { + rejectOnce("UNARCHIVE_CANCELLED", "Unarchive operation cancelled by user") + return@launch + } + + for ((index, item) in items.withIndex()) { + // Check for cancellation cooperatively + if (!isActive) { + debugLog("Cancellation detected at entry $index") + rejectOnce("UNARCHIVE_CANCELLED", "Unarchive operation cancelled by user") + return@launch + } - for (item in items) { if (!item.isFolder) { - val itemPath = item.path ?: "unknown_file" - val outputFile = File(outputDir, itemPath) + val itemPath = item.path ?: "unknown_file_$index" + + debugLog("Processing entry: $itemPath") + + // Zip-slip sanitization + val outputFile = isSafeEntryPath(itemPath, tempDir) + if (outputFile == null) { + val errorMsg = "Unsafe entry path detected (ZIP-SLIP): $itemPath" + Log.e(TAG, errorMsg) + rejectOnce("UNARCHIVE_ENTRY_INVALID", errorMsg) + return@launch + } // Create parent directories if they don't exist outputFile.parentFile?.mkdirs() @@ -72,38 +263,59 @@ class UnarchiveModule(reactContext: ReactApplicationContext) : } try { - // Extract the entire file content at once + // Single FileOutputStream per entry + var fos: FileOutputStream? = null + val extractResult = item.extractSlow { data -> try { - // Append mode to handle multiple data chunks - FileOutputStream(outputFile, true).use { fos -> - fos.write(data) - fos.flush() + // Open stream on first chunk + if (fos == null) { + fos = FileOutputStream(outputFile, false) } + + fos?.write(data) data.size } catch (e: Exception) { - android.util.Log.e("UnarchiveModule", "Error writing chunk for $itemPath: ${e.message}", e) + Log.e(TAG, "Error writing chunk for $itemPath: ${e.message}", e) + fos?.close() + fos = null + if (outputFile.exists()) { + outputFile.delete() + } 0 } } + // Close stream after all chunks + try { + fos?.flush() + fos?.close() + } catch (e: Exception) { + Log.e(TAG, "Error closing stream for $itemPath: ${e.message}", e) + } + // Verify extraction was successful if (extractResult == ExtractOperationResult.OK && outputFile.exists() && outputFile.length() > 0) { - val fileInfo = WritableNativeMap() - fileInfo.putString("path", outputFile.absolutePath) - fileInfo.putString("name", outputFile.name) - fileInfo.putDouble("size", outputFile.length().toDouble()) - extractedFiles.pushMap(fileInfo) - android.util.Log.d("UnarchiveModule", "Successfully extracted: $itemPath (${outputFile.length()} bytes)") + // Collect as POJO + val relativePath = tempDir.toPath().relativize(outputFile.toPath()).toString() + extractedFilesList.add( + ExtractedFileInfo( + path = outputFile.absolutePath, + name = outputFile.name, + relativePath = relativePath, + size = outputFile.length() + ) + ) + debugLog("Successfully extracted: $itemPath (${outputFile.length()} bytes)") } else { - android.util.Log.w("UnarchiveModule", "Extraction failed or file is empty: $itemPath, result: $extractResult") + Log.w(TAG, "Extraction failed or file is empty: $itemPath, result: $extractResult") // Clean up empty or failed files if (outputFile.exists() && outputFile.length() == 0L) { outputFile.delete() } } } catch (e: Exception) { - android.util.Log.e("UnarchiveModule", "Exception during extraction of $itemPath: ${e.message}", e) + Log.e(TAG, "Exception during extraction of $itemPath: ${e.message}", e) // Clean up partial files if (outputFile.exists()) { outputFile.delete() @@ -112,29 +324,182 @@ class UnarchiveModule(reactContext: ReactApplicationContext) : } } - val result = WritableNativeMap() - result.putArray("files", extractedFiles) - result.putString("outputPath", outputPath) + // Check for cancellation before final move + if (!isActive) { + rejectOnce("UNARCHIVE_CANCELLED", "Unarchive operation cancelled by user") + return@launch + } + + // Atomic move from temp to final destination + if (outputDir.exists()) { + outputDir.deleteRecursively() + } + val moveSuccess = atomicMoveOrFallback(tempDir, outputDir) + + if (!moveSuccess) { + val errorInfo = WritableNativeMap() + errorInfo.putInt("partialFilesCount", extractedFilesList.size) + errorInfo.putString("tempPath", tempDir.absolutePath) + + // Debug diagnostics + if (BuildConfig.DEBUG) { + val partialFiles = WritableNativeArray() + extractedFilesList.forEach { fileInfo -> + partialFiles.pushString(fileInfo.relativePath) + } + errorInfo.putArray("partialFilesList", partialFiles) + } + + rejectOnce("ATOMIC_MOVE_FAILED", "Failed to move extracted files to final destination", null, errorInfo) + return@launch + } + + debugLog("Atomic move completed successfully") + + // After atomic move, files are now in outputDir with the same relative structure + // We need to enumerate the actual files in the final location to get correct paths + val finalFilesList = mutableListOf() + + // Recursively enumerate all files in the output directory + fun enumerateFiles(dir: File, baseDir: File) { + dir.listFiles()?.forEach { file -> + if (file.isDirectory) { + enumerateFiles(file, baseDir) + } else { + val relativePath = baseDir.toPath().relativize(file.toPath()).toString() + finalFilesList.add( + ExtractedFileInfo( + path = file.absolutePath, + name = file.name, + relativePath = relativePath, + size = file.length() + ) + ) + debugLog("Final file: ${file.absolutePath} (${file.length()} bytes)") + } + } + } + + enumerateFiles(outputDir, outputDir) + debugLog("Enumerated ${finalFilesList.size} files in final location") + + // Convert to WritableMap on main thread withContext(Dispatchers.Main) { - promise.resolve(result) + val extractedFiles = WritableNativeArray() + finalFilesList.forEach { fileInfo -> + val fileInfoMap = WritableNativeMap() + fileInfoMap.putString("path", fileInfo.path) + fileInfoMap.putString("name", fileInfo.name) + fileInfoMap.putString("relativePath", fileInfo.relativePath) + fileInfoMap.putDouble("size", fileInfo.size.toDouble()) + extractedFiles.pushMap(fileInfoMap) + } + + val result = WritableNativeMap() + result.putArray("files", extractedFiles) + result.putString("outputPath", outputPath) + + resolveOnce(result) } + + debugLog("Unarchive completed successfully, extracted ${finalFilesList.size} files") } finally { - inArchive.close() - inStream.close() - randomAccessFile.close() + inArchive?.close() + inStream?.close() + randomAccessFile?.close() } } catch (e: Exception) { + Log.e(TAG, "Extraction error: ${e.message}", e) + + // Include partial extraction diagnostics + val errorInfo = WritableNativeMap() + errorInfo.putInt("partialFilesCount", extractedFilesList.size) + if (tempDir != null) { + errorInfo.putString("tempPath", tempDir.absolutePath) + } + + if (BuildConfig.DEBUG) { + val partialFiles = WritableNativeArray() + extractedFilesList.forEach { fileInfo -> + partialFiles.pushString(fileInfo.relativePath) + } + errorInfo.putArray("partialFilesList", partialFiles) + } + + rejectOnce("EXTRACTION_ERROR", "Failed to extract archive: ${e.message}", e, errorInfo) + } finally { + // Cleanup temp directory if it still exists + try { + tempDir?.let { + if (it.exists()) { + debugLog("Cleaning up temp directory: ${it.absolutePath}") + it.deleteRecursively() + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to cleanup temp directory: ${e.message}", e) + } + + // Release busy lock + activeExtraction.set(false) + // Clear job reference + currentJobRef.set(null) + } + } + + // Store job for cancellation + currentJobRef.set(job) + } + + // Cancellation API + override fun cancelUnarchive(promise: Promise) { + CoroutineScope(Dispatchers.IO).launch { + try { + val job = currentJobRef.get() + if (job == null) { + withContext(Dispatchers.Main) { + val result = WritableNativeMap() + result.putBoolean("cancelled", false) + promise.resolve(result) + } + return@launch + } + + debugLog("Cancelling active unarchive operation") + + // Cancel the job + job.cancel() + + // Wait for cleanup to complete + job.join() + + debugLog("Cancellation completed") + withContext(Dispatchers.Main) { - promise.reject("EXTRACTION_ERROR", "Failed to extract archive: ${e.message}", e) + val result = WritableNativeMap() + result.putBoolean("cancelled", true) + promise.resolve(result) + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + promise.reject("CANCELLATION_ERROR", "Failed to cancel unarchive: ${e.message}", e) } } } } + // Debug logging helper + private fun debugLog(message: String) { + if (BuildConfig.DEBUG) { + Log.d(TAG, message) + } + } + companion object { const val NAME = "Unarchive" + private const val TAG = "UnarchiveModule" } } diff --git a/example/android/.kotlin/sessions/kotlin-compiler-4452506504675743289.salive b/example/android/.kotlin/sessions/kotlin-compiler-4452506504675743289.salive deleted file mode 100644 index e69de29..0000000 diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 4fd95d3..2332ab0 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -2398,7 +2398,7 @@ PODS: - Yoga - SocketRocket (0.7.1) - SSZipArchive (2.6.0) - - Unarchive (1.0.0): + - Unarchive (1.0.1): - boost - DoubleConversion - fast_float @@ -2747,10 +2747,10 @@ SPEC CHECKSUMS: ReactNativeFs: f3dacdcf3e377c002507ade0c6fb8c206bab71db SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 SSZipArchive: 8a6ee5677c8e304bebc109e39cf0da91ccef22ea - Unarchive: ef2ac39f71afb2c18af64405754161efd74aee07 + Unarchive: d817b0dff1d74744cbcd50c8d29b5c66fe72a557 UnrarKit: 62f535c7a34ec52d2514b9b148f33dcfa9a9dc39 Yoga: 064221e341545b28511ea7d59a085819bf499b13 PODFILE CHECKSUM: 88babf7def1fbdd12cdc1007ffb781a6ac7006ca -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/example/package.json b/example/package.json index b3286e1..273572e 100644 --- a/example/package.json +++ b/example/package.json @@ -7,7 +7,10 @@ "ios": "react-native run-ios", "start": "react-native start", "build:android": "react-native build-android --extra-params \"--no-daemon --console=plain -PreactNativeArchitectures=arm64-v8a\"", - "build:ios": "react-native build-ios --mode Debug" + "build:ios": "react-native build-ios --mode Debug", + "xcode": "xed -b ios", + "pod": "npx pod-install", + "clean": "watchman watch-del-all" }, "dependencies": { "@dr.pogodin/react-native-fs": "2.35.1", diff --git a/example/src/App.tsx b/example/src/App.tsx index 545ba01..eaa19ae 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import { Text, View, @@ -14,6 +14,7 @@ import { import { multiply, unarchive, + cancelUnarchive, type FileInfo, type UnarchiveResult, } from 'react-native-unarchive'; @@ -26,6 +27,7 @@ import { copyFile, } from '@dr.pogodin/react-native-fs'; import { pick } from '@react-native-documents/picker'; +import { PermissionsAndroid, Share } from 'react-native'; const multiplyResult = multiply(3, 7); @@ -37,14 +39,6 @@ const uriToPath = (uri: string): string => { return uri; }; -// Utility function to convert file path to URI if needed -const pathToUri = (path: string): string => { - if (path.startsWith('file://')) { - return path; - } - return `file://${encodeURI(path)}`; -}; - function App() { // Debug: Log the file system paths on startup console.log('=== FILE SYSTEM PATHS DEBUG ==='); @@ -56,12 +50,10 @@ function App() { const [archivePath, setArchivePath] = useState(''); const [outputPath, setOutputPath] = useState(() => { - if (Platform.OS === 'android') { - return `${ExternalStorageDirectoryPath}/Download/UnarchiveApp`; - } else { - // iOS: Use DocumentDirectoryPath, it should always be available - return `${DocumentDirectoryPath || '/tmp'}/UnarchiveApp`; - } + // Both platforms now require app-scoped directories for security + // Android: Use DocumentDirectoryPath (filesDir) which is always allowed + // iOS: Use DocumentDirectoryPath as before + return `${DocumentDirectoryPath}/UnarchiveApp`; }); const [extractionResult, setExtractionResult] = useState([]); const [loading, setLoading] = useState(false); @@ -69,49 +61,51 @@ function App() { const [modalImage, setModalImage] = useState(''); const selectOutputDirectory = async () => { - // For now, let's use a simple preset directory or let user type manually - Alert.alert('Output Directory', 'Choose where to extract files:', [ - { - text: 'Downloads Folder', - onPress: () => { - const downloadsPath = - Platform.OS === 'android' - ? `${ExternalStorageDirectoryPath}/Download/UnarchiveApp` - : `${DownloadDirectoryPath || '/var/mobile/Containers/Data/Application/Documents'}/UnarchiveApp`; - setOutputPath(downloadsPath); + // Security: Only app-scoped directories are allowed on both platforms + Alert.alert( + 'Output Directory', + 'For security, extraction is limited to app directories. Choose a location:', + [ + { + text: 'App Documents', + onPress: () => { + setOutputPath(`${DocumentDirectoryPath}/UnarchiveApp`); + }, }, - }, - { - text: 'External Storage', - onPress: () => { - const externalPath = - Platform.OS === 'android' - ? `${ExternalStorageDirectoryPath}/UnarchiveApp` - : `${DocumentDirectoryPath || '/var/mobile/Containers/Data/Application/Documents'}/UnarchiveApp`; - setOutputPath(externalPath); + { + text: 'App Cache', + onPress: () => { + const cachePath = DocumentDirectoryPath.replace( + '/Documents', + '/Library/Caches' + ); + setOutputPath(`${cachePath}/UnarchiveApp`); + }, }, - }, - { - text: 'Custom Path', - onPress: () => { - Alert.alert( - 'Custom Path', - 'Enter the full path where you want to extract files:', - [ - { - text: 'Cancel', - style: 'cancel', - }, - { - text: 'Use /sdcard/UnarchiveApp', - onPress: () => setOutputPath('/sdcard/UnarchiveApp'), - }, - ] - ); + { + text: 'App Files (Android)', + onPress: () => { + if (Platform.OS === 'android') { + // Derive the Android app package name from the internal DocumentDirectoryPath + // DocumentDirectoryPath on Android looks like: /data/user/0//files + // Use the same package to construct the app-scoped external files path so + // it matches the native allowed root and avoids UNARCHIVE_INVALID_PATH. + const pkgRegex = /\/data\/user\/(?:0\/)?([^/]+)\/files/; + const match = (DocumentDirectoryPath || '').match(pkgRegex); + const packageName = match && match[1] ? match[1] : null; + const androidPath = packageName + ? `${ExternalStorageDirectoryPath}/Android/data/${packageName}/files/UnarchiveApp` + : `${DocumentDirectoryPath}/UnarchiveApp`; + + setOutputPath(androidPath); + } else { + setOutputPath(`${DocumentDirectoryPath}/UnarchiveApp`); + } + }, }, - }, - { text: 'Cancel', style: 'cancel' }, - ]); + { text: 'Cancel', style: 'cancel' }, + ] + ); }; const handleUnarchive = async () => { @@ -174,7 +168,7 @@ function App() { Alert.alert( 'Success', - `Extracted ${result.files.length} files to:\n${result.outputPath}\n\nFirst file: ${result.files[0]?.name || 'None'}` + `Extracted ${result.files.length} files\n\nLocation: ${result.outputPath}\n\nFirst file: ${result.files[0]?.name || 'None'}\n\nNote: Files are in app directory for security.` ); // Also check what's actually in the output directory @@ -193,15 +187,64 @@ function App() { } catch (dirError) { console.error('Error reading output directory:', dirError); } - } catch (error) { + } catch (error: any) { console.error('=== UNARCHIVE ERROR ==='); console.error('Error details:', error); - Alert.alert('Error', `Failed to extract archive: ${error}`); + + // Handle specific error codes + let errorMessage = `Failed to extract archive: ${error.message || error}`; + + if (error.code === 'UNARCHIVE_BUSY') { + errorMessage = + 'Another extraction is already in progress. Please wait for it to complete.'; + } else if (error.code === 'UNARCHIVE_INVALID_PATH') { + errorMessage = `Invalid output path. For security, extraction is only allowed to app directories.\n\nCurrent path: ${outputPath}\n\nPlease use "Select Output Folder" to choose a valid location.`; + } else if (error.code === 'UNARCHIVE_ENTRY_INVALID') { + errorMessage = + 'Archive contains unsafe entries (possible ZIP-SLIP attack). The archive may be malicious or corrupted.'; + } else if (error.code === 'UNARCHIVE_CANCELLED') { + errorMessage = 'Extraction was cancelled.'; + } else if (error.code === 'FILE_NOT_FOUND') { + errorMessage = 'Archive file not found. Please select the file again.'; + } + + // Include debug info if available + if (error.userInfo) { + const debugInfo: string[] = []; + if (error.userInfo.partialFilesCount) { + debugInfo.push(`Partial files: ${error.userInfo.partialFilesCount}`); + } + if (error.userInfo.tempPath) { + debugInfo.push(`Temp path: ${error.userInfo.tempPath}`); + } + if (debugInfo.length > 0) { + errorMessage += `\n\nDebug info:\n${debugInfo.join('\n')}`; + } + } + + Alert.alert('Extraction Error', errorMessage); } finally { setLoading(false); } }; + const handleCancelUnarchive = async () => { + if (!loading) { + Alert.alert('Info', 'No extraction in progress'); + return; + } + + try { + console.log('=== CANCELLING UNARCHIVE ==='); + const result = await cancelUnarchive(); + console.log('Cancellation result:', result); + Alert.alert('Cancelled', 'Extraction has been cancelled'); + } catch (error) { + console.error('Cancellation error:', error); + Alert.alert('Error', `Failed to cancel: ${error}`); + } + }; + const formatFileSize = (bytes: number) => { if (bytes === 0) return '0 Bytes'; const k = 1024; @@ -285,7 +328,10 @@ function App() { try { // Create a unique filename to avoid conflicts const timestamp = Date.now(); - const safeFileName = (fileName || 'archive').replace(/[^a-zA-Z0-9.-]/g, '_'); + const safeFileName = (fileName || 'archive').replace( + /[^a-zA-Z0-9.-]/g, + '_' + ); const permanentPath = `${DocumentDirectoryPath}/imported_${timestamp}_${safeFileName}`; console.log('Copying Android content URI to permanent location:'); @@ -438,24 +484,131 @@ function App() { const handleImagePress = (path: string) => { console.log('=== IMAGE PRESS ==='); console.log('Original path:', path); - + // Ensure proper file URI format for image display let imageUri = path; if (Platform.OS === 'android' && !path.startsWith('file://')) { imageUri = `file://${path}`; } console.log('Image URI for display:', imageUri); - + setShowModal(true); setModalImage(imageUri); }; + // Export extracted files to the user's Downloads folder so they are visible + // in the system Files app. Note: on Android 11+ scoped storage prevents + // writing into shared folders without special APIs; we attempt to copy and + // surface friendly errors / fallbacks. + const exportToDownloads = async () => { + if (extractionResult.length === 0) { + Alert.alert('No files', 'There are no extracted files to export'); + return; + } + + if (Platform.OS !== 'android') { + Alert.alert( + 'Unsupported', + 'Export to Downloads is currently Android-only' + ); + return; + } + + // For Android < 29, request WRITE_EXTERNAL_STORAGE + const sdk = Platform.Version as number; + if (sdk < 29) { + try { + const granted = await PermissionsAndroid.request( + PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE, + { + title: 'Storage Permission', + message: + 'This app needs permission to write to external storage to export files', + buttonNeutral: 'Ask Me Later', + buttonNegative: 'Cancel', + buttonPositive: 'OK', + } + ); + + if (granted !== PermissionsAndroid.RESULTS.GRANTED) { + Alert.alert( + 'Permission denied', + 'Cannot export files without storage permission' + ); + return; + } + } catch (permErr) { + console.error('Permission request failed', permErr); + Alert.alert( + 'Permission error', + `Permission request failed: ${permErr}` + ); + return; + } + } + + const results: { name: string; ok: boolean; error?: string }[] = []; + for (const file of extractionResult) { + try { + const destPath = `${DownloadDirectoryPath}/${file.name}`; + console.log('Copying to Downloads:', file.path, '->', destPath); + await copyFile(file.path, destPath); + const ok = await exists(destPath); + if (ok) { + results.push({ name: file.name, ok: true }); + } else { + results.push({ + name: file.name, + ok: false, + error: 'Verification failed', + }); + } + } catch (copyErr: any) { + console.error('Export failed for', file.path, copyErr); + results.push({ + name: file.name, + ok: false, + error: `${copyErr?.message || copyErr}`, + }); + } + } + + const successCount = results.filter((r) => r.ok).length; + const failed = results.filter((r) => !r.ok); + + let message = `Exported ${successCount}/${results.length} files to your Downloads folder.`; + if (failed.length > 0) { + message += `\n\nFailed:\n${failed.map((f) => `${f.name}: ${f.error}`).join('\n')}`; + message += `\n\nNote: On Android 11+ the system may prevent apps from writing directly to Downloads. Use "Share" on a file to move it to another app or use a file manager export.`; + } + + Alert.alert('Export Complete', message); + }; + + // Share a single file using the system share sheet. This is the recommended + // way for users to move files to locations not directly writable by the app. + const shareFile = async (filePath: string) => { + try { + const uri = + Platform.OS === 'android' && !filePath.startsWith('file://') + ? `file://${filePath}` + : filePath; + await Share.share({ url: uri, title: 'Share file' }); + } catch (e: any) { + console.error('Share failed', e); + Alert.alert('Share failed', `${e?.message || e}`); + } + }; + return ( React Native Unarchive CBR/CBZ Extraction Demo Multiply Test: {multiplyResult} + + 🔒 Secure extraction to app directories only + @@ -508,12 +661,28 @@ function App() { + {loading && ( + + Cancel Extraction + + )} + Check Output Directory + + + Export to Downloads + {extractionResult.length > 0 && ( @@ -523,23 +692,43 @@ function App() { {extractionResult.map((file: FileInfo, index: number) => ( - handleImagePress(file.path)} - key={index} - style={styles.fileItem} - > - - {file.name} - - - {formatFileSize(file.size)} - - - {file.path} - - + + handleImagePress(file.path)} + style={styles.fileItemTouchable} + > + + {file.name} + + + {formatFileSize(file.size)} + + + {file.path} + + + + shareFile(file.path)} + > + Share + + + ))} + + + + + Export All to Downloads + + + )} @@ -591,6 +780,12 @@ const styles = StyleSheet.create({ color: '#888', marginTop: 10, }, + securityNote: { + fontSize: 12, + color: '#34C759', + marginTop: 8, + fontWeight: '500', + }, section: { backgroundColor: '#fff', marginHorizontal: 20, @@ -652,6 +847,18 @@ const styles = StyleSheet.create({ buttonDisabled: { opacity: 0.6, }, + cancelButton: { + backgroundColor: '#FF3B30', + paddingVertical: 10, + borderRadius: 6, + alignItems: 'center', + marginBottom: 10, + }, + cancelButtonText: { + color: '#fff', + fontSize: 14, + fontWeight: '600', + }, buttonText: { color: '#fff', fontSize: 16, @@ -682,6 +889,48 @@ const styles = StyleSheet.create({ marginTop: 2, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', }, + fileItemRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 8, + paddingHorizontal: 10, + borderBottomWidth: 1, + borderBottomColor: '#eee', + }, + fileItemTouchable: { + flex: 1, + }, + fileActions: { + marginLeft: 8, + alignItems: 'center', + justifyContent: 'center', + }, + shareButton: { + backgroundColor: '#007AFF', + paddingVertical: 6, + paddingHorizontal: 10, + borderRadius: 6, + }, + shareButtonText: { + color: '#fff', + fontSize: 12, + fontWeight: '600', + }, + exportRow: { + marginTop: 10, + alignItems: 'center', + }, + exportButton: { + backgroundColor: '#34C759', + paddingVertical: 10, + paddingHorizontal: 14, + borderRadius: 6, + }, + exportButtonText: { + color: '#fff', + fontWeight: '600', + }, modalContainer: { flex: 1, backgroundColor: '#000', diff --git a/ios/Unarchive.h b/ios/Unarchive.h index f7face0..c756a94 100644 --- a/ios/Unarchive.h +++ b/ios/Unarchive.h @@ -1,7 +1,9 @@ +#import #import + #import #import -@interface Unarchive : NSObject +@interface Unarchive : RCTEventEmitter @end diff --git a/ios/Unarchive.mm b/ios/Unarchive.mm index eed00aa..d9c719f 100644 --- a/ios/Unarchive.mm +++ b/ios/Unarchive.mm @@ -1,7 +1,165 @@ #import "Unarchive.h" +#import -@implementation Unarchive -RCT_EXPORT_MODULE() +@implementation Unarchive { + std::atomic_bool _activeExtraction; + std::atomic_bool _cancellationRequested; + NSString *_currentTempPath; +} + +- (instancetype)init { + if (self = [super init]) { + _activeExtraction.store(false); + _cancellationRequested.store(false); + _currentTempPath = nil; + } + return self; +} + +// Helper method to validate output path is within app sandbox +- (BOOL)isOutputPathInSandbox:(NSString *)outputPath + error:(NSError **)error { + if (!outputPath) { + if (error) { + *error = [NSError errorWithDomain:@"UnarchiveError" + code:-10 + userInfo:@{NSLocalizedDescriptionKey: @"Output path is nil"}]; + } + return NO; + } + + // Canonicalize the output path + NSURL *outputURL = [NSURL fileURLWithPath:outputPath]; + NSURL *canonicalOutputURL = [[outputURL URLByResolvingSymlinksInPath] URLByStandardizingPath]; + NSString *canonicalOutput = [canonicalOutputURL path]; + + // Get allowed sandbox directories + NSString *documentsPath = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) firstObject]; + NSString *cachesPath = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) firstObject]; + NSString *tmpPath = NSTemporaryDirectory(); + + // Canonicalize sandbox paths + NSURL *documentsURL = [[NSURL fileURLWithPath:documentsPath] URLByStandardizingPath]; + NSURL *cachesURL = [[NSURL fileURLWithPath:cachesPath] URLByStandardizingPath]; + NSURL *tmpURL = [[NSURL fileURLWithPath:tmpPath] URLByStandardizingPath]; + + NSString *canonicalDocuments = [documentsURL path]; + NSString *canonicalCaches = [cachesURL path]; + NSString *canonicalTmp = [tmpURL path]; + + // Check if output path is within any allowed directory + BOOL isInDocuments = [canonicalOutput hasPrefix:canonicalDocuments]; + BOOL isInCaches = [canonicalOutput hasPrefix:canonicalCaches]; + BOOL isInTmp = [canonicalOutput hasPrefix:canonicalTmp]; + + if (!isInDocuments && !isInCaches && !isInTmp) { +#if DEBUG + NSLog(@"[Unarchive] INVALID_PATH: Output path '%@' is outside app sandbox", outputPath); + NSLog(@"[Unarchive] Allowed directories: Documents='%@', Caches='%@', Tmp='%@'", + canonicalDocuments, canonicalCaches, canonicalTmp); +#endif + if (error) { + *error = [NSError errorWithDomain:@"UnarchiveError" + code:-11 + userInfo:@{ + NSLocalizedDescriptionKey: @"Output path must be within app sandbox (Documents/Caches/tmp)", + @"outputPath": outputPath, + @"canonicalPath": canonicalOutput + }]; + } + return NO; + } + + return YES; +} + +// Helper method for zip-slip sanitization +- (BOOL)isSafePath:(NSString *)entryPath + withinBaseURL:(NSURL *)baseURL + error:(NSError **)error { + if (!entryPath || !baseURL) { + if (error) { + *error = [NSError errorWithDomain:@"UnarchiveError" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: @"Invalid path or base URL"}]; + } + return NO; + } + + // Normalize entry path and remove leading slashes or dots + NSString *normalizedEntry = [entryPath stringByStandardizingPath]; + while ([normalizedEntry hasPrefix:@"/"] || [normalizedEntry hasPrefix:@"../"]) { + if ([normalizedEntry hasPrefix:@"/"]) { + normalizedEntry = [normalizedEntry substringFromIndex:1]; + } else if ([normalizedEntry hasPrefix:@"../"]) { + normalizedEntry = [normalizedEntry substringFromIndex:3]; + } + } + + // Construct the full destination path + NSURL *destinationURL = [baseURL URLByAppendingPathComponent:normalizedEntry]; + NSURL *canonicalDestination = [destinationURL URLByStandardizingPath]; + NSURL *canonicalBase = [baseURL URLByStandardizingPath]; + + // Check if canonical destination is within canonical base + NSString *destPath = [canonicalDestination path]; + NSString *basePath = [canonicalBase path]; + + if (![destPath hasPrefix:basePath]) { +#if DEBUG + NSLog(@"[Unarchive] ZIP-SLIP detected: Entry '%@' would escape base directory", entryPath); +#endif + if (error) { + *error = [NSError errorWithDomain:@"UnarchiveError" + code:-2 + userInfo:@{ + NSLocalizedDescriptionKey: @"Archive contains unsafe path that attempts to escape extraction directory", + @"entryPath": entryPath + }]; + } + return NO; + } + + return YES; +} + +// Helper method to recursively enumerate all files and preserve relative paths +- (NSArray *)enumerateFilesRecursively:(NSString *)directoryPath + baseDirectory:(NSString *)baseDirectory + fileManager:(NSFileManager *)fileManager + error:(NSError **)error { + NSMutableArray *allFiles = [NSMutableArray array]; + NSDirectoryEnumerator *enumerator = [fileManager enumeratorAtPath:directoryPath]; + + for (NSString *relativePath in enumerator) { + NSString *fullPath = [directoryPath stringByAppendingPathComponent:relativePath]; + BOOL isDirectory = NO; + + if ([fileManager fileExistsAtPath:fullPath isDirectory:&isDirectory]) { + if (!isDirectory) { + // Get file attributes + NSError * __autoreleasing attrError = nil; + NSDictionary *attributes = [fileManager attributesOfItemAtPath:fullPath error:&attrError]; + + if (attrError) { +#if DEBUG + NSLog(@"[Unarchive] Warning: Could not get attributes for %@: %@", relativePath, attrError.localizedDescription); +#endif + } + + // Include relativePath in result + NSMutableDictionary *fileDict = [NSMutableDictionary dictionary]; + fileDict[@"path"] = [baseDirectory stringByAppendingPathComponent:relativePath]; + fileDict[@"name"] = [relativePath lastPathComponent]; + fileDict[@"relativePath"] = relativePath; + fileDict[@"size"] = attributes[NSFileSize] ?: @0; + [allFiles addObject:fileDict]; + } + } + } + + return allFiles; +} - (NSNumber *)multiply:(double)a b:(double)b { NSNumber *result = @(a * b); @@ -9,41 +167,128 @@ - (NSNumber *)multiply:(double)a b:(double)b { return result; } +// Helper methods for single-callback guard +- (void)resolveOnce:(RCTPromiseResolveBlock)resolve + result:(id)result + invoked:(std::atomic_bool *)cbInvoked { + bool expected = false; + if (cbInvoked->compare_exchange_strong(expected, true)) { + dispatch_async(dispatch_get_main_queue(), ^{ + resolve(result); + }); + } +} + +- (void)rejectOnce:(RCTPromiseRejectBlock)reject + code:(NSString *)code + message:(NSString *)message + error:(NSError *)error + invoked:(std::atomic_bool *)cbInvoked { + bool expected = false; + if (cbInvoked->compare_exchange_strong(expected, true)) { + dispatch_async(dispatch_get_main_queue(), ^{ + reject(code, message, error); + }); + } +} + - (void)unarchive:(NSString *)archivePath outputPath:(NSString *)outputPath resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + // Immediate-busy concurrency check + bool expected = false; + if (!_activeExtraction.compare_exchange_strong(expected, true)) { + dispatch_async(dispatch_get_main_queue(), ^{ + reject(@"UNARCHIVE_BUSY", + @"Another unarchive operation is already in progress", nil); + }); + return; + } + + // Reset cancellation flag for new operation + _cancellationRequested.store(false); + + // Validate outputPath is within app sandbox + NSError *sandboxError = nil; + if (![self isOutputPathInSandbox:outputPath error:&sandboxError]) { + _activeExtraction.store(false); + dispatch_async(dispatch_get_main_queue(), ^{ + reject(@"UNARCHIVE_INVALID_PATH", + sandboxError.localizedDescription ?: @"Output path must be within app sandbox", + sandboxError); + }); + return; + } + + // Per-invocation callback guard + std::shared_ptr cbInvoked = + std::make_shared(false); + dispatch_async( dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + // Per-invocation NSFileManager + NSFileManager *fileManager = [[NSFileManager alloc] init]; NSError *error = nil; // Check if archive file exists - if (![[NSFileManager defaultManager] fileExistsAtPath:archivePath]) { - dispatch_async(dispatch_get_main_queue(), ^{ - reject( - @"FILE_NOT_FOUND", - [NSString stringWithFormat:@"Archive file does not exist: %@", - archivePath], - nil); - }); + if (![fileManager fileExistsAtPath:archivePath]) { + [self rejectOnce:reject + code:@"FILE_NOT_FOUND" + message:[NSString + stringWithFormat: + @"Archive file does not exist: %@", + archivePath] + error:nil + invoked:cbInvoked.get()]; + _activeExtraction.store(false); return; } - // Create output directory if it doesn't exist - [[NSFileManager defaultManager] createDirectoryAtPath:outputPath - withIntermediateDirectories:YES - attributes:nil - error:&error]; - if (error) { - dispatch_async(dispatch_get_main_queue(), ^{ - reject( - @"DIRECTORY_ERROR", - [NSString - stringWithFormat:@"Failed to create output directory: %@", - error.localizedDescription], - error); - }); + // Create unique temporary directory for extraction + NSString *outputParent = [outputPath stringByDeletingLastPathComponent]; + NSString *tempDirName = + [NSString stringWithFormat:@".unarchive_temp_%@_%ld", + [[NSUUID UUID] UUIDString], + (long)[[NSDate date] timeIntervalSince1970]]; + NSString *tempPath = + [outputParent stringByAppendingPathComponent:tempDirName]; + + // Track current temp path for cancellation cleanup + _currentTempPath = tempPath; + + // Create temp directory + if (![fileManager createDirectoryAtPath:tempPath + withIntermediateDirectories:YES + attributes:nil + error:&error]) { + [self rejectOnce:reject + code:@"DIRECTORY_ERROR" + message:[NSString + stringWithFormat: + @"Failed to create temp directory: %@", + error.localizedDescription] + error:error + invoked:cbInvoked.get()]; + _activeExtraction.store(false); + _currentTempPath = nil; + return; + } + + // Check for cancellation after temp directory creation + if (_cancellationRequested.load()) { +#if DEBUG + NSLog(@"[Unarchive] Cancellation detected before extraction"); +#endif + [fileManager removeItemAtPath:tempPath error:nil]; + [self rejectOnce:reject + code:@"UNARCHIVE_CANCELLED" + message:@"Extraction was cancelled by user" + error:nil + invoked:cbInvoked.get()]; + _activeExtraction.store(false); + _currentTempPath = nil; return; } @@ -54,7 +299,10 @@ - (void)unarchive:(NSString *)archivePath [fileExtension isEqualToString:@"rar"]) { // Use UnrarKit for RAR/CBR files [self extractCBRFile:archivePath + tempPath:tempPath outputPath:outputPath + fileManager:fileManager + cbInvoked:cbInvoked resolve:resolve reject:reject]; @@ -62,168 +310,571 @@ - (void)unarchive:(NSString *)archivePath [fileExtension isEqualToString:@"zip"]) { // Use SSZipArchive for ZIP/CBZ files [self extractCBZFile:archivePath + tempPath:tempPath outputPath:outputPath + fileManager:fileManager + cbInvoked:cbInvoked resolve:resolve reject:reject]; } else { - dispatch_async(dispatch_get_main_queue(), ^{ - reject(@"UNSUPPORTED_FORMAT", - @"Unsupported archive format. Only CBR and CBZ files are " - @"supported.", - nil); - }); + // Clean up temp directory on error + [fileManager removeItemAtPath:tempPath error:nil]; + [self rejectOnce:reject + code:@"UNSUPPORTED_FORMAT" + message:@"Unsupported archive format. Only CBR and CBZ files " + @"are supported." + error:nil + invoked:cbInvoked.get()]; + _activeExtraction.store(false); + _currentTempPath = nil; // Clear temp path } }); } // Helper method for CBR extraction using UnrarKit - (void)extractCBRFile:(NSString *)archivePath + tempPath:(NSString *)tempPath outputPath:(NSString *)outputPath + fileManager:(NSFileManager *)fileManager + cbInvoked:(std::shared_ptr)cbInvoked resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - NSError *error = nil; +#if DEBUG + NSLog(@"[Unarchive] Starting CBR extraction: %@", [archivePath lastPathComponent]); +#endif + + NSError * __autoreleasing error = nil; URKArchive *archive = [[URKArchive alloc] initWithPath:archivePath error:&error]; if (error) { - dispatch_async(dispatch_get_main_queue(), ^{ - reject(@"ARCHIVE_ERROR", - [NSString stringWithFormat:@"Failed to open CBR archive: %@", - error.localizedDescription], - error); - }); +#if DEBUG + NSLog(@"[Unarchive] Error: Failed to open CBR archive: %@", error.localizedDescription); +#endif + [fileManager removeItemAtPath:tempPath error:nil]; + [self rejectOnce:reject + code:@"ARCHIVE_ERROR" + message:[NSString stringWithFormat:@"Failed to open CBR archive: %@", + error.localizedDescription] + error:error + invoked:cbInvoked.get()]; + _activeExtraction.store(false); + _currentTempPath = nil; // Clear temp path + return; + } + + // Check for cancellation after opening archive + if (_cancellationRequested.load()) { +#if DEBUG + NSLog(@"[Unarchive] Cancellation detected after opening archive"); +#endif + [fileManager removeItemAtPath:tempPath error:nil]; + [self rejectOnce:reject + code:@"UNARCHIVE_CANCELLED" + message:@"Extraction was cancelled by user" + error:nil + invoked:cbInvoked.get()]; + _activeExtraction.store(false); + _currentTempPath = nil; return; } NSArray *filenames = [archive listFilenames:&error]; if (error) { - dispatch_async(dispatch_get_main_queue(), ^{ - reject(@"LIST_ERROR", - [NSString stringWithFormat:@"Failed to list CBR contents: %@", - error.localizedDescription], - error); - }); +#if DEBUG + NSLog(@"[Unarchive] Error: Failed to list CBR contents: %@", error.localizedDescription); +#endif + [fileManager removeItemAtPath:tempPath error:nil]; + [self rejectOnce:reject + code:@"LIST_ERROR" + message:[NSString stringWithFormat:@"Failed to list CBR contents: %@", + error.localizedDescription] + error:error + invoked:cbInvoked.get()]; + _activeExtraction.store(false); + _currentTempPath = nil; // Clear temp path + return; + } + +#if DEBUG + NSLog(@"[Unarchive] Archive contains %lu entries", (unsigned long)filenames.count); +#endif + + // Check for cancellation before validation + if (_cancellationRequested.load()) { +#if DEBUG + NSLog(@"[Unarchive] Cancellation detected before validation"); +#endif + [fileManager removeItemAtPath:tempPath error:nil]; + [self rejectOnce:reject + code:@"UNARCHIVE_CANCELLED" + message:@"Extraction was cancelled by user" + error:nil + invoked:cbInvoked.get()]; + _activeExtraction.store(false); + _currentTempPath = nil; return; } + // Validate all entry paths for zip-slip before extraction + NSURL *tempBaseURL = [NSURL fileURLWithPath:tempPath]; + for (NSString *entryPath in filenames) { + NSError * __autoreleasing pathError = nil; + if (![self isSafePath:entryPath withinBaseURL:tempBaseURL error:&pathError]) { + [fileManager removeItemAtPath:tempPath error:nil]; + [self rejectOnce:reject + code:@"UNSAFE_PATH" + message:[NSString stringWithFormat:@"Archive contains unsafe path: %@", entryPath] + error:pathError + invoked:cbInvoked.get()]; + _activeExtraction.store(false); + _currentTempPath = nil; // Clear temp path + return; + } + } + NSMutableArray *extractedFiles = [NSMutableArray array]; - // Extract all files from the CBR archive - BOOL success = [archive extractFilesTo:outputPath overwrite:YES error:&error]; + // Check for cancellation before extraction + if (_cancellationRequested.load()) { +#if DEBUG + NSLog(@"[Unarchive] Cancellation detected before extraction"); +#endif + [fileManager removeItemAtPath:tempPath error:nil]; + [self rejectOnce:reject + code:@"UNARCHIVE_CANCELLED" + message:@"Extraction was cancelled by user" + error:nil + invoked:cbInvoked.get()]; + _activeExtraction.store(false); + _currentTempPath = nil; + return; + } + + // Extract all files to temp directory +#if DEBUG + NSLog(@"[Unarchive] Extracting to temp directory: %@", tempPath); +#endif + BOOL success = [archive extractFilesTo:tempPath overwrite:YES error:&error]; if (success && !error) { - // List extracted files - NSError *listError = nil; - NSArray *extractedFileNames = - [[NSFileManager defaultManager] contentsOfDirectoryAtPath:outputPath - error:&listError]; - - if (listError) { - dispatch_async(dispatch_get_main_queue(), ^{ - reject(@"LIST_ERROR", - [NSString stringWithFormat:@"Failed to list extracted files: %@", - listError.localizedDescription], - listError); - }); +#if DEBUG + NSLog(@"[Unarchive] Extraction successful, enumerating files..."); +#endif + // Use recursive enumeration to preserve relative paths + NSError * __autoreleasing enumError = nil; + extractedFiles = [[self enumerateFilesRecursively:tempPath + baseDirectory:outputPath + fileManager:fileManager + error:&enumError] mutableCopy]; + + if (enumError || extractedFiles.count == 0) { +#if DEBUG + NSLog(@"[Unarchive] Error: Failed to enumerate extracted files: %@", enumError.localizedDescription); +#endif + // Include partial diagnostics on failure + NSError *diagError = enumError; + NSMutableDictionary *userInfo = [NSMutableDictionary dictionary]; + if (enumError) { + userInfo[NSLocalizedDescriptionKey] = [NSString stringWithFormat:@"Failed to enumerate extracted files: %@", enumError.localizedDescription]; + } else { + userInfo[NSLocalizedDescriptionKey] = @"No files were extracted from archive"; + } + userInfo[@"partialFilesCount"] = @(extractedFiles.count); + userInfo[@"tempPath"] = tempPath; +#if DEBUG + // Include detailed file list in debug builds only + if (extractedFiles.count > 0) { + userInfo[@"partialFilesList"] = extractedFiles; + } +#endif + diagError = [NSError errorWithDomain:@"UnarchiveError" + code:-3 + userInfo:userInfo]; + + [fileManager removeItemAtPath:tempPath error:nil]; + [self rejectOnce:reject + code:@"LIST_ERROR" + message:userInfo[NSLocalizedDescriptionKey] + error:diagError + invoked:cbInvoked.get()]; + _activeExtraction.store(false); + _currentTempPath = nil; // Clear temp path return; } - // Filter only filenames that were originally in the archive - for (NSString *originalFilename in filenames) { - NSString *baseFilename = [originalFilename lastPathComponent]; - if ([extractedFileNames containsObject:baseFilename]) { - NSString *filePath = - [outputPath stringByAppendingPathComponent:baseFilename]; +#if DEBUG + NSLog(@"[Unarchive] Enumerated %lu files", (unsigned long)extractedFiles.count); +#endif + + // Check for cancellation after enumeration + if (_cancellationRequested.load()) { +#if DEBUG + NSLog(@"[Unarchive] Cancellation detected after enumeration"); +#endif + [fileManager removeItemAtPath:tempPath error:nil]; + [self rejectOnce:reject + code:@"UNARCHIVE_CANCELLED" + message:@"Extraction was cancelled by user" + error:nil + invoked:cbInvoked.get()]; + _activeExtraction.store(false); + _currentTempPath = nil; + return; + } - // Get file attributes for size - NSDictionary *attributes = - [[NSFileManager defaultManager] attributesOfItemAtPath:filePath - error:nil]; + // Atomic replace - use replaceItemAtURL for atomic swap + NSURL *tempURL = [NSURL fileURLWithPath:tempPath]; + NSURL *outputURL = [NSURL fileURLWithPath:outputPath]; + NSURL *resultingURL = nil; + NSError * __autoreleasing replaceError = nil; + +#if DEBUG + NSLog(@"[Unarchive] Performing atomic replacement to final location"); +#endif + + BOOL replaceSuccess = NO; + if ([fileManager fileExistsAtPath:outputPath]) { + // Output exists - use atomic replaceItemAtURL + replaceSuccess = [fileManager replaceItemAtURL:outputURL + withItemAtURL:tempURL + backupItemName:nil + options:NSFileManagerItemReplacementUsingNewMetadataOnly + resultingItemURL:&resultingURL + error:&replaceError]; + } else { + // Output doesn't exist - simple move + replaceSuccess = [fileManager moveItemAtURL:tempURL + toURL:outputURL + error:&replaceError]; + } - NSMutableDictionary *fileDict = [NSMutableDictionary dictionary]; - fileDict[@"path"] = filePath; - fileDict[@"name"] = baseFilename; - fileDict[@"size"] = attributes[NSFileSize] ?: @0; - [extractedFiles addObject:fileDict]; - } + if (replaceSuccess) { +#if DEBUG + NSLog(@"[Unarchive] Extraction completed successfully with %lu files", (unsigned long)extractedFiles.count); +#endif + // Convert extracted files array to immutable copy for thread safety + NSArray *filesCopy = [extractedFiles copy]; + NSString *outputPathCopy = [outputPath copy]; + + // Build result payload on main thread + dispatch_async(dispatch_get_main_queue(), ^{ + NSDictionary *result = @{ + @"files": filesCopy, + @"outputPath": outputPathCopy + }; + [self resolveOnce:resolve result:result invoked:cbInvoked.get()]; + self->_activeExtraction.store(false); + self->_currentTempPath = nil; + }); + } else { +#if DEBUG + NSLog(@"[Unarchive] Error: Atomic replacement failed: %@", replaceError.localizedDescription); +#endif + [fileManager removeItemAtPath:tempPath error:nil]; + [self rejectOnce:reject + code:@"ATOMIC_REPLACE_ERROR" + message:[NSString + stringWithFormat: + @"Failed to atomically replace output directory: %@", + replaceError.localizedDescription] + error:replaceError + invoked:cbInvoked.get()]; + _activeExtraction.store(false); + _currentTempPath = nil; } } else { - dispatch_async(dispatch_get_main_queue(), ^{ - reject(@"EXTRACTION_ERROR", - [NSString stringWithFormat:@"Failed to extract CBR archive: %@", - error ? error.localizedDescription - : @"Unknown error"], - error); - }); +#if DEBUG + NSLog(@"[Unarchive] Error: Extraction failed: %@", error ? error.localizedDescription : @"Unknown error"); +#endif + // Include partial diagnostics on failure + NSMutableDictionary *userInfo = [NSMutableDictionary dictionary]; + userInfo[NSLocalizedDescriptionKey] = [NSString stringWithFormat:@"Failed to extract CBR archive: %@", + error ? error.localizedDescription : @"Unknown error"]; + userInfo[@"partialFilesCount"] = @(extractedFiles.count); + userInfo[@"tempPath"] = tempPath; +#if DEBUG + // Include partial file list in debug builds only + if (extractedFiles.count > 0) { + userInfo[@"partialFilesList"] = extractedFiles; + } +#endif + + NSError *diagError = [NSError errorWithDomain:@"UnarchiveError" + code:-4 + userInfo:userInfo]; + + [fileManager removeItemAtPath:tempPath error:nil]; + [self rejectOnce:reject + code:@"EXTRACTION_ERROR" + message:userInfo[NSLocalizedDescriptionKey] + error:diagError + invoked:cbInvoked.get()]; + _activeExtraction.store(false); + _currentTempPath = nil; // Clear temp path return; } - - NSDictionary *result = - @{@"files" : extractedFiles, @"outputPath" : outputPath}; - - dispatch_async(dispatch_get_main_queue(), ^{ - resolve(result); - }); } // Helper method for CBZ extraction using SSZipArchive - (void)extractCBZFile:(NSString *)archivePath + tempPath:(NSString *)tempPath outputPath:(NSString *)outputPath + fileManager:(NSFileManager *)fileManager + cbInvoked:(std::shared_ptr)cbInvoked resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { +#if DEBUG + NSLog(@"[Unarchive] Starting CBZ extraction: %@", [archivePath lastPathComponent]); +#endif + + // Note - SSZipArchive performs its own path validation, but we add post-extraction validation BOOL success = [SSZipArchive unzipFileAtPath:archivePath - toDestination:outputPath]; + toDestination:tempPath]; if (!success) { - dispatch_async(dispatch_get_main_queue(), ^{ - reject(@"EXTRACTION_ERROR", - @"Failed to extract CBZ archive using SSZipArchive", nil); - }); +#if DEBUG + NSLog(@"[Unarchive] Error: Failed to extract CBZ archive"); +#endif + [fileManager removeItemAtPath:tempPath error:nil]; + [self rejectOnce:reject + code:@"EXTRACTION_ERROR" + message:@"Failed to extract CBZ archive using SSZipArchive" + error:nil + invoked:cbInvoked.get()]; + _activeExtraction.store(false); + _currentTempPath = nil; // Clear temp path return; } - // Get list of extracted files - NSMutableArray *extractedFiles = [NSMutableArray array]; - NSError *error = nil; - NSArray *contents = - [[NSFileManager defaultManager] contentsOfDirectoryAtPath:outputPath - error:&error]; + // Check for cancellation after extraction + if (_cancellationRequested.load()) { +#if DEBUG + NSLog(@"[Unarchive] Cancellation detected after extraction"); +#endif + [fileManager removeItemAtPath:tempPath error:nil]; + [self rejectOnce:reject + code:@"UNARCHIVE_CANCELLED" + message:@"Extraction was cancelled by user" + error:nil + invoked:cbInvoked.get()]; + _activeExtraction.store(false); + _currentTempPath = nil; + return; + } - if (error) { - dispatch_async(dispatch_get_main_queue(), ^{ - reject(@"LIST_ERROR", - [NSString stringWithFormat:@"Failed to list extracted files: %@", - error.localizedDescription], - error); - }); +#if DEBUG + NSLog(@"[Unarchive] Extraction successful, enumerating files..."); +#endif + + // Post-extraction validation - verify no files escaped temp directory + // Canonicalize both the temp directory and each extracted file path so + // equivalent paths with different representations (for example + // '/private/var/...' vs '/var/...') do not trigger false positives. + NSURL *tempBaseURL = [NSURL fileURLWithPath:tempPath]; + NSURL *canonicalTempURL = [[tempBaseURL URLByResolvingSymlinksInPath] URLByStandardizingPath]; + NSString *canonicalTempPath = [canonicalTempURL path]; + + NSDirectoryEnumerator *validator = [fileManager enumeratorAtURL:tempBaseURL + includingPropertiesForKeys:nil + options:0 + errorHandler:nil]; + for (NSURL *fileURL in validator) { + NSURL *canonicalFileURL = [[fileURL URLByResolvingSymlinksInPath] URLByStandardizingPath]; + NSString *canonicalFilePath = [canonicalFileURL path]; + + if (![canonicalFilePath hasPrefix:canonicalTempPath]) { +#if DEBUG + NSLog(@"[Unarchive] Error: File escaped temp directory: %@ (canonical: %@), temp: %@", [fileURL path], canonicalFilePath, canonicalTempPath); +#endif + [fileManager removeItemAtPath:tempPath error:nil]; + + NSError *pathError = [NSError errorWithDomain:@"UnarchiveError" + code:-2 + userInfo:@{ + NSLocalizedDescriptionKey: @"Archive contains unsafe path that attempts to escape extraction directory", + @"filePath": canonicalFilePath, + @"tempPath": canonicalTempPath + }]; + + [self rejectOnce:reject + code:@"UNSAFE_PATH" + message:@"Archive contained files that attempted to escape extraction directory" + error:pathError + invoked:cbInvoked.get()]; + _activeExtraction.store(false); + _currentTempPath = nil; // Clear temp path + return; + } + } + + // Use recursive enumeration to preserve relative paths + NSError * __autoreleasing enumError = nil; + NSMutableArray *extractedFiles = [[self enumerateFilesRecursively:tempPath + baseDirectory:outputPath + fileManager:fileManager + error:&enumError] mutableCopy]; + + if (enumError || extractedFiles.count == 0) { +#if DEBUG + NSLog(@"[Unarchive] Error: Failed to enumerate extracted files: %@", enumError.localizedDescription); +#endif + // Include partial diagnostics on failure + NSMutableDictionary *userInfo = [NSMutableDictionary dictionary]; + if (enumError) { + userInfo[NSLocalizedDescriptionKey] = [NSString stringWithFormat:@"Failed to enumerate extracted files: %@", enumError.localizedDescription]; + } else { + userInfo[NSLocalizedDescriptionKey] = @"No files were extracted from archive"; + } + userInfo[@"partialFilesCount"] = @(extractedFiles.count); + userInfo[@"tempPath"] = tempPath; +#if DEBUG + if (extractedFiles.count > 0) { + userInfo[@"partialFilesList"] = extractedFiles; + } +#endif + NSError *diagError = [NSError errorWithDomain:@"UnarchiveError" + code:-5 + userInfo:userInfo]; + + [fileManager removeItemAtPath:tempPath error:nil]; + [self rejectOnce:reject + code:@"LIST_ERROR" + message:userInfo[NSLocalizedDescriptionKey] + error:diagError + invoked:cbInvoked.get()]; + _activeExtraction.store(false); + _currentTempPath = nil; // Clear temp path return; } - for (NSString *filename in contents) { - NSString *filePath = [outputPath stringByAppendingPathComponent:filename]; - NSDictionary *attributes = - [[NSFileManager defaultManager] attributesOfItemAtPath:filePath - error:nil]; +#if DEBUG + NSLog(@"[Unarchive] Enumerated %lu files", (unsigned long)extractedFiles.count); +#endif + + // Check for cancellation after enumeration + if (_cancellationRequested.load()) { +#if DEBUG + NSLog(@"[Unarchive] Cancellation detected after enumeration"); +#endif + [fileManager removeItemAtPath:tempPath error:nil]; + [self rejectOnce:reject + code:@"UNARCHIVE_CANCELLED" + message:@"Extraction was cancelled by user" + error:nil + invoked:cbInvoked.get()]; + _activeExtraction.store(false); + _currentTempPath = nil; + return; + } - NSMutableDictionary *fileDict = [NSMutableDictionary dictionary]; - fileDict[@"path"] = filePath; - fileDict[@"name"] = filename; - fileDict[@"size"] = attributes[NSFileSize] ?: @0; - [extractedFiles addObject:fileDict]; + // Atomic replace - use replaceItemAtURL for atomic swap + NSURL *tempURL = [NSURL fileURLWithPath:tempPath]; + NSURL *outputURL = [NSURL fileURLWithPath:outputPath]; + NSURL *resultingURL = nil; + NSError * __autoreleasing replaceError = nil; + +#if DEBUG + NSLog(@"[Unarchive] Performing atomic replacement to final location"); +#endif + + BOOL replaceSuccess = NO; + if ([fileManager fileExistsAtPath:outputPath]) { + // Output exists - use atomic replaceItemAtURL + replaceSuccess = [fileManager replaceItemAtURL:outputURL + withItemAtURL:tempURL + backupItemName:nil + options:NSFileManagerItemReplacementUsingNewMetadataOnly + resultingItemURL:&resultingURL + error:&replaceError]; + } else { + // Output doesn't exist - simple move + replaceSuccess = [fileManager moveItemAtURL:tempURL + toURL:outputURL + error:&replaceError]; } - NSDictionary *result = - @{@"files" : extractedFiles, @"outputPath" : outputPath}; + if (replaceSuccess) { +#if DEBUG + NSLog(@"[Unarchive] Extraction completed successfully with %lu files", (unsigned long)extractedFiles.count); +#endif + // Convert extracted files array to immutable copy for thread safety + NSArray *filesCopy = [extractedFiles copy]; + NSString *outputPathCopy = [outputPath copy]; + + // Build result payload on main thread + dispatch_async(dispatch_get_main_queue(), ^{ + NSDictionary *result = @{ + @"files": filesCopy, + @"outputPath": outputPathCopy + }; + [self resolveOnce:resolve result:result invoked:cbInvoked.get()]; + self->_activeExtraction.store(false); + self->_currentTempPath = nil; + }); + } else { +#if DEBUG + NSLog(@"[Unarchive] Error: Atomic replacement failed: %@", replaceError.localizedDescription); +#endif + [fileManager removeItemAtPath:tempPath error:nil]; + [self rejectOnce:reject + code:@"ATOMIC_REPLACE_ERROR" + message:[NSString + stringWithFormat: + @"Failed to atomically replace output directory: %@", + replaceError.localizedDescription] + error:replaceError + invoked:cbInvoked.get()]; + _activeExtraction.store(false); + _currentTempPath = nil; + } +} - dispatch_async(dispatch_get_main_queue(), ^{ - resolve(result); +// Helper method to get the app's documents directory +// Cancellation API +- (void)cancelUnarchive:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { +#if DEBUG + NSLog(@"[Unarchive] Cancellation requested"); +#endif + + // Set cancellation flag + _cancellationRequested.store(true); + + // Give the extraction operation time to notice cancellation and clean up + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), + dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + // Try to clean up temp directory if it exists + NSString *tempPath = self->_currentTempPath; + if (tempPath) { + NSFileManager *fileManager = [[NSFileManager alloc] init]; + NSError * __autoreleasing cleanupError = nil; + if ([fileManager fileExistsAtPath:tempPath]) { +#if DEBUG + NSLog(@"[Unarchive] Cleaning up temp directory: %@", tempPath); +#endif + [fileManager removeItemAtPath:tempPath error:&cleanupError]; + if (cleanupError) { +#if DEBUG + NSLog(@"[Unarchive] Warning: Failed to clean up temp directory: %@", + cleanupError.localizedDescription); +#endif + } + } + } + + dispatch_async(dispatch_get_main_queue(), ^{ +#if DEBUG + NSLog(@"[Unarchive] Cancellation completed"); +#endif + resolve(@{@"cancelled": @YES}); + }); }); } -// Helper method to get the app's documents directory - (NSString *)documentsDirectory { NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES); diff --git a/src/NativeUnarchive.ts b/src/NativeUnarchive.ts index 0ff1e68..7b1af26 100644 --- a/src/NativeUnarchive.ts +++ b/src/NativeUnarchive.ts @@ -3,6 +3,7 @@ import { TurboModuleRegistry, type TurboModule } from 'react-native'; export interface FileInfo { path: string; name: string; + relativePath: string; size: number; } @@ -11,9 +12,14 @@ export interface UnarchiveResult { outputPath: string; } +export interface CancelResult { + cancelled: boolean; +} + export interface Spec extends TurboModule { multiply(a: number, b: number): number; unarchive(archivePath: string, outputPath: string): Promise; + cancelUnarchive(): Promise; } export default TurboModuleRegistry.getEnforcing('Unarchive'); diff --git a/src/index.tsx b/src/index.tsx index 7454a05..b569932 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,5 +1,9 @@ import Unarchive from './NativeUnarchive'; -import type { UnarchiveResult, FileInfo } from './NativeUnarchive'; +import type { + UnarchiveResult, + FileInfo, + CancelResult, +} from './NativeUnarchive'; export function multiply(a: number, b: number): number { return Unarchive.multiply(a, b); @@ -12,5 +16,13 @@ export function unarchive( return Unarchive.unarchive(archivePath, outputPath); } +/** + * Cancel an ongoing extraction operation + * @returns Promise that resolves when cancellation is complete + */ +export function cancelUnarchive(): Promise { + return Unarchive.cancelUnarchive(); +} + // Export types for consumers -export type { UnarchiveResult, FileInfo }; +export type { UnarchiveResult, FileInfo, CancelResult }; diff --git a/yarn.lock b/yarn.lock index 3c41618..5adbc89 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1788,19 +1788,12 @@ __metadata: languageName: node linkType: hard -"@eslint/config-helpers@npm:^0.3.1": - version: 0.3.1 - resolution: "@eslint/config-helpers@npm:0.3.1" - checksum: b95c239264078a430761afb344402d517134289a7d8b69a6ff1378ebe5eec9da6ad22b5e6d193b9e02899aeda30817ac47178d5927247092cc6d73a52f8d07c9 - languageName: node - linkType: hard - -"@eslint/core@npm:^0.15.2": - version: 0.15.2 - resolution: "@eslint/core@npm:0.15.2" +"@eslint/config-helpers@npm:^0.4.0": + version: 0.4.0 + resolution: "@eslint/config-helpers@npm:0.4.0" dependencies: - "@types/json-schema": ^7.0.15 - checksum: 535fc4e657760851826ceae325a72dde664b99189bd975715de3526db655c66d7a35b72dbb1c7641ab9201ed4e2130f79c5be51f96c820b5407c3766dcf94f23 + "@eslint/core": ^0.16.0 + checksum: f17af9d6de60e0d8be5131451ef489f32984f92aff00cb1c5c8f1790baf07ea7ad803e0f21f1519eded4ce247871ffe593b7e51ddc094b5337d22f29dd720ba5 languageName: node linkType: hard @@ -1830,10 +1823,10 @@ __metadata: languageName: node linkType: hard -"@eslint/js@npm:9.36.0, @eslint/js@npm:^9.35.0": - version: 9.36.0 - resolution: "@eslint/js@npm:9.36.0" - checksum: 17ff28272337357783b55e76417e61306e528dced99bb49d49e06298023b4071cb30f4aeb0bf30a337817d3eb3132784db6b8edd3a90118c5217833136712713 +"@eslint/js@npm:9.37.0, @eslint/js@npm:^9.35.0": + version: 9.37.0 + resolution: "@eslint/js@npm:9.37.0" + checksum: 916f2ff7f70eadaa3a1c3f7d6d375fccfb676723484e1c54c5d63ff8a462746090097b73d21f4cb876ff2276d04af3f1c4c9e9a93729a9305213ca3aaa75008c languageName: node linkType: hard @@ -1844,22 +1837,22 @@ __metadata: languageName: node linkType: hard -"@eslint/plugin-kit@npm:^0.3.5": - version: 0.3.5 - resolution: "@eslint/plugin-kit@npm:0.3.5" +"@eslint/plugin-kit@npm:^0.4.0": + version: 0.4.0 + resolution: "@eslint/plugin-kit@npm:0.4.0" dependencies: - "@eslint/core": ^0.15.2 + "@eslint/core": ^0.16.0 levn: ^0.4.1 - checksum: 1808d7e2538335b8e4536ef372840e93468ecc6f4a5bf72ad665795290b6a8a72f51ef4ffd8bcfc601b133a5d5f67b59ab256d945f8c825c5c307aad29efaf86 + checksum: bb82be19c99eea256f7ec8e0996d28bd4b95b796bd1b27659b92e83278ef813485ada55995314887e7812cca02b0a9672d63f547c2a110eb5a7f0022c8e0f23d languageName: node linkType: hard "@evilmartians/lefthook@npm:^1.12.3": - version: 1.13.5 - resolution: "@evilmartians/lefthook@npm:1.13.5" + version: 1.13.6 + resolution: "@evilmartians/lefthook@npm:1.13.6" bin: lefthook: bin/index.js - checksum: 139f12fec89c1517c4fe26bb9c4abc5ee1fd75f39314bbcfe749f9f93a6fa071b851d77b8650a6c64b0da24821e5a024108ecd995627c0cee35f6bb1bc875952 + checksum: 6cceca3e874015678f50818ae14a74d959816cfaba6638f8852d007332404d6819b15c71538985a3650a1ef057aa6975c17fadfe43ece7a0da1aeb9faaf02946 conditions: (os=darwin | os=linux | os=win32) & (cpu=x64 | cpu=arm64 | cpu=ia32) languageName: node linkType: hard @@ -3525,11 +3518,11 @@ __metadata: linkType: hard "@types/node@npm:*": - version: 24.5.2 - resolution: "@types/node@npm:24.5.2" + version: 24.6.2 + resolution: "@types/node@npm:24.6.2" dependencies: - undici-types: ~7.12.0 - checksum: 5d859c117a3e15e2e7cca429ba2db9b7c5ef167eb6386ab3db9f9aad7f705baee45957ad11d6c3d7514dc189ee9ec311905944dfbe9823497ad80a9f15add048 + undici-types: ~7.13.0 + checksum: 95766998060f005403a1aea198c2c472fd1d695cb9e7cebb62adb0e3aceb871ae1201e90577df31a7c1d6a2c2fadccbd9a9868f9014cb77ebd3232104de8f4fb languageName: node linkType: hard @@ -3548,11 +3541,11 @@ __metadata: linkType: hard "@types/react@npm:^19.1.0": - version: 19.1.15 - resolution: "@types/react@npm:19.1.15" + version: 19.2.0 + resolution: "@types/react@npm:19.2.0" dependencies: csstype: ^3.0.2 - checksum: 3fcdeaddcca2ca09aadbce93c7f8105a46933f5f9ca8643b5f3599f41207c46a9982267e9095b67b2b69c5b4ac6e4450c74698cd99406baf8d31fa5333e86e22 + checksum: 1bfef433bb4d237487835423eaf4789bad94bcf96d4106f5c53f3109dc8451edea6b04be3f6e9d374f1a76672bb66e58d68e64f45535f0b6c804930a731dd19e languageName: node linkType: hard @@ -4137,6 +4130,13 @@ __metadata: languageName: node linkType: hard +"async-generator-function@npm:^1.0.0": + version: 1.0.0 + resolution: "async-generator-function@npm:1.0.0" + checksum: 74a71a4a2dd7afd06ebb612f6d612c7f4766a351bedffde466023bf6dae629e46b0d2cd38786239e0fbf245de0c7df76035465e16d1213774a0efb22fec0d713 + languageName: node + linkType: hard + "async-limiter@npm:~1.0.0": version: 1.0.1 resolution: "async-limiter@npm:1.0.1" @@ -4318,12 +4318,12 @@ __metadata: languageName: node linkType: hard -"baseline-browser-mapping@npm:^2.8.3": - version: 2.8.9 - resolution: "baseline-browser-mapping@npm:2.8.9" +"baseline-browser-mapping@npm:^2.8.9": + version: 2.8.12 + resolution: "baseline-browser-mapping@npm:2.8.12" bin: baseline-browser-mapping: dist/cli.js - checksum: ad426d6e239ffaad388e126c52263c0bf4248b9482b56db219f7c866a3f5a6cef39a5c08b7ace1bdf3f87f26ac96b0e9f9bfc199fc148d838ea68e43e3104d92 + checksum: 2972a58d059d0ffc2a11bf6ad4cc78490f939cfd8276896e01913699d37059337eeca27f42591c1ffab898db16cc490855991be7306480894160932086a81bcd languageName: node linkType: hard @@ -4401,17 +4401,17 @@ __metadata: linkType: hard "browserslist@npm:^4.20.4, browserslist@npm:^4.24.0, browserslist@npm:^4.25.3": - version: 4.26.2 - resolution: "browserslist@npm:4.26.2" + version: 4.26.3 + resolution: "browserslist@npm:4.26.3" dependencies: - baseline-browser-mapping: ^2.8.3 - caniuse-lite: ^1.0.30001741 - electron-to-chromium: ^1.5.218 + baseline-browser-mapping: ^2.8.9 + caniuse-lite: ^1.0.30001746 + electron-to-chromium: ^1.5.227 node-releases: ^2.0.21 update-browserslist-db: ^1.1.3 bin: browserslist: cli.js - checksum: ebd96e8895cdfc72be074281eb377332b69ceb944ec0c063739d8eeb8e513b168ac1e27d26ce5cc260e69a340a44c6bb5e9408565449d7a16739e5844453d4c7 + checksum: aa5bbcda9db1eeb9952b4c2f11f9a5a2247da7bcce7fa14d3cc215e67246a93394eda2f86378a41c3f73e6e1a1561bf0e7eade93c5392cb6d37bc66f70d0c53f languageName: node linkType: hard @@ -4565,10 +4565,10 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.30001741": - version: 1.0.30001745 - resolution: "caniuse-lite@npm:1.0.30001745" - checksum: a018bfbf6eda6e2728184cd39f3d0438cea04011893664fc7de19568d8e6f26cbc09e59460137bb2f4e792d1cdb7f1a48ad35f31a1c1388c1d7f74b3c889d35b +"caniuse-lite@npm:^1.0.30001746": + version: 1.0.30001747 + resolution: "caniuse-lite@npm:1.0.30001747" + checksum: 1c0192838c384af64badc25a1380ec2d4c705dc37e84955634bfbe818b62a2596be2db75e1e9227887b25e57d06569960da542d2a7bcf7d35da8becb1365369a languageName: node linkType: hard @@ -4662,9 +4662,9 @@ __metadata: linkType: hard "ci-info@npm:^4.3.0": - version: 4.3.0 - resolution: "ci-info@npm:4.3.0" - checksum: 77a851ec826e1fbcd993e0e3ef402e6a5e499c733c475af056b7808dea9c9ede53e560ed433020489a8efea2d824fd68ca203446c9988a0bac8475210b0d4491 + version: 4.3.1 + resolution: "ci-info@npm:4.3.1" + checksum: 66c159d92648e8a07acab0a3a0681bff6ccc39aa44916263208c4d97bbbeedbbc886d7611fd30c21df1aa624ce3c6fcdfde982e74689e3e014e064e1d0805f94 languageName: node linkType: hard @@ -5506,9 +5506,9 @@ __metadata: linkType: hard "dotenv@npm:^17.2.2": - version: 17.2.2 - resolution: "dotenv@npm:17.2.2" - checksum: 673825993b16a6722332b2e1f8c24b1c2ebe3dd3b81ae5df9be35f1483bf52e0b463555b09da65b756c7abee3cf55ba2ae2628c22874a899556fa787fac56019 + version: 17.2.3 + resolution: "dotenv@npm:17.2.3" + checksum: fde23eb88649041ec7a0f6a47bbe59cac3c454fc2007cf2e40b9c984aaf0636347218c56cfbbf067034b0a73f530a2698a19b4058695787eb650ec69fe234624 languageName: node linkType: hard @@ -5537,10 +5537,10 @@ __metadata: languageName: node linkType: hard -"electron-to-chromium@npm:^1.5.218": - version: 1.5.227 - resolution: "electron-to-chromium@npm:1.5.227" - checksum: 6a798b53216b300f20dbad56c0ef3777da59cb37a761c169fbf8b1dcbeca74ccf137f83a5b3b959ae82cf3cd454aae1d4fda8c7dabd99a72bef645ac18fa6a45 +"electron-to-chromium@npm:^1.5.227": + version: 1.5.230 + resolution: "electron-to-chromium@npm:1.5.230" + checksum: a3fdb59b95950e75f4426212645b7852d91100ec64af249ff9a163fa50fe15e9bb1714f0467dda020394f453a9ec0a2f63969d9b131962373f93f56d1b52a4f6 languageName: node linkType: hard @@ -5605,11 +5605,11 @@ __metadata: linkType: hard "envinfo@npm:^7.13.0": - version: 7.15.0 - resolution: "envinfo@npm:7.15.0" + version: 7.16.0 + resolution: "envinfo@npm:7.16.0" bin: envinfo: dist/cli.js - checksum: 38595c11134ecb66a40289980d8ca82e89fdcd68849dd72560c1bbc3cfc55c867573b4150967707ff9ff2e5cad6f1d0cb6cc56c333a6eccdcd3533452141c0a8 + checksum: 7f732ac35f1da1a964d32b86bfb1421bc2712a58fe86cba4421c5fb25aa42b458ba70bd799b550cd0e744ec92bbcf69bd176b09ca7616640f26766e96ceba0d8 languageName: node linkType: hard @@ -6031,17 +6031,17 @@ __metadata: linkType: hard "eslint@npm:^9.35.0": - version: 9.36.0 - resolution: "eslint@npm:9.36.0" + version: 9.37.0 + resolution: "eslint@npm:9.37.0" dependencies: "@eslint-community/eslint-utils": ^4.8.0 "@eslint-community/regexpp": ^4.12.1 "@eslint/config-array": ^0.21.0 - "@eslint/config-helpers": ^0.3.1 - "@eslint/core": ^0.15.2 + "@eslint/config-helpers": ^0.4.0 + "@eslint/core": ^0.16.0 "@eslint/eslintrc": ^3.3.1 - "@eslint/js": 9.36.0 - "@eslint/plugin-kit": ^0.3.5 + "@eslint/js": 9.37.0 + "@eslint/plugin-kit": ^0.4.0 "@humanfs/node": ^0.16.6 "@humanwhocodes/module-importer": ^1.0.1 "@humanwhocodes/retry": ^0.4.2 @@ -6076,7 +6076,7 @@ __metadata: optional: true bin: eslint: bin/eslint.js - checksum: 08a02a1d474cf7ea63ef9328e638751c939a1c08b99f7812f0f44a96e3b8346ab3bbca3af57da8b3e74cbc6619e41645fd3dcb3adda94d1cb826f02664e2d44c + checksum: 78e813174acef58d361d557a4d083d2d03f20cd70dd96f59973414305acaedf72bad52271c789174a19ee0407f8bece017ce42a05c89014b93e457d033285aeb languageName: node linkType: hard @@ -6549,9 +6549,9 @@ __metadata: linkType: hard "generator-function@npm:^2.0.0": - version: 2.0.0 - resolution: "generator-function@npm:2.0.0" - checksum: 12b5ca9c9cb21196aa4d8a53de3104956a917e806e00b0370d442fcd1142e3ae2f9401211204b951352bf22daf0373dd055d080cf6b9392d8c1281fe497c5de9 + version: 2.0.1 + resolution: "generator-function@npm:2.0.1" + checksum: 3bf87f7b0230de5d74529677e6c3ceb3b7b5d9618b5a22d92b45ce3876defbaf5a77791b25a61b0fa7d13f95675b5ff67a7769f3b9af33f096e34653519e873d languageName: node linkType: hard @@ -6577,20 +6577,23 @@ __metadata: linkType: hard "get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.2.7, get-intrinsic@npm:^1.3.0": - version: 1.3.0 - resolution: "get-intrinsic@npm:1.3.0" + version: 1.3.1 + resolution: "get-intrinsic@npm:1.3.1" dependencies: + async-function: ^1.0.0 + async-generator-function: ^1.0.0 call-bind-apply-helpers: ^1.0.2 es-define-property: ^1.0.1 es-errors: ^1.3.0 es-object-atoms: ^1.1.1 function-bind: ^1.1.2 + generator-function: ^2.0.0 get-proto: ^1.0.1 gopd: ^1.2.0 has-symbols: ^1.1.0 hasown: ^2.0.2 math-intrinsics: ^1.1.0 - checksum: 301008e4482bb9a9cb49e132b88fee093bff373b4e6def8ba219b1e96b60158a6084f273ef5cafe832e42cd93462f4accb46a618d35fe59a2b507f2388c5b79d + checksum: c02b3b6a445f9cd53e14896303794ac60f9751f58a69099127248abdb0251957174c6524245fc68579dc8e6a35161d3d94c93e665f808274716f4248b269436a languageName: node linkType: hard @@ -7404,15 +7407,15 @@ __metadata: linkType: hard "is-generator-function@npm:^1.0.10": - version: 1.1.1 - resolution: "is-generator-function@npm:1.1.1" + version: 1.1.2 + resolution: "is-generator-function@npm:1.1.2" dependencies: - call-bound: ^1.0.3 + call-bound: ^1.0.4 generator-function: ^2.0.0 - get-proto: ^1.0.0 + get-proto: ^1.0.1 has-tostringtag: ^1.0.2 safe-regex-test: ^1.1.0 - checksum: 194a7a1654ec8ae1e49df4f6774956e686fba7ba7dbe545de5ba9cd06bb93e2c4c0bfe6924f5211b800d3fa16cb151cd272d1da01d0392d07317a6b4900b923a + checksum: 0b81c613752a5e534939e5b3835ff722446837a5b94c3a3934af5ded36a651d9aa31c3f11f8a3453884b9658bf26dbfb7eb855e744d920b07f084bd890a43414 languageName: node linkType: hard @@ -8282,11 +8285,11 @@ __metadata: linkType: hard "jiti@npm:^2.4.1, jiti@npm:^2.5.1": - version: 2.6.0 - resolution: "jiti@npm:2.6.0" + version: 2.6.1 + resolution: "jiti@npm:2.6.1" bin: jiti: lib/jiti-cli.mjs - checksum: 2bd869527bfbb23b5210344881b4f2f5fd86b7c9c703001036544762411af73fe0f95097ba025a738874085143939664173360aafea7d7cbc4ca3bbc325774a9 + checksum: 9394e29c5e40d1ca8267923160d8d86706173c9ff30c901097883434b0c4866de2c060427b6a9a5843bb3e42fa3a3c8b5b2228531d3dd4f4f10c5c6af355bb86 languageName: node linkType: hard @@ -8810,69 +8813,69 @@ __metadata: languageName: node linkType: hard -"metro-babel-transformer@npm:0.83.2": - version: 0.83.2 - resolution: "metro-babel-transformer@npm:0.83.2" +"metro-babel-transformer@npm:0.83.3": + version: 0.83.3 + resolution: "metro-babel-transformer@npm:0.83.3" dependencies: "@babel/core": ^7.25.2 flow-enums-runtime: ^0.0.6 hermes-parser: 0.32.0 nullthrows: ^1.1.1 - checksum: 8ca98216c3fc32757cbb445d2e42042617b5a2399d3d409759b168fbd3d52aadf8bb2b8471e4b204ddf5c654b7b146397edb7693f48a0582e7e4e169cf3bbfbb + checksum: dd178409d1718dae12dfffb6572ebc5bb78f1e0d7e93dce829c945957f8a686cb1b4c466c69585d7b982b3937fbea28d5c53a80691f2fc66717a0bcc800bc5b8 languageName: node linkType: hard -"metro-cache-key@npm:0.83.2": - version: 0.83.2 - resolution: "metro-cache-key@npm:0.83.2" +"metro-cache-key@npm:0.83.3": + version: 0.83.3 + resolution: "metro-cache-key@npm:0.83.3" dependencies: flow-enums-runtime: ^0.0.6 - checksum: ad60492b1db35b7d4eb1f9ed6f8aa79a051dcb1be3183fcd5b0a810e7c4ba5dba5e9f02e131ccd271d6db2efaa9893ef0e316ef26ebb3ab49cb074fada4de1b5 + checksum: a6f9d2bf8b810f57d330d6f8f1ebf029e1224f426c5895f73d9bc1007482684048bfc7513a855626ee7f3ae72ca46e1b08cf983aefbfa84321bb7c0cef4ba4ae languageName: node linkType: hard -"metro-cache@npm:0.83.2": - version: 0.83.2 - resolution: "metro-cache@npm:0.83.2" +"metro-cache@npm:0.83.3": + version: 0.83.3 + resolution: "metro-cache@npm:0.83.3" dependencies: exponential-backoff: ^3.1.1 flow-enums-runtime: ^0.0.6 https-proxy-agent: ^7.0.5 - metro-core: 0.83.2 - checksum: 29e914de2c3da88f94a5cb2708cb87ea1a1d7dba73a0f0f45d974e36e635132190a00330803cc8226e784700322576e68b96c52a03d10725d3a7afbf3a5845df + metro-core: 0.83.3 + checksum: 95606275411d85de071fd95171a9548406cd1154320850a554bf00207804f7844ed252f9750a802d6612ade839c579b23bd87927ae173f43c368e8f5d900149d languageName: node linkType: hard -"metro-config@npm:0.83.2, metro-config@npm:^0.83.1": - version: 0.83.2 - resolution: "metro-config@npm:0.83.2" +"metro-config@npm:0.83.3, metro-config@npm:^0.83.1": + version: 0.83.3 + resolution: "metro-config@npm:0.83.3" dependencies: connect: ^3.6.5 flow-enums-runtime: ^0.0.6 jest-validate: ^29.7.0 - metro: 0.83.2 - metro-cache: 0.83.2 - metro-core: 0.83.2 - metro-runtime: 0.83.2 + metro: 0.83.3 + metro-cache: 0.83.3 + metro-core: 0.83.3 + metro-runtime: 0.83.3 yaml: ^2.6.1 - checksum: d8b8ddd0ce77cf6c1173288af1b38676918d6465b8542061a6be6ff61022d0363ae0479a58fc343baac812b38b4876e22d0a50a97d1207ea44cffa7bbc893aa0 + checksum: a14b77668a9712abbcebe5bf6a0081f0fd46caf8d37405174f261765abcd44d7a99910533fcc05edde3de10f9b22820cc9910c7dee2b01e761692a0a322f2608 languageName: node linkType: hard -"metro-core@npm:0.83.2, metro-core@npm:^0.83.1": - version: 0.83.2 - resolution: "metro-core@npm:0.83.2" +"metro-core@npm:0.83.3, metro-core@npm:^0.83.1": + version: 0.83.3 + resolution: "metro-core@npm:0.83.3" dependencies: flow-enums-runtime: ^0.0.6 lodash.throttle: ^4.1.1 - metro-resolver: 0.83.2 - checksum: 58ce33dcfe0b5803aadd1681b37bf51b481582437738afed701b124da77bf476e082124da8c2b60161f15290043ecc8086c51fdc44f241fcc3bb9d7887fffd0e + metro-resolver: 0.83.3 + checksum: d06871313310cd718094ecbae805bcacea3f325340f6dff3c5044b62457c4690dd729cdb938349bdd3c41efa6f28032ae07696467ef006d5509fec9045c1966f languageName: node linkType: hard -"metro-file-map@npm:0.83.2": - version: 0.83.2 - resolution: "metro-file-map@npm:0.83.2" +"metro-file-map@npm:0.83.3": + version: 0.83.3 + resolution: "metro-file-map@npm:0.83.3" dependencies: debug: ^4.4.0 fb-watchman: ^2.0.0 @@ -8883,76 +8886,76 @@ __metadata: micromatch: ^4.0.4 nullthrows: ^1.1.1 walker: ^1.0.7 - checksum: 16ea37fa9c252686aafd1bc5fc5d4791273ff1be606303582035d52865b2ff16f1f13fc0a867c5b2385479563f748e0ee96b6fb83d16e739e413e60c0e22a079 + checksum: 0dea599206e93b6e8628be2aa98452d4dae16e805b810759ec8b50cebcd83f2d053f7e5865196d464f3793f86b3b5003830c6713f91bf62fa406a4af7c93a776 languageName: node linkType: hard -"metro-minify-terser@npm:0.83.2": - version: 0.83.2 - resolution: "metro-minify-terser@npm:0.83.2" +"metro-minify-terser@npm:0.83.3": + version: 0.83.3 + resolution: "metro-minify-terser@npm:0.83.3" dependencies: flow-enums-runtime: ^0.0.6 terser: ^5.15.0 - checksum: ee164bdd3ddf797e1b0f9fd71960b662b40fc3abead77521b1e1435291d38cc151442348362d6afee0596d52fcff48cc6a055a04a7928905e9557968e05293ac + checksum: 1de88b70b7c903147807baa46497491a87600594fd0868b6538bbb9d7785242cabfbe8bccf36cc2285d0e17be72445b512d00c496952a159572545f3e6bcb199 languageName: node linkType: hard -"metro-resolver@npm:0.83.2": - version: 0.83.2 - resolution: "metro-resolver@npm:0.83.2" +"metro-resolver@npm:0.83.3": + version: 0.83.3 + resolution: "metro-resolver@npm:0.83.3" dependencies: flow-enums-runtime: ^0.0.6 - checksum: f3b97ac389c7cbf624db1558a07e48d3e8be5f581c010a3a1d26f8a5ef95ab9ba14bb959d4102da4e637eb66643f178499348e60d06f6cce7fa3068ecb5fd3d6 + checksum: de2ae5ced6239b004a97712f98934c6e830870d11614e2dba48250930214581f0746df8a4f0f1cb71060fe21c2cf919d3359106ad4f375c2500ba08e10922896 languageName: node linkType: hard -"metro-runtime@npm:0.83.2, metro-runtime@npm:^0.83.1": - version: 0.83.2 - resolution: "metro-runtime@npm:0.83.2" +"metro-runtime@npm:0.83.3, metro-runtime@npm:^0.83.1": + version: 0.83.3 + resolution: "metro-runtime@npm:0.83.3" dependencies: "@babel/runtime": ^7.25.0 flow-enums-runtime: ^0.0.6 - checksum: 1868bffbb7dc8a9c69a2d480d7d8e1019548f68522f9368f5513aa9325c39ed9dfaae052cfe0209cb03bc70a908e08d72eb852e1cff56bc6f32a73c8dc92a5ff + checksum: dcbdc5502020d1e20cee1a3a8019323ab2f3ca2aa2d6ddb2b7a2b8547835a20b84fe4afc23c397f788584e108c70411db93df2f61322b44a4f0f119275052d03 languageName: node linkType: hard -"metro-source-map@npm:0.83.2, metro-source-map@npm:^0.83.1": - version: 0.83.2 - resolution: "metro-source-map@npm:0.83.2" +"metro-source-map@npm:0.83.3, metro-source-map@npm:^0.83.1": + version: 0.83.3 + resolution: "metro-source-map@npm:0.83.3" dependencies: "@babel/traverse": ^7.25.3 "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3" "@babel/types": ^7.25.2 flow-enums-runtime: ^0.0.6 invariant: ^2.2.4 - metro-symbolicate: 0.83.2 + metro-symbolicate: 0.83.3 nullthrows: ^1.1.1 - ob1: 0.83.2 + ob1: 0.83.3 source-map: ^0.5.6 vlq: ^1.0.0 - checksum: 50dc6eebc0a6d36c8a93acc57cc0311cbf0485a0b1fdb81c265c8950afefcf16b7cfb56e2dbb211a04bd0fa59b5a0369cd2e7499ea489ce6f98719aa88b2d097 + checksum: 5bf3b7a1561bc1f0ad6ab3b7b550d4b4581da31964a7f218727a3201576912076c909a2e50fba4dd3c649d79312324dec683a37228f4559811c37b69ecca8831 languageName: node linkType: hard -"metro-symbolicate@npm:0.83.2": - version: 0.83.2 - resolution: "metro-symbolicate@npm:0.83.2" +"metro-symbolicate@npm:0.83.3": + version: 0.83.3 + resolution: "metro-symbolicate@npm:0.83.3" dependencies: flow-enums-runtime: ^0.0.6 invariant: ^2.2.4 - metro-source-map: 0.83.2 + metro-source-map: 0.83.3 nullthrows: ^1.1.1 source-map: ^0.5.6 vlq: ^1.0.0 bin: metro-symbolicate: src/index.js - checksum: fdf5a0d35dfad39d9cda8beda85f09f26e4ae662cbd05623492574299dde3660561502f54396cce3b25818a9079219d1fdbd217c5000619b8d14d6357739a59c + checksum: 943cc2456d56ae2ed8369495c18966d91feff636b37909b5225ffb8ce2a50eba8fbedf116f3bea3059d431ebc621c9c9af8a8bfd181b0cd1fece051507e10ffd languageName: node linkType: hard -"metro-transform-plugins@npm:0.83.2": - version: 0.83.2 - resolution: "metro-transform-plugins@npm:0.83.2" +"metro-transform-plugins@npm:0.83.3": + version: 0.83.3 + resolution: "metro-transform-plugins@npm:0.83.3" dependencies: "@babel/core": ^7.25.2 "@babel/generator": ^7.25.0 @@ -8960,34 +8963,34 @@ __metadata: "@babel/traverse": ^7.25.3 flow-enums-runtime: ^0.0.6 nullthrows: ^1.1.1 - checksum: 455cf6811172351ed61ae498f2fed20a1830b23a47d591066bcd1bf52f9b0cc7d0daf8c97ffedc0e0b1e5a7d2da65d16fac869a3c09d0e84ac4ffa5df0777ccb + checksum: 6f92b9dfa53bdb63e79038bbd4d68791379ab26cf874679e64563618c578eeed3a828795debf8076ffd518431dff53191990784fb619046bcc03fff114b0cb21 languageName: node linkType: hard -"metro-transform-worker@npm:0.83.2": - version: 0.83.2 - resolution: "metro-transform-worker@npm:0.83.2" +"metro-transform-worker@npm:0.83.3": + version: 0.83.3 + resolution: "metro-transform-worker@npm:0.83.3" dependencies: "@babel/core": ^7.25.2 "@babel/generator": ^7.25.0 "@babel/parser": ^7.25.3 "@babel/types": ^7.25.2 flow-enums-runtime: ^0.0.6 - metro: 0.83.2 - metro-babel-transformer: 0.83.2 - metro-cache: 0.83.2 - metro-cache-key: 0.83.2 - metro-minify-terser: 0.83.2 - metro-source-map: 0.83.2 - metro-transform-plugins: 0.83.2 + metro: 0.83.3 + metro-babel-transformer: 0.83.3 + metro-cache: 0.83.3 + metro-cache-key: 0.83.3 + metro-minify-terser: 0.83.3 + metro-source-map: 0.83.3 + metro-transform-plugins: 0.83.3 nullthrows: ^1.1.1 - checksum: 955e4f8f190151e62c75167168d85c4cde2cfb5121e72f9f7459ba371f3ce41d131ec3bb6c2d0097c036f66a38183ecdd383375648c29736c2345c45f6f4d4e9 + checksum: fcb25ebc1ce703d830ef60c9af87325f996af4c3946325ab957b65ca59d12d181fe6c527c9ba1f932cd954d23a400052293117fe56f9a2727dfbc0a118e7bb27 languageName: node linkType: hard -"metro@npm:0.83.2, metro@npm:^0.83.1": - version: 0.83.2 - resolution: "metro@npm:0.83.2" +"metro@npm:0.83.3, metro@npm:^0.83.1": + version: 0.83.3 + resolution: "metro@npm:0.83.3" dependencies: "@babel/code-frame": ^7.24.7 "@babel/core": ^7.25.2 @@ -9010,18 +9013,18 @@ __metadata: jest-worker: ^29.7.0 jsc-safe-url: ^0.2.2 lodash.throttle: ^4.1.1 - metro-babel-transformer: 0.83.2 - metro-cache: 0.83.2 - metro-cache-key: 0.83.2 - metro-config: 0.83.2 - metro-core: 0.83.2 - metro-file-map: 0.83.2 - metro-resolver: 0.83.2 - metro-runtime: 0.83.2 - metro-source-map: 0.83.2 - metro-symbolicate: 0.83.2 - metro-transform-plugins: 0.83.2 - metro-transform-worker: 0.83.2 + metro-babel-transformer: 0.83.3 + metro-cache: 0.83.3 + metro-cache-key: 0.83.3 + metro-config: 0.83.3 + metro-core: 0.83.3 + metro-file-map: 0.83.3 + metro-resolver: 0.83.3 + metro-runtime: 0.83.3 + metro-source-map: 0.83.3 + metro-symbolicate: 0.83.3 + metro-transform-plugins: 0.83.3 + metro-transform-worker: 0.83.3 mime-types: ^2.1.27 nullthrows: ^1.1.1 serialize-error: ^2.1.0 @@ -9031,7 +9034,7 @@ __metadata: yargs: ^17.6.2 bin: metro: src/cli.js - checksum: 0f2ddde7644f58f1f7580e665e4ea627a8329e73a5c595892cae7d91f5568e0c70e6f8d3cec85db35db5171991a42e265e7615091ef7b78b4a49f321be6da785 + checksum: 306d8c06b5a1a45e18df6e41f494bbc8b439700985429284eea7b3c3c82108e3c3795d859a8ab3ed7a85793d64e3160519be9aa84c6418d6ed37bd5ae4500b57 languageName: node linkType: hard @@ -9349,9 +9352,9 @@ __metadata: linkType: hard "node-releases@npm:^2.0.21": - version: 2.0.21 - resolution: "node-releases@npm:2.0.21" - checksum: 191f8245e18272971650eb45151c5891313bca27507a8f634085bd8c98a9cb9492686ef6182176866ceebff049646ef6cd5fb5ca46d5b5ca00ce2c69185d84c4 + version: 2.0.23 + resolution: "node-releases@npm:2.0.23" + checksum: dc3194ffdf04975f8525a5e175c03f5a95cecd7607b6b0e80d28aaa03900706d920722b5f2ae2e8e28e029e6ae75f0d0f7eae87e8ee2a363c704785e3118f13d languageName: node linkType: hard @@ -9431,12 +9434,12 @@ __metadata: languageName: node linkType: hard -"ob1@npm:0.83.2": - version: 0.83.2 - resolution: "ob1@npm:0.83.2" +"ob1@npm:0.83.3": + version: 0.83.3 + resolution: "ob1@npm:0.83.3" dependencies: flow-enums-runtime: ^0.0.6 - checksum: 8eb482589b66cf46600d1231c2ea50a365f47ee5db0274795d1d3f5c43112e255b931a41ce1ef8a220f31b4fb985fb269c6a54bf7e9719f90dac3f4001a89a6c + checksum: 20dfe91d48d0cadd97159cfd53f5abdca435b55d58b1f562e0687485e8f44f8a95e8ab3c835badd13d0d8c01e3d7b14d639a316aa4bf82841ac78b49611d4e5c languageName: node linkType: hard @@ -11724,22 +11727,22 @@ __metadata: linkType: hard "typescript@npm:^5.9.2": - version: 5.9.2 - resolution: "typescript@npm:5.9.2" + version: 5.9.3 + resolution: "typescript@npm:5.9.3" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: f619cf6773cfe31409279711afd68cdf0859780006c50bc2a7a0c3227f85dea89a3b97248846326f3a17dad72ea90ec27cf61a8387772c680b2252fd02d8497b + checksum: 0d0ffb84f2cd072c3e164c79a2e5a1a1f4f168e84cb2882ff8967b92afe1def6c2a91f6838fb58b168428f9458c57a2ba06a6737711fdd87a256bbe83e9a217f languageName: node linkType: hard "typescript@patch:typescript@^5.9.2#~builtin": - version: 5.9.2 - resolution: "typescript@patch:typescript@npm%3A5.9.2#~builtin::version=5.9.2&hash=14eedb" + version: 5.9.3 + resolution: "typescript@patch:typescript@npm%3A5.9.3#~builtin::version=5.9.3&hash=14eedb" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: e42a701947325500008334622321a6ad073f842f5e7d5e7b588a6346b31fdf51d56082b9ce5cef24312ecd3e48d6c0d4d44da7555f65e2feec18cf62ec540385 + checksum: 8bb8d86819ac86a498eada254cad7fb69c5f74778506c700c2a712daeaff21d3a6f51fd0d534fe16903cb010d1b74f89437a3d02d4d0ff5ca2ba9a4660de8497 languageName: node linkType: hard @@ -11771,10 +11774,10 @@ __metadata: languageName: node linkType: hard -"undici-types@npm:~7.12.0": - version: 7.12.0 - resolution: "undici-types@npm:7.12.0" - checksum: 4ad2770b92835757eee6416e8518972d83fc77286c11af81d368a55578d9e4f7ab1b8a3b13c304b0e25a400583e66f3c58464a051f8b5c801ab5d092da13903e +"undici-types@npm:~7.13.0": + version: 7.13.0 + resolution: "undici-types@npm:7.13.0" + checksum: fcb3e1195a36615fce3935eb97c21ebe4dbafe968f831ed00e6f22e8e73c0655b8e3242acc6ba4ff0f3c34e3f3f860f19fbb59c00b261bd4e20b515abbc2de7c languageName: node linkType: hard