From 872b2ec69087ad2db57b01e5c1991940569dec3c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 13:01:43 +0000 Subject: [PATCH 1/3] fix(ios): zip interoperability for server-side unzippers (#367) Rebased onto #370 after #369 was squash-merged. Co-authored-by: Perry --- .github/workflows/e2e.yml | 38 ++-------- CHANGELOG.md | 11 +++ README.md | 23 ++++-- RNZipArchive.podspec | 6 +- .../com/rnziparchive/RNZipArchiveModule.java | 2 +- ios/RNZipArchive.mm | 71 +++++++++++++++++-- package.json | 2 +- scripts/validate-zip-header.js | 64 +++++++++++++++++ 8 files changed, 166 insertions(+), 51 deletions(-) create mode 100755 scripts/validate-zip-header.js diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ce11f1e3..7d8c40c0 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -46,23 +46,8 @@ jobs: - name: Install Maestro run: | - 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 + curl -fsSL "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> $GITHUB_PATH - name: Cache node_modules uses: actions/cache@v4 @@ -174,23 +159,8 @@ jobs: - name: Install Maestro run: | - 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 + curl -fsSL "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> $GITHUB_PATH - name: Cache node_modules uses: actions/cache@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index c7e96864..f056d2a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [9.3.0] - 2026-07-25 + +### Fixed +- iOS: `zipFilesWithPassword` now honors `encryptionType` — `'STANDARD'` uses ZipCrypto instead of always writing WinZip-AES (improves server-side unzip with Node/Java tools) (#367, #333, #323) +- iOS: fsync zip output after successful `zip` / `zipWithPassword` so immediate uploads/reads see full bytes (#367) +- iOS: file-array `zip` / `zipWithPassword` now apply the requested compression level (previously always `Z_DEFAULT_COMPRESSION`) + +### Added +- `scripts/validate-zip-header.js` — checks local-file and EOCD signatures for interoperability smoke tests +- README guidance for server-side unzip compatibility + ## [9.2.0] - 2026-07-25 ### Added diff --git a/README.md b/README.md index b5874553..2abc18ed 100644 --- a/README.md +++ b/README.md @@ -262,9 +262,9 @@ useEffect(() => { | Feature | iOS | Android | Notes | |---------|-----|---------|-------| | `zip` (folder) | ✅ | ✅ | — | -| `zip` (files array) | ✅ | ✅ | Compression level ignored on iOS | -| `zipWithPassword` (folder) | ✅ | ✅ | AES encryption supported | -| `zipWithPassword` (files array) | ⚠️ | ✅ | iOS: only `STANDARD` encryption | +| `zip` (files array) | ✅ | ✅ | — | +| `zipWithPassword` (folder) | ✅ | ✅ | Prefer `STANDARD` for server unzip | +| `zipWithPassword` (files array) | ✅ | ✅ | iOS honors `STANDARD` vs AES | | `unzip` | ✅ | ✅ | Optional `entries` for selective extract; charset ignored on iOS | | `unzipWithPassword` | ✅ | ✅ | Optional `entries` for selective extract | | `listContents` | ✅ | ✅ | Charset ignored on iOS | @@ -276,11 +276,24 @@ useEffect(() => { ### Cross-Platform Notes -- **Compression levels:** Android supports 0–9 for all operations. iOS supports them only for folder operations. -- **Encryption:** Android supports AES-128, AES-256, and Standard ZIP encryption for all operations. iOS supports AES and Standard for folders, but only Standard for file arrays. +- **Compression levels:** Android supports 0–9 for all operations. iOS supports 0–9 for folder and file-array zips. +- **Encryption:** Android supports AES-128, AES-256, and Standard ZIP encryption for all operations. On iOS, pass `'STANDARD'` (default) for ZipCrypto archives that Node `unzipper` / Java `ZipInputStream` can read; `'AES-128'` / `'AES-256'` produce WinZip-AES archives that many server tools cannot open. - **Charset:** Android supports custom charsets (default UTF-8). iOS always uses UTF-8. - **unzipAssets:** Supports `assets/` folder and `content://` URIs on Android. Not supported on iOS. +### Server-side unzip interoperability + +Plain (non-AES) zips created on iOS and Android are intended to open with common server unzippers (`unzip`, Node `unzipper`, Java `ZipInputStream`). Practical tips: + +- Prefer `zip(...)` or `zipWithPassword(..., 'STANDARD')` when the archive will be extracted off-device. +- Avoid AES password zips if the consumer is stock Java/`unzipper` — use `'STANDARD'` instead. +- Decode URL-encoded paths (`decodeURIComponent`) before passing them in; `%20` in paths has been mistaken for corrupt archives (#333). +- After upgrading, you can sanity-check a produced file with: + +```bash +node scripts/validate-zip-header.js /path/to/archive.zip +``` + ## Expo This library **requires an Expo Development Build** and does not work in Expo Go because it includes custom native code. See [playground-expo](./playground-expo/) for a working Expo Development Build example. diff --git a/RNZipArchive.podspec b/RNZipArchive.podspec index f2be6c29..9e12d730 100644 --- a/RNZipArchive.podspec +++ b/RNZipArchive.podspec @@ -12,9 +12,6 @@ Pod::Spec.new do |s| s.source = { :git => 'https://github.com/mockingbot/react-native-zip-archive.git', :tag => "#{s.version}"} s.platform = :ios, '15.5' s.preserve_paths = '*.js' - s.pod_target_xcconfig = { - 'HEADER_SEARCH_PATHS' => '$(inherited) "$(PODS_ROOT)/SSZipArchive/SSZipArchive/minizip"' - } if defined?(install_modules_dependencies) != nil install_modules_dependencies(s) @@ -22,6 +19,9 @@ Pod::Spec.new do |s| s.dependency 'React-Core' end s.dependency 'SSZipArchive', '~>2.5.5' + s.pod_target_xcconfig = { + 'HEADER_SEARCH_PATHS' => '$(inherited) "${PODS_ROOT}/SSZipArchive" "${PODS_ROOT}/SSZipArchive/SSZipArchive/minizip"' + } s.source_files = 'ios/*.{h,m,mm}' s.public_header_files = ['ios/RNZipArchive.h'] diff --git a/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java b/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java index 057495bf..3937bd4b 100644 --- a/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java +++ b/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java @@ -574,7 +574,7 @@ private void zipWithPassword(final List filesOrDirectory, final String d } } else if ("STANDARD".equals(encryptionMethod)) { // ZipCrypto (ZIP_STANDARD). ZIP_STANDARD_VARIANT_STRONG is write-only in zip4j - // and fails extract with "encryption method is not supported". + // and fails create/extract with "encryption method is not supported". parameters.setEncryptionMethod(EncryptionMethod.ZIP_STANDARD); Log.d(TAG, "Standard Encryption"); } else { diff --git a/ios/RNZipArchive.mm b/ios/RNZipArchive.mm index 5db22f4d..eb2e6517 100644 --- a/ios/RNZipArchive.mm +++ b/ios/RNZipArchive.mm @@ -7,8 +7,14 @@ // #import "RNZipArchive.h" +#if __has_include() +#import +#else #import "mz_compat.h" +#endif #import +#import +#import #if __has_include() #import @@ -636,10 +642,13 @@ - (void)zipFolder:(NSString *)from success = [SSZipArchive createZipFileAtPath:destinationPath withContentsOfDirectory:from keepParentDirectory:NO - compressionLevel:compressionLevel + compressionLevel:[self zlibCompressionLevel:compressionLevel] password:nil AES:NO progressHandler:self.progressHandler]; + if (success) { + [self synchronizeZipFileAtPath:destinationPath]; + } self.progress = 1.0; [self zipArchiveProgressEvent:1 total:1]; // force 100% @@ -686,6 +695,37 @@ - (void)zipFolder:(NSString *)from return entries; } +- (int)zlibCompressionLevel:(double)compressionLevel { + // Map JS compression constants onto zlib levels. Negative values mean default. + if (compressionLevel < 0) { + return Z_DEFAULT_COMPRESSION; + } + if (compressionLevel > 9) { + return Z_BEST_COMPRESSION; + } + return (int)compressionLevel; +} + +- (BOOL)usesAESForEncryptionType:(NSString *)encryptionType { + // Empty / STANDARD → traditional ZipCrypto for maximum server-side compatibility. + // AES-128 / AES-256 → WinZip AES (many Java/Node unzippers cannot read this). + return encryptionType.length > 0 && ![encryptionType isEqualToString:@"STANDARD"]; +} + +/** + * Flush zip bytes to durable storage before resolving. Callers that upload or hash + * the archive immediately after `zip(...)` otherwise risk reading a partial file + * (see #323 / #355-class races). + */ +- (void)synchronizeZipFileAtPath:(NSString *)path { + int fd = open(path.fileSystemRepresentation, O_RDONLY); + if (fd < 0) { + return; + } + fsync(fd); + close(fd); +} + - (BOOL)writeZipEntriesToPath:(NSString *)destinationPath paths:(NSArray *)paths compressionLevel:(int)compressionLevel @@ -711,6 +751,9 @@ - (BOOL)writeZipEntriesToPath:(NSString *)destinationPath } } success &= [zipArchive close]; + if (success) { + [self synchronizeZipFileAtPath:destinationPath]; + } } return success; } @@ -729,7 +772,13 @@ - (void)zipFiles:(NSArray *)from BOOL success; [self setProgressHandler]; - success = [self writeZipEntriesToPath:destinationPath paths:from compressionLevel:Z_DEFAULT_COMPRESSION password:nil AES:NO]; + // Honor the requested compression level (previously ignored for file arrays) and + // never enable AES for plaintext zips — both matter for Node/Java unzippers (#333, #323). + success = [self writeZipEntriesToPath:destinationPath + paths:from + compressionLevel:[self zlibCompressionLevel:compressionLevel] + password:nil + AES:NO]; self.progress = 1.0; [self zipArchiveProgressEvent:1 total:1]; // force 100% @@ -759,14 +808,17 @@ - (void)zipFolderWithPassword:(NSString *)from BOOL success; [self setProgressHandler]; - BOOL useAES = encryptionType && [encryptionType length] > 0 && ![encryptionType isEqualToString:@"STANDARD"]; + BOOL useAES = [self usesAESForEncryptionType:encryptionType]; success = [SSZipArchive createZipFileAtPath:destinationPath withContentsOfDirectory:from keepParentDirectory:NO - compressionLevel:compressionLevel + compressionLevel:[self zlibCompressionLevel:compressionLevel] password:password AES:useAES progressHandler:self.progressHandler]; + if (success) { + [self synchronizeZipFileAtPath:destinationPath]; + } self.progress = 1.0; [self zipArchiveProgressEvent:1 total:1]; // force 100% @@ -796,9 +848,14 @@ - (void)zipFilesWithPassword:(NSArray *)from BOOL success; [self setProgressHandler]; - // Note: entries are written with AES:YES, matching the previous behavior of - // createZipFileAtPath:withFilesAtPaths: (which routes through AES:YES writes) - success = [self writeZipEntriesToPath:destinationPath paths:from compressionLevel:Z_DEFAULT_COMPRESSION password:password AES:YES]; + // Prefer STANDARD (ZipCrypto) unless the caller explicitly requests AES. + // Always-on AES was a common source of "works on device, fails on server" reports. + BOOL useAES = [self usesAESForEncryptionType:encryptionType]; + success = [self writeZipEntriesToPath:destinationPath + paths:from + compressionLevel:[self zlibCompressionLevel:compressionLevel] + password:password + AES:useAES]; self.progress = 1.0; [self zipArchiveProgressEvent:1 total:1]; // force 100% diff --git a/package.json b/package.json index e918b076..d68ed07b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-zip-archive", - "version": "9.2.0", + "version": "9.3.0", "description": "A TurboModule wrapper on ZipArchive for React Native's New Architecture", "main": "index.js", "scripts": { diff --git a/scripts/validate-zip-header.js b/scripts/validate-zip-header.js new file mode 100755 index 00000000..93ad91af --- /dev/null +++ b/scripts/validate-zip-header.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node +/** + * Lightweight ZIP interoperability check for archives produced by this library. + * + * Validates: + * - local file header signature 0x04034b50 + * - end-of-central-directory signature 0x06054b50 + * + * Usage: node scripts/validate-zip-header.js [more.zip ...] + */ +const fs = require('fs'); + +const LOCAL_FILE_HEADER = 0x04034b50; +const END_OF_CENTRAL_DIR = 0x06054b50; + +function readUInt32LE(buf, offset) { + return buf.readUInt32LE(offset); +} + +function validateZip(filePath) { + const buf = fs.readFileSync(filePath); + if (buf.length < 22) { + throw new Error(`${filePath}: file too small to be a zip (${buf.length} bytes)`); + } + + const localSig = readUInt32LE(buf, 0); + if (localSig !== LOCAL_FILE_HEADER) { + throw new Error( + `${filePath}: bad local header signature 0x${localSig.toString(16)} (expected 0x04034b50)` + ); + } + + // EOCD is at the end; comment can make it earlier. Scan last 64KiB. + const scanFrom = Math.max(0, buf.length - 65557); + let eocd = -1; + for (let i = buf.length - 22; i >= scanFrom; i--) { + if (readUInt32LE(buf, i) === END_OF_CENTRAL_DIR) { + eocd = i; + break; + } + } + if (eocd < 0) { + throw new Error(`${filePath}: end-of-central-directory signature not found`); + } + + console.log(`OK ${filePath} (local=0x04034b50, eocd@${eocd})`); +} + +const files = process.argv.slice(2); +if (files.length === 0) { + console.error('Usage: node scripts/validate-zip-header.js ...'); + process.exit(2); +} + +let failed = false; +for (const file of files) { + try { + validateZip(file); + } catch (err) { + console.error(String(err.message || err)); + failed = true; + } +} +process.exit(failed ? 1 : 0); From 8f9d26d03ba799b20c9a0ec62fb37e2be0c124d0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 13:12:52 +0000 Subject: [PATCH 2/3] docs: file-array zipWithPassword honors encryptionType on iOS The interoperability change writes ZipCrypto vs AES based on encryptionType for file arrays; drop the outdated README callout. Co-authored-by: Perry --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2abc18ed..0c6d534a 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Zip with password protection. - `'AES-128'` — AES 128-bit - `'AES-256'` — AES 256-bit -> **iOS:** Both AES-128 and AES-256 use AES-256 internally. AES encryption is **not supported** for file arrays on iOS — only `STANDARD` works. +> **iOS:** Both AES-128 and AES-256 use AES-256 internally. File arrays honor `encryptionType` the same as folders. Prefer `'STANDARD'` (ZipCrypto) when the archive will be unzipped by Node, Java, or other non-WinZip tools. ```js const sourcePath = DocumentDirectoryPath From 31fd8c076ce11874b974b4890b1f1cf941cddc77 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 14:08:46 +0000 Subject: [PATCH 3/3] docs: note iOS file-array zipWithPassword default is now ZipCrypto Omitting encryptionType used to always write WinZip-AES for iOS file arrays. Callers who need AES must pass AES-128 or AES-256. Co-authored-by: Perry --- CHANGELOG.md | 3 +++ README.md | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f056d2a9..3de9498d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [9.3.0] - 2026-07-25 +### Changed +- iOS: `zipWithPassword` with a files array now honors `encryptionType`. Omitting it (JS default, treated as `'STANDARD'`) writes ZipCrypto instead of the previous always-AES (WinZip-AES) default. ZipCrypto is weaker encryption than AES; pass `'AES-128'` or `'AES-256'` to keep AES. This matches Android's default and common server unzippers (#367). + ### Fixed - iOS: `zipFilesWithPassword` now honors `encryptionType` — `'STANDARD'` uses ZipCrypto instead of always writing WinZip-AES (improves server-side unzip with Node/Java tools) (#367, #333, #323) - iOS: fsync zip output after successful `zip` / `zipWithPassword` so immediate uploads/reads see full bytes (#367) diff --git a/README.md b/README.md index 0c6d534a..c99e0bf7 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Zip with password protection. - `'AES-128'` — AES 128-bit - `'AES-256'` — AES 256-bit -> **iOS:** Both AES-128 and AES-256 use AES-256 internally. File arrays honor `encryptionType` the same as folders. Prefer `'STANDARD'` (ZipCrypto) when the archive will be unzipped by Node, Java, or other non-WinZip tools. +> **iOS:** Both AES-128 and AES-256 use AES-256 internally. File arrays honor `encryptionType` the same as folders. The default is ZipCrypto (`'STANDARD'`), including when the 4th argument is omitted — file arrays previously always wrote WinZip-AES. Pass `'AES-128'` or `'AES-256'` if you need AES. Prefer `'STANDARD'` when the archive will be unzipped by Node, Java, or other non-WinZip tools. ```js const sourcePath = DocumentDirectoryPath