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
34 changes: 34 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,42 @@ concurrency:
cancel-in-progress: false

jobs:
zip-interop:
name: Zip interop (Node unzipper + Java ZipInputStream)
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '22'

- name: Setup Java
uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1
with:
distribution: 'zulu'
java-version: '17'

- name: Install dependencies
run: npm install

- name: Extract plain fixture with unzipper and ZipInputStream
run: |
if [ ! -f scripts/verify-zip-interop.js ]; then
echo "Interop script not on this ref — skipping (7.x / older tags)."
exit 0
fi
node scripts/verify-zip-interop.js --expect-fail fixtures/interop/winzip-aes-marker.zip
node scripts/verify-zip-interop.js --fixtures

publish:
name: Publish to npm
needs: zip-interop
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
Expand Down
44 changes: 44 additions & 0 deletions .github/workflows/zip-interop.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Zip Interop

# RNZA-16: non-password zips must extract with Node unzipper and Java ZipInputStream.
# This is the #333 / #323 class of "iOS zip unreadable on the server" regressions.

on:
pull_request:
branches: [master]
push:
branches: [master]
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
zip-interop:
name: Node unzipper + Java ZipInputStream
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20

- name: Setup Java
uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1
with:
distribution: 'zulu'
java-version: '17'

- name: Install dependencies
run: npm install

- name: Reject WinZip-AES marker fixture
run: node scripts/verify-zip-interop.js --expect-fail fixtures/interop/winzip-aes-marker.zip

- name: Extract plain fixture with unzipper and ZipInputStream
run: node scripts/verify-zip-interop.js --fixtures
5 changes: 5 additions & 0 deletions .maestro/flows/_list-contents-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ appId: ${APP_ID}
timeout: 10000
- assertVisible: "Extracted readme.md"
- assertVisible: "Extracted docs/guide.md"
- scrollUntilVisible:
element:
text: "Skipped hello.txt"
direction: DOWN
timeout: 10000
- assertVisible: "Skipped hello.txt"
- scrollUntilVisible:
element:
Expand Down
1 change: 1 addition & 0 deletions .npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,6 @@ playground-rn/
# Development files (not for npm)
__tests__/
__mocks__/
fixtures/
babel.config.js
NEW_ARCHITECTURE_MIGRATION_PLAN.md
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## [Unreleased]

### Added
- CI + npm publish gate: non-password zip fixtures must extract with Node `unzipper` and Java `ZipInputStream`; WinZip-AES extra field `0x9901` fails the job (RNZA-16, #333 / #323 class)
- `AbortSignal` on `zip` / `zipWithPassword` / `unzip` / `unzipWithPassword` / `unzipAssets` via an options object (`{ signal, compressionLevel?, entries? }`)
- `ZipError` with a stable `.code` (`ERR_CANCELLED`, `ERR_INVALID_ARGS`, …) — implemented as a factory so Metro does not need `@babel/runtime` helpers when bundling the library
- `package.json` `"types": "index.d.ts"` so TypeScript and reactnative.directory `hasTypes` resolve

### Changed
- SECURITY.md: 7.x Zip Slip / symlink backport is **7.1.2** (`maintenance-7`), not 7.1.1

## [9.4.1] - 2026-08-29

### Fixed
Expand Down
36 changes: 34 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# React Native Zip Archive [![npm](https://img.shields.io/npm/v/react-native-zip-archive.svg)](https://www.npmjs.com/package/react-native-zip-archive) [![React Native New Architecture](https://img.shields.io/badge/React%20Native-New%20Architecture%20(TurboModules)-61dafb)](https://reactnative.dev/docs/new-architecture-intro)
# React Native Zip Archive [![npm](https://img.shields.io/npm/v/react-native-zip-archive.svg)](https://www.npmjs.com/package/react-native-zip-archive) [![npm downloads](https://img.shields.io/npm/dw/react-native-zip-archive.svg)](https://www.npmjs.com/package/react-native-zip-archive) [![TypeScript](https://img.shields.io/badge/TypeScript-types-3178C6?logo=typescript&logoColor=white)](./index.d.ts) [![React Native New Architecture](https://img.shields.io/badge/React%20Native-New%20Architecture%20(TurboModules)-61dafb)](https://reactnative.dev/docs/new-architecture-intro)

Zip archive utility for React Native.

Expand Down Expand Up @@ -84,6 +84,7 @@ import {
isPasswordProtected,
getUncompressedSize,
ErrorCodes,
ZipError,
DEFAULT_COMPRESSION,
NO_COMPRESSION,
BEST_SPEED,
Expand All @@ -105,15 +106,36 @@ import * as FileSystem from 'expo-file-system/legacy'
const DocumentDirectoryPath = FileSystem.documentDirectory
```

List, extract a subset, and abort with `AbortSignal`:

```js
const controller = new AbortController()

const entries = await listContents(`${DocumentDirectoryPath}/bundle.zip`)
const assets = entries
.filter((entry) => !entry.isDirectory && entry.path.startsWith('assets/'))
.map((entry) => entry.path)

await unzip(`${DocumentDirectoryPath}/bundle.zip`, `${DocumentDirectoryPath}/out`, {
entries: assets,
signal: controller.signal,
})

// controller.abort() → rejects with ZipError code ERR_CANCELLED
```

`zip` / `zipWithPassword` / `unzipAssets` accept the same `{ signal }` option. `cancel()` still aborts the in-flight native operation.

## API

### `zip(source: string | string[], target: string, compressionLevel?: number): Promise<string>`
### `zip(source: string | string[], target: string, compressionLevelOrOptions?: number | { compressionLevel?: number, signal?: AbortSignal }): Promise<string>`

Zip a folder (string) or an array of files to the target path.

- To zip a single file, pass it as an array: `zip([file], target)`.
- Array items may also be directories: their contents are added recursively with entry paths relative to the listed directory (the directory's own name is not included). This behaves the same on Android and iOS. Empty directories are preserved on both platforms.
- `compressionLevel` applies on both platforms for folder and file-array sources.
- Or pass an options object: `zip(source, target, { compressionLevel: BEST_SPEED, signal })`.

**Compression Level Constants:**
- `DEFAULT_COMPRESSION` (-1)
Expand Down Expand Up @@ -170,6 +192,12 @@ Or with an explicit charset:
unzip(sourcePath, targetPath, 'UTF-8', ['readme.md', 'docs'])
```

Or with `AbortSignal` / selective extract as an options object:

```js
unzip(sourcePath, targetPath, { entries: ['readme.md', 'docs'], signal })
```

> The `charset` parameter defaults to `UTF-8`. On Android, other charsets are supported. On iOS, non-UTF-8 values reject with `ERR_UNSUPPORTED`.

```js
Expand Down Expand Up @@ -236,6 +264,8 @@ unzipAssets('./myFile.zip', DocumentDirectoryPath)
.catch((error) => console.error(error))
```

Optional `{ signal }` as the third argument.

### `getUncompressedSize(source: string, charset?: string): Promise<number>`

Returns the total uncompressed size of all files in the zip archive (in bytes).
Expand Down Expand Up @@ -348,6 +378,8 @@ Plain (non-AES) zips created on iOS and Android are intended to open with common
node scripts/validate-zip-header.js /path/to/archive.zip
```

CI and the npm publish workflow also extract a committed non-password fixture with Node `unzipper` and Java `ZipInputStream` (`npm run test:interop`). A WinZip-AES archive fails that gate — that was the #333 / #323 class of iOS default-AES zips.

## Expo

Works in Expo development builds / EAS only — not Expo Go. Install and plugin setup are under [Installation](#installation). See [playground-expo](./playground-expo/) for a working example.
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
| **7.x** | Security fixes only through **2027-02-19**. After that, 7.x is unsupported. Stay on 7.x if you are on React Native &lt; 0.70 until you upgrade RN. 7.x will not be deleted or unpublished. |
| **&lt; 7** | Unsupported except for critical issues |

Zip Slip / symlink fixes shipped in 9.x will be **evaluated for 7.x backports**. If a patch is warranted, it will be published as `7.x.y`. **7.1.1** backports Zip Slip validation and symlink skipping for Android and iOS extract paths.
Zip Slip / symlink fixes shipped in 9.x will be **evaluated for 7.x backports**. If a patch is warranted, it will be published as `7.x.y`. **7.1.2** (`maintenance-7` dist-tag) backports Zip Slip validation and symlink skipping for Android and iOS extract paths. 7.x stays on security-only support through the EOL date above; it is not unpublished.

## Reporting a vulnerability

Expand Down
88 changes: 84 additions & 4 deletions __tests__/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const {
cancel,
subscribe,
ErrorCodes,
ZipError,
DEFAULT_COMPRESSION,
NO_COMPRESSION,
BEST_SPEED,
Expand Down Expand Up @@ -50,6 +51,13 @@ describe('react-native-zip-archive API', () => {
expect(ErrorCodes.FILE_NOT_FOUND).toBe('ERR_FILE_NOT_FOUND');
});

test('ZipError carries a stable code', () => {
const err = new ZipError(ErrorCodes.CANCELLED, 'Operation cancelled');
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe('ZipError');
expect(err.code).toBe('ERR_CANCELLED');
});

describe('cancel', () => {
test('cancel calls native module', async () => {
await cancel();
Expand Down Expand Up @@ -78,6 +86,39 @@ describe('react-native-zip-archive API', () => {
await zip('/source', '/target.zip', BEST_COMPRESSION);
expect(mockRNZipArchive.zipFolder).toHaveBeenCalledWith('/source', '/target.zip', 9);
});

test('zip options object sets compressionLevel', async () => {
await zip('/source', '/target.zip', { compressionLevel: BEST_SPEED });
expect(mockRNZipArchive.zipFolder).toHaveBeenCalledWith('/source', '/target.zip', 1);
});

test('zip aborted signal rejects before native call', async () => {
const controller = new AbortController();
controller.abort();
await expect(
zip('/source', '/target.zip', { signal: controller.signal })
).rejects.toMatchObject({
name: 'ZipError',
code: ErrorCodes.CANCELLED,
});
expect(mockRNZipArchive.zipFolder).not.toHaveBeenCalled();
});

test('zip abort mid-flight calls cancel', async () => {
let resolveNative;
mockRNZipArchive.zipFolder.mockReturnValueOnce(
new Promise((resolve) => {
resolveNative = resolve;
})
);
const controller = new AbortController();
const pending = zip('/source', '/target.zip', { signal: controller.signal });
await Promise.resolve();
controller.abort();
await expect(pending).rejects.toMatchObject({ code: ErrorCodes.CANCELLED });
expect(mockRNZipArchive.cancel).toHaveBeenCalled();
resolveNative('/mock/path.zip');
});
});

describe('zipWithPassword', () => {
Expand All @@ -95,6 +136,20 @@ describe('react-native-zip-archive API', () => {
await zipWithPassword('file:///folder', 'file:///out.zip', 'pass');
expect(mockRNZipArchive.zipFolderWithPassword).toHaveBeenCalledWith('/folder', '/out.zip', 'pass', '', -1);
});

test('zipWithPassword options object', async () => {
await zipWithPassword('/folder', '/out.zip', 'pass', {
encryptionMethod: 'AES-256',
compressionLevel: 9,
});
expect(mockRNZipArchive.zipFolderWithPassword).toHaveBeenCalledWith(
'/folder',
'/out.zip',
'pass',
'AES-256',
9
);
});
});

describe('unzip', () => {
Expand Down Expand Up @@ -134,8 +189,20 @@ describe('react-native-zip-archive API', () => {
});

test('unzip rejects empty entries', async () => {
await expect(unzip('/source.zip', '/dest', [])).rejects.toThrow(
'unzip: entries must be a non-empty array when provided'
await expect(unzip('/source.zip', '/dest', [])).rejects.toMatchObject({
name: 'ZipError',
code: ErrorCodes.INVALID_ARGS,
message: 'unzip: entries must be a non-empty array when provided',
});
});

test('unzip options object passes entries and default charset', async () => {
await unzip('/source.zip', '/dest', { entries: ['a.txt'] });
expect(mockRNZipArchive.unzip).toHaveBeenCalledWith(
'/source.zip',
'/dest',
'UTF-8',
['a.txt']
);
});
});
Expand Down Expand Up @@ -174,8 +241,21 @@ describe('react-native-zip-archive API', () => {
test('unzipWithPassword rejects empty entries', async () => {
await expect(
unzipWithPassword('/source.zip', '/dest', 'secret', [])
).rejects.toThrow(
'unzipWithPassword: entries must be a non-empty array when provided'
).rejects.toMatchObject({
name: 'ZipError',
code: ErrorCodes.INVALID_ARGS,
});
});

test('unzipWithPassword options object', async () => {
await unzipWithPassword('/source.zip', '/dest', 'secret', {
entries: ['a.txt'],
});
expect(mockRNZipArchive.unzipWithPassword).toHaveBeenCalledWith(
'/source.zip',
'/dest',
'secret',
['a.txt']
);
});
});
Expand Down
15 changes: 15 additions & 0 deletions __tests__/package-metadata.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ describe('npm listing (RNZA-13) and packed files', () => {
}
});

test('package.json points TypeScript at index.d.ts', () => {
expect(pkg.types).toBe('index.d.ts');
});

test('npm pack includes the Expo plugin and SECURITY.md', () => {
const packed = spawnSync('npm', ['pack', '--dry-run', '--json'], {
cwd: path.join(__dirname, '..'),
Expand All @@ -33,7 +37,9 @@ describe('npm listing (RNZA-13) and packed files', () => {
expect(files).toContain('SECURITY.md');
expect(files).toContain('package.json');
expect(files).toContain('README.md');
expect(files).toContain('index.d.ts');
expect(files).not.toContain('playground-expo/app.json');
expect(files.some((f) => f.startsWith('fixtures/'))).toBe(false);
});
});

Expand Down Expand Up @@ -70,6 +76,8 @@ describe('docs claims vs native source (RNZA-7/15/17/19)', () => {
const ios = read('ios/RNZipArchive.mm');
expect(security).toMatch(/9\.x/);
expect(security).toMatch(/2027-02-19/);
expect(security).toMatch(/7\.1\.2/);
expect(security).toMatch(/maintenance-7/);
expect(zipSecurity).toMatch(/setExtractSymbolicLinks\(false\)/);
expect(zipSecurity).toMatch(/Zip Path Traversal Vulnerability/);
expect(ios).toMatch(/isSafeExtractPath/);
Expand All @@ -83,4 +91,11 @@ describe('docs claims vs native source (RNZA-7/15/17/19)', () => {
expect(readme).toMatch(/old-architecture 0\.70\+ app/);
expect(readme).not.toMatch(/old architecture is (fully )?supported/i);
});

test('README records AbortSignal and ZipError usage', () => {
const readme = read('README.md');
expect(readme).toMatch(/test:interop/);
expect(readme).toMatch(/AbortSignal/);
expect(readme).toMatch(/ZipError/);
});
});
Loading
Loading