Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 4 additions & 34 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## [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)
- 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
Expand Down
25 changes: 19 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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
Expand Down Expand Up @@ -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 |
Expand All @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions RNZipArchive.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,16 @@ 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)
else
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']
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,7 @@ private void zipWithPassword(final List<String> 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 {
Expand Down
71 changes: 64 additions & 7 deletions ios/RNZipArchive.mm
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@
//

#import "RNZipArchive.h"
#if __has_include(<SSZipArchive/minizip/mz_compat.h>)
#import <SSZipArchive/minizip/mz_compat.h>
#else
#import "mz_compat.h"
#endif
#import <zlib.h>
#import <fcntl.h>
#import <unistd.h>

#if __has_include(<React/RCTEventDispatcher.h>)
#import <React/RCTEventDispatcher.h>
Expand Down Expand Up @@ -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%
Expand Down Expand Up @@ -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<NSString *> *)paths
compressionLevel:(int)compressionLevel
Expand All @@ -711,6 +751,9 @@ - (BOOL)writeZipEntriesToPath:(NSString *)destinationPath
}
}
success &= [zipArchive close];
if (success) {
[self synchronizeZipFileAtPath:destinationPath];
}
}
return success;
}
Expand All @@ -729,7 +772,13 @@ - (void)zipFiles:(NSArray<NSString *> *)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%
Expand Down Expand Up @@ -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%
Expand Down Expand Up @@ -796,9 +848,14 @@ - (void)zipFilesWithPassword:(NSArray<NSString *> *)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];
Comment thread
cursor[bot] marked this conversation as resolved.

self.progress = 1.0;
[self zipArchiveProgressEvent:1 total:1]; // force 100%
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
64 changes: 64 additions & 0 deletions scripts/validate-zip-header.js
Original file line number Diff line number Diff line change
@@ -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 <file.zip> [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 <file.zip>...');
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);