From 0b6cd23178e29c05ed7eeec51dad74c3051c9d37 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 01:53:40 +0000 Subject: [PATCH 1/4] ci: gate npm publish on zip interop and document P3 non-goals Fail the release workflow if a non-password zip cannot extract with Node unzipper and Java ZipInputStream, and reject WinZip-AES extra field 0x9901 (the #333 / #323 class). README now states tar/7z is out of scope and a Nitro rewrite is not a growth plan. SECURITY.md names 7.1.2 as the v7 Zip Slip backport. Co-authored-by: plrthink --- .github/workflows/publish.yml | 34 ++++ .github/workflows/zip-interop.yml | 44 ++++++ .npmignore | 1 + CHANGELOG.md | 9 ++ README.md | 4 + SECURITY.md | 2 +- __tests__/package-metadata.test.js | 10 ++ __tests__/zip-interop.test.js | 70 +++++++++ fixtures/interop/README.md | 17 ++ fixtures/interop/plain-deflate.zip | Bin 0 -> 370 bytes fixtures/interop/winzip-aes-marker.zip | Bin 0 -> 141 bytes package.json | 4 +- pnpm-lock.yaml | 51 ++++++ scripts/ZipInputStreamCheck.java | 43 +++++ scripts/generate-interop-fixtures.py | 93 +++++++++++ scripts/validate-zip-header.js | 55 ++++--- scripts/verify-zip-interop.js | 209 +++++++++++++++++++++++++ 17 files changed, 623 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/zip-interop.yml create mode 100644 __tests__/zip-interop.test.js create mode 100644 fixtures/interop/README.md create mode 100644 fixtures/interop/plain-deflate.zip create mode 100644 fixtures/interop/winzip-aes-marker.zip create mode 100644 scripts/ZipInputStreamCheck.java create mode 100644 scripts/generate-interop-fixtures.py create mode 100644 scripts/verify-zip-interop.js diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 26eb103e..280ec126 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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: diff --git a/.github/workflows/zip-interop.yml b/.github/workflows/zip-interop.yml new file mode 100644 index 00000000..eb9f42f7 --- /dev/null +++ b/.github/workflows/zip-interop.yml @@ -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 diff --git a/.npmignore b/.npmignore index 5f0ab3cb..da8f5a15 100644 --- a/.npmignore +++ b/.npmignore @@ -40,5 +40,6 @@ playground-rn/ # Development files (not for npm) __tests__/ __mocks__/ +fixtures/ babel.config.js NEW_ARCHITECTURE_MIGRATION_PLAN.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 286454a7..52ea242f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # 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) + +### Changed +- README: tar/7z is an explicit non-goal; Nitro rewrite is not a growth plan (RNZA-23, RNZA-24) +- 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 diff --git a/README.md b/README.md index 6837f1a9..93ea455e 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ Zip archive utility for React Native. Use this library for native zip/unzip on device. Use JSZip when you only need small archives in JS. Nitro may fit if you want additional archive formats and accept the extra Nitro dependency and smaller install base. +**Non-goals:** tar and 7z are out of scope unless a dependent asks in writing — this package stays zip-excellent. A Nitro rewrite is not a growth plan; revisit only if Interop + Expo are done and a named app is still blocked on performance. + ## Installation ### React Native (bare) @@ -348,6 +350,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. diff --git a/SECURITY.md b/SECURITY.md index 80a611a3..2da836af 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 < 0.70 until you upgrade RN. 7.x will not be deleted or unpublished. | | **< 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 diff --git a/__tests__/package-metadata.test.js b/__tests__/package-metadata.test.js index 4be46b12..77a0cdc7 100644 --- a/__tests__/package-metadata.test.js +++ b/__tests__/package-metadata.test.js @@ -34,6 +34,7 @@ describe('npm listing (RNZA-13) and packed files', () => { expect(files).toContain('package.json'); expect(files).toContain('README.md'); expect(files).not.toContain('playground-expo/app.json'); + expect(files.some((f) => f.startsWith('fixtures/'))).toBe(false); }); }); @@ -70,6 +71,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/); @@ -83,4 +86,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 tar/7z non-goal and no Nitro rewrite for growth', () => { + const readme = read('README.md'); + expect(readme).toMatch(/tar and 7z are out of scope/i); + expect(readme).toMatch(/Nitro rewrite is not a growth plan/i); + expect(readme).toMatch(/test:interop/); + }); }); diff --git a/__tests__/zip-interop.test.js b/__tests__/zip-interop.test.js new file mode 100644 index 00000000..035b648f --- /dev/null +++ b/__tests__/zip-interop.test.js @@ -0,0 +1,70 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const { assertPlainZip, WINZIP_AES_EXTRA } = require('../scripts/verify-zip-interop'); +const { validateZip } = require('../scripts/validate-zip-header'); + +const FIXTURES = path.join(__dirname, '..', 'fixtures', 'interop'); +const PLAIN = path.join(FIXTURES, 'plain-deflate.zip'); +const AES = path.join(FIXTURES, 'winzip-aes-marker.zip'); + +describe('zip interop gate (RNZA-16)', () => { + test('plain fixture has PKZIP signatures and no AES extra field', () => { + const { buf } = validateZip(PLAIN); + expect(() => assertPlainZip(PLAIN, buf)).not.toThrow(); + }); + + test('WinZip-AES marker fixture is rejected before extractors run', () => { + const buf = fs.readFileSync(AES); + const nameLen = buf.readUInt16LE(26); + const extraStart = 30 + nameLen; + expect(buf.subarray(30, extraStart).toString()).toBe('secret.txt'); + expect(buf.readUInt16LE(extraStart)).toBe(WINZIP_AES_EXTRA); + expect(() => assertPlainZip(AES, buf)).toThrow(/0x9901/); + }); + + test('CLI extracts the plain fixture with unzipper and ZipInputStream', () => { + const result = spawnSync( + process.execPath, + [path.join(__dirname, '..', 'scripts', 'verify-zip-interop.js'), '--fixtures'], + { encoding: 'utf8', cwd: path.join(__dirname, '..') } + ); + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + expect(result.stdout).toMatch(/OK node unzipper/); + expect(result.stdout).toMatch(/OK java ZipInputStream/); + }); + + test('CLI --expect-fail passes on the AES marker', () => { + const result = spawnSync( + process.execPath, + [ + path.join(__dirname, '..', 'scripts', 'verify-zip-interop.js'), + '--expect-fail', + AES, + ], + { encoding: 'utf8', cwd: path.join(__dirname, '..') } + ); + expect(result.status).toBe(0); + expect(result.stdout).toMatch(/OK expected failure/); + }); + + test('publish workflow requires the zip-interop job', () => { + const yml = fs.readFileSync( + path.join(__dirname, '..', '.github', 'workflows', 'publish.yml'), + 'utf8' + ); + expect(yml).toMatch(/needs:\s*zip-interop/); + expect(yml).toMatch(/verify-zip-interop\.js --fixtures/); + }); + + test('does not leave class files in the repo', () => { + const tmpHint = os.tmpdir(); + expect(tmpHint.length).toBeGreaterThan(0); + expect(fs.existsSync(path.join(__dirname, '..', 'scripts', 'ZipInputStreamCheck.class'))).toBe( + false + ); + }); +}); diff --git a/fixtures/interop/README.md b/fixtures/interop/README.md new file mode 100644 index 00000000..50b475a6 --- /dev/null +++ b/fixtures/interop/README.md @@ -0,0 +1,17 @@ +# Zip interop fixtures (RNZA-16) + +`plain-deflate.zip` is the release gate: a non-password PKZIP/deflate archive that **must** extract with Node `unzipper` and Java `ZipInputStream`. + +`winzip-aes-marker.zip` is a negative fixture (WinZip-AES extra field `0x9901`). The gate must reject it. That is the #333 / #323 class of iOS default-AES archives. + +Refresh: + +```bash +python3 scripts/generate-interop-fixtures.py +``` + +To lock a zip produced on-device (preferred when replacing `plain-deflate.zip`): + +1. Zip a folder with `zip(...)` in playground-ios (no password). +2. Copy the archive here as `plain-deflate.zip`. +3. `node scripts/verify-zip-interop.js --fixtures` must pass. diff --git a/fixtures/interop/plain-deflate.zip b/fixtures/interop/plain-deflate.zip new file mode 100644 index 0000000000000000000000000000000000000000..94db66326b8e843439561d6656b2e0c6604b3135 GIT binary patch literal 370 zcmWIWW@Zs#U|`^2Sj?v!``hf)dKDlKggJpYBQ+-{U$3O1*{&>26~)6=91!MW;ASXJO)g3;(JQGaVVud%z{KF_%FMt}5#Y_pB*K7O Z7tAyWjn#|*Z&o&tG$Rl?18EbmS^#U85!?U( literal 0 HcmV?d00001 diff --git a/package.json b/package.json index 7c4be179..e7dd9bf3 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "main": "index.js", "scripts": { "test": "jest", + "test:interop": "node scripts/verify-zip-interop.js --expect-fail fixtures/interop/winzip-aes-marker.zip && node scripts/verify-zip-interop.js --fixtures", "lint": "eslint index.js", "test:e2e:expo:ios": "./scripts/e2e-ios.sh", "test:e2e:expo:android": "./scripts/e2e-android.sh", @@ -45,7 +46,8 @@ "eslint": "^6.7.2", "jest": "^29.0.0", "react": ">=18.0.0", - "react-native": ">=0.70.0" + "react-native": ">=0.70.0", + "unzipper": "^0.12.5" }, "jest": { "testEnvironment": "node", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b58ff30..2cd0c6a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: react-native: specifier: '>=0.70.0' version: 0.74.1(@babel/core@7.24.5)(@babel/preset-env@7.29.2(@babel/core@7.24.5))(react@18.3.1) + unzipper: + specifier: ^0.12.5 + version: 0.12.5 packages: @@ -1550,6 +1553,9 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} @@ -1849,6 +1855,9 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} + duplexer2@0.1.4: + resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -2145,6 +2154,10 @@ packages: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} + fs-extra@11.3.1: + resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==} + engines: {node: '>=14.14'} + fs-extra@8.1.0: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} @@ -2707,6 +2720,9 @@ packages: jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -3724,10 +3740,17 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + unzipper@0.12.5: + resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==} + update-browserslist-db@1.0.15: resolution: {integrity: sha512-K9HWH62x3/EalU1U6sjSZiylm9C8tgq2mSvshZpqc7QE69RaA2qjhkW2HlNA0tFpEbtyFz7HTqbSdN4MSwUodA==} hasBin: true @@ -6044,6 +6067,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + bluebird@3.7.2: {} + brace-expansion@1.1.11: dependencies: balanced-match: 1.0.2 @@ -6342,6 +6367,10 @@ snapshots: dependencies: esutils: 2.0.3 + duplexer2@0.1.4: + dependencies: + readable-stream: 2.3.8 + ee-first@1.1.1: {} electron-to-chromium@1.4.757: {} @@ -6738,6 +6767,12 @@ snapshots: fresh@0.5.2: {} + fs-extra@11.3.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fs-extra@8.1.0: dependencies: graceful-fs: 4.2.11 @@ -7481,6 +7516,12 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.8 @@ -8628,8 +8669,18 @@ snapshots: universalify@0.1.2: {} + universalify@2.0.1: {} + unpipe@1.0.0: {} + unzipper@0.12.5: + dependencies: + bluebird: 3.7.2 + duplexer2: 0.1.4 + fs-extra: 11.3.1 + graceful-fs: 4.2.11 + node-int64: 0.4.0 + update-browserslist-db@1.0.15(browserslist@4.23.0): dependencies: browserslist: 4.23.0 diff --git a/scripts/ZipInputStreamCheck.java b/scripts/ZipInputStreamCheck.java new file mode 100644 index 00000000..aa8d5f62 --- /dev/null +++ b/scripts/ZipInputStreamCheck.java @@ -0,0 +1,43 @@ +import java.io.BufferedInputStream; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * Extract a zip with stock {@link ZipInputStream} (the #333 / #323 server-side check). + * Exits 0 if at least one file entry drains; 1 on extract failure; 2 on usage error. + */ +public final class ZipInputStreamCheck { + private ZipInputStreamCheck() {} + + public static void main(String[] args) throws IOException { + if (args.length != 1) { + System.err.println("Usage: ZipInputStreamCheck "); + System.exit(2); + } + + final String zipPath = args[0]; + int files = 0; + try (ZipInputStream zis = + new ZipInputStream(new BufferedInputStream(new FileInputStream(zipPath)))) { + final byte[] buf = new byte[8192]; + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (!entry.isDirectory()) { + while (zis.read(buf) != -1) { + // drain + } + files++; + } + zis.closeEntry(); + } + } + + if (files < 1) { + System.err.println("Java ZipInputStream: no file entries extracted from " + zipPath); + System.exit(1); + } + System.out.println("OK java ZipInputStream " + zipPath + " files=" + files); + } +} diff --git a/scripts/generate-interop-fixtures.py b/scripts/generate-interop-fixtures.py new file mode 100644 index 00000000..2cb82076 --- /dev/null +++ b/scripts/generate-interop-fixtures.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Create committed zip fixtures for scripts/verify-zip-interop.js (RNZA-16).""" + +from __future__ import annotations + +import io +import struct +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / "fixtures" / "interop" + + +def write_plain_deflate() -> Path: + """PKZIP deflate archive matching a non-password iOS/Android zip.""" + dest = OUT / "plain-deflate.zip" + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=False) as zf: + zf.writestr("hello.txt", "hello from react-native-zip-archive\n") + zf.writestr("nested/file.txt", "nested file\n") + zf.writestr("empty-dir/", "") + dest.write_bytes(buf.getvalue()) + return dest + + +def write_winzip_aes_marker() -> Path: + """Minimal zip whose extra field is 0x9901 — must fail the plain-zip gate.""" + dest = OUT / "winzip-aes-marker.zip" + name = b"secret.txt" + data = b"x" + extra = struct.pack(" None: + OUT.mkdir(parents=True, exist_ok=True) + plain = write_plain_deflate() + aes = write_winzip_aes_marker() + print(f"wrote {plain} ({plain.stat().st_size} bytes)") + print(f"wrote {aes} ({aes.stat().st_size} bytes)") + + +if __name__ == "__main__": + main() diff --git a/scripts/validate-zip-header.js b/scripts/validate-zip-header.js index 93ad91af..ef360211 100755 --- a/scripts/validate-zip-header.js +++ b/scripts/validate-zip-header.js @@ -17,6 +17,16 @@ function readUInt32LE(buf, offset) { return buf.readUInt32LE(offset); } +function findEocdOffset(buf) { + const scanFrom = Math.max(0, buf.length - 65557); + for (let i = buf.length - 22; i >= scanFrom; i--) { + if (readUInt32LE(buf, i) === END_OF_CENTRAL_DIR) { + return i; + } + } + return -1; +} + function validateZip(filePath) { const buf = fs.readFileSync(filePath); if (buf.length < 22) { @@ -31,34 +41,37 @@ function validateZip(filePath) { } // 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; - } - } + const eocd = findEocdOffset(buf); if (eocd < 0) { throw new Error(`${filePath}: end-of-central-directory signature not found`); } console.log(`OK ${filePath} (local=0x04034b50, eocd@${eocd})`); + return { buf, eocd }; } -const files = process.argv.slice(2); -if (files.length === 0) { - console.error('Usage: node scripts/validate-zip-header.js ...'); - process.exit(2); -} +module.exports = { + LOCAL_FILE_HEADER, + END_OF_CENTRAL_DIR, + findEocdOffset, + validateZip, +}; + +if (require.main === module) { + 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; + 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); } -process.exit(failed ? 1 : 0); diff --git a/scripts/verify-zip-interop.js b/scripts/verify-zip-interop.js new file mode 100644 index 00000000..cb3ab246 --- /dev/null +++ b/scripts/verify-zip-interop.js @@ -0,0 +1,209 @@ +#!/usr/bin/env node +/** + * RNZA-16: fail if a non-password zip cannot be extracted by the tools that + * caused #333 / #323 churn — Node `unzipper` and Java `ZipInputStream`. + * + * Also rejects WinZip-AES extra field 0x9901 (the old iOS default) unless + * `--allow-aes` is passed. + * + * Usage: + * node scripts/verify-zip-interop.js [more.zip ...] + * node scripts/verify-zip-interop.js --fixtures + * node scripts/verify-zip-interop.js --expect-fail + */ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const { validateZip } = require('./validate-zip-header'); + +const WINZIP_AES_EXTRA = 0x9901; +const CENTRAL_DIRECTORY_HEADER = 0x02014b50; +const FLAG_ENCRYPTED = 0x0001; + +function parseArgs(argv) { + const files = []; + let fixtures = false; + let expectFail = false; + let allowAes = false; + for (const arg of argv) { + if (arg === '--fixtures') { + fixtures = true; + } else if (arg === '--expect-fail') { + expectFail = true; + } else if (arg === '--allow-aes') { + allowAes = true; + } else if (arg.startsWith('-')) { + throw new Error(`Unknown flag: ${arg}`); + } else { + files.push(arg); + } + } + return { files, fixtures, expectFail, allowAes }; +} + +function defaultFixtures() { + const dir = path.join(__dirname, '..', 'fixtures', 'interop'); + return fs + .readdirSync(dir) + .filter((name) => name.startsWith('plain-') && name.endsWith('.zip')) + .map((name) => path.join(dir, name)); +} + +function scanExtraFields(extra, where) { + let offset = 0; + while (offset + 4 <= extra.length) { + const id = extra.readUInt16LE(offset); + const size = extra.readUInt16LE(offset + 2); + if (id === WINZIP_AES_EXTRA) { + throw new Error(`${where}: WinZip-AES extra field 0x9901 (use STANDARD/ZipCrypto, not AES)`); + } + offset += 4 + size; + } +} + +function assertPlainZip(filePath, buf) { + let offset = 0; + let localHeaders = 0; + while (offset + 30 <= buf.length && buf.readUInt32LE(offset) === 0x04034b50) { + const flags = buf.readUInt16LE(offset + 6); + const nameLen = buf.readUInt16LE(offset + 26); + const extraLen = buf.readUInt16LE(offset + 28); + const compressedSize = buf.readUInt32LE(offset + 18); + if (flags & FLAG_ENCRYPTED) { + throw new Error(`${filePath}: local header ${localHeaders} is encrypted (plain fixture required)`); + } + const extraStart = offset + 30 + nameLen; + scanExtraFields(buf.subarray(extraStart, extraStart + extraLen), `${filePath} local extra`); + offset = extraStart + extraLen + compressedSize; + if (flags & 0x0008) { + // data descriptor: skip 12 or 16 bytes if present + if (offset + 4 <= buf.length && buf.readUInt32LE(offset) === 0x08074b50) { + offset += 16; + } else { + offset += 12; + } + } + localHeaders++; + } + + for (let i = 0; i + 46 <= buf.length; i++) { + if (buf.readUInt32LE(i) !== CENTRAL_DIRECTORY_HEADER) { + continue; + } + const flags = buf.readUInt16LE(i + 8); + const nameLen = buf.readUInt16LE(i + 28); + const extraLen = buf.readUInt16LE(i + 30); + const commentLen = buf.readUInt16LE(i + 32); + if (flags & FLAG_ENCRYPTED) { + throw new Error(`${filePath}: central directory entry is encrypted (plain fixture required)`); + } + const extraStart = i + 46 + nameLen; + if (extraStart + extraLen <= buf.length) { + scanExtraFields(buf.subarray(extraStart, extraStart + extraLen), `${filePath} central extra`); + } + i += 46 + nameLen + extraLen + commentLen - 1; + } + + if (localHeaders < 1) { + throw new Error(`${filePath}: no local file headers walked`); + } +} + +function extractWithUnzipper(filePath) { + let unzipper; + try { + unzipper = require('unzipper'); + } catch (err) { + throw new Error( + 'Node unzipper is not installed. Run `npm install unzipper --no-save` (CI) or add it as a devDependency.' + ); + } + + return unzipper.Open.file(filePath).then(async (directory) => { + let files = 0; + for (const file of directory.files) { + if (file.type === 'Directory') { + continue; + } + const body = await file.buffer(); + if (!Buffer.isBuffer(body)) { + throw new Error(`${filePath}: unzipper returned non-buffer for ${file.path}`); + } + files++; + } + if (files < 1) { + throw new Error(`${filePath}: Node unzipper extracted 0 file entries`); + } + console.log(`OK node unzipper ${filePath} files=${files}`); + }); +} + +function extractWithJava(filePath) { + const javaFile = path.join(__dirname, 'ZipInputStreamCheck.java'); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rnza-zip-interop-')); + const compile = spawnSync('javac', ['-d', tmp, javaFile], { encoding: 'utf8' }); + if (compile.status !== 0) { + throw new Error( + `javac failed:\n${compile.stdout || ''}${compile.stderr || ''}` + ); + } + const run = spawnSync('java', ['-cp', tmp, 'ZipInputStreamCheck', filePath], { + encoding: 'utf8', + }); + if (run.status !== 0) { + throw new Error( + `Java ZipInputStream failed for ${filePath}:\n${run.stdout || ''}${run.stderr || ''}` + ); + } + process.stdout.write(run.stdout); +} + +async function verifyOne(filePath, { allowAes }) { + const { buf } = validateZip(filePath); + if (!allowAes) { + assertPlainZip(filePath, buf); + } + await extractWithUnzipper(filePath); + extractWithJava(filePath); +} + +async function main() { + const { files, fixtures, expectFail, allowAes } = parseArgs(process.argv.slice(2)); + const targets = fixtures ? defaultFixtures() : files; + if (targets.length === 0) { + console.error( + 'Usage: node scripts/verify-zip-interop.js [--fixtures] [--expect-fail] [--allow-aes] ...' + ); + process.exit(2); + } + + let failed = false; + for (const file of targets) { + try { + await verifyOne(file, { allowAes }); + if (expectFail) { + console.error(`${file}: expected interop failure, but the archive extracted`); + failed = true; + } + } catch (err) { + if (expectFail) { + console.log(`OK expected failure ${file}: ${err.message || err}`); + } else { + console.error(String(err.message || err)); + failed = true; + } + } + } + process.exit(failed ? 1 : 0); +} + +if (require.main === module) { + main().catch((err) => { + console.error(String(err && err.stack ? err.stack : err)); + process.exit(1); + }); +} + +module.exports = { assertPlainZip, WINZIP_AES_EXTRA }; From 89c756a65c10a12e06b3b61e23e136018363d1fb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 02:01:20 +0000 Subject: [PATCH 2/4] feat(js): AbortSignal, ZipError, and published TypeScript types Publish index.d.ts via package.json "types" so TypeScript and reactnative.directory hasTypes resolve. zip/unzip accept an options object with AbortSignal; JS-side failures are ZipError with a stable .code. Existing positional arguments stay compatible. Co-authored-by: plrthink --- CHANGELOG.md | 3 + README.md | 34 +++- __tests__/api.test.js | 88 ++++++++- __tests__/package-metadata.test.js | 7 + index.d.ts | 237 +++++++++++++++--------- index.js | 284 +++++++++++++++++++++-------- package.json | 1 + 7 files changed, 489 insertions(+), 165 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52ea242f..63f9a65a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### 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`, …) +- `package.json` `"types": "index.d.ts"` so TypeScript and reactnative.directory `hasTypes` resolve ### Changed - README: tar/7z is an explicit non-goal; Nitro rewrite is not a growth plan (RNZA-23, RNZA-24) diff --git a/README.md b/README.md index 93ea455e..1a8d69fe 100644 --- a/README.md +++ b/README.md @@ -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. @@ -86,6 +86,7 @@ import { isPasswordProtected, getUncompressedSize, ErrorCodes, + ZipError, DEFAULT_COMPRESSION, NO_COMPRESSION, BEST_SPEED, @@ -107,15 +108,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` +### `zip(source: string | string[], target: string, compressionLevelOrOptions?: number | { compressionLevel?: number, signal?: AbortSignal }): Promise` 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) @@ -172,6 +194,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 @@ -238,6 +266,8 @@ unzipAssets('./myFile.zip', DocumentDirectoryPath) .catch((error) => console.error(error)) ``` +Optional `{ signal }` as the third argument. + ### `getUncompressedSize(source: string, charset?: string): Promise` Returns the total uncompressed size of all files in the zip archive (in bytes). diff --git a/__tests__/api.test.js b/__tests__/api.test.js index 62368f43..40a813b7 100644 --- a/__tests__/api.test.js +++ b/__tests__/api.test.js @@ -10,6 +10,7 @@ const { cancel, subscribe, ErrorCodes, + ZipError, DEFAULT_COMPRESSION, NO_COMPRESSION, BEST_SPEED, @@ -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(); @@ -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', () => { @@ -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', () => { @@ -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'] ); }); }); @@ -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'] ); }); }); diff --git a/__tests__/package-metadata.test.js b/__tests__/package-metadata.test.js index 77a0cdc7..dadb8efd 100644 --- a/__tests__/package-metadata.test.js +++ b/__tests__/package-metadata.test.js @@ -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, '..'), @@ -33,6 +37,7 @@ 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); }); @@ -92,5 +97,7 @@ describe('docs claims vs native source (RNZA-7/15/17/19)', () => { expect(readme).toMatch(/tar and 7z are out of scope/i); expect(readme).toMatch(/Nitro rewrite is not a growth plan/i); expect(readme).toMatch(/test:interop/); + expect(readme).toMatch(/AbortSignal/); + expect(readme).toMatch(/ZipError/); }); }); diff --git a/index.d.ts b/index.d.ts index 85653d56..8109dbe1 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,89 +1,150 @@ -import { NativeEventSubscription } from "react-native"; - -declare module "react-native-zip-archive" { - export enum EncryptionMethods { - STANDARD = "STANDARD", - AES_128 = "AES-128", - 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; - export const BEST_COMPRESSION: number; - - export function isPasswordProtected(source: string): Promise; - - export function zip( - source: string | string[], - target: string, - compressionLevel?: number - ): Promise; - - export function zipWithPassword( - source: string | string[], - target: string, - password: string, - encryptionMethod?: EncryptionMethods, - compressionLevel?: number - ): Promise; - - /** - * Unzip an archive. Pass `entries` to extract only those paths - * (directories include nested children). When using `entries`, you may - * omit charset (`unzip(src, dest, ['a.txt'])`) or pass it explicitly - * (`unzip(src, dest, 'GBK', ['a.txt'])`). - */ - export function unzip( - source: string, - target: string, - charset?: string | string[], - entries?: string[] - ): Promise; - - /** - * Unzip a password-protected archive. Pass `entries` to extract only - * those paths (directories include nested children). - */ - export function unzipWithPassword( - source: string, - target: string, - password: string, - entries?: string[] - ): Promise; - - export function unzipAssets(assetPath: string, target: string): Promise; - - export type ZipEntry = { - path: string; - size: number; - compressedSize: number; - isDirectory: boolean; - isEncrypted: boolean; - }; - - export function listContents(source: string, charset?: string): Promise; - - export function subscribe( - callback: ({ progress, filePath }: { progress: number; filePath: string }) => void - ): NativeEventSubscription; - - export function getUncompressedSize(source: string, charset?: string): Promise; - - export function cancel(): Promise; +export const EncryptionMethods: { + readonly STANDARD: "STANDARD"; + readonly AES_128: "AES-128"; + readonly AES_256: "AES-256"; +}; + +export const ErrorCodes: { + readonly FILE_NOT_FOUND: "ERR_FILE_NOT_FOUND"; + readonly INVALID_PATH: "ERR_INVALID_PATH"; + readonly INVALID_ARGS: "ERR_INVALID_ARGS"; + readonly WRONG_PASSWORD: "ERR_WRONG_PASSWORD"; + readonly NOT_PASSWORD_PROTECTED: "ERR_NOT_PASSWORD_PROTECTED"; + readonly CORRUPT_ARCHIVE: "ERR_CORRUPT_ARCHIVE"; + readonly UNSAFE_PATH: "ERR_UNSAFE_PATH"; + readonly CANCELLED: "ERR_CANCELLED"; + readonly BUSY: "ERR_BUSY"; + readonly ZIP: "ERR_ZIP"; + readonly UNZIP: "ERR_UNZIP"; + readonly UNSUPPORTED: "ERR_UNSUPPORTED"; +}; + +export type ZipErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]; + +export class ZipError extends Error { + name: "ZipError"; + code: ZipErrorCode; + constructor(code: ZipErrorCode, message: string); } + +export const DEFAULT_COMPRESSION: -1; +export const NO_COMPRESSION: 0; +export const BEST_SPEED: 1; +export const BEST_COMPRESSION: 9; + +export type ZipEntry = { + path: string; + size: number; + compressedSize: number; + isDirectory: boolean; + isEncrypted: boolean; +}; + +export type ZipProgressEvent = { + progress: number; + filePath: string; +}; + +export type ZipSubscription = { + remove: () => void; +}; + +export type Abortable = { + signal?: AbortSignal; +}; + +export type ZipOptions = Abortable & { + compressionLevel?: number; +}; + +export type ZipPasswordOptions = ZipOptions & { + encryptionMethod?: "STANDARD" | "AES-128" | "AES-256" | ""; +}; + +export type UnzipOptions = Abortable & { + charset?: string; + entries?: string[]; +}; + +export type UnzipPasswordOptions = Abortable & { + entries?: string[]; +}; + +export function isPasswordProtected(source: string): Promise; + +export function zip( + source: string | string[], + target: string, + compressionLevel?: number +): Promise; +export function zip( + source: string | string[], + target: string, + options: ZipOptions +): Promise; + +export function zipWithPassword( + source: string | string[], + target: string, + password: string, + encryptionMethod?: string, + compressionLevel?: number +): Promise; +export function zipWithPassword( + source: string | string[], + target: string, + password: string, + options: ZipPasswordOptions +): Promise; + +/** + * Unzip an archive. Pass `entries` to extract only those paths + * (directories include nested children). When using `entries`, you may + * omit charset (`unzip(src, dest, ['a.txt'])`), pass it explicitly + * (`unzip(src, dest, 'GBK', ['a.txt'])`), or use an options object + * (`unzip(src, dest, { entries, signal })`). + */ +export function unzip( + source: string, + target: string, + charset?: string | string[], + entries?: string[] +): Promise; +export function unzip( + source: string, + target: string, + options: UnzipOptions +): Promise; + +/** + * Unzip a password-protected archive. Pass `entries` to extract only + * those paths, or `{ entries, signal }`. + */ +export function unzipWithPassword( + source: string, + target: string, + password: string, + entries?: string[] | UnzipPasswordOptions +): Promise; + +export function unzipAssets( + assetPath: string, + target: string, + options?: Abortable +): Promise; + +export function listContents( + source: string, + charset?: string +): Promise; + +export function subscribe( + callback: (event: ZipProgressEvent) => void +): ZipSubscription; + +export function getUncompressedSize( + source: string, + charset?: string +): Promise; + +export function cancel(): Promise; diff --git a/index.js b/index.js index 831cab26..4b6ffe11 100644 --- a/index.js +++ b/index.js @@ -7,30 +7,6 @@ import { let _RNZipArchive = null; let _rnzaEmitter = null; -function getRNZipArchive() { - if (!_RNZipArchive) { - // Try TurboModuleRegistry first (New Architecture / Bridgeless) - _RNZipArchive = TurboModuleRegistry.get("RNZipArchive"); - - // Fallback to NativeModules (Old Architecture / Interop) - if (!_RNZipArchive) { - _RNZipArchive = NativeModules.RNZipArchive; - } - - if (!_RNZipArchive) { - throw new Error( - "react-native-zip-archive: Native module not found. " + - "Please ensure the library is properly linked and you are using React Native >= 0.70.0" - ); - } - _rnzaEmitter = new NativeEventEmitter(_RNZipArchive); - } - return _RNZipArchive; -} - -const normalizeFilePath = (path) => - path.startsWith("file://") ? path.slice(7) : path; - export const EncryptionMethods = { STANDARD: "STANDARD", AES_128: "AES-128", @@ -57,7 +33,149 @@ export const NO_COMPRESSION = 0; export const BEST_SPEED = 1; export const BEST_COMPRESSION = 9; +export class ZipError extends Error { + constructor(code, message) { + super(message); + this.name = "ZipError"; + this.code = code; + } +} + +function zipError(code, message) { + return new ZipError(code, message); +} + +function getRNZipArchive() { + if (!_RNZipArchive) { + // Try TurboModuleRegistry first (New Architecture / Bridgeless) + _RNZipArchive = TurboModuleRegistry.get("RNZipArchive"); + + // Fallback to NativeModules (Old Architecture / Interop) + if (!_RNZipArchive) { + _RNZipArchive = NativeModules.RNZipArchive; + } + + if (!_RNZipArchive) { + throw zipError( + ErrorCodes.UNSUPPORTED, + "react-native-zip-archive: Native module not found. " + + "Please ensure the library is properly linked and you are using React Native >= 0.70.0" + ); + } + _rnzaEmitter = new NativeEventEmitter(_RNZipArchive); + } + return _RNZipArchive; +} + +const normalizeFilePath = (path) => + path.startsWith("file://") ? path.slice(7) : path; + +function isPlainOptions(value) { + return value != null && typeof value === "object" && !Array.isArray(value); +} + +function withAbort(signal, work) { + if (!signal) { + return Promise.resolve().then(work); + } + if (signal.aborted) { + return Promise.reject( + zipError(ErrorCodes.CANCELLED, "Operation cancelled") + ); + } + + let settled = false; + let rejectAbort; + const abortGate = new Promise((_, reject) => { + rejectAbort = reject; + }); + const onAbort = () => { + cancel().catch(() => {}); + if (!settled) { + rejectAbort(zipError(ErrorCodes.CANCELLED, "Operation cancelled")); + } + }; + signal.addEventListener("abort", onAbort, { once: true }); + + const run = Promise.resolve() + .then(work) + .then( + (value) => { + settled = true; + return value; + }, + (err) => { + settled = true; + if (signal.aborted) { + throw zipError(ErrorCodes.CANCELLED, "Operation cancelled"); + } + throw err; + } + ); + + return Promise.race([run, abortGate]).finally(() => { + signal.removeEventListener("abort", onAbort); + run.catch(() => {}); + abortGate.catch(() => {}); + }); +} + +function resolveZipOptions(compressionLevelOrOptions) { + if (isPlainOptions(compressionLevelOrOptions)) { + const level = compressionLevelOrOptions.compressionLevel; + return { + compressionLevel: level === undefined ? DEFAULT_COMPRESSION : level, + signal: compressionLevelOrOptions.signal, + }; + } + return { + compressionLevel: + compressionLevelOrOptions === undefined + ? DEFAULT_COMPRESSION + : compressionLevelOrOptions, + signal: undefined, + }; +} + +function resolvePasswordOptions(encryptionMethod, compressionLevel) { + if (isPlainOptions(encryptionMethod)) { + return { + encryptionMethod: encryptionMethod.encryptionMethod ?? "", + compressionLevel: + encryptionMethod.compressionLevel === undefined + ? DEFAULT_COMPRESSION + : encryptionMethod.compressionLevel, + signal: encryptionMethod.signal, + }; + } + return { + encryptionMethod: encryptionMethod ?? "", + compressionLevel: + compressionLevel === undefined ? DEFAULT_COMPRESSION : compressionLevel, + signal: undefined, + }; +} + function resolveUnzipArgs(charsetOrEntries, entries) { + if (isPlainOptions(charsetOrEntries)) { + const selected = charsetOrEntries.entries; + if (selected !== undefined && selected !== null) { + if (!Array.isArray(selected) || selected.length === 0) { + return { + error: zipError( + ErrorCodes.INVALID_ARGS, + "unzip: entries must be a non-empty array when provided" + ), + }; + } + } + return { + charset: charsetOrEntries.charset ?? "UTF-8", + entries: selected ?? null, + signal: charsetOrEntries.signal, + }; + } + let charset = "UTF-8"; let selected = entries; if (Array.isArray(charsetOrEntries)) { @@ -68,7 +186,8 @@ function resolveUnzipArgs(charsetOrEntries, entries) { if (selected !== undefined && selected !== null) { if (!Array.isArray(selected) || selected.length === 0) { return { - error: new Error( + error: zipError( + ErrorCodes.INVALID_ARGS, "unzip: entries must be a non-empty array when provided" ), }; @@ -76,7 +195,7 @@ function resolveUnzipArgs(charsetOrEntries, entries) { } else { selected = null; } - return { charset, entries: selected }; + return { charset, entries: selected, signal: undefined }; } export const unzip = (source, target, charsetOrEntries = "UTF-8", entries) => { @@ -84,11 +203,13 @@ export const unzip = (source, target, charsetOrEntries = "UTF-8", entries) => { if (resolved.error) { return Promise.reject(resolved.error); } - return getRNZipArchive().unzip( - normalizeFilePath(source), - normalizeFilePath(target), - resolved.charset, - resolved.entries + return withAbort(resolved.signal, () => + getRNZipArchive().unzip( + normalizeFilePath(source), + normalizeFilePath(target), + resolved.charset, + resolved.entries + ) ); }; @@ -99,20 +220,29 @@ export const isPasswordProtected = (source) => { }; export const unzipWithPassword = (source, target, password, entries) => { - if (entries !== undefined && entries !== null) { - if (!Array.isArray(entries) || entries.length === 0) { + let selected = entries; + let signal; + if (isPlainOptions(entries)) { + selected = entries.entries; + signal = entries.signal; + } + if (selected !== undefined && selected !== null) { + if (!Array.isArray(selected) || selected.length === 0) { return Promise.reject( - new Error( + zipError( + ErrorCodes.INVALID_ARGS, "unzipWithPassword: entries must be a non-empty array when provided" ) ); } } - return getRNZipArchive().unzipWithPassword( - normalizeFilePath(source), - normalizeFilePath(target), - password, - entries ?? null + return withAbort(signal, () => + getRNZipArchive().unzipWithPassword( + normalizeFilePath(source), + normalizeFilePath(target), + password, + selected ?? null + ) ); }; @@ -127,48 +257,60 @@ export const zipWithPassword = ( encryptionMethod = "", compressionLevel = DEFAULT_COMPRESSION ) => { + const options = resolvePasswordOptions(encryptionMethod, compressionLevel); const RNZipArchive = getRNZipArchive(); - return Array.isArray(source) - ? RNZipArchive.zipFilesWithPassword( - source.map(normalizeFilePath), - normalizeFilePath(target), - password, - encryptionMethod, - compressionLevel - ) - : RNZipArchive.zipFolderWithPassword( - normalizeFilePath(source), - normalizeFilePath(target), - password, - encryptionMethod, - compressionLevel - ); + return withAbort(options.signal, () => + Array.isArray(source) + ? RNZipArchive.zipFilesWithPassword( + source.map(normalizeFilePath), + normalizeFilePath(target), + password, + options.encryptionMethod, + options.compressionLevel + ) + : RNZipArchive.zipFolderWithPassword( + normalizeFilePath(source), + normalizeFilePath(target), + password, + options.encryptionMethod, + options.compressionLevel + ) + ); }; -export const zip = (source, target, compressionLevel = DEFAULT_COMPRESSION) => { +export const zip = (source, target, compressionLevelOrOptions) => { + const options = resolveZipOptions(compressionLevelOrOptions); const RNZipArchive = getRNZipArchive(); - return Array.isArray(source) - ? RNZipArchive.zipFiles( - source.map(normalizeFilePath), - normalizeFilePath(target), - compressionLevel - ) - : RNZipArchive.zipFolder( - normalizeFilePath(source), - normalizeFilePath(target), - compressionLevel - ); + return withAbort(options.signal, () => + Array.isArray(source) + ? RNZipArchive.zipFiles( + source.map(normalizeFilePath), + normalizeFilePath(target), + options.compressionLevel + ) + : RNZipArchive.zipFolder( + normalizeFilePath(source), + normalizeFilePath(target), + options.compressionLevel + ) + ); }; -export const unzipAssets = (source, target) => { +export const unzipAssets = (source, target, options) => { const RNZipArchive = getRNZipArchive(); if (!RNZipArchive.unzipAssets) { - throw new Error("unzipAssets not supported on this platform"); + throw zipError( + ErrorCodes.UNSUPPORTED, + "unzipAssets not supported on this platform" + ); } - return RNZipArchive.unzipAssets( - normalizeFilePath(source), - normalizeFilePath(target) + const signal = isPlainOptions(options) ? options.signal : undefined; + return withAbort(signal, () => + RNZipArchive.unzipAssets( + normalizeFilePath(source), + normalizeFilePath(target) + ) ); }; diff --git a/package.json b/package.json index e7dd9bf3..caa6e0d5 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "9.4.1", "description": "Zip and unzip files in React Native and Expo (iOS & Android)", "main": "index.js", + "types": "index.d.ts", "scripts": { "test": "jest", "test:interop": "node scripts/verify-zip-interop.js --expect-fail fixtures/interop/winzip-aes-marker.zip && node scripts/verify-zip-interop.js --fixtures", From 3745e982ec51fb67cf0960d361f586e91d844c2a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 02:37:41 +0000 Subject: [PATCH 3/4] fix: avoid class ZipError so playground-rn Metro can bundle Metro transforms a class in the library's index.js into @babel/runtime/helpers/interopRequireDefault, which playground-rn could not resolve from the parent package. Keep ZipError as a factory. Also point playground-rn Metro at the app node_modules, and scroll to "Skipped hello.txt" in the list-contents Maestro flow. Co-authored-by: plrthink --- .maestro/flows/_list-contents-test.yaml | 5 +++++ CHANGELOG.md | 2 +- index.d.ts | 6 +++--- index.js | 11 +++++------ playground-rn/metro.config.js | 4 ++++ 5 files changed, 18 insertions(+), 10 deletions(-) diff --git a/.maestro/flows/_list-contents-test.yaml b/.maestro/flows/_list-contents-test.yaml index 3a73348d..30bd75fa 100644 --- a/.maestro/flows/_list-contents-test.yaml +++ b/.maestro/flows/_list-contents-test.yaml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 63f9a65a..13a893d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### 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`, …) +- `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 diff --git a/index.d.ts b/index.d.ts index 8109dbe1..3a0dd40f 100644 --- a/index.d.ts +++ b/index.d.ts @@ -21,11 +21,11 @@ export const ErrorCodes: { export type ZipErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]; -export class ZipError extends Error { +/** Runtime is a factory (not an ES class) so Metro does not inject @babel/runtime helpers. */ +export function ZipError(code: ZipErrorCode, message: string): Error & { name: "ZipError"; code: ZipErrorCode; - constructor(code: ZipErrorCode, message: string); -} +}; export const DEFAULT_COMPRESSION: -1; export const NO_COMPRESSION: 0; diff --git a/index.js b/index.js index 4b6ffe11..923d1869 100644 --- a/index.js +++ b/index.js @@ -33,12 +33,11 @@ export const NO_COMPRESSION = 0; export const BEST_SPEED = 1; export const BEST_COMPRESSION = 9; -export class ZipError extends Error { - constructor(code, message) { - super(message); - this.name = "ZipError"; - this.code = code; - } +export function ZipError(code, message) { + const err = new Error(message); + err.name = "ZipError"; + err.code = code; + return err; } function zipError(code, message) { diff --git a/playground-rn/metro.config.js b/playground-rn/metro.config.js index 37c08a5a..dbcc492f 100644 --- a/playground-rn/metro.config.js +++ b/playground-rn/metro.config.js @@ -10,6 +10,10 @@ const path = require('path'); const config = { watchFolders: [path.resolve(__dirname, '..')], resolver: { + nodeModulesPaths: [ + path.resolve(__dirname, 'node_modules'), + path.resolve(__dirname, '..', 'node_modules'), + ], resolveRequest: (context, moduleName, platform) => { if (moduleName === 'react-native') { return { From bafe5451a103ed7f5b49070de177d362d4eb2f26 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 08:24:44 +0000 Subject: [PATCH 4/4] docs: drop README non-goals paragraph The Comparison table already covers zip vs JSZip vs Nitro. The extra tar/7z and Nitro-rewrite copy does not belong in the README. Co-authored-by: plrthink --- CHANGELOG.md | 1 - README.md | 2 -- __tests__/package-metadata.test.js | 4 +--- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13a893d0..e2d162c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,6 @@ - `package.json` `"types": "index.d.ts"` so TypeScript and reactnative.directory `hasTypes` resolve ### Changed -- README: tar/7z is an explicit non-goal; Nitro rewrite is not a growth plan (RNZA-23, RNZA-24) - SECURITY.md: 7.x Zip Slip / symlink backport is **7.1.2** (`maintenance-7`), not 7.1.1 ## [9.4.1] - 2026-08-29 diff --git a/README.md b/README.md index 1a8d69fe..d4b482c5 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,6 @@ Zip archive utility for React Native. Use this library for native zip/unzip on device. Use JSZip when you only need small archives in JS. Nitro may fit if you want additional archive formats and accept the extra Nitro dependency and smaller install base. -**Non-goals:** tar and 7z are out of scope unless a dependent asks in writing — this package stays zip-excellent. A Nitro rewrite is not a growth plan; revisit only if Interop + Expo are done and a named app is still blocked on performance. - ## Installation ### React Native (bare) diff --git a/__tests__/package-metadata.test.js b/__tests__/package-metadata.test.js index dadb8efd..50c09e32 100644 --- a/__tests__/package-metadata.test.js +++ b/__tests__/package-metadata.test.js @@ -92,10 +92,8 @@ describe('docs claims vs native source (RNZA-7/15/17/19)', () => { expect(readme).not.toMatch(/old architecture is (fully )?supported/i); }); - test('README records tar/7z non-goal and no Nitro rewrite for growth', () => { + test('README records AbortSignal and ZipError usage', () => { const readme = read('README.md'); - expect(readme).toMatch(/tar and 7z are out of scope/i); - expect(readme).toMatch(/Nitro rewrite is not a growth plan/i); expect(readme).toMatch(/test:interop/); expect(readme).toMatch(/AbortSignal/); expect(readme).toMatch(/ZipError/);