diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 7d8c40c..ce11f1e 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -46,8 +46,23 @@ jobs: - name: Install Maestro run: | - curl -fsSL "https://get.maestro.mobile.dev" | bash - echo "$HOME/.maestro/bin" >> $GITHUB_PATH + set -euo pipefail + for attempt in 1 2 3; do + echo "Installing Maestro (attempt $attempt)..." + if curl -fsSL "https://get.maestro.mobile.dev" | bash \ + && test -x "$HOME/.maestro/bin/maestro"; then + break + fi + echo "Maestro install failed on attempt $attempt" + rm -rf "$HOME/.maestro" + if [ "$attempt" -eq 3 ]; then + echo "Maestro install failed after 3 attempts" + exit 1 + fi + sleep $((attempt * 5)) + done + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + "$HOME/.maestro/bin/maestro" --version - name: Cache node_modules uses: actions/cache@v4 @@ -159,8 +174,23 @@ jobs: - name: Install Maestro run: | - curl -fsSL "https://get.maestro.mobile.dev" | bash - echo "$HOME/.maestro/bin" >> $GITHUB_PATH + set -euo pipefail + for attempt in 1 2 3; do + echo "Installing Maestro (attempt $attempt)..." + if curl -fsSL "https://get.maestro.mobile.dev" | bash \ + && test -x "$HOME/.maestro/bin/maestro"; then + break + fi + echo "Maestro install failed on attempt $attempt" + rm -rf "$HOME/.maestro" + if [ "$attempt" -eq 3 ]; then + echo "Maestro install failed after 3 attempts" + exit 1 + fi + sleep $((attempt * 5)) + done + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + "$HOME/.maestro/bin/maestro" --version - name: Cache node_modules uses: actions/cache@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a8d01c..c7e9686 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [9.2.0] - 2026-07-25 + +### Added +- `cancel()` — best-effort abort of the in-flight zip/unzip operation; rejects with `ERR_CANCELLED` (#366) +- Stable cross-platform error codes (`ERR_FILE_NOT_FOUND`, `ERR_WRONG_PASSWORD`, `ERR_UNSAFE_PATH`, …) and JS `ErrorCodes` map (#366) + +### Fixed +- iOS: `cancel()` now interrupts in-flight work. Zip/unzip run on a background serial queue so `cancel()` is not blocked behind the operation it is meant to stop. +- Android: reset the cancel flag when enqueueing work, not when the worker starts — `cancel()` immediately after `unzip`/`zip` is no longer discarded. +- iOS: selective extract checks `fwrite` byte counts and `unzCloseCurrentFile` CRC; wrong-password / not-protected cases map to `ERR_WRONG_PASSWORD` / `ERR_NOT_PASSWORD_PROTECTED` instead of generic `ERR_UNZIP`. +- iOS: `listContents` uses 64-bit zip entry info (`unzGetCurrentFileInfo64`) so entries ≥ 4 GiB report correct sizes. +- iOS: selective extract emits 0% progress on failure (matches Android) instead of a 100% event before reject. +- Android: `unzipWithPassword` selective extract no longer forces UTF-8 charset, matching the full-extract path. + ## [9.1.0] - 2026-07-25 ### Added @@ -7,6 +21,9 @@ - Optional `entries` on `unzip` / `unzipWithPassword` — extract only selected entry paths; directory names include nested children (#365) - Android unit tests for selective-extract entry matching +### Changed +- Android: `'STANDARD'` password encryption writes ZipCrypto (`ZIP_STANDARD`). zip4j's `ZIP_STANDARD_VARIANT_STRONG` is write-only and produced archives that common unzippers (including this library) could not extract. + ## [9.0.2] - 2026-07-22 ### Fixed diff --git a/README.md b/README.md index 61ff683..b587455 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,11 @@ import { unzipWithPassword, listContents, unzipAssets, + cancel, subscribe, isPasswordProtected, getUncompressedSize, + ErrorCodes, DEFAULT_COMPRESSION, NO_COMPRESSION, BEST_SPEED, @@ -90,7 +92,7 @@ Zip with password protection. - `compressionLevel` is ignored on iOS when the source is a file array. **Encryption Types:** -- `'STANDARD'` — Standard ZIP encryption (default) +- `'STANDARD'` — Traditional ZIP encryption / ZipCrypto (default). This is **not** PKWARE Strong Encryption. On Android this writes zip4j `ZIP_STANDARD` so iOS and common unzip tools can decrypt the archive. - `'AES-128'` — AES 128-bit - `'AES-256'` — AES 256-bit @@ -196,6 +198,39 @@ getUncompressedSize(sourcePath) .catch((error) => console.error(error)) ``` +### `cancel(): Promise` + +Cancel the in-flight zip/unzip operation (best-effort). The active operation's promise rejects with `ErrorCodes.CANCELLED` (`ERR_CANCELLED`). + +```js +const unzipPromise = unzip(sourcePath, targetPath) +cancel() +unzipPromise.catch((error) => { + if (error.code === ErrorCodes.CANCELLED) { + console.log('unzip cancelled') + } +}) +``` + +### Error codes + +Native rejections use stable `error.code` values on both platforms: + +| Code | When | +|------|------| +| `ERR_FILE_NOT_FOUND` | Source missing | +| `ERR_INVALID_PATH` | Bad / null path | +| `ERR_INVALID_ARGS` | Empty password, empty entries, etc. | +| `ERR_WRONG_PASSWORD` | Password decrypt failed | +| `ERR_NOT_PASSWORD_PROTECTED` | Password API used on a plain archive | +| `ERR_CORRUPT_ARCHIVE` | Not a zip / truncated / unreadable | +| `ERR_UNSAFE_PATH` | Zip Slip / path traversal | +| `ERR_CANCELLED` | `cancel()` interrupted the operation | +| `ERR_ZIP` / `ERR_UNZIP` | Generic zip/unzip failure | +| `ERR_UNSUPPORTED` | API not available on this platform | + +Also exported as the `ErrorCodes` constant map. + ### `subscribe(callback: ({ progress: number, filePath: string }) => void): EmitterSubscription` Subscribe to progress events. Useful for showing a progress bar. @@ -234,6 +269,7 @@ useEffect(() => { | `unzipWithPassword` | ✅ | ✅ | Optional `entries` for selective extract | | `listContents` | ✅ | ✅ | Charset ignored on iOS | | `unzipAssets` | ❌ | ✅ | Android only | +| `cancel` | ✅ | ✅ | Best-effort mid-operation abort | | `isPasswordProtected` | ✅ | ✅ | — | | `getUncompressedSize` | ✅ | ✅ | Charset ignored on iOS | | Progress Events | ✅ | ✅ | File path empty on iOS for zip | diff --git a/__mocks__/react-native.js b/__mocks__/react-native.js index ac17fb1..22ec107 100644 --- a/__mocks__/react-native.js +++ b/__mocks__/react-native.js @@ -20,6 +20,7 @@ const mockRNZipArchive = { unzipAssets: jest.fn(() => Promise.resolve('/mock/dest')), isPasswordProtected: jest.fn(() => Promise.resolve(true)), getUncompressedSize: jest.fn(() => Promise.resolve(1024)), + cancel: jest.fn(() => Promise.resolve()), addListener: jest.fn(), removeListeners: jest.fn(), }; diff --git a/__tests__/api.test.js b/__tests__/api.test.js index d08a07b..62368f4 100644 --- a/__tests__/api.test.js +++ b/__tests__/api.test.js @@ -7,7 +7,9 @@ const { unzipAssets, isPasswordProtected, getUncompressedSize, + cancel, subscribe, + ErrorCodes, DEFAULT_COMPRESSION, NO_COMPRESSION, BEST_SPEED, @@ -30,6 +32,7 @@ describe('react-native-zip-archive API', () => { expect(typeof unzipAssets).toBe('function'); expect(typeof isPasswordProtected).toBe('function'); expect(typeof getUncompressedSize).toBe('function'); + expect(typeof cancel).toBe('function'); expect(typeof subscribe).toBe('function'); }); @@ -40,6 +43,20 @@ describe('react-native-zip-archive API', () => { expect(BEST_COMPRESSION).toBe(9); }); + test('exports stable ErrorCodes', () => { + expect(ErrorCodes.CANCELLED).toBe('ERR_CANCELLED'); + expect(ErrorCodes.WRONG_PASSWORD).toBe('ERR_WRONG_PASSWORD'); + expect(ErrorCodes.UNSAFE_PATH).toBe('ERR_UNSAFE_PATH'); + expect(ErrorCodes.FILE_NOT_FOUND).toBe('ERR_FILE_NOT_FOUND'); + }); + + describe('cancel', () => { + test('cancel calls native module', async () => { + await cancel(); + expect(mockRNZipArchive.cancel).toHaveBeenCalled(); + }); + }); + describe('zip', () => { test('zip resolves with target path', async () => { const result = await zip('/source', '/target.zip'); diff --git a/__tests__/module-integration.test.js b/__tests__/module-integration.test.js index d1b7f87..71b1438 100644 --- a/__tests__/module-integration.test.js +++ b/__tests__/module-integration.test.js @@ -10,6 +10,7 @@ const mockRNZipArchive = { unzipAssets: jest.fn(() => Promise.resolve('/mock/dest')), isPasswordProtected: jest.fn(() => Promise.resolve(true)), getUncompressedSize: jest.fn(() => Promise.resolve(1024)), + cancel: jest.fn(() => Promise.resolve()), addListener: jest.fn(), removeListeners: jest.fn(), }; diff --git a/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java b/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java index df7f276..057495b 100644 --- a/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java +++ b/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java @@ -29,6 +29,7 @@ import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -53,6 +54,7 @@ public class RNZipArchiveModule extends NativeZipArchiveSpec { r -> new Thread(r, "RNZipArchiveWorker") ); private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private final AtomicBoolean cancelled = new AtomicBoolean(false); public RNZipArchiveModule(ReactApplicationContext reactContext) { super(reactContext); @@ -75,13 +77,46 @@ public void onCatalystInstanceDestroy() { invalidate(); } + + private void beginOperation() { + cancelled.set(false); + } + + private void submitWork(Runnable work) { + beginOperation(); + executor.submit(work); + } + + private boolean rejectIfCancelled(Promise promise) { + if (cancelled.get()) { + promise.reject(ZipErrorCodes.CANCELLED, "Operation cancelled"); + return true; + } + return false; + } + + private void rejectMapped(Promise promise, Exception ex, String fallback) { + if (cancelled.get()) { + promise.reject(ZipErrorCodes.CANCELLED, "Operation cancelled"); + return; + } + String message = ex.getMessage() != null ? ex.getMessage() : fallback; + promise.reject(ZipErrorCodes.mapException(ex, fallback), message); + } + + @Override + public void cancel(final Promise promise) { + cancelled.set(true); + promise.resolve(null); + } + @Override public void isPasswordProtected(final String zipFilePath, final Promise promise) { - executor.submit(() -> { + submitWork(() -> { try (net.lingala.zip4j.ZipFile zipFile = new net.lingala.zip4j.ZipFile(zipFilePath)) { promise.resolve(zipFile.isEncrypted()); } catch (Exception ex) { - promise.reject("RNZipArchiveError", String.format("Unable to check for encryption due to: %s", getStackTrace(ex))); + rejectMapped(promise, ex, ZipErrorCodes.UNZIP); } }); } @@ -95,15 +130,15 @@ public void unzipWithPassword(final String zipFilePath, final String destDirecto return; } if (entryList != null) { - extractSelectedEntries(zipFilePath, destDirectory, entryList, "UTF-8", password, promise); + extractSelectedEntries(zipFilePath, destDirectory, entryList, null, password, promise); return; } - executor.submit(() -> { + submitWork(() -> { try (net.lingala.zip4j.ZipFile zipFile = new net.lingala.zip4j.ZipFile(zipFilePath)) { if (zipFile.isEncrypted()) { zipFile.setPassword(password.toCharArray()); } else { - promise.reject("RNZipArchiveError", String.format("Zip file: %s is not password protected", zipFilePath)); + promise.reject(ZipErrorCodes.NOT_PASSWORD_PROTECTED, String.format("Zip file: %s is not password protected", zipFilePath)); return; } @@ -113,6 +148,9 @@ public void unzipWithPassword(final String zipFilePath, final String destDirecto updateProgress(0, 1, zipFilePath); // force 0% for (FileHeader fileHeader : fileHeaderList) { + if (rejectIfCancelled(promise)) { + return; + } ZipSecurity.validateExtractPath(destDirectory, fileHeader.getFileName()); if (!fileHeader.isDirectory()) { @@ -125,7 +163,7 @@ public void unzipWithPassword(final String zipFilePath, final String destDirecto promise.resolve(destDirectory); } catch (Exception ex) { updateProgress(0, 1, zipFilePath); // force 0% - promise.reject("RNZipArchiveError", String.format("Failed to unzip file, due to: %s", getStackTrace(ex))); + rejectMapped(promise, ex, ZipErrorCodes.UNZIP); } }); } @@ -141,14 +179,14 @@ public void unzip(final String zipFilePath, final String destDirectory, final St extractSelectedEntries(zipFilePath, destDirectory, entryList, charset, null, promise); return; } - executor.submit(() -> { + submitWork(() -> { if (zipFilePath == null) { - promise.reject("RNZipArchiveError", "Couldn't open file null. "); + promise.reject(ZipErrorCodes.INVALID_PATH, "Couldn't open file null."); return; } File zipFileRef = new File(zipFilePath); if (!zipFileRef.exists()) { - promise.reject("RNZipArchiveError", "Couldn't open file " + zipFilePath + ". "); + promise.reject(ZipErrorCodes.FILE_NOT_FOUND, "Couldn't open file " + zipFilePath + "."); return; } @@ -165,6 +203,9 @@ public void unzip(final String zipFilePath, final String destDirectory, final St updateProgress(0, 1, zipFilePath); // force 0% for (FileHeader fileHeader : fileHeaderList) { + if (rejectIfCancelled(promise)) { + return; + } ZipSecurity.validateExtractPath(destDirectory, fileHeader.getFileName()); if (!fileHeader.isDirectory()) { @@ -178,7 +219,7 @@ public void unzip(final String zipFilePath, final String destDirectory, final St promise.resolve(destDirectory); } catch (Exception ex) { updateProgress(0, 1, zipFilePath); // force 0% - promise.reject("RNZipArchiveError", "Failed to extract file " + ex.getLocalizedMessage()); + rejectMapped(promise, ex, ZipErrorCodes.UNZIP); } }); } @@ -200,26 +241,26 @@ private List optionalEntriesList(ReadableArray entries, Promise promise) try { List entryList = readableArrayToStringList(entries); if (entryList.isEmpty()) { - promise.reject("RNZipArchiveError", "entries must be a non-empty array"); + promise.reject(ZipErrorCodes.INVALID_ARGS, "entries must be a non-empty array"); return REJECTED_ENTRIES; } return entryList; } catch (IllegalArgumentException ex) { - promise.reject("RNZipArchiveError", "Invalid entries array: " + ex.getMessage()); + promise.reject(ZipErrorCodes.INVALID_ARGS, "Invalid entries array: " + ex.getMessage()); return REJECTED_ENTRIES; } } @Override public void listContents(final String zipFilePath, final String charset, final Promise promise) { - executor.submit(() -> { + submitWork(() -> { if (zipFilePath == null) { - promise.reject("RNZipArchiveError", "Couldn't open file null. "); + promise.reject(ZipErrorCodes.INVALID_PATH, "Couldn't open file null."); return; } File zipFileRef = new File(zipFilePath); if (!zipFileRef.exists()) { - promise.reject("RNZipArchiveError", "Couldn't open file " + zipFilePath + ". "); + promise.reject(ZipErrorCodes.FILE_NOT_FOUND, "Couldn't open file " + zipFilePath + "."); return; } @@ -236,7 +277,7 @@ public void listContents(final String zipFilePath, final String charset, final P } promise.resolve(entries); } catch (Exception ex) { - promise.reject("RNZipArchiveError", "Failed to list contents: " + ex.getLocalizedMessage()); + rejectMapped(promise, ex, ZipErrorCodes.UNZIP); } }); } @@ -244,25 +285,25 @@ public void listContents(final String zipFilePath, final String charset, final P private void extractSelectedEntries(final String zipFilePath, final String destDirectory, final List wantedEntries, final String charset, final String password, final Promise promise) { - executor.submit(() -> { + submitWork(() -> { if (zipFilePath == null) { - promise.reject("RNZipArchiveError", "Couldn't open file null. "); + promise.reject(ZipErrorCodes.INVALID_PATH, "Couldn't open file null."); return; } File zipFileRef = new File(zipFilePath); if (!zipFileRef.exists()) { - promise.reject("RNZipArchiveError", "Couldn't open file " + zipFilePath + ". "); + promise.reject(ZipErrorCodes.FILE_NOT_FOUND, "Couldn't open file " + zipFilePath + "."); return; } if (wantedEntries == null || wantedEntries.isEmpty()) { - promise.reject("RNZipArchiveError", "entries must be a non-empty array"); + promise.reject(ZipErrorCodes.INVALID_ARGS, "entries must be a non-empty array"); return; } try (net.lingala.zip4j.ZipFile zipFile = openZipFile(zipFilePath, charset)) { if (password != null) { if (!zipFile.isEncrypted()) { - promise.reject("RNZipArchiveError", + promise.reject(ZipErrorCodes.NOT_PASSWORD_PROTECTED, String.format("Zip file: %s is not password protected", zipFilePath)); return; } @@ -283,7 +324,7 @@ private void extractSelectedEntries(final String zipFilePath, final String destD } if (selected.isEmpty()) { - promise.reject("RNZipArchiveError", "None of the requested entries were found in the archive"); + promise.reject(ZipErrorCodes.INVALID_ARGS, "None of the requested entries were found in the archive"); return; } @@ -292,6 +333,9 @@ private void extractSelectedEntries(final String zipFilePath, final String destD updateProgress(0, 1, zipFilePath); for (FileHeader fileHeader : selected) { + if (rejectIfCancelled(promise)) { + return; + } ZipSecurity.validateExtractPath(destDirectory, fileHeader.getFileName()); if (!fileHeader.isDirectory()) { @@ -309,7 +353,7 @@ private void extractSelectedEntries(final String zipFilePath, final String destD promise.resolve(destDirectory); } catch (Exception ex) { updateProgress(0, 1, zipFilePath); - promise.reject("RNZipArchiveError", "Failed to extract selected files: " + ex.getLocalizedMessage()); + rejectMapped(promise, ex, ZipErrorCodes.UNZIP); } }); } @@ -362,7 +406,7 @@ private static String stripTrailingSlash(String path) { */ @Override public void unzipAssets(final String assetsPath, final String destDirectory, final Promise promise) { - executor.submit(() -> { + submitWork(() -> { InputStream assetsInputStream = null; AssetFileDescriptor fileDescriptor = null; long compressedSize; @@ -394,7 +438,7 @@ public void unzipAssets(final String assetsPath, final String destDirectory, fin } if (assetsInputStream == null) { - promise.reject("RNZipArchiveError", String.format("Asset file `%s` could not be opened", assetsPath)); + promise.reject(ZipErrorCodes.FILE_NOT_FOUND, String.format("Asset file `%s` could not be opened", assetsPath)); return; } @@ -412,6 +456,9 @@ public void unzipAssets(final String assetsPath, final String destDirectory, fin updateProgress(extractedBytes, compressedSize, assetsPath); // force 0% while ((entry = zipIn.getNextEntry()) != null) { + if (rejectIfCancelled(promise)) { + return; + } if (entry.isDirectory()) continue; Log.i("rnziparchive", "Extracting: " + entry.getName()); @@ -445,7 +492,7 @@ public void unzipAssets(final String assetsPath, final String destDirectory, fin } catch (Exception ex) { Log.e(TAG, "Failed to extract asset: " + assetsPath, ex); updateProgress(0, 1, assetsPath); // force 0% - promise.reject("RNZipArchiveError", ex.getMessage()); + rejectMapped(promise, ex, ZipErrorCodes.UNZIP); } finally { if (fileDescriptor != null) { try { @@ -469,7 +516,7 @@ public void zipFiles(final ReadableArray files, final String destDirectory, fina try { fileList = readableArrayToStringList(files); } catch (IllegalArgumentException ex) { - promise.reject("RNZipArchiveError", "Invalid files array: " + ex.getMessage()); + promise.reject(ZipErrorCodes.INVALID_ARGS, "Invalid files array: " + ex.getMessage()); return; } zip(fileList, destDirectory, compressionLevel, promise); @@ -489,7 +536,7 @@ public void zipFilesWithPassword(final ReadableArray files, final String destFil try { fileList = readableArrayToStringList(files); } catch (IllegalArgumentException ex) { - promise.reject("RNZipArchiveError", "Invalid files array: " + ex.getMessage()); + promise.reject(ZipErrorCodes.INVALID_ARGS, "Invalid files array: " + ex.getMessage()); return; } zipWithPassword(fileList, destFile, password, encryptionMethod, compressionLevel, promise); @@ -509,7 +556,7 @@ private void zipWithPassword(final List filesOrDirectory, final String d ZipParameters parameters = buildZipParameters(compressionLevel); if (password == null || password.isEmpty()) { - promise.reject("RNZipArchiveError", "Password is empty"); + promise.reject(ZipErrorCodes.INVALID_ARGS, "Password is empty"); return; } @@ -537,7 +584,7 @@ private void zipWithPassword(final List filesOrDirectory, final String d processZip(filesOrDirectory, destFile, parameters, promise, password.toCharArray()); } catch (Exception ex) { - promise.reject("RNZipArchiveError", ex.getMessage()); + rejectMapped(promise, ex, ZipErrorCodes.ZIP); } } @@ -546,12 +593,12 @@ private void zip(final List filesOrDirectory, final String destFile, fin ZipParameters parameters = buildZipParameters(compressionLevel); processZip(filesOrDirectory, destFile, parameters, promise, null); } catch (Exception ex) { - promise.reject("RNZipArchiveError", ex.getMessage()); + rejectMapped(promise, ex, ZipErrorCodes.ZIP); } } private void processZip(final List entries, final String destFile, final ZipParameters parameters, final Promise promise, final char[] password) { - executor.submit(() -> { + submitWork(() -> { try (net.lingala.zip4j.ZipFile zipFile = password != null ? new net.lingala.zip4j.ZipFile(destFile, password) : new net.lingala.zip4j.ZipFile(destFile)) { @@ -562,6 +609,9 @@ private void processZip(final List entries, final String destFile, final int fileCounter = 0; for (int i = 0; i < entries.size(); i++) { + if (rejectIfCancelled(promise)) { + return; + } File f = new File(entries.get(i)); if (f.exists()) { @@ -571,6 +621,9 @@ private void processZip(final List entries, final String destFile, final totalFiles += files.size(); for (int j = 0; j < files.size(); j++) { + if (rejectIfCancelled(promise)) { + return; + } if (files.get(j).isDirectory()) { zipFile.addFolder(files.get(j), parameters); } else { @@ -587,12 +640,12 @@ private void processZip(final List entries, final String destFile, final updateProgress(fileCounter, totalFiles, destFile); } } else { - promise.reject("RNZipArchiveError", "File or folder does not exist"); + promise.reject(ZipErrorCodes.FILE_NOT_FOUND, "File or folder does not exist"); return; } } } catch (Exception ex) { - promise.reject("RNZipArchiveError", ex.getMessage()); + rejectMapped(promise, ex, ZipErrorCodes.ZIP); return; } updateProgress(1, 1, destFile); // force 100% @@ -602,16 +655,16 @@ private void processZip(final List entries, final String destFile, final @Override public void getUncompressedSize(String zipFilePath, String charset, final Promise promise) { - executor.submit(() -> { + submitWork(() -> { try { long totalSize = getUncompressedSize(zipFilePath, charset); if (totalSize == -1) { - promise.reject("RNZipArchiveError", "Failed to get uncompressed size"); + promise.reject(ZipErrorCodes.CORRUPT_ARCHIVE, "Failed to get uncompressed size"); } else { promise.resolve((double) totalSize); } } catch (Exception e) { - promise.reject("RNZipArchiveError", "Failed to get uncompressed size: " + e.getMessage()); + rejectMapped(promise, e, ZipErrorCodes.UNZIP); } }); } diff --git a/android/src/main/java/com/rnziparchive/ZipErrorCodes.java b/android/src/main/java/com/rnziparchive/ZipErrorCodes.java new file mode 100644 index 0000000..80654ee --- /dev/null +++ b/android/src/main/java/com/rnziparchive/ZipErrorCodes.java @@ -0,0 +1,51 @@ +package com.rnziparchive; + +import net.lingala.zip4j.exception.ZipException; + +/** + * Stable promise rejection codes shared with the JS API and iOS implementation. + */ +public final class ZipErrorCodes { + public static final String FILE_NOT_FOUND = "ERR_FILE_NOT_FOUND"; + public static final String INVALID_PATH = "ERR_INVALID_PATH"; + public static final String INVALID_ARGS = "ERR_INVALID_ARGS"; + public static final String WRONG_PASSWORD = "ERR_WRONG_PASSWORD"; + public static final String NOT_PASSWORD_PROTECTED = "ERR_NOT_PASSWORD_PROTECTED"; + public static final String CORRUPT_ARCHIVE = "ERR_CORRUPT_ARCHIVE"; + public static final String UNSAFE_PATH = "ERR_UNSAFE_PATH"; + public static final String CANCELLED = "ERR_CANCELLED"; + public static final String BUSY = "ERR_BUSY"; + public static final String ZIP = "ERR_ZIP"; + public static final String UNZIP = "ERR_UNZIP"; + public static final String UNSUPPORTED = "ERR_UNSUPPORTED"; + + private ZipErrorCodes() { + } + + public static String mapException(Exception ex, String fallback) { + if (ex instanceof SecurityException) { + return UNSAFE_PATH; + } + if (ex instanceof ZipException) { + ZipException zipException = (ZipException) ex; + if (zipException.getType() == ZipException.Type.WRONG_PASSWORD) { + return WRONG_PASSWORD; + } + String message = zipException.getMessage(); + if (message != null) { + String lower = message.toLowerCase(); + if (lower.contains("not a zip") || lower.contains("corrupt") + || lower.contains("invalid") || lower.contains("malformed")) { + return CORRUPT_ARCHIVE; + } + if (lower.contains("password")) { + return WRONG_PASSWORD; + } + } + } + if (ex instanceof java.io.FileNotFoundException) { + return FILE_NOT_FOUND; + } + return fallback; + } +} diff --git a/android/src/paper/java/com/rnziparchive/NativeZipArchiveSpec.java b/android/src/paper/java/com/rnziparchive/NativeZipArchiveSpec.java index edea453..1572821 100644 --- a/android/src/paper/java/com/rnziparchive/NativeZipArchiveSpec.java +++ b/android/src/paper/java/com/rnziparchive/NativeZipArchiveSpec.java @@ -74,6 +74,10 @@ public NativeZipArchiveSpec(ReactApplicationContext reactContext) { @DoNotStrip public abstract void unzipAssets(String source, String target, Promise promise); + @ReactMethod + @DoNotStrip + public abstract void cancel(Promise promise); + @ReactMethod @DoNotStrip public abstract void addListener(String eventName); diff --git a/android/src/test/java/com/rnziparchive/ZipErrorCodesTest.java b/android/src/test/java/com/rnziparchive/ZipErrorCodesTest.java new file mode 100644 index 0000000..ca201c7 --- /dev/null +++ b/android/src/test/java/com/rnziparchive/ZipErrorCodesTest.java @@ -0,0 +1,30 @@ +package com.rnziparchive; + +import static org.junit.Assert.assertEquals; + +import net.lingala.zip4j.exception.ZipException; + +import org.junit.Test; + +public class ZipErrorCodesTest { + + @Test + public void mapsSecurityExceptionToUnsafePath() { + assertEquals( + ZipErrorCodes.UNSAFE_PATH, + ZipErrorCodes.mapException(new SecurityException("traversal"), ZipErrorCodes.UNZIP)); + } + + @Test + public void mapsWrongPasswordZipException() { + ZipException ex = new ZipException("bad password", ZipException.Type.WRONG_PASSWORD); + assertEquals(ZipErrorCodes.WRONG_PASSWORD, ZipErrorCodes.mapException(ex, ZipErrorCodes.UNZIP)); + } + + @Test + public void mapsUnknownExceptionToFallback() { + assertEquals( + ZipErrorCodes.ZIP, + ZipErrorCodes.mapException(new RuntimeException("boom"), ZipErrorCodes.ZIP)); + } +} diff --git a/index.d.ts b/index.d.ts index 13c09af..85653d5 100644 --- a/index.d.ts +++ b/index.d.ts @@ -7,6 +7,21 @@ declare module "react-native-zip-archive" { AES_256 = "AES-256", } + export const ErrorCodes: { + FILE_NOT_FOUND: "ERR_FILE_NOT_FOUND"; + INVALID_PATH: "ERR_INVALID_PATH"; + INVALID_ARGS: "ERR_INVALID_ARGS"; + WRONG_PASSWORD: "ERR_WRONG_PASSWORD"; + NOT_PASSWORD_PROTECTED: "ERR_NOT_PASSWORD_PROTECTED"; + CORRUPT_ARCHIVE: "ERR_CORRUPT_ARCHIVE"; + UNSAFE_PATH: "ERR_UNSAFE_PATH"; + CANCELLED: "ERR_CANCELLED"; + BUSY: "ERR_BUSY"; + ZIP: "ERR_ZIP"; + UNZIP: "ERR_UNZIP"; + UNSUPPORTED: "ERR_UNSUPPORTED"; + }; + export const DEFAULT_COMPRESSION: number; export const NO_COMPRESSION: number; export const BEST_SPEED: number; @@ -69,4 +84,6 @@ declare module "react-native-zip-archive" { ): NativeEventSubscription; export function getUncompressedSize(source: string, charset?: string): Promise; + + export function cancel(): Promise; } diff --git a/index.js b/index.js index f0ad7b8..831cab2 100644 --- a/index.js +++ b/index.js @@ -37,6 +37,21 @@ export const EncryptionMethods = { AES_256: "AES-256", }; +export const ErrorCodes = { + FILE_NOT_FOUND: "ERR_FILE_NOT_FOUND", + INVALID_PATH: "ERR_INVALID_PATH", + INVALID_ARGS: "ERR_INVALID_ARGS", + WRONG_PASSWORD: "ERR_WRONG_PASSWORD", + NOT_PASSWORD_PROTECTED: "ERR_NOT_PASSWORD_PROTECTED", + CORRUPT_ARCHIVE: "ERR_CORRUPT_ARCHIVE", + UNSAFE_PATH: "ERR_UNSAFE_PATH", + CANCELLED: "ERR_CANCELLED", + BUSY: "ERR_BUSY", + ZIP: "ERR_ZIP", + UNZIP: "ERR_UNZIP", + UNSUPPORTED: "ERR_UNSUPPORTED", +}; + export const DEFAULT_COMPRESSION = -1; export const NO_COMPRESSION = 0; export const BEST_SPEED = 1; @@ -168,3 +183,7 @@ export const getUncompressedSize = (source, charset = "UTF-8") => { charset ); }; + +export const cancel = () => { + return getRNZipArchive().cancel(); +}; diff --git a/ios/RNZipArchive.h b/ios/RNZipArchive.h index 96bfa20..7dbc932 100644 --- a/ios/RNZipArchive.h +++ b/ios/RNZipArchive.h @@ -20,6 +20,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, copy, nullable) NSString *processedFilePath; @property (nonatomic) float progress; @property (nonatomic, copy, nullable) void (^progressHandler)(NSUInteger entryNumber, NSUInteger total); +@property (atomic) BOOL cancelled; @end diff --git a/ios/RNZipArchive.mm b/ios/RNZipArchive.mm index 8eb2cff..5db22f4 100644 --- a/ios/RNZipArchive.mm +++ b/ios/RNZipArchive.mm @@ -17,6 +17,35 @@ #import "RCTEventDispatcher.h" #endif +static NSString *const kZipErrFileNotFound = @"ERR_FILE_NOT_FOUND"; +static NSString *const kZipErrInvalidPath = @"ERR_INVALID_PATH"; +static NSString *const kZipErrInvalidArgs = @"ERR_INVALID_ARGS"; +static NSString *const kZipErrWrongPassword = @"ERR_WRONG_PASSWORD"; +static NSString *const kZipErrNotPasswordProtected = @"ERR_NOT_PASSWORD_PROTECTED"; +static NSString *const kZipErrCorruptArchive = @"ERR_CORRUPT_ARCHIVE"; +static NSString *const kZipErrUnsafePath = @"ERR_UNSAFE_PATH"; +static NSString *const kZipErrCancelled = @"ERR_CANCELLED"; +static NSString *const kZipErrZip = @"ERR_ZIP"; +static NSString *const kZipErrUnzip = @"ERR_UNZIP"; +static NSString *const kZipErrUnsupported = @"ERR_UNSUPPORTED"; + +@interface RNZipCancelDelegate : NSObject +@property (nonatomic, weak) RNZipArchive *owner; +@end + +@implementation RNZipCancelDelegate +- (BOOL)zipArchiveShouldUnzipFileAtIndex:(NSInteger)fileIndex + totalFiles:(NSInteger)totalFiles + archivePath:(NSString *)archivePath + fileInfo:(unz_file_info)fileInfo { + (void)fileIndex; + (void)totalFiles; + (void)archivePath; + (void)fileInfo; + return self.owner != nil && !self.owner.cancelled; +} +@end + @implementation RNZipArchive { bool hasListeners; @@ -49,11 +78,47 @@ -(void)stopObserving { return @[@"zipArchiveProgressEvent"]; } +- (void)beginOperation { + self.cancelled = NO; +} + +- (dispatch_queue_t)workQueue { + static dispatch_queue_t queue; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + queue = dispatch_queue_create("com.mockingbot.ReactNative.ZipArchiveWorkQueue", DISPATCH_QUEUE_SERIAL); + }); + return queue; +} + +- (void)runAsync:(void (^)(void))block { + dispatch_async([self workQueue], block); +} + +- (BOOL)rejectIfCancelled:(RCTPromiseRejectBlock)reject { + if (self.cancelled) { + reject(kZipErrCancelled, @"Operation cancelled", nil); + return YES; + } + return NO; +} + +- (void)cancel:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + (void)reject; + self.cancelled = YES; + resolve(nil); +} + - (void)isPasswordProtected:(NSString *)file resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - BOOL isPasswordProtected = [SSZipArchive isFilePasswordProtectedAtPath:file]; - resolve([NSNumber numberWithBool:isPasswordProtected]); + (void)reject; + [self beginOperation]; + [self runAsync:^{ + BOOL isPasswordProtected = [SSZipArchive isFilePasswordProtectedAtPath:file]; + resolve([NSNumber numberWithBool:isPasswordProtected]); + }]; } - (void)unzip:(NSString *)from @@ -63,16 +128,19 @@ - (void)unzip:(NSString *)from resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { (void)charset; - if (entries != nil && entries.count > 0) { - [self unzipSelectedEntries:from - destinationPath:destinationPath - entries:entries - password:nil - resolve:resolve - reject:reject]; - return; - } - [self unzipFile:from destinationPath:destinationPath password:nil resolve:resolve reject:reject]; + [self beginOperation]; + [self runAsync:^{ + if (entries != nil && entries.count > 0) { + [self unzipSelectedEntries:from + destinationPath:destinationPath + entries:entries + password:nil + resolve:resolve + reject:reject]; + return; + } + [self unzipFile:from destinationPath:destinationPath password:nil resolve:resolve reject:reject]; + }]; } - (void)unzipWithPassword:(NSString *)from @@ -81,16 +149,19 @@ - (void)unzipWithPassword:(NSString *)from entries:(NSArray *)entries resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - if (entries != nil && entries.count > 0) { - [self unzipSelectedEntries:from - destinationPath:destinationPath - entries:entries - password:password - resolve:resolve - reject:reject]; - return; - } - [self unzipFile:from destinationPath:destinationPath password:password resolve:resolve reject:reject]; + [self beginOperation]; + [self runAsync:^{ + if (entries != nil && entries.count > 0) { + [self unzipSelectedEntries:from + destinationPath:destinationPath + entries:entries + password:password + resolve:resolve + reject:reject]; + return; + } + [self unzipFile:from destinationPath:destinationPath password:password resolve:resolve reject:reject]; + }]; } - (void)listContents:(NSString *)source @@ -98,66 +169,108 @@ - (void)listContents:(NSString *)source resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { (void)charset; // iOS always reads entry names as UTF-8 / Latin-1 fallback - - zipFile zip = unzOpen(source.fileSystemRepresentation); - if (zip == NULL) { - reject(@"list_contents_error", @"failed to open zip file", nil); - return; - } - - NSMutableArray *entries = [NSMutableArray array]; - // Empty archives return a non-UNZ_OK code from unzGoToFirstFile; treat as an empty list. - int ret = unzGoToFirstFile(zip); - while (ret == UNZ_OK) { - unz_file_info fileInfo; - memset(&fileInfo, 0, sizeof(unz_file_info)); - ret = unzGetCurrentFileInfo(zip, &fileInfo, NULL, 0, NULL, 0, NULL, 0); - if (ret != UNZ_OK) { - unzClose(zip); - reject(@"list_contents_error", @"failed to retrieve info for zip entry", nil); + [self beginOperation]; + [self runAsync:^{ + zipFile zip = unzOpen(source.fileSystemRepresentation); + if (zip == NULL) { + reject(kZipErrFileNotFound, @"failed to open zip file", nil); return; } - char *filename = (char *)malloc(fileInfo.size_filename + 1); - if (filename == NULL) { - unzClose(zip); - reject(@"list_contents_error", @"out of memory while listing zip contents", nil); - return; - } - unzGetCurrentFileInfo(zip, &fileInfo, filename, fileInfo.size_filename + 1, NULL, 0, NULL, 0); - filename[fileInfo.size_filename] = '\0'; - - NSString *path = [NSString stringWithUTF8String:filename]; - if (path == nil) { - path = [[NSString alloc] initWithBytes:filename - length:fileInfo.size_filename - encoding:NSISOLatin1StringEncoding]; - } - BOOL isDirectory = NO; - if (fileInfo.size_filename > 0 && - (filename[fileInfo.size_filename - 1] == '/' || filename[fileInfo.size_filename - 1] == '\\')) { - isDirectory = YES; - } - free(filename); + NSMutableArray *entries = [NSMutableArray array]; + // Empty archives return a non-UNZ_OK code from unzGoToFirstFile; treat as an empty list. + int ret = unzGoToFirstFile(zip); + while (ret == UNZ_OK) { + NSString *path = nil; + unsigned long long size = 0; + unsigned long long compressedSize = 0; + BOOL isDirectory = NO; + BOOL isEncrypted = NO; + NSString *entryError = nil; + if (![self readCurrentZipEntry:zip + path:&path + size:&size + compressedSize:&compressedSize + isDirectory:&isDirectory + isEncrypted:&isEncrypted + error:&entryError]) { + unzClose(zip); + reject(kZipErrCorruptArchive, entryError ?: @"failed to retrieve info for zip entry", nil); + return; + } + + [entries addObject:@{ + @"path": path, + @"size": @((double)size), + @"compressedSize": @((double)compressedSize), + @"isDirectory": @(isDirectory), + @"isEncrypted": @(isEncrypted), + }]; - if (path == nil) { - path = @""; + ret = unzGoToNextFile(zip); } - BOOL isEncrypted = (fileInfo.flag & 1) != 0; - [entries addObject:@{ - @"path": path, - @"size": @((double)fileInfo.uncompressed_size), - @"compressedSize": @((double)fileInfo.compressed_size), - @"isDirectory": @(isDirectory), - @"isEncrypted": @(isEncrypted), - }]; + unzClose(zip); + resolve(entries); + }]; +} - ret = unzGoToNextFile(zip); +- (BOOL)readCurrentZipEntry:(unzFile)zip + path:(NSString **)outPath + size:(unsigned long long *)outSize + compressedSize:(unsigned long long *)outCompressedSize + isDirectory:(BOOL *)outIsDirectory + isEncrypted:(BOOL *)outIsEncrypted + error:(NSString **)outError { + unz_file_info64 fileInfo; + memset(&fileInfo, 0, sizeof(fileInfo)); + int ret = unzGetCurrentFileInfo64(zip, &fileInfo, NULL, 0, NULL, 0, NULL, 0); + if (ret != UNZ_OK) { + if (outError != NULL) { + *outError = @"failed to retrieve info for zip entry"; + } + return NO; } - unzClose(zip); - resolve(entries); + size_t nameLen = (size_t)fileInfo.size_filename; + char *filename = (char *)malloc(nameLen + 1); + if (filename == NULL) { + if (outError != NULL) { + *outError = @"out of memory while reading zip entry"; + } + return NO; + } + unzGetCurrentFileInfo64(zip, &fileInfo, filename, nameLen + 1, NULL, 0, NULL, 0); + filename[nameLen] = '\0'; + + NSString *path = [NSString stringWithUTF8String:filename]; + if (path == nil) { + path = [[NSString alloc] initWithBytes:filename + length:nameLen + encoding:NSISOLatin1StringEncoding]; + } + BOOL isDirectory = NO; + if (nameLen > 0 && (filename[nameLen - 1] == '/' || filename[nameLen - 1] == '\\')) { + isDirectory = YES; + } + free(filename); + + if (outPath != NULL) { + *outPath = path ?: @""; + } + if (outSize != NULL) { + *outSize = (unsigned long long)fileInfo.uncompressed_size; + } + if (outCompressedSize != NULL) { + *outCompressedSize = (unsigned long long)fileInfo.compressed_size; + } + if (outIsDirectory != NULL) { + *outIsDirectory = isDirectory; + } + if (outIsEncrypted != NULL) { + *outIsEncrypted = (fileInfo.flag & 1) != 0; + } + return YES; } - (NSString *)normalizedZipPath:(NSString *)path { @@ -206,8 +319,18 @@ - (void)unzipSelectedEntries:(NSString *)from password:(NSString *)password resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + if ([self rejectIfCancelled:reject]) { + return; + } if (entries.count == 0) { - reject(@"unzip_files_error", @"entries must be a non-empty array", nil); + reject(kZipErrInvalidArgs, @"entries must be a non-empty array", nil); + return; + } + + if (password.length > 0 && ![SSZipArchive isFilePasswordProtectedAtPath:from]) { + reject(kZipErrNotPasswordProtected, + [NSString stringWithFormat:@"Zip file: %@ is not password protected", from], + nil); return; } @@ -223,14 +346,14 @@ - (void)unzipSelectedEntries:(NSString *)from attributes:nil error:&dirError]; if (dirError != nil) { - reject(@"unzip_files_error", dirError.localizedDescription, dirError); + reject(kZipErrUnzip, dirError.localizedDescription, dirError); return; } } zipFile zip = unzOpen(from.fileSystemRepresentation); if (zip == NULL) { - reject(@"unzip_files_error", @"failed to open zip file", nil); + reject(kZipErrFileNotFound, @"failed to open zip file", nil); return; } @@ -239,35 +362,29 @@ - (void)unzipSelectedEntries:(NSString *)from NSUInteger matchCount = 0; int ret = unzGoToFirstFile(zip); while (ret == UNZ_OK) { - unz_file_info fileInfo; - memset(&fileInfo, 0, sizeof(unz_file_info)); - ret = unzGetCurrentFileInfo(zip, &fileInfo, NULL, 0, NULL, 0, NULL, 0); - if (ret != UNZ_OK) { - break; - } - char *filenameBuf = (char *)malloc(fileInfo.size_filename + 1); - if (filenameBuf == NULL) { - break; - } - unzGetCurrentFileInfo(zip, &fileInfo, filenameBuf, fileInfo.size_filename + 1, NULL, 0, NULL, 0); - filenameBuf[fileInfo.size_filename] = '\0'; - NSString *path = [NSString stringWithUTF8String:filenameBuf]; - if (path == nil) { - path = [[NSString alloc] initWithBytes:filenameBuf - length:fileInfo.size_filename - encoding:NSISOLatin1StringEncoding]; + if ([self rejectIfCancelled:reject]) { + unzClose(zip); + return; } - free(filenameBuf); - if ([self entry:path matchesSelection:entries]) { + NSString *path = nil; + unsigned long long size = 0; + if ([self readCurrentZipEntry:zip + path:&path + size:&size + compressedSize:NULL + isDirectory:NULL + isEncrypted:NULL + error:NULL] && + [self entry:path matchesSelection:entries]) { matchCount += 1; - totalSize += fileInfo.uncompressed_size; + totalSize += size; } ret = unzGoToNextFile(zip); } if (matchCount == 0) { unzClose(zip); - reject(@"unzip_files_error", @"None of the requested entries were found in the archive", nil); + reject(kZipErrInvalidArgs, @"None of the requested entries were found in the archive", nil); return; } @@ -279,44 +396,33 @@ - (void)unzipSelectedEntries:(NSString *)from unsigned long long extractedBytes = 0; BOOL success = YES; NSError *extractError = nil; + NSString *extractCode = kZipErrUnzip; ret = unzGoToFirstFile(zip); while (ret == UNZ_OK) { - unz_file_info fileInfo; - memset(&fileInfo, 0, sizeof(unz_file_info)); - ret = unzGetCurrentFileInfo(zip, &fileInfo, NULL, 0, NULL, 0, NULL, 0); - if (ret != UNZ_OK) { + NSString *strPath = nil; + unsigned long long uncompressedSize = 0; + BOOL isDirectory = NO; + NSString *entryError = nil; + if (![self readCurrentZipEntry:zip + path:&strPath + size:&uncompressedSize + compressedSize:NULL + isDirectory:&isDirectory + isEncrypted:NULL + error:&entryError]) { success = NO; extractError = [NSError errorWithDomain:@"RNZipArchive" code:-1 - userInfo:@{NSLocalizedDescriptionKey: @"failed to retrieve info for zip entry"}]; + userInfo:@{NSLocalizedDescriptionKey: entryError ?: @"failed to retrieve info for zip entry"}]; break; } - char *filename = (char *)malloc(fileInfo.size_filename + 1); - if (filename == NULL) { - success = NO; - extractError = [NSError errorWithDomain:@"RNZipArchive" - code:-1 - userInfo:@{NSLocalizedDescriptionKey: @"out of memory while extracting"}]; - break; - } - unzGetCurrentFileInfo(zip, &fileInfo, filename, fileInfo.size_filename + 1, NULL, 0, NULL, 0); - filename[fileInfo.size_filename] = '\0'; - - NSString *strPath = [NSString stringWithUTF8String:filename]; - if (strPath == nil) { - strPath = [[NSString alloc] initWithBytes:filename - length:fileInfo.size_filename - encoding:NSISOLatin1StringEncoding]; - } - BOOL isDirectory = NO; - if (fileInfo.size_filename > 0 && - (filename[fileInfo.size_filename - 1] == '/' || filename[fileInfo.size_filename - 1] == '\\')) { - isDirectory = YES; + if ([self rejectIfCancelled:reject]) { + unzClose(zip); + return; } - free(filename); - if (strPath == nil || ![self entry:strPath matchesSelection:entries]) { + if (strPath.length == 0 || ![self entry:strPath matchesSelection:entries]) { ret = unzGoToNextFile(zip); continue; } @@ -328,6 +434,7 @@ - (void)unzipSelectedEntries:(NSString *)from if (![self isSafeExtractPath:strPath intoDestination:destinationPath]) { success = NO; + extractCode = kZipErrUnsafePath; extractError = [NSError errorWithDomain:@"RNZipArchive" code:-1 userInfo:@{NSLocalizedDescriptionKey: @@ -343,7 +450,7 @@ - (void)unzipSelectedEntries:(NSString *)from withIntermediateDirectories:YES attributes:nil error:nil]; - extractedBytes += fileInfo.uncompressed_size; + extractedBytes += uncompressedSize; [self zipArchiveProgressEvent:extractedBytes total:totalSize]; ret = unzGoToNextFile(zip); continue; @@ -362,9 +469,12 @@ - (void)unzipSelectedEntries:(NSString *)from } if (ret != UNZ_OK) { success = NO; + extractCode = password.length > 0 ? kZipErrWrongPassword : kZipErrUnzip; extractError = [NSError errorWithDomain:@"RNZipArchive" code:-1 - userInfo:@{NSLocalizedDescriptionKey: @"failed to open file in zip archive"}]; + userInfo:@{NSLocalizedDescriptionKey: password.length > 0 + ? @"wrong password or failed to open encrypted zip entry" + : @"failed to open file in zip archive"}]; break; } @@ -381,41 +491,64 @@ - (void)unzipSelectedEntries:(NSString *)from unsigned char buffer[4096]; int readBytes; do { + if ([self rejectIfCancelled:reject]) { + fclose(out); + unzCloseCurrentFile(zip); + unzClose(zip); + return; + } readBytes = unzReadCurrentFile(zip, buffer, sizeof(buffer)); if (readBytes < 0) { success = NO; + extractCode = password.length > 0 ? kZipErrWrongPassword : kZipErrUnzip; extractError = [NSError errorWithDomain:@"RNZipArchive" code:-1 userInfo:@{NSLocalizedDescriptionKey: @"failed to read zip entry"}]; break; } if (readBytes > 0) { - fwrite(buffer, 1, readBytes, out); + if (fwrite(buffer, 1, (size_t)readBytes, out) != (size_t)readBytes) { + success = NO; + extractError = [NSError errorWithDomain:@"RNZipArchive" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: @"failed to write extracted file"}]; + break; + } } } while (readBytes > 0); fclose(out); - unzCloseCurrentFile(zip); + int closeRet = unzCloseCurrentFile(zip); + if (success && closeRet != UNZ_OK) { + success = NO; + extractCode = password.length > 0 ? kZipErrWrongPassword : kZipErrCorruptArchive; + extractError = [NSError errorWithDomain:@"RNZipArchive" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: @"failed to extract zip entry (wrong password or corrupt archive)"}]; + } if (!success) { break; } - extractedBytes += fileInfo.uncompressed_size; + extractedBytes += uncompressedSize; [self zipArchiveProgressEvent:extractedBytes total:totalSize]; ret = unzGoToNextFile(zip); } unzClose(zip); - self.progress = 1.0; - [self zipArchiveProgressEvent:1 total:1]; - if (success) { + self.progress = 1.0; + [self zipArchiveProgressEvent:1 total:1]; resolve(destinationPath); + } else if (self.cancelled) { + reject(kZipErrCancelled, @"Operation cancelled", nil); } else { + self.progress = 0.0; + [self zipArchiveProgressEvent:0 total:1]; NSString *message = extractError ? extractError.localizedDescription : @"unable to unzip selected entries"; - reject(@"unzip_files_error", message, extractError); + reject(extractCode, message, extractError); } } @@ -424,6 +557,9 @@ - (void)unzipFile:(NSString *)from password:(NSString *)password resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + if ([self rejectIfCancelled:reject]) { + return; + } self.progress = 0.0; self.processedFilePath = @""; [self zipArchiveProgressEvent:0 total:1]; // force 0% @@ -437,6 +573,8 @@ - (void)unzipFile:(NSString *)from __block unsigned long long extractedBytes = 0; __weak RNZipArchive *weakSelf = self; + RNZipCancelDelegate *cancelDelegate = [RNZipCancelDelegate new]; + cancelDelegate.owner = self; BOOL success = [SSZipArchive unzipFileAtPath:from toDestination:destinationPath @@ -445,7 +583,7 @@ - (void)unzipFile:(NSString *)from nestedZipLevel:0 password:password error:&error - delegate:nil + delegate:cancelDelegate progressHandler:^(NSString *entry, unz_file_info zipInfo, long entryNumber, long total) { RNZipArchive *strongSelf = weakSelf; if (strongSelf == nil) { @@ -464,11 +602,20 @@ - (void)unzipFile:(NSString *)from self.progress = 1.0; [self zipArchiveProgressEvent:1 total:1]; // force 100% - if (success) { + if (self.cancelled) { + reject(kZipErrCancelled, @"Operation cancelled", nil); + } else if (success) { resolve(destinationPath); } else { NSString *errorMessage = error ? [error localizedDescription] : @"unable to unzip"; - reject(@"unzip_error", errorMessage, error); + NSString *code = kZipErrUnzip; + NSString *lower = errorMessage.lowercaseString; + if ([lower containsString:@"password"]) { + code = kZipErrWrongPassword; + } else if ([lower containsString:@"failed to open zip"]) { + code = kZipErrFileNotFound; + } + reject(code, errorMessage, error); } } @@ -477,6 +624,8 @@ - (void)zipFolder:(NSString *)from compressionLevel:(double)compressionLevel resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self beginOperation]; + [self runAsync:^{ self.progress = 0.0; self.processedFilePath = @""; [self zipArchiveProgressEvent:0 total:1]; // force 0% @@ -495,12 +644,14 @@ - (void)zipFolder:(NSString *)from self.progress = 1.0; [self zipArchiveProgressEvent:1 total:1]; // force 100% - if (success) { + if (self.cancelled) { + reject(kZipErrCancelled, @"Operation cancelled", nil); + } else if (success) { resolve(destinationPath); } else { - NSError *error = nil; - reject(@"zip_error", @"unable to zip", error); + reject(kZipErrZip, @"unable to zip", nil); } + }]; } // Expands `paths` into (full path, entry name) pairs. Files keep their base @@ -549,6 +700,10 @@ - (BOOL)writeZipEntriesToPath:(NSString *)destinationPath if (success) { NSUInteger total = entries.count, complete = 0; for (NSArray *entry in entries) { + if (self.cancelled) { + success = NO; + break; + } success &= [zipArchive writeFileAtPath:entry[0] withFileName:entry[1] compressionLevel:compressionLevel password:password AES:aes]; if (self.progressHandler) { complete++; @@ -565,6 +720,8 @@ - (void)zipFiles:(NSArray *)from compressionLevel:(double)compressionLevel resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self beginOperation]; + [self runAsync:^{ self.progress = 0.0; self.processedFilePath = @""; [self zipArchiveProgressEvent:0 total:1]; // force 0% @@ -577,12 +734,14 @@ - (void)zipFiles:(NSArray *)from self.progress = 1.0; [self zipArchiveProgressEvent:1 total:1]; // force 100% - if (success) { + if (self.cancelled) { + reject(kZipErrCancelled, @"Operation cancelled", nil); + } else if (success) { resolve(destinationPath); } else { - NSError *error = nil; - reject(@"zip_error", @"unable to zip", error); + reject(kZipErrZip, @"unable to zip", nil); } + }]; } - (void)zipFolderWithPassword:(NSString *)from @@ -592,6 +751,8 @@ - (void)zipFolderWithPassword:(NSString *)from compressionLevel:(double)compressionLevel resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self beginOperation]; + [self runAsync:^{ self.progress = 0.0; self.processedFilePath = @""; [self zipArchiveProgressEvent:0 total:1]; // force 0% @@ -610,12 +771,14 @@ - (void)zipFolderWithPassword:(NSString *)from self.progress = 1.0; [self zipArchiveProgressEvent:1 total:1]; // force 100% - if (success) { + if (self.cancelled) { + reject(kZipErrCancelled, @"Operation cancelled", nil); + } else if (success) { resolve(destinationPath); } else { - NSError *error = nil; - reject(@"zip_error", @"unable to zip", error); + reject(kZipErrZip, @"unable to zip", nil); } + }]; } - (void)zipFilesWithPassword:(NSArray *)from @@ -625,6 +788,8 @@ - (void)zipFilesWithPassword:(NSArray *)from compressionLevel:(double)compressionLevel resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self beginOperation]; + [self runAsync:^{ self.progress = 0.0; self.processedFilePath = @""; [self zipArchiveProgressEvent:0 total:1]; // force 0% @@ -638,18 +803,23 @@ - (void)zipFilesWithPassword:(NSArray *)from self.progress = 1.0; [self zipArchiveProgressEvent:1 total:1]; // force 100% - if (success) { + if (self.cancelled) { + reject(kZipErrCancelled, @"Operation cancelled", nil); + } else if (success) { resolve(destinationPath); } else { - NSError *error = nil; - reject(@"zip_error", @"unable to zip", error); + reject(kZipErrZip, @"unable to zip", nil); } + }]; } - (void)getUncompressedSize:(NSString *)path charset:(NSString *)charset resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + (void)charset; + [self beginOperation]; + [self runAsync:^{ NSError *error = nil; NSNumber *wantedFileSize = [SSZipArchive payloadSizeForArchiveAtPath:path error:&error]; @@ -658,6 +828,7 @@ - (void)getUncompressedSize:(NSString *)path } else { resolve(@-1); } + }]; } - (void)unzipAssets:(NSString *)source @@ -666,7 +837,7 @@ - (void)unzipAssets:(NSString *)source reject:(RCTPromiseRejectBlock)reject { // iOS doesn't have assets like Android, return error NSError *error = [NSError errorWithDomain:@"RNZipArchive" code:-1 userInfo:@{NSLocalizedDescriptionKey: @"unzipAssets is not supported on iOS"}]; - reject(@"unzip_assets_not_supported", @"unzipAssets is not supported on iOS", error); + reject(kZipErrUnsupported, @"unzipAssets is not supported on iOS", error); } - (void)addListener:(NSString *)eventName { diff --git a/package.json b/package.json index 92c5232..e918b07 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-zip-archive", - "version": "9.1.0", + "version": "9.2.0", "description": "A TurboModule wrapper on ZipArchive for React Native's New Architecture", "main": "index.js", "scripts": { diff --git a/specs/NativeZipArchive.ts b/specs/NativeZipArchive.ts index 71c905d..38c24e3 100644 --- a/specs/NativeZipArchive.ts +++ b/specs/NativeZipArchive.ts @@ -50,6 +50,7 @@ export interface Spec extends TurboModule { ): Promise; getUncompressedSize(path: string, charset: string): Promise; unzipAssets(source: string, target: string): Promise; + cancel(): Promise; addListener(eventName: string): void; removeListeners(count: number): void; }