diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 26eb103..280ec12 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 0000000..eb9f42f --- /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/.maestro/flows/_list-contents-test.yaml b/.maestro/flows/_list-contents-test.yaml index 3a73348..30bd75f 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/.npmignore b/.npmignore index 5f0ab3c..da8f5a1 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 286454a..e2d162c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 6837f1a..d4b482c 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. @@ -84,6 +84,7 @@ import { isPasswordProtected, getUncompressedSize, ErrorCodes, + ZipError, DEFAULT_COMPRESSION, NO_COMPRESSION, BEST_SPEED, @@ -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` +### `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) @@ -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 @@ -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` Returns the total uncompressed size of all files in the zip archive (in bytes). @@ -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. diff --git a/SECURITY.md b/SECURITY.md index 80a611a..2da836a 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__/api.test.js b/__tests__/api.test.js index 62368f4..40a813b 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 4be46b1..50c09e3 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,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); }); }); @@ -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/); @@ -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/); + }); }); diff --git a/__tests__/zip-interop.test.js b/__tests__/zip-interop.test.js new file mode 100644 index 0000000..035b648 --- /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 0000000..50b475a --- /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 0000000..94db663 Binary files /dev/null and b/fixtures/interop/plain-deflate.zip differ diff --git a/fixtures/interop/winzip-aes-marker.zip b/fixtures/interop/winzip-aes-marker.zip new file mode 100644 index 0000000..2e6e9f1 Binary files /dev/null and b/fixtures/interop/winzip-aes-marker.zip differ diff --git a/index.d.ts b/index.d.ts index 85653d5..3a0dd40 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]; + +/** 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; +}; + +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 831cab2..923d186 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,148 @@ export const NO_COMPRESSION = 0; export const BEST_SPEED = 1; export const BEST_COMPRESSION = 9; +export function ZipError(code, message) { + const err = new Error(message); + err.name = "ZipError"; + err.code = code; + return err; +} + +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 +185,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 +194,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 +202,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 +219,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 +256,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 7c4be17..caa6e0d 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,10 @@ "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", "lint": "eslint index.js", "test:e2e:expo:ios": "./scripts/e2e-ios.sh", "test:e2e:expo:android": "./scripts/e2e-android.sh", @@ -45,7 +47,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/playground-rn/metro.config.js b/playground-rn/metro.config.js index 37c08a5..dbcc492 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 { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b58ff3..2cd0c6a 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 0000000..aa8d5f6 --- /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 0000000..2cb8207 --- /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 93ad91a..ef36021 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 0000000..cb3ab24 --- /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 };