diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5fafaeb..be1c5f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,10 +108,15 @@ jobs: quality=true site=true ;; - scripts/prepare-package.mjs|scripts/test-package-consumers.mjs) + scripts/prepare-package.mjs|scripts/check-package-contract.mjs|scripts/test-package-consumers.mjs|scripts/test-sdk-consumers.mjs|scripts/sdk-consumer/**|examples/web-sdk/**) quality=true web=true ;; + bin/**|node/**|toolkit/**|action/**|action.yml|scripts/test-node-cli.mjs|scripts/test-toolkit.mjs|scripts/test-action.mjs) + quality=true + site=true + web=true + ;; benchmarks/**|scripts/benchmark-web.mjs) quality=true site=true @@ -413,7 +418,13 @@ jobs: - name: Checkout the code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - - name: Set up Node.js + - name: Set up repository Node.js and dependencies + uses: ./.github/actions/setup + + - name: Build package for prepack contract + run: yarn prepare + + - name: Set up React Native fixture Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: ${{ matrix.node }} @@ -451,7 +462,11 @@ jobs: yarn test:web yarn test:web:browser yarn test:web:metro + yarn test:toolkit + yarn test:node + yarn test:action yarn test:package + yarn test:sdk - name: Verify npm package contents run: npm pack --dry-run --ignore-scripts diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 0dcfc41..05df4f3 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -16,7 +16,7 @@ jobs: publish-npm: name: Publish to npm with OIDC runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 30 steps: - name: Checkout the release tag uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -86,7 +86,11 @@ jobs: yarn test:web yarn test:web:browser yarn test:web:metro + yarn test:toolkit + yarn test:node + yarn test:action yarn test:package + yarn test:sdk npm pack --dry-run --ignore-scripts env: CHROME_PATH: /usr/bin/google-chrome @@ -109,3 +113,9 @@ jobs: done echo 'Published package did not expose provenance metadata in time.' >&2 exit 1 + + - name: Smoke test the published SDK + env: + PACKAGE_SPEC: react-native-bs-diff-patch@${{ steps.release.outputs.package_version }} + CHROME_PATH: /usr/bin/google-chrome + run: yarn test:sdk diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 4d98ea7..67c7b5d 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -8,6 +8,7 @@ on: - 'docs/**' - 'site/**' - 'web/**' + - 'toolkit/**' - 'scripts/build-site.mjs' - 'scripts/test-site.mjs' - 'scripts/test-site-browser.mjs' diff --git a/CHANGELOG.md b/CHANGELOG.md index c38d993..c9d602b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,51 @@ All notable changes to this project are documented in this file. Releases use [Semantic Versioning](https://semver.org/) and are generated from Conventional Commits by release-it. +## [0.5.0](https://github.com/JimmyDaddy/react-native-bs-diff-patch/compare/v0.4.0...v0.5.0) (2026-08-31) + +### Added + +- add the explicit ESM `react-native-bs-diff-patch/web` entry for browser and + desktop WebView byte operations, including typed declarations, Worker jobs, + progress, cancellation, input/output limits, patch inspection, and + byte-for-byte verification; +- publish a Node-free browser/Worker WebAssembly artifact alongside the + Node-compatible artifact, while keeping the existing root React Native, + Node, and CLI loading paths; +- add the ESM `react-native-bs-diff-patch/toolkit` entry for normalized + manifests, multi-baseline bundles, canonical payloads, candidate selection, + error classification, and header-only inspection; +- add tarball consumer checks for `/web` and `/toolkit`, production Vite + resource loading, offline browser execution, and TypeScript resolution; +- add the Node release helpers, CLI, GitHub Action, and BSDIFF40 converter + needed to prepare verified release artifacts without changing the runtime's + `ENDSLEY/BSDIFF43` compatibility contract; +- add bilingual WebView integration, packaging, lifecycle, CSP, resource + budget, trust-boundary, and release documentation. + +### Compatibility and release boundaries + +- keep the existing root CommonJS build, React Native conditional exports, + native path API, and Node-compatible `web/bsdiffpatch.mjs`; +- keep `/web` and `/toolkit` ESM-only, and map the browser Worker graph to + `web/bsdiffpatch.browser.mjs` without a CDN or Node runtime fallback; +- keep `BSDIFF40` as a header-inspection/conversion case only; runtime + generation and application remain `ENDSLEY/BSDIFF43`; +- leave file authorization, persistence, and final replacement to downstream + desktop applications. Tauri WebView acceptance remains a downstream test, + and registry smoke checks remain a post-release validation step. + +### Security and compatibility scope + +- enforce a zero-byte output budget during C compressed output writes, and add + control-flow guards for malformed or non-progressing patch streams; +- classify toolkit array cycles as `EINVALID_MANIFEST`, preserve a literal + `__proto__` object key during canonicalization, and reject invalid selection + budgets even when the requested baseline has no matching candidate; +- retain first-match candidate selection rather than silently choosing the + smallest patch, while keeping the root React Native/Node compatibility paths + and the `ENDSLEY/BSDIFF43` runtime format unchanged. + ## [0.4.0](https://github.com/JimmyDaddy/react-native-bs-diff-patch/compare/v0.3.0...v0.4.0) (2026-07-23) ### Features diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 08327f5..a09855e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -72,8 +72,14 @@ Web implementation changes should also pass: yarn test:web yarn test:web:browser yarn test:web:metro +yarn test:sdk ``` +`test:sdk` installs the prepared package tarball into an isolated consumer and +checks the public `/web` and `/toolkit` ESM entries, the production Vite +resource graph, and real byte round trips. It does not use a workspace link or +the registry's older package. + Documentation and site changes should pass: ```sh @@ -119,12 +125,29 @@ Maintainers should run the quality gates, then create the release: ```sh yarn prepare +yarn test:sdk yarn typecheck yarn lint yarn test --runInBand -yarn release +yarn pack --dry-run +yarn release --no-increment ``` +`yarn prepare` runs the React Native Builder Bob output step, package +preparation, and `scripts/check-package-contract.mjs`. The same contract check +runs from the `prepack` lifecycle before a tarball is created. Run +`node scripts/check-package-contract.mjs` directly when inspecting a prepared +tree without rebuilding it. `yarn build:web` produces separate Node and +browser/Worker WASM modules: `web/bsdiffpatch.mjs` keeps NODEFS for Node and +the CLI, while `web/bsdiffpatch.browser.mjs` is the Node-free browser build. + +When `package.json` already contains the prepared version, use +`yarn release --no-increment` so release-it does not bump it again. This command +creates the release commit, tag, and GitHub Release; the GitHub Release then +triggers the npm workflow. Run it only with explicit maintainer authorization. +A local 0.5.0 tarball is not a registry release; do not describe it as +published until the GitHub Release and npm provenance checks have completed. + The npm package already trusts the `JimmyDaddy/react-native-bs-diff-patch` repository and the `npm-publish.yml` workflow. No npm-side configuration is required for a release. The release tag must exactly match @@ -145,6 +168,10 @@ The `package.json` file contains various scripts for common tasks: - `yarn test:web`: verify the WebAssembly patch format and round trip. - `yarn test:web:browser`: exercise the public Web Worker API in Chrome. - `yarn test:web:metro`: verify Metro resolves the React Native Web entry. +- `yarn test:sdk`: install the prepared tarball and verify `/web` and `/toolkit` + from an isolated Vite consumer. +- `node scripts/check-package-contract.mjs`: verify exports, declarations, + packed assets, and the Node-free browser resource graph. - `yarn site:build`: render public Markdown and static site assets into `site-dist/`. - `yarn site:test`: validate site structure and local links. - `yarn site:test:browser`: verify the live Playground, docs, and mobile viewport. diff --git a/README.md b/README.md index d7e500c..b2d5eb7 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@

- Turn two versions of a file into a compact binary patch, then reconstruct the new file from the old file plus that patch.
- One compatible format across React Native Android, iOS, and Web. + A verified binary delta pipeline for React Native, Web, Node.js, and release CI.
+ Create compact patches, prove restored bytes, and plan multi-baseline delivery with one compatible format.

@@ -22,6 +22,7 @@ Documentation · Live Playground · Binary Patch Toolkit · + Release Planner · 中文说明 · npm

@@ -53,6 +54,8 @@ replaces live data. browser. - **Inspect and prove compatibility:** read patch metadata and verify restored bytes through the same API shape on native and Web. +- **Release-side tooling:** generate patches through `npx`, publish verified + manifests, and choose a patch or full-file fallback for each baseline. ## Platform overview @@ -60,16 +63,46 @@ replaces live data. | -------------- | -------------------------------------------- | -------------------------------------------------- | | Input | Absolute file paths | `ArrayBuffer`, typed arrays, `DataView`, or `Blob` | | Basic API | `diff()` / `patch()` | `diffBytes()` / `patchBytes()` | -| Controlled API | `startDiff()` / `startPatch()` | `AbortSignal` and binary limits | +| Controlled API | `startDiff()` / `startPatch()` | Binary `startDiff()` / `startPatch()` jobs | | Verification | Paths via `inspectPatch()` / `verifyPatch()` | Binary values via the same APIs | | Engine | Native C via JNI / ObjC++ | Same C core via WASM Worker | +## Release-side CLI and bundles + +The same npm package includes a Node.js CLI for release pipelines: + +```sh +npx react-native-bs-diff-patch diff old.bin new.bin -o update.patch +npx react-native-bs-diff-patch verify old.bin update.patch new.bin +npx react-native-bs-diff-patch bundle \ + --from releases/ \ + --to dist/app.bin \ + --out dist/update-bundle +``` + +`bundle` evaluates every baseline, retains efficient patches, adds a full-file +fallback, and writes a canonical verified manifest suitable for CDN selection +and detached signing. The CLI mounts host paths through NODEFS, while verified +patch application uses the bounded streaming core. Try the workflow in the browser with the +[Release Planner](https://bs-dff-patch.corerobin.com/planner/). + ## Install ```sh -npm install react-native-bs-diff-patch +npm install react-native-bs-diff-patch@^0.5.0 +``` + +The explicit `/web` and `/toolkit` entries are part of 0.5.0. For pre-release +verification of a locally prepared package, the same entries can be tested from +its tarball instead: + +```sh +npm install ./react-native-bs-diff-patch-0.5.0.tgz ``` +The registry's 0.4.x package predates these subpaths. See the [Web and desktop +WebView SDK guide](./docs/web-sdk.md) for the resource graph and consumer checks. + For iOS, install Pods and rebuild the native application: ```sh @@ -123,24 +156,35 @@ try { ## Web: first round trip -```ts -import { diffBytes, patchBytes } from 'react-native-bs-diff-patch'; +Standalone browser, Vite, and Tauri consumers should import the explicit ESM +entry `react-native-bs-diff-patch/web`; it exposes byte APIs and does not +require React Native. The root package keeps its conditional React Native and +browser resolution for existing applications. See the [Web and desktop WebView +SDK guide](./docs/web-sdk.md) for the published resource graph and CSP. -const oldBytes = await oldFile.arrayBuffer(); -const newBytes = await newFile.arrayBuffer(); +```ts +import { diffBytes, patchBytes } from 'react-native-bs-diff-patch/web'; -const patchBytesValue = await diffBytes(oldBytes, newBytes, { +const patchBytesValue = await diffBytes(oldFile, newFile, { signal: abortController.signal, maxInputBytes: 64 * 1024 * 1024, + onProgress: ({ phase, progress }) => { + renderProgress(phase, progress); + }, }); -const restoredBytes = await patchBytes(oldBytes, patchBytesValue, { +const restoredBytes = await patchBytes(oldFile, patchBytesValue, { maxOutputBytes: 64 * 1024 * 1024, }); ``` Web calls return a new `Uint8Array` and leave caller-owned buffers usable. Aborted operations reject with `EABORTED`; configured binary limits reject with -`ERESOURCE`. +`ERESOURCE`. `Blob` and `File` inputs are mounted read-only in the Worker, so +they do not need a full main-thread copy before the C core reads them. + +Use `startDiff()` / `startPatch()` with binary inputs on Web, or the explicit +`startDiffBytes()` / `startPatchBytes()` aliases, when UI code needs a job +object with `result`, `cancel()`, and real C-core progress events. ## Inspect and verify a patch @@ -169,17 +213,18 @@ update manifest before replacing live data. ## API matrix -| API | Android | iOS | Web | -| --------------------------------------------- | ------- | --- | --- | -| `diff(oldPath, newPath, patchPath)` | Yes | Yes | No | -| `patch(oldPath, outputPath, patchPath)` | Yes | Yes | No | -| `startDiff(...)` / `startPatch(...)` | Yes | Yes | No | -| `diffBytes(oldData, newData, options?)` | No | No | Yes | -| `patchBytes(oldData, patchData, options?)` | No | No | Yes | -| `inspectPatch(path or binary, options?)` | Yes | Yes | Yes | -| `verifyPatch(old, patch, expected, options?)` | Yes | Yes | Yes | -| Legacy architecture, while supplied by RN | Yes | Yes | N/A | -| New Architecture / TurboModule | Yes | Yes | N/A | +| API | Android | iOS | Web | +| ---------------------------------------------- | ------- | ----- | ------ | +| `diff(oldPath, newPath, patchPath)` | Yes | Yes | No | +| `patch(oldPath, outputPath, patchPath)` | Yes | Yes | No | +| `startDiff(...)` / `startPatch(...)` | Paths | Paths | Binary | +| `startDiffBytes(...)` / `startPatchBytes(...)` | No | No | Yes | +| `diffBytes(oldData, newData, options?)` | No | No | Yes | +| `patchBytes(oldData, patchData, options?)` | No | No | Yes | +| `inspectPatch(path or binary, options?)` | Yes | Yes | Yes | +| `verifyPatch(old, patch, expected, options?)` | Yes | Yes | Yes | +| Legacy architecture, while supplied by RN | Yes | Yes | N/A | +| New Architecture / TurboModule | Yes | Yes | N/A | Unavailable platform APIs reject with `EUNSUPPORTED`; the package never silently switches to a different input model. @@ -191,27 +236,34 @@ silently switches to a different input model. - Use unique native output paths and remove outputs you no longer need. - Set product-specific resource limits. Binary diffing can use several times the input size in peak memory. -- Generate and apply patches with this library. Generic `BSDIFF40` patches are - not interchangeable with `ENDSLEY/BSDIFF43` patches. +- Runtime APIs accept `ENDSLEY/BSDIFF43`. Convert existing `BSDIFF40` files + offline with `npx react-native-bs-diff-patch convert legacy.patch -o +compatible.patch`, then verify them before publishing. See [Production recipes](./docs/recipes.md) for integrity checks, downloads, cross-runtime exchange, error handling, and cleanup patterns. ## Verified compatibility -CI compiles the Android and iOS APIs against React Native 0.73.11, 0.74.7, and -0.86.0, and runs device-level New Architecture assertions on Android and iOS. -Packed-consumer tests verify browser, ESM, CommonJS, Metro, and TypeScript -resolution from the real npm package shape. +CI covers Android and iOS API builds against React Native 0.73.11, 0.74.7, and +0.86.0, and runs the configured New Architecture assertions. These checks do +not constitute Tauri WebView acceptance or downstream physical-device +acceptance. Packed-consumer tests verify browser, ESM, CommonJS, Metro, and +TypeScript resolution from the real npm package shape. ## Documentation +- [Web and desktop WebView SDK](./docs/web-sdk.md) — use the explicit ESM + `/web` and `/toolkit` entries from Vite or Tauri without React Native or a + Node sidecar. - [Getting started](./docs/getting-started.md) - [API reference](./docs/api-reference.md) - [Production recipes](./docs/recipes.md) +- [Verified Delta Pipeline](./docs/verified-delta-pipeline.md) - [Platform support](./docs/platform-support.md) - [Architecture and patch format](./docs/architecture.md) - [Controllable native operations](./docs/native-operations-v03.md) +- [Large-file roadmap](./docs/large-files-roadmap.md) - [Troubleshooting](./docs/troubleshooting.md) - [Development and verification](./docs/development.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index 3993231..5803ab7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -7,8 +7,8 @@

- 比较文件的两个版本,生成紧凑的二进制补丁;再用旧文件和补丁还原新文件。
- React Native Android、iOS 与 Web 共用同一种兼容格式。 + 面向 React Native、Web、Node.js 与发布 CI 的可验证二进制增量工具链。
+ 用同一种兼容格式生成紧凑补丁、验证还原字节,并规划多基线分发。

@@ -22,6 +22,7 @@ 中文文档 · 在线 Playground · 二进制补丁工具箱 · + 发布规划器 · English · npm

@@ -49,6 +50,8 @@ 暴露未完成的输出文件。 - **Web 无需补丁服务:** 差分和还原完全在浏览器本地执行。 - **检查并证明兼容性:** 原生与 Web 使用相同 API 读取补丁元数据,并验证还原字节。 +- **发布端工具:** 通过 `npx` 生成补丁、发布可验证 manifest,并为每个基线选择 + 补丁或完整文件回退。 ## 平台概览 @@ -56,16 +59,44 @@ | -------- | ----------------------------------------- | ----------------------------------------------- | | 输入 | 绝对文件路径 | `ArrayBuffer`、TypedArray、`DataView` 或 `Blob` | | 基础 API | `diff()` / `patch()` | `diffBytes()` / `patchBytes()` | -| 可控 API | `startDiff()` / `startPatch()` | `AbortSignal` 与二进制大小限制 | +| 可控 API | `startDiff()` / `startPatch()` | 二进制 `startDiff()` / `startPatch()` job | | 验证能力 | 路径版 `inspectPatch()` / `verifyPatch()` | 相同 API 的二进制输入 | | 执行核心 | JNI / ObjC++ 调用原生 C | WASM Worker 运行同一 C 核心 | +## 发布端 CLI 与 bundle + +同一个 npm 包提供面向发布流程的 Node.js CLI: + +```sh +npx react-native-bs-diff-patch diff old.bin new.bin -o update.patch +npx react-native-bs-diff-patch verify old.bin update.patch new.bin +npx react-native-bs-diff-patch bundle \ + --from releases/ \ + --to dist/app.bin \ + --out dist/update-bundle +``` + +`bundle` 会评估每个基线,保留高收益补丁,增加完整文件回退,并写出适合 CDN +选择和 detached signature 的 canonical manifest。CLI 通过 NODEFS 挂载宿主路径, +验证 patch 时使用有界流式核心。也可以直接在浏览器打开 +[发布规划器](https://bs-dff-patch.corerobin.com/zh-CN/planner/)体验完整流程。 + ## 安装 ```sh -npm install react-native-bs-diff-patch +npm install react-native-bs-diff-patch@^0.5.0 +``` + +明确的 `/web` 与 `/toolkit` 入口属于 0.5.0。发布前验证本地准备的包时,也可以改用其 +tarball: + +```sh +npm install ./react-native-bs-diff-patch-0.5.0.tgz ``` +registry 的 0.4.x 包尚未包含这些子路径。资源图和消费者检查详见 +[Web 与桌面 WebView SDK](./docs/zh-CN/web-sdk.md)。 + iOS 还需要安装 Pods,并重新构建原生应用: ```sh @@ -119,23 +150,33 @@ try { ## Web:第一次往返 -```ts -import { diffBytes, patchBytes } from 'react-native-bs-diff-patch'; +独立浏览器、Vite 和 Tauri 消费者应导入明确的 +`react-native-bs-diff-patch/web` ESM 入口;它提供字节 API,不需要 React Native。 +根包继续为已有应用保留 React Native 与 browser 条件解析。发布资源图和 CSP 见 +[Web 与桌面 WebView SDK](./docs/zh-CN/web-sdk.md)。 -const oldBytes = await oldFile.arrayBuffer(); -const newBytes = await newFile.arrayBuffer(); +```ts +import { diffBytes, patchBytes } from 'react-native-bs-diff-patch/web'; -const patchBytesValue = await diffBytes(oldBytes, newBytes, { +const patchBytesValue = await diffBytes(oldFile, newFile, { signal: abortController.signal, maxInputBytes: 64 * 1024 * 1024, + onProgress: ({ phase, progress }) => { + renderProgress(phase, progress); + }, }); -const restoredBytes = await patchBytes(oldBytes, patchBytesValue, { +const restoredBytes = await patchBytes(oldFile, patchBytesValue, { maxOutputBytes: 64 * 1024 * 1024, }); ``` Web API 返回新的 `Uint8Array`,不会转移或失效调用方的缓冲区。主动取消以 -`EABORTED` 拒绝;命中二进制大小限制时以 `ERESOURCE` 拒绝。 +`EABORTED` 拒绝;命中二进制大小限制时以 `ERESOURCE` 拒绝。`Blob` 与 `File` +会只读挂载到 Worker,不需要先在主线程生成完整副本。 + +Web 端可以用二进制输入调用 `startDiff()` / `startPatch()`,也可以使用明确的 +`startDiffBytes()` / `startPatchBytes()` 别名,获得带 `result`、`cancel()` 和 +真实 C 核心进度事件的 job。 ## 检查并验证补丁 @@ -163,17 +204,18 @@ if (!metadata.valid || !result.verified) { ## API 矩阵 -| API | Android | iOS | Web | -| --------------------------------------------- | ------- | ------ | ------ | -| `diff(oldPath, newPath, patchPath)` | 支持 | 支持 | 不支持 | -| `patch(oldPath, outputPath, patchPath)` | 支持 | 支持 | 不支持 | -| `startDiff(...)` / `startPatch(...)` | 支持 | 支持 | 不支持 | -| `diffBytes(oldData, newData, options?)` | 不支持 | 不支持 | 支持 | -| `patchBytes(oldData, patchData, options?)` | 不支持 | 不支持 | 支持 | -| `inspectPatch(path 或 binary, options?)` | 支持 | 支持 | 支持 | -| `verifyPatch(old, patch, expected, options?)` | 支持 | 支持 | 支持 | -| 旧架构(限 RN 仍提供时) | 支持 | 支持 | 不适用 | -| 新架构 / TurboModule | 支持 | 支持 | 不适用 | +| API | Android | iOS | Web | +| ---------------------------------------------- | ------- | ------ | ------ | +| `diff(oldPath, newPath, patchPath)` | 支持 | 支持 | 不支持 | +| `patch(oldPath, outputPath, patchPath)` | 支持 | 支持 | 不支持 | +| `startDiff(...)` / `startPatch(...)` | 路径 | 路径 | 二进制 | +| `startDiffBytes(...)` / `startPatchBytes(...)` | 不支持 | 不支持 | 支持 | +| `diffBytes(oldData, newData, options?)` | 不支持 | 不支持 | 支持 | +| `patchBytes(oldData, patchData, options?)` | 不支持 | 不支持 | 支持 | +| `inspectPatch(path 或 binary, options?)` | 支持 | 支持 | 支持 | +| `verifyPatch(old, patch, expected, options?)` | 支持 | 支持 | 支持 | +| 旧架构(限 RN 仍提供时) | 支持 | 支持 | 不适用 | +| 新架构 / TurboModule | 支持 | 支持 | 不适用 | 调用当前平台不可用的 API 会以 `EUNSUPPORTED` 拒绝,不会静默切换成其他输入 模型。 @@ -184,26 +226,31 @@ if (!metadata.valid || !result.verified) { - 替换业务数据前,验证还原结果与目标文件完全一致。 - 原生端使用唯一输出路径,并清理不再需要的输出文件。 - 按业务设置资源限制;二进制差分的峰值内存可能达到输入大小的数倍。 -- 使用本库配套生成和应用补丁;通用 `BSDIFF40` 与 - `ENDSLEY/BSDIFF43` 不兼容。 +- 运行时只接受 `ENDSLEY/BSDIFF43`。已有 `BSDIFF40` 可以通过 + `npx react-native-bs-diff-patch convert legacy.patch -o compatible.patch` + 离线转换,并在发布前完成验证。 完整性校验、补丁下载、跨运行时交换、错误处理与清理模式见 [生产实践](./docs/zh-CN/recipes.md)。 ## 已验证的兼容性 -CI 会使用 React Native 0.73.11、0.74.7 与 0.86.0 编译 Android 和 iOS API, -并在 Android 与 iOS 上执行新架构设备级断言。真实 npm 包消费测试还覆盖 browser、 -ESM、CommonJS、Metro 与 TypeScript 解析。 +CI 覆盖使用 React Native 0.73.11、0.74.7 与 0.86.0 的 Android 和 iOS API 构建, +并运行配置的新架构断言。这些检查不等同于 Tauri WebView 验收或下游真机验收。真实 +npm 包消费测试还覆盖 browser、ESM、CommonJS、Metro 与 TypeScript 解析。 ## 完整文档 +- [Web 与桌面 WebView SDK](./docs/zh-CN/web-sdk.md) — 从 Vite 或 Tauri 使用明确的 + `/web` 与 `/toolkit` ESM 入口,无需 React Native 或 Node sidecar。 - [快速开始](./docs/zh-CN/getting-started.md) - [API 参考](./docs/zh-CN/api-reference.md) - [生产实践](./docs/zh-CN/recipes.md) +- [可验证增量发布工具链](./docs/zh-CN/verified-delta-pipeline.md) - [平台支持](./docs/zh-CN/platform-support.md) - [架构与补丁格式](./docs/zh-CN/architecture.md) - [可控制的原生操作](./docs/zh-CN/native-operations-v03.md) +- [大文件演进路线](./docs/zh-CN/large-files-roadmap.md) - [常见问题与排障](./docs/zh-CN/troubleshooting.md) - [开发与验证](./docs/zh-CN/development.md) diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..b138a30 --- /dev/null +++ b/action.yml @@ -0,0 +1,50 @@ +name: Verified Delta Patch +description: Create a verified BSDIFF43 patch and manifest for a release artifact +author: JimmyDaddy + +inputs: + old-file: + description: Baseline release file + required: true + new-file: + description: Target release file + required: true + patch-file: + description: Output patch path + required: false + default: update.patch + manifest-file: + description: Output verified manifest path + required: false + default: patch-manifest.json + max-patch-ratio: + description: Full-file fallback threshold between 0 and 1 + required: false + default: '0.85' + release-id: + description: Optional release identifier stored in the manifest + required: false + +outputs: + strategy: + description: patch when the delta is efficient, otherwise full + patch-file: + description: Generated patch path + manifest-file: + description: Generated manifest path + patch-bytes: + description: Generated patch size + target-bytes: + description: Target file size + patch-ratio: + description: Patch size divided by target size + savings-ratio: + description: Transfer ratio saved compared with the full target + +runs: + using: node24 + main: action/index.mjs + +branding: + icon: package + color: blue diff --git a/action/index.mjs b/action/index.mjs new file mode 100644 index 0000000..ccc37fe --- /dev/null +++ b/action/index.mjs @@ -0,0 +1,86 @@ +import { appendFile, writeFile } from 'node:fs/promises'; +import process from 'node:process'; + +import { + createFilePatchManifest, + describeFile, + diffFiles, +} from '../node/index.mjs'; + +function input(name, options = {}) { + const value = process.env[`INPUT_${name.toUpperCase()}`]?.trim(); + if (!value && options.required) { + const error = new Error(`missing required action input: ${name}`); + error.code = 'EINVAL'; + throw error; + } + return value || options.defaultValue; +} + +async function setOutputs(values) { + const outputPath = process.env.GITHUB_OUTPUT; + if (!outputPath) { + for (const [name, value] of Object.entries(values)) { + process.stdout.write(`${name}=${value}\n`); + } + return; + } + const lines = Object.entries(values) + .map(([name, value]) => `${name}=${String(value).replace(/\r?\n/g, ' ')}`) + .join('\n'); + await appendFile(outputPath, `${lines}\n`); +} + +function escapeWorkflowCommand(value) { + return String(value) + .replace(/%/g, '%25') + .replace(/\r/g, '%0D') + .replace(/\n/g, '%0A'); +} + +async function main() { + const oldPath = input('OLD-FILE', { required: true }); + const newPath = input('NEW-FILE', { required: true }); + const patchPath = input('PATCH-FILE', { defaultValue: 'update.patch' }); + const manifestPath = input('MANIFEST-FILE', { + defaultValue: 'patch-manifest.json', + }); + const releaseId = input('RELEASE-ID'); + const maximumRatio = Number( + input('MAX-PATCH-RATIO', { defaultValue: '0.85' }) + ); + if (!Number.isFinite(maximumRatio) || maximumRatio < 0 || maximumRatio > 1) { + const error = new Error('max-patch-ratio must be between 0 and 1'); + error.code = 'EINVAL'; + throw error; + } + + const patch = await diffFiles(oldPath, newPath, patchPath); + const target = await describeFile(newPath); + const ratio = patch.bytes / Math.max(1, target.bytes); + const manifest = await createFilePatchManifest(oldPath, patchPath, newPath, { + releaseId, + }); + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { + flag: 'wx', + }); + await setOutputs({ + 'strategy': ratio <= maximumRatio ? 'patch' : 'full', + 'patch-file': patchPath, + 'manifest-file': manifestPath, + 'patch-bytes': patch.bytes, + 'target-bytes': target.bytes, + 'patch-ratio': ratio.toFixed(6), + 'savings-ratio': Math.max(0, 1 - ratio).toFixed(6), + }); +} + +main().catch((error) => { + const code = error && error.code ? error.code : 'EACTION'; + process.stderr.write( + `::error title=${escapeWorkflowCommand(code)}::${escapeWorkflowCommand( + error.message || error + )}\n` + ); + process.exitCode = 1; +}); diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 3e5a881..bf04d18 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -7,7 +7,9 @@ set (CMAKE_CXX_STANDARD 11) file(GLOB BZIP2_SOURCES "../cpp/bzlib/*.c") set(SOURCES ../cpp/bsdiff.c + ../cpp/bsdiff40_converter.c ../cpp/bspatch.c + ../cpp/bspatch_streaming.c ../cpp/react-native-bs-diff-patch.cpp ${BZIP2_SOURCES} ) diff --git a/benchmarks/README.md b/benchmarks/README.md index bc6829f..105c10b 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -25,5 +25,5 @@ On the recorded Apple M3 Pro baseline, native completed all three large sizes. Web completed 16 and 64 MiB, but its 128 MiB diff returned `EWEBASSEMBLY` after reaching the current WebAssembly memory boundary. The failed sample is retained intentionally: it is a measured limitation, not a flaky result. See the -[large-file roadmap](../docs/large-files-v04.md) before interpreting or changing +[large-file roadmap](../docs/large-files-roadmap.md) before interpreting or changing these limits. diff --git a/bin/react-native-bs-diff-patch.mjs b/bin/react-native-bs-diff-patch.mjs new file mode 100755 index 0000000..f254f03 --- /dev/null +++ b/bin/react-native-bs-diff-patch.mjs @@ -0,0 +1,302 @@ +#!/usr/bin/env node + +import { + constants as fsConstants, + copyFile, + mkdir, + readdir, + rm, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; + +import { + createFilePatchManifest, + convertBsdiff40File, + describeFile, + diffFiles, + inspectPatchFile, + patchFiles, + verifyPatchFiles, +} from '../node/index.mjs'; +import { + canonicalJson, + createPatchBundle, + PATCH_FORMAT, +} from '../toolkit/index.mjs'; + +const HELP = `Verified Delta Pipeline for react-native-bs-diff-patch + +Usage: + react-native-bs-diff-patch diff -o + react-native-bs-diff-patch patch -o + react-native-bs-diff-patch inspect [--json] + react-native-bs-diff-patch verify + react-native-bs-diff-patch manifest -o + react-native-bs-diff-patch convert -o + react-native-bs-diff-patch bundle --from --to [--out ] + +Bundle options: + --max-ratio <0..1> Use the full file when a patch exceeds this ratio (default: 0.85) + --release-id Add a release identifier to the generated manifest +`; + +function fail(message) { + const error = new Error(message); + error.code = 'EINVAL'; + throw error; +} + +function parseArguments(argv) { + const [command, ...tokens] = argv; + const positionals = []; + const options = {}; + const flags = new Set(['--json', '--help', '-h']); + const aliases = new Map([ + ['-o', 'output'], + ['--output', 'output'], + ['--from', 'from'], + ['--to', 'to'], + ['--out', 'out'], + ['--max-ratio', 'maxRatio'], + ['--release-id', 'releaseId'], + ]); + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (flags.has(token)) { + options[token.replace(/^-+/, '')] = true; + continue; + } + if (aliases.has(token)) { + const value = tokens[index + 1]; + if (value === undefined || value.startsWith('-')) { + fail(`${token} requires a value`); + } + options[aliases.get(token)] = value; + index += 1; + continue; + } + if (token.startsWith('-')) { + fail(`unknown option: ${token}`); + } + positionals.push(token); + } + return { command, options, positionals }; +} + +function print(value) { + process.stdout.write( + `${typeof value === 'string' ? value : JSON.stringify(value, null, 2)}\n` + ); +} + +function requirePositionals(positionals, count, usage) { + if (positionals.length !== count) { + fail(`expected ${usage}`); + } +} + +function requireOutput(options) { + if (!options.output) { + fail('missing -o '); + } + return options.output; +} + +async function makeBundle(options) { + if (!options.from || !options.to) { + fail('bundle requires --from and --to '); + } + const maximumRatio = + options.maxRatio === undefined ? 0.85 : Number(options.maxRatio); + if (!Number.isFinite(maximumRatio) || maximumRatio < 0 || maximumRatio > 1) { + fail('--max-ratio must be between 0 and 1'); + } + + const targetPath = path.resolve(options.to); + const outputDirectory = path.resolve( + options.out ?? `${targetPath}.verified-bundle` + ); + await mkdir(path.dirname(outputDirectory), { recursive: true }); + try { + await mkdir(outputDirectory); + } catch (error) { + if (error && error.code === 'EEXIST') { + const wrapped = new Error( + `bundle output directory already exists: ${outputDirectory}` + ); + wrapped.code = 'EDESTEXISTS'; + throw wrapped; + } + throw error; + } + + try { + const targetName = `full-${path.basename(targetPath)}`; + const bundledTargetPath = path.join(outputDirectory, targetName); + await copyFile(targetPath, bundledTargetPath, fsConstants.COPYFILE_EXCL); + const target = await describeFile(bundledTargetPath, { + name: targetName, + url: targetName, + }); + const baselineEntries = ( + await readdir(options.from, { withFileTypes: true }) + ) + .filter((entry) => entry.isFile()) + .sort((left, right) => left.name.localeCompare(right.name)); + if (baselineEntries.length === 0) { + fail(`no baseline files found in ${options.from}`); + } + + const patches = []; + const decisions = []; + for (let index = 0; index < baselineEntries.length; index += 1) { + const entry = baselineEntries[index]; + const baselinePath = path.join(options.from, entry.name); + const patchName = `${String(index + 1).padStart(3, '0')}-${ + entry.name + }.patch`; + const patchPath = path.join(outputDirectory, patchName); + const result = await diffFiles(baselinePath, targetPath, patchPath); + const baseline = await describeFile(baselinePath, { name: entry.name }); + const ratio = result.bytes / Math.max(1, target.bytes); + + if (ratio <= maximumRatio) { + patches.push({ + format: PATCH_FORMAT, + baseline, + patch: { + bytes: result.bytes, + name: patchName, + sha256: result.sha256, + url: patchName, + }, + declaredTargetBytes: String(target.bytes), + }); + decisions.push({ + baseline: entry.name, + patchBytes: result.bytes, + ratio, + strategy: 'patch', + }); + } else { + await rm(patchPath); + decisions.push({ + baseline: entry.name, + patchBytes: result.bytes, + ratio, + reason: 'PATCH_RATIO_EXCEEDED', + strategy: 'full', + }); + } + } + + const bundle = createPatchBundle({ + full: target, + patches, + releaseId: options.releaseId, + target, + }); + const manifestPath = path.join(outputDirectory, 'bundle-manifest.json'); + await writeFile(manifestPath, `${JSON.stringify(bundle, null, 2)}\n`, { + flag: 'wx', + }); + await writeFile( + path.join(outputDirectory, 'bundle-manifest.canonical.json'), + canonicalJson(bundle), + { flag: 'wx' } + ); + return { + decisions, + manifest: manifestPath, + outputDirectory, + patchCount: patches.length, + targetBytes: target.bytes, + }; + } catch (error) { + await rm(outputDirectory, { force: true, recursive: true }); + throw error; + } +} + +async function main() { + const { command, options, positionals } = parseArguments( + process.argv.slice(2) + ); + if (!command || command === 'help' || options.help || options.h) { + print(HELP.trimEnd()); + return; + } + + if (command === 'diff') { + requirePositionals(positionals, 2, 'diff '); + print( + await diffFiles(positionals[0], positionals[1], requireOutput(options)) + ); + return; + } + if (command === 'patch') { + requirePositionals(positionals, 2, 'patch '); + print( + await patchFiles(positionals[0], positionals[1], requireOutput(options)) + ); + return; + } + if (command === 'inspect') { + requirePositionals(positionals, 1, 'inspect '); + const result = await inspectPatchFile(positionals[0]); + print( + options.json + ? result + : [ + `format: ${result.format}`, + `valid: ${result.valid}`, + `patch bytes: ${result.patchBytes}`, + `target bytes: ${result.declaredTargetBytes ?? 'unknown'}`, + ...(result.issue ? [`issue: ${result.issue}`] : []), + ].join('\n') + ); + return; + } + if (command === 'verify') { + requirePositionals(positionals, 3, 'verify '); + const result = await verifyPatchFiles(...positionals); + print(result); + if (!result.verified) { + process.exitCode = 1; + } + return; + } + if (command === 'manifest') { + requirePositionals(positionals, 3, 'manifest '); + const manifest = await createFilePatchManifest(...positionals, { + releaseId: options.releaseId, + }); + await writeFile( + requireOutput(options), + `${JSON.stringify(manifest, null, 2)}\n`, + { flag: 'wx' } + ); + print(manifest); + return; + } + if (command === 'convert') { + requirePositionals(positionals, 1, 'convert '); + print(await convertBsdiff40File(positionals[0], requireOutput(options))); + return; + } + if (command === 'bundle') { + requirePositionals(positionals, 0, 'bundle options only'); + print(await makeBundle(options)); + return; + } + fail(`unknown command: ${command}`); +} + +main().catch((error) => { + const code = error && error.code ? error.code : 'ECLI'; + process.stderr.write(`[${code}] ${error.message || String(error)}\n`); + process.exitCode = 1; +}); diff --git a/cpp/bsdiff.c b/cpp/bsdiff.c index 471703f..ef2dc18 100644 --- a/cpp/bsdiff.c +++ b/cpp/bsdiff.c @@ -454,7 +454,7 @@ static int input_limit_result( const struct bs_operation_options *options, int64_t size) { - if (options != NULL && options->max_input_bytes > 0 && + if (bs_operation_has_input_limit(options) && size > options->max_input_bytes) return BS_OPERATION_INPUT_TOO_LARGE; return BS_OPERATION_OK; @@ -520,7 +520,7 @@ static int bz2_write(struct bsdiff_stream *stream, const void *buffer, int size) return -1; } - if (context->options != NULL && context->options->max_output_bytes > 0) { + if (bs_operation_has_output_limit(context->options)) { long position = ftell(context->file); if (position >= 0 && position > context->options->max_output_bytes) { context->result = BS_OPERATION_OUTPUT_TOO_LARGE; @@ -570,6 +570,11 @@ static int bsDiffFileInternal( result = BS_OPERATION_CANCELLED; goto cleanup; } + if (bs_operation_has_output_limit(options) && + options->max_output_bytes < 24) { + result = BS_OPERATION_OUTPUT_TOO_LARGE; + goto cleanup; + } operation_progress(options, BS_OPERATION_READING, 0.0); errorStage = "read-old"; @@ -620,12 +625,6 @@ static int bsDiffFileInternal( goto cleanup; operation_progress(options, BS_OPERATION_READING, 0.15); - if (options != NULL && options->max_output_bytes > 0 && - options->max_output_bytes < 24) { - result = BS_OPERATION_OUTPUT_TOO_LARGE; - goto cleanup; - } - errorStage = "open-output"; fd = outputFd >= 0 ? outputFd : open(patchFile, O_CREAT|O_EXCL|O_WRONLY, 0666); outputFd = -1; @@ -665,7 +664,7 @@ static int bsDiffFileInternal( context.bz2 = NULL; if (bz2err != BZ_OK) goto cleanup; - if (options != NULL && options->max_output_bytes > 0) { + if (bs_operation_has_output_limit(options)) { long position = ftell(pf); if (position < 0 || position > options->max_output_bytes) { result = BS_OPERATION_OUTPUT_TOO_LARGE; diff --git a/cpp/bsdiff40_converter.c b/cpp/bsdiff40_converter.c new file mode 100644 index 0000000..19985ed --- /dev/null +++ b/cpp/bsdiff40_converter.c @@ -0,0 +1,257 @@ +#include "bsdiff40_converter.h" + +#include "bzlib/bzlib.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define CONVERTER_CHUNK (64 * 1024) + +static int64_t decode_offset(const uint8_t *buffer) +{ + int64_t value = buffer[7] & 0x7f; + int index; + for (index = 6; index >= 0; index--) + value = value * 256 + buffer[index]; + return (buffer[7] & 0x80) != 0 ? -value : value; +} + +static void encode_offset(int64_t value, uint8_t *buffer) +{ + int64_t magnitude = value < 0 ? -value : value; + int index; + for (index = 0; index < 8; index++) { + buffer[index] = (uint8_t)(magnitude & 0xff); + magnitude >>= 8; + } + if (value < 0) + buffer[7] |= 0x80; +} + +static int checked_add(int64_t left, int64_t right, int64_t *result) +{ + if ((right > 0 && left > INT64_MAX - right) || + (right < 0 && left < INT64_MIN - right)) + return -1; + *result = left + right; + return 0; +} + +static int read_exact(BZFILE *stream, void *buffer, int length) +{ + int offset = 0; + while (offset < length) { + int error; + int count = BZ2_bzRead( + &error, + stream, + (uint8_t *)buffer + offset, + length - offset); + if (count <= 0 || (error != BZ_OK && error != BZ_STREAM_END)) + return -1; + offset += count; + } + return 0; +} + +static int write_exact(BZFILE *stream, const void *buffer, int length) +{ + int error; + BZ2_bzWrite(&error, stream, (void *)buffer, length); + return error == BZ_OK ? 0 : -1; +} + +static int copy_bytes(BZFILE *input, BZFILE *output, int64_t length) +{ + uint8_t buffer[CONVERTER_CHUNK]; + int64_t offset = 0; + while (offset < length) { + int chunk = length - offset > CONVERTER_CHUNK + ? CONVERTER_CHUNK + : (int)(length - offset); + if (read_exact(input, buffer, chunk) != 0 || + write_exact(output, buffer, chunk) != 0) + return -1; + offset += chunk; + } + return 0; +} + +static FILE *open_at(const char *path, int64_t offset) +{ + FILE *file = fopen(path, "rb"); + if (file == NULL) + return NULL; + if (fseeko(file, (off_t)offset, SEEK_SET) != 0) { + fclose(file); + return NULL; + } + return file; +} + +int bsConvertBsdiff40File( + const char *legacy_patch_file, + const char *patch_file) +{ + FILE *header_file = NULL; + FILE *control_file = NULL; + FILE *diff_file = NULL; + FILE *extra_file = NULL; + FILE *output_file = NULL; + BZFILE *control_stream = NULL; + BZFILE *diff_stream = NULL; + BZFILE *extra_stream = NULL; + BZFILE *output_stream = NULL; + int output_fd = -1; + int bz_error = BZ_OK; + int result = -1; + int output_created = 0; + struct stat patch_stat; + uint8_t header[32]; + uint8_t target_size_buffer[8]; + uint8_t control_buffer[24]; + int64_t control_length; + int64_t diff_length; + int64_t target_size; + int64_t target_position = 0; + int64_t old_position = 0; + int64_t block_offset; + + if (legacy_patch_file == NULL || patch_file == NULL) + goto cleanup; + header_file = fopen(legacy_patch_file, "rb"); + if (header_file == NULL || + fstat(fileno(header_file), &patch_stat) != 0 || + fread(header, 1, sizeof(header), header_file) != sizeof(header) || + memcmp(header, "BSDIFF40", 8) != 0) + goto cleanup; + control_length = decode_offset(header + 8); + diff_length = decode_offset(header + 16); + target_size = decode_offset(header + 24); + if (control_length <= 0 || diff_length <= 0 || target_size < 0 || + checked_add(32, control_length, &block_offset) != 0 || + checked_add(block_offset, diff_length, &block_offset) != 0 || + block_offset >= patch_stat.st_size) + goto cleanup; + fclose(header_file); + header_file = NULL; + + control_file = open_at(legacy_patch_file, 32); + diff_file = open_at(legacy_patch_file, 32 + control_length); + extra_file = open_at( + legacy_patch_file, + 32 + control_length + diff_length); + if (control_file == NULL || diff_file == NULL || extra_file == NULL) + goto cleanup; + control_stream = BZ2_bzReadOpen( + &bz_error, + control_file, + 0, + 1, + NULL, + 0); + if (control_stream == NULL || bz_error != BZ_OK) + goto cleanup; + diff_stream = BZ2_bzReadOpen( + &bz_error, + diff_file, + 0, + 1, + NULL, + 0); + if (diff_stream == NULL || bz_error != BZ_OK) + goto cleanup; + extra_stream = BZ2_bzReadOpen( + &bz_error, + extra_file, + 0, + 1, + NULL, + 0); + if (extra_stream == NULL || bz_error != BZ_OK) + goto cleanup; + + output_fd = open(patch_file, O_CREAT | O_EXCL | O_WRONLY, 0666); + if (output_fd < 0) + goto cleanup; + output_created = 1; + output_file = fdopen(output_fd, "wb"); + if (output_file == NULL) + goto cleanup; + output_fd = -1; + encode_offset(target_size, target_size_buffer); + if (fwrite("ENDSLEY/BSDIFF43", 16, 1, output_file) != 1 || + fwrite(target_size_buffer, sizeof(target_size_buffer), 1, output_file) != 1) + goto cleanup; + output_stream = BZ2_bzWriteOpen(&bz_error, output_file, 9, 0, 0); + if (output_stream == NULL || bz_error != BZ_OK) + goto cleanup; + + while (target_position < target_size) { + int64_t control[3]; + int index; + int64_t next_old_position; + if (read_exact( + control_stream, + control_buffer, + sizeof(control_buffer)) != 0) + goto cleanup; + for (index = 0; index < 3; index++) + control[index] = decode_offset(control_buffer + index * 8); + if (control[0] < 0 || control[1] < 0 || + control[0] > target_size - target_position || + checked_add(target_position, control[0], &target_position) != 0 || + control[1] > target_size - target_position || + checked_add(target_position, control[1], &target_position) != 0 || + checked_add(old_position, control[0], &next_old_position) != 0 || + checked_add(next_old_position, control[2], &old_position) != 0) + goto cleanup; + if (write_exact( + output_stream, + control_buffer, + sizeof(control_buffer)) != 0 || + copy_bytes(diff_stream, output_stream, control[0]) != 0 || + copy_bytes(extra_stream, output_stream, control[1]) != 0) + goto cleanup; + } + + BZ2_bzWriteClose(&bz_error, output_stream, 0, NULL, NULL); + output_stream = NULL; + if (bz_error != BZ_OK || fflush(output_file) != 0 || + fsync(fileno(output_file)) != 0 || fclose(output_file) != 0) + goto cleanup; + output_file = NULL; + result = 0; + +cleanup: + if (output_stream != NULL) + BZ2_bzWriteClose(&bz_error, output_stream, 1, NULL, NULL); + if (control_stream != NULL) + BZ2_bzReadClose(&bz_error, control_stream); + if (diff_stream != NULL) + BZ2_bzReadClose(&bz_error, diff_stream); + if (extra_stream != NULL) + BZ2_bzReadClose(&bz_error, extra_stream); + if (header_file != NULL) + fclose(header_file); + if (control_file != NULL) + fclose(control_file); + if (diff_file != NULL) + fclose(diff_file); + if (extra_file != NULL) + fclose(extra_file); + if (output_file != NULL) + fclose(output_file); + if (output_fd >= 0) + close(output_fd); + if (result != 0 && output_created) + unlink(patch_file); + return result; +} diff --git a/cpp/bsdiff40_converter.h b/cpp/bsdiff40_converter.h new file mode 100644 index 0000000..5197daf --- /dev/null +++ b/cpp/bsdiff40_converter.h @@ -0,0 +1,16 @@ +#ifndef BSDIFF40_CONVERTER_H +#define BSDIFF40_CONVERTER_H + +#ifdef __cplusplus +extern "C" { +#endif + +int bsConvertBsdiff40File( + const char *legacy_patch_file, + const char *patch_file); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/cpp/bsdiffpatch_operation.h b/cpp/bsdiffpatch_operation.h index 1906353..39358b7 100644 --- a/cpp/bsdiffpatch_operation.h +++ b/cpp/bsdiffpatch_operation.h @@ -2,6 +2,7 @@ #define BSDIFFPATCH_OPERATION_H #include +#include #ifdef __cplusplus extern "C" { @@ -13,7 +14,8 @@ enum bs_operation_result { BS_OPERATION_INPUT_TOO_LARGE = -2, BS_OPERATION_OUTPUT_TOO_LARGE = -3, BS_OPERATION_CANCELLED = -4, - BS_OPERATION_DESTINATION_EXISTS = -5 + BS_OPERATION_DESTINATION_EXISTS = -5, + BS_OPERATION_INVALID_ARGUMENT = -6 }; enum bs_operation_phase { @@ -22,14 +24,40 @@ enum bs_operation_phase { BS_OPERATION_WRITING = 2 }; +/* + * A positive limit historically enabled the corresponding guard. The flags + * preserve that behaviour for zero-initialized callers rebuilt with this + * header while allowing the Web bridge to express a deliberate zero-byte + * budget (where zero must not mean "unlimited"). + */ +enum bs_operation_limit_flags { + BS_OPERATION_LIMIT_INPUT = 1 << 0, + BS_OPERATION_LIMIT_OUTPUT = 1 << 1 +}; + struct bs_operation_options { int64_t max_input_bytes; int64_t max_output_bytes; void *opaque; int (*is_cancelled)(void *opaque); void (*progress)(void *opaque, int phase, double progress); + unsigned int limit_flags; }; +static inline int bs_operation_has_input_limit( + const struct bs_operation_options *options) +{ + return options != NULL && (options->max_input_bytes > 0 || + (options->limit_flags & BS_OPERATION_LIMIT_INPUT) != 0); +} + +static inline int bs_operation_has_output_limit( + const struct bs_operation_options *options) +{ + return options != NULL && (options->max_output_bytes > 0 || + (options->limit_flags & BS_OPERATION_LIMIT_OUTPUT) != 0); +} + int bsDiffFileWithOptions( const char *old_file, const char *new_file, @@ -42,6 +70,12 @@ int bsPatchFileWithOptions( const char *patch_file, const struct bs_operation_options *options); +int bsPatchFileStreamingWithOptions( + const char *old_file, + const char *new_file, + const char *patch_file, + const struct bs_operation_options *options); + #ifdef __cplusplus } #endif diff --git a/cpp/bspatch.c b/cpp/bspatch.c index b6b6764..9bacdd5 100644 --- a/cpp/bspatch.c +++ b/cpp/bspatch.c @@ -31,6 +31,7 @@ #include #include "bspatch.h" +#include "bspatch_streaming.h" #include "bsdiffpatch_operation.h" #include @@ -42,8 +43,6 @@ #include #endif -#define BSPATCH_IO_CHUNK (64 * 1024) - static int bspatch_cancelled(const struct bspatch_stream *stream) { return stream->is_cancelled != NULL && stream->is_cancelled(stream); @@ -165,254 +164,6 @@ static void operation_progress( options->progress(options->opaque, phase, progress); } -static int input_limit_result( - const struct bs_operation_options *options, - int64_t size) -{ - if (options != NULL && options->max_input_bytes > 0 && - size > options->max_input_bytes) - return BS_OPERATION_INPUT_TOO_LARGE; - return BS_OPERATION_OK; -} - -struct bspatch_file_stream_context { - BZFILE *bz2; - const struct bs_operation_options *options; - int result; -}; - -static int bspatch_file_cancelled(const struct bspatch_stream *stream) -{ - struct bspatch_file_stream_context *context = stream->opaque; - return operation_cancelled(context->options); -} - -static void bspatch_file_progress(const struct bspatch_stream *stream, double progress) -{ - struct bspatch_file_stream_context *context = stream->opaque; - operation_progress( - context->options, - BS_OPERATION_PROCESSING, - 0.15 + progress * 0.70); -} - -static int bz2_read(const struct bspatch_stream *stream, void *buffer, int length) -{ - struct bspatch_file_stream_context *context = stream->opaque; - int offset = 0; - - while (offset < length) { - int bz2err; - int chunk = length - offset > BSPATCH_IO_CHUNK - ? BSPATCH_IO_CHUNK - : length - offset; - int count; - - if (operation_cancelled(context->options)) { - context->result = BS_OPERATION_CANCELLED; - return -1; - } - count = BZ2_bzRead(&bz2err, context->bz2, (uint8_t *)buffer + offset, chunk); - if (count != chunk) { - context->result = BS_OPERATION_ERROR; - return -1; - } - offset += count; - } - return 0; -} - -static off_t readFileToBuffer( - int fd, - uint8_t *buffer, - off_t bufferSize, - const struct bs_operation_options *options) -{ - off_t bytesRead = 0; - while (bytesRead < bufferSize) { - size_t remaining = (size_t)(bufferSize - bytesRead); - size_t chunk = remaining > BSPATCH_IO_CHUNK ? BSPATCH_IO_CHUNK : remaining; - ssize_t count; - - if (operation_cancelled(options)) - break; - count = read(fd, buffer + bytesRead, chunk); - if (count <= 0) - break; - bytesRead += count; - } - return bytesRead; -} - -static off_t writeFileFromBuffer( - int fd, - uint8_t *buffer, - off_t bufferSize, - const struct bs_operation_options *options) -{ - off_t bytesWritten = 0; - while (bytesWritten < bufferSize) { - size_t remaining = (size_t)(bufferSize - bytesWritten); - size_t chunk = remaining > BSPATCH_IO_CHUNK ? BSPATCH_IO_CHUNK : remaining; - ssize_t count; - - if (operation_cancelled(options)) - break; - count = write(fd, buffer + bytesWritten, chunk); - if (count <= 0) - break; - bytesWritten += count; - operation_progress( - options, - BS_OPERATION_WRITING, - bufferSize > 0 - ? 0.85 + 0.15 * ((double)bytesWritten / (double)bufferSize) - : 1.0); - } - return bytesWritten; -} - -static int bsPatchFileInternal( - const char *oldFile, - const char *newFile, - const char *patchFile, - int outputFd, - const struct bs_operation_options *options) -{ - FILE *f = NULL; - int fd = -1; - int bz2err; - int closeResult; - int result = BS_OPERATION_ERROR; - int outputCreated = outputFd >= 0; - uint8_t header[24]; - uint8_t *old = NULL, *new = NULL; - int64_t oldsize = 0, newsize = 0; - off_t measuredSize; - BZFILE *bz2 = NULL; - struct bspatch_stream stream; - struct bspatch_file_stream_context context; - struct stat patchStat; - - memset(&stream, 0, sizeof(stream)); - memset(&context, 0, sizeof(context)); - stream.read = bz2_read; - stream.is_cancelled = bspatch_file_cancelled; - stream.progress = bspatch_file_progress; - stream.opaque = &context; - context.options = options; - context.result = BS_OPERATION_ERROR; - - if (oldFile == NULL || newFile == NULL || patchFile == NULL) - goto cleanup; - if (operation_cancelled(options)) { - result = BS_OPERATION_CANCELLED; - goto cleanup; - } - - operation_progress(options, BS_OPERATION_READING, 0.0); - f = fopen(patchFile, "rb"); - if (f == NULL) - goto cleanup; - if (fstat(fileno(f), &patchStat) != 0 || patchStat.st_size < 0) - goto cleanup; - if (input_limit_result(options, (int64_t)patchStat.st_size) != BS_OPERATION_OK) { - result = BS_OPERATION_INPUT_TOO_LARGE; - goto cleanup; - } - if (fread(header, 1, 24, f) != 24) - goto cleanup; - if (memcmp(header, "ENDSLEY/BSDIFF43", 16) != 0) - goto cleanup; - newsize = offtin(header + 16); - if (newsize < 0 || (uint64_t)newsize > SIZE_MAX - 1) - goto cleanup; - if (options != NULL && options->max_output_bytes > 0 && - newsize > options->max_output_bytes) { - result = BS_OPERATION_OUTPUT_TOO_LARGE; - goto cleanup; - } - operation_progress(options, BS_OPERATION_READING, 0.05); - - fd = open(oldFile, O_RDONLY, 0); - if (fd < 0) - goto cleanup; - measuredSize = lseek(fd, 0, SEEK_END); - if (measuredSize < 0 || (uint64_t)measuredSize > SIZE_MAX - 1) - goto cleanup; - oldsize = (int64_t)measuredSize; - if (input_limit_result(options, oldsize) != BS_OPERATION_OK) { - result = BS_OPERATION_INPUT_TOO_LARGE; - goto cleanup; - } - old = malloc((size_t)oldsize + 1); - if (old == NULL || lseek(fd, 0, SEEK_SET) != 0 || - readFileToBuffer(fd, old, (off_t)oldsize, options) != (off_t)oldsize) { - if (operation_cancelled(options)) result = BS_OPERATION_CANCELLED; - goto cleanup; - } - closeResult = close(fd); - fd = -1; - if (closeResult != 0) - goto cleanup; - operation_progress(options, BS_OPERATION_READING, 0.15); - - new = malloc((size_t)newsize + 1); - if (new == NULL) - goto cleanup; - bz2 = BZ2_bzReadOpen(&bz2err, f, 0, 1, NULL, 0); - if (bz2 == NULL || bz2err != BZ_OK) - goto cleanup; - context.bz2 = bz2; - if (bspatch(old, oldsize, new, newsize, &stream)) { - result = context.result; - if (operation_cancelled(options)) result = BS_OPERATION_CANCELLED; - goto cleanup; - } - - BZ2_bzReadClose(&bz2err, bz2); - bz2 = NULL; - context.bz2 = NULL; - closeResult = fclose(f); - f = NULL; - if (closeResult != 0) - goto cleanup; - - fd = outputFd >= 0 ? outputFd : open(newFile, O_CREAT|O_EXCL|O_WRONLY, 0666); - outputFd = -1; - if (fd < 0) { - if (errno == EEXIST) result = BS_OPERATION_DESTINATION_EXISTS; - goto cleanup; - } - outputCreated = 1; - if (writeFileFromBuffer(fd, new, (off_t)newsize, options) != (off_t)newsize) { - if (operation_cancelled(options)) result = BS_OPERATION_CANCELLED; - goto cleanup; - } - if (options != NULL && fsync(fd) != 0) - goto cleanup; - closeResult = close(fd); - fd = -1; - if (closeResult != 0) - goto cleanup; - result = BS_OPERATION_OK; - -cleanup: - if (bz2 != NULL) - BZ2_bzReadClose(&bz2err, bz2); - if (f != NULL) - fclose(f); - if (fd >= 0) - close(fd); - if (outputFd >= 0) - close(outputFd); - if (result != BS_OPERATION_OK && outputCreated && newFile != NULL) - unlink(newFile); - free(new); - free(old); - return result; -} - static int create_sibling_temp(const char *destination, char **temporaryPath) { size_t length; @@ -503,10 +254,14 @@ static int commit_sibling_temp(const char *temporaryPath, const char *destinatio int bsPatchFile(const char *oldFile, const char *newFile, const char *patchFile) { - return bsPatchFileInternal(oldFile, newFile, patchFile, -1, NULL); + return bsPatchFileStreamingWithOptions( + oldFile, + newFile, + patchFile, + NULL); } -int bsPatchFileWithOptions( +int bsPatchFileStreamingWithOptions( const char *oldFile, const char *newFile, const char *patchFile, @@ -526,7 +281,11 @@ int bsPatchFileWithOptions( temporaryFd = create_sibling_temp(newFile, &temporaryPath); if (temporaryFd < 0) return BS_OPERATION_ERROR; - result = bsPatchFileInternal(oldFile, temporaryPath, patchFile, temporaryFd, options); + result = bsPatchFileStreamingToFd( + oldFile, + patchFile, + temporaryFd, + options); if (result == BS_OPERATION_OK) { if (operation_cancelled(options)) { result = BS_OPERATION_CANCELLED; @@ -541,3 +300,16 @@ int bsPatchFileWithOptions( free(temporaryPath); return result; } + +int bsPatchFileWithOptions( + const char *oldFile, + const char *newFile, + const char *patchFile, + const struct bs_operation_options *options) +{ + return bsPatchFileStreamingWithOptions( + oldFile, + newFile, + patchFile, + options); +} diff --git a/cpp/bspatch_streaming.c b/cpp/bspatch_streaming.c new file mode 100644 index 0000000..99eb200 --- /dev/null +++ b/cpp/bspatch_streaming.c @@ -0,0 +1,348 @@ +#include "bspatch_streaming.h" + +#include "bzlib/bzlib.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define STREAM_CHUNK (64 * 1024) + +static int is_cancelled(const struct bs_operation_options *options) +{ + return options != NULL && options->is_cancelled != NULL && + options->is_cancelled(options->opaque); +} + +static void report_progress( + const struct bs_operation_options *options, + int phase, + double progress) +{ + if (options != NULL && options->progress != NULL) + options->progress(options->opaque, phase, progress); +} + +static int64_t decode_offset(const uint8_t *buffer) +{ + int64_t value = buffer[7] & 0x7f; + int index; + for (index = 6; index >= 0; index--) + value = value * 256 + buffer[index]; + return (buffer[7] & 0x80) != 0 ? -value : value; +} + +static int checked_add(int64_t left, int64_t right, int64_t *result) +{ + if ((right > 0 && left > INT64_MAX - right) || + (right < 0 && left < INT64_MIN - right)) + return -1; + *result = left + right; + return 0; +} + +static int read_bzip_exact( + BZFILE *stream, + void *buffer, + size_t length, + const struct bs_operation_options *options) +{ + size_t offset = 0; + while (offset < length) { + int error; + int chunk = length - offset > INT_MAX + ? INT_MAX + : (int)(length - offset); + int count; + if (is_cancelled(options)) + return BS_OPERATION_CANCELLED; + count = BZ2_bzRead( + &error, + stream, + (uint8_t *)buffer + offset, + chunk); + if (count != chunk || (error != BZ_OK && error != BZ_STREAM_END)) + return BS_OPERATION_ERROR; + offset += (size_t)count; + } + return BS_OPERATION_OK; +} + +static int read_old_exact(int fd, void *buffer, size_t length) +{ + size_t offset = 0; + while (offset < length) { + ssize_t count = read(fd, (uint8_t *)buffer + offset, length - offset); + if (count <= 0) + return BS_OPERATION_ERROR; + offset += (size_t)count; + } + return BS_OPERATION_OK; +} + +static int write_exact( + int fd, + const void *buffer, + size_t length, + const struct bs_operation_options *options) +{ + size_t offset = 0; + while (offset < length) { + ssize_t count; + if (is_cancelled(options)) + return BS_OPERATION_CANCELLED; + count = write(fd, (const uint8_t *)buffer + offset, length - offset); + if (count <= 0) + return BS_OPERATION_ERROR; + offset += (size_t)count; + } + return BS_OPERATION_OK; +} + +static int add_old_bytes( + int old_fd, + int64_t old_size, + int64_t old_position, + uint8_t *diff, + uint8_t *old, + size_t length) +{ + int64_t block_end; + int64_t overlap_start; + int64_t overlap_end; + size_t overlap_offset; + size_t overlap_length; + size_t index; + + if (checked_add(old_position, (int64_t)length, &block_end) != 0) + return BS_OPERATION_ERROR; + memset(old, 0, length); + overlap_start = old_position < 0 ? 0 : old_position; + overlap_end = block_end > old_size ? old_size : block_end; + if (overlap_start >= overlap_end) + return BS_OPERATION_OK; + overlap_offset = (size_t)(overlap_start - old_position); + overlap_length = (size_t)(overlap_end - overlap_start); + if (lseek(old_fd, (off_t)overlap_start, SEEK_SET) < 0) + return BS_OPERATION_ERROR; + if (read_old_exact(old_fd, old + overlap_offset, overlap_length) != + BS_OPERATION_OK) + return BS_OPERATION_ERROR; + for (index = 0; index < length; index++) + diff[index] = (uint8_t)(diff[index] + old[index]); + return BS_OPERATION_OK; +} + +int bsPatchFileStreamingToFd( + const char *old_file, + const char *patch_file, + int output_fd, + const struct bs_operation_options *options) +{ + FILE *patch = NULL; + BZFILE *compressed = NULL; + int old_fd = -1; + int bz_error = BZ_OK; + int result = BS_OPERATION_ERROR; + struct stat file_stat; + uint8_t header[24]; + uint8_t control_buffer[8]; + uint8_t *diff_buffer = NULL; + uint8_t *old_buffer = NULL; + int64_t control[3]; + int64_t old_size; + int64_t new_size; + int64_t old_position = 0; + int64_t new_position = 0; + + if (old_file == NULL || patch_file == NULL || output_fd < 0) + goto cleanup; + if (is_cancelled(options)) { + result = BS_OPERATION_CANCELLED; + goto cleanup; + } + + report_progress(options, BS_OPERATION_READING, 0.0); + patch = fopen(patch_file, "rb"); + if (patch == NULL || fstat(fileno(patch), &file_stat) != 0 || + file_stat.st_size < 24) + goto cleanup; + if (bs_operation_has_input_limit(options) && + file_stat.st_size > options->max_input_bytes) { + result = BS_OPERATION_INPUT_TOO_LARGE; + goto cleanup; + } + if (fread(header, 1, sizeof(header), patch) != sizeof(header) || + memcmp(header, "ENDSLEY/BSDIFF43", 16) != 0) + goto cleanup; + new_size = decode_offset(header + 16); + if (new_size < 0) { + result = BS_OPERATION_ERROR; + goto cleanup; + } + if (bs_operation_has_output_limit(options) && + new_size > options->max_output_bytes) { + result = BS_OPERATION_OUTPUT_TOO_LARGE; + goto cleanup; + } + report_progress(options, BS_OPERATION_READING, 0.05); + + old_fd = open(old_file, O_RDONLY); + if (old_fd < 0 || fstat(old_fd, &file_stat) != 0 || + file_stat.st_size < 0) + goto cleanup; + old_size = (int64_t)file_stat.st_size; + if (bs_operation_has_input_limit(options) && + old_size > options->max_input_bytes) { + result = BS_OPERATION_INPUT_TOO_LARGE; + goto cleanup; + } + report_progress(options, BS_OPERATION_READING, 0.15); + + diff_buffer = malloc(STREAM_CHUNK); + old_buffer = malloc(STREAM_CHUNK); + if (diff_buffer == NULL || old_buffer == NULL) + goto cleanup; + + compressed = BZ2_bzReadOpen(&bz_error, patch, 0, 1, NULL, 0); + if (compressed == NULL || bz_error != BZ_OK) + goto cleanup; + + while (new_position < new_size) { + int index; + int64_t next_old_position; + int64_t processed; + + if (is_cancelled(options)) { + result = BS_OPERATION_CANCELLED; + goto cleanup; + } + for (index = 0; index < 3; index++) { + result = read_bzip_exact( + compressed, + control_buffer, + sizeof(control_buffer), + options); + if (result != BS_OPERATION_OK) + goto cleanup; + control[index] = decode_offset(control_buffer); + } + if (control[0] < 0 || control[1] < 0 || + control[0] > new_size - new_position || + checked_add(old_position, control[0], &next_old_position) != 0) { + result = BS_OPERATION_ERROR; + goto cleanup; + } + if (control[0] == 0 && control[1] == 0) { + result = BS_OPERATION_ERROR; + goto cleanup; + } + + processed = 0; + while (processed < control[0]) { + size_t chunk = (size_t)(control[0] - processed); + int64_t block_old_position; + if (chunk > STREAM_CHUNK) + chunk = STREAM_CHUNK; + result = read_bzip_exact( + compressed, + diff_buffer, + chunk, + options); + if (result != BS_OPERATION_OK) + goto cleanup; + if (checked_add( + old_position, + processed, + &block_old_position) != 0 || + add_old_bytes( + old_fd, + old_size, + block_old_position, + diff_buffer, + old_buffer, + chunk) != BS_OPERATION_OK) { + result = BS_OPERATION_ERROR; + goto cleanup; + } + result = write_exact( + output_fd, + diff_buffer, + chunk, + options); + if (result != BS_OPERATION_OK) + goto cleanup; + processed += (int64_t)chunk; + new_position += (int64_t)chunk; + } + old_position = next_old_position; + + if (control[1] > new_size - new_position) { + result = BS_OPERATION_ERROR; + goto cleanup; + } + processed = 0; + while (processed < control[1]) { + size_t chunk = (size_t)(control[1] - processed); + if (chunk > STREAM_CHUNK) + chunk = STREAM_CHUNK; + result = read_bzip_exact( + compressed, + diff_buffer, + chunk, + options); + if (result != BS_OPERATION_OK) + goto cleanup; + result = write_exact( + output_fd, + diff_buffer, + chunk, + options); + if (result != BS_OPERATION_OK) + goto cleanup; + processed += (int64_t)chunk; + new_position += (int64_t)chunk; + } + if (checked_add( + old_position, + control[2], + &next_old_position) != 0) { + result = BS_OPERATION_ERROR; + goto cleanup; + } + old_position = next_old_position; + report_progress( + options, + BS_OPERATION_PROCESSING, + new_size == 0 + ? 0.85 + : 0.15 + 0.70 * + ((double)new_position / (double)new_size)); + } + + if (fsync(output_fd) != 0) + goto cleanup; + report_progress(options, BS_OPERATION_WRITING, 0.95); + result = BS_OPERATION_OK; + +cleanup: + if (compressed != NULL) + BZ2_bzReadClose(&bz_error, compressed); + if (patch != NULL) + fclose(patch); + if (old_fd >= 0) + close(old_fd); + if (output_fd >= 0 && close(output_fd) != 0 && + result == BS_OPERATION_OK) + result = BS_OPERATION_ERROR; + free(diff_buffer); + free(old_buffer); + return result; +} diff --git a/cpp/bspatch_streaming.h b/cpp/bspatch_streaming.h new file mode 100644 index 0000000..17f6e22 --- /dev/null +++ b/cpp/bspatch_streaming.h @@ -0,0 +1,12 @@ +#ifndef BSPATCH_STREAMING_H +#define BSPATCH_STREAMING_H + +#include "bsdiffpatch_operation.h" + +int bsPatchFileStreamingToFd( + const char *old_file, + const char *patch_file, + int output_fd, + const struct bs_operation_options *options); + +#endif diff --git a/cpp/fuzz/bspatch_fuzzer.c b/cpp/fuzz/bspatch_fuzzer.c index 362b8aa..7c68a70 100644 --- a/cpp/fuzz/bspatch_fuzzer.c +++ b/cpp/fuzz/bspatch_fuzzer.c @@ -51,6 +51,7 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) input.data = data + 2 + copied_old_size; input.size = size - 2 - copied_old_size; input.offset = 0; + memset(&stream, 0, sizeof(stream)); stream.opaque = &input; stream.read = fuzz_read; diff --git a/cpp/tests/native_operations_test.c b/cpp/tests/native_operations_test.c index 7968f78..ad00b88 100644 --- a/cpp/tests/native_operations_test.c +++ b/cpp/tests/native_operations_test.c @@ -9,6 +9,7 @@ #endif #include "bsdiff.h" +#include "bsdiff40_converter.h" #include "bsdiffpatch_operation.h" #include "bspatch.h" @@ -94,6 +95,110 @@ static int write_fixture(const char *path, int modified) return fclose(file); } +static void encode_offset(int64_t value, uint8_t *buffer) +{ + int64_t magnitude = value < 0 ? -value : value; + int index; + for (index = 0; index < 8; index++) { + buffer[index] = (uint8_t)(magnitude & 0xff); + magnitude >>= 8; + } + if (value < 0) + buffer[7] |= 0x80; +} + +static int write_bzip_block(FILE *file, const uint8_t *data, int length) +{ + int error; + BZFILE *stream = BZ2_bzWriteOpen(&error, file, 9, 0, 0); + if (stream == NULL || error != BZ_OK) + return -1; + if (length > 0) { + BZ2_bzWrite(&error, stream, (void *)data, length); + if (error != BZ_OK) { + BZ2_bzWriteClose(&error, stream, 1, NULL, NULL); + return -1; + } + } + BZ2_bzWriteClose(&error, stream, 0, NULL, NULL); + return error == BZ_OK ? 0 : -1; +} + +static int write_bsdiff40_fixture( + const char *path, + const char *target_path) +{ + FILE *target = NULL; + FILE *patch = NULL; + uint8_t *target_data = NULL; + uint8_t header[32]; + uint8_t control[24]; + long control_end; + long diff_end; + int result = -1; + + memset(header, 0, sizeof(header)); + memset(control, 0, sizeof(control)); + target_data = malloc(FIXTURE_SIZE); + target = fopen(target_path, "rb"); + patch = fopen(path, "wb+"); + if (target_data == NULL || target == NULL || patch == NULL || + fread(target_data, 1, FIXTURE_SIZE, target) != FIXTURE_SIZE || + fwrite(header, 1, sizeof(header), patch) != sizeof(header)) + goto cleanup; + encode_offset(FIXTURE_SIZE, control + 8); + if (write_bzip_block(patch, control, sizeof(control)) != 0) + goto cleanup; + control_end = ftell(patch); + if (control_end < 32 || write_bzip_block(patch, NULL, 0) != 0) + goto cleanup; + diff_end = ftell(patch); + if (diff_end < control_end || + write_bzip_block(patch, target_data, FIXTURE_SIZE) != 0) + goto cleanup; + + memcpy(header, "BSDIFF40", 8); + encode_offset(control_end - 32, header + 8); + encode_offset(diff_end - control_end, header + 16); + encode_offset(FIXTURE_SIZE, header + 24); + if (fseek(patch, 0, SEEK_SET) != 0 || + fwrite(header, 1, sizeof(header), patch) != sizeof(header)) + goto cleanup; + result = 0; + +cleanup: + if (target != NULL) + fclose(target); + if (patch != NULL && fclose(patch) != 0) + result = -1; + free(target_data); + return result; +} + +static int write_nonprogress_patch(const char *path) +{ + FILE *patch = NULL; + uint8_t header[24]; + uint8_t control[24]; + int result = -1; + + memset(header, 0, sizeof(header)); + memset(control, 0, sizeof(control)); + memcpy(header, "ENDSLEY/BSDIFF43", 16); + encode_offset(1, header + 16); + patch = fopen(path, "wb"); + if (patch == NULL || + fwrite(header, 1, sizeof(header), patch) != sizeof(header) || + write_bzip_block(patch, control, sizeof(control)) != 0) + goto cleanup; + result = 0; + +cleanup: + if (patch != NULL && fclose(patch) != 0) + result = -1; + return result; +} + static int files_equal(const char *leftPath, const char *rightPath) { FILE *left = fopen(leftPath, "rb"); @@ -158,11 +263,16 @@ int main(void) char restoredPath[512]; char limitedPath[512]; char cancelledPath[512]; + char cancelledPatchOutputPath[512]; char corruptPatchPath[512]; char corruptOutputPath[512]; char racedPath[512]; char legacyPatchPath[512]; char legacyRestoredPath[512]; + char bsdiff40PatchPath[512]; + char convertedPatchPath[512]; + char convertedRestoredPath[512]; + char nonprogressPatchPath[512]; char *directory = mkdtemp(directoryTemplate); struct callback_state state; struct bs_operation_options options; @@ -175,11 +285,20 @@ int main(void) snprintf(restoredPath, sizeof(restoredPath), "%s/restored.bin", directory); snprintf(limitedPath, sizeof(limitedPath), "%s/limited.bin", directory); snprintf(cancelledPath, sizeof(cancelledPath), "%s/cancelled.patch", directory); + snprintf( + cancelledPatchOutputPath, + sizeof(cancelledPatchOutputPath), + "%s/cancelled-output.bin", + directory); snprintf(corruptPatchPath, sizeof(corruptPatchPath), "%s/corrupt.patch", directory); snprintf(corruptOutputPath, sizeof(corruptOutputPath), "%s/corrupt-output.bin", directory); snprintf(racedPath, sizeof(racedPath), "%s/raced.patch", directory); snprintf(legacyPatchPath, sizeof(legacyPatchPath), "%s/legacy.patch", directory); snprintf(legacyRestoredPath, sizeof(legacyRestoredPath), "%s/legacy-restored.bin", directory); + snprintf(bsdiff40PatchPath, sizeof(bsdiff40PatchPath), "%s/legacy-bsdiff40.patch", directory); + snprintf(convertedPatchPath, sizeof(convertedPatchPath), "%s/converted.patch", directory); + snprintf(convertedRestoredPath, sizeof(convertedRestoredPath), "%s/converted.bin", directory); + snprintf(nonprogressPatchPath, sizeof(nonprogressPatchPath), "%s/nonprogress.patch", directory); CHECK(write_fixture(oldPath, 0) == 0, "old fixture creation failed"); CHECK(write_fixture(newPath, 1) == 0, "new fixture creation failed"); @@ -195,6 +314,12 @@ int main(void) "legacy patch accepted malformed input"); CHECK(access(corruptOutputPath, F_OK) != 0, "legacy malformed patch committed an output"); + CHECK(write_nonprogress_patch(nonprogressPatchPath) == 0, + "non-progress patch creation failed"); + CHECK(bsPatchFile(oldPath, corruptOutputPath, nonprogressPatchPath) != BS_OPERATION_OK, + "non-progress patch was accepted"); + CHECK(access(corruptOutputPath, F_OK) != 0, + "non-progress patch committed an output"); CHECK(bsDiffFile(oldPath, newPath, legacyPatchPath) == BS_OPERATION_OK, "legacy diff failed"); CHECK(bsPatchFile(oldPath, legacyRestoredPath, legacyPatchPath) == BS_OPERATION_OK, @@ -202,6 +327,15 @@ int main(void) CHECK(files_equal(newPath, legacyRestoredPath), "legacy round trip differs from fixture"); + CHECK(write_bsdiff40_fixture(bsdiff40PatchPath, newPath) == 0, + "BSDIFF40 fixture creation failed"); + CHECK(bsConvertBsdiff40File(bsdiff40PatchPath, convertedPatchPath) == 0, + "BSDIFF40 conversion failed"); + CHECK(bsPatchFile(oldPath, convertedRestoredPath, convertedPatchPath) == + BS_OPERATION_OK, "converted patch could not be applied"); + CHECK(files_equal(newPath, convertedRestoredPath), + "converted BSDIFF40 patch differs from target"); + memset(&state, 0, sizeof(state)); state.last_phase = -1; state.monotonic = 1; @@ -234,6 +368,36 @@ int main(void) CHECK(result == BS_OPERATION_OUTPUT_TOO_LARGE, "output limit returned the wrong status"); CHECK(access(limitedPath, F_OK) != 0, "output limit committed an output"); + memset(&state, 0, sizeof(state)); + options = options_for(&state); + options.max_input_bytes = 0; + options.limit_flags = BS_OPERATION_LIMIT_INPUT; + result = bsDiffFileWithOptions(oldPath, newPath, limitedPath, &options); + CHECK(result == BS_OPERATION_INPUT_TOO_LARGE, + "explicit zero input limit returned the wrong status"); + CHECK(access(limitedPath, F_OK) != 0, + "explicit zero input limit committed an output"); + + memset(&state, 0, sizeof(state)); + options = options_for(&state); + options.max_output_bytes = 0; + options.limit_flags = BS_OPERATION_LIMIT_OUTPUT; + result = bsDiffFileWithOptions(oldPath, newPath, limitedPath, &options); + CHECK(result == BS_OPERATION_OUTPUT_TOO_LARGE, + "explicit zero diff output limit returned the wrong status"); + CHECK(access(limitedPath, F_OK) != 0, + "explicit zero diff output limit committed an output"); + + memset(&state, 0, sizeof(state)); + options = options_for(&state); + options.max_output_bytes = 0; + options.limit_flags = BS_OPERATION_LIMIT_OUTPUT; + result = bsPatchFileWithOptions(oldPath, limitedPath, patchPath, &options); + CHECK(result == BS_OPERATION_OUTPUT_TOO_LARGE, + "explicit zero patch output limit returned the wrong status"); + CHECK(access(limitedPath, F_OK) != 0, + "explicit zero patch output limit committed an output"); + memset(&state, 0, sizeof(state)); state.cancel_during_processing = 1; state.last_phase = -1; @@ -244,6 +408,23 @@ int main(void) CHECK(access(cancelledPath, F_OK) != 0, "cancelled operation committed an output"); CHECK(!has_temporary_output(directory), "cancelled operation leaked a temporary file"); + memset(&state, 0, sizeof(state)); + state.cancel_during_processing = 1; + state.last_phase = -1; + state.monotonic = 1; + options = options_for(&state); + result = bsPatchFileStreamingWithOptions( + oldPath, + cancelledPatchOutputPath, + patchPath, + &options); + CHECK(result == BS_OPERATION_CANCELLED, + "streaming patch cancellation returned the wrong status"); + CHECK(access(cancelledPatchOutputPath, F_OK) != 0, + "cancelled streaming patch committed an output"); + CHECK(!has_temporary_output(directory), + "cancelled streaming patch leaked a temporary file"); + CHECK(write_fixture(limitedPath, 0) == 0, "destination fixture creation failed"); memset(&state, 0, sizeof(state)); options = options_for(&state); @@ -273,6 +454,10 @@ int main(void) unlink(racedPath); unlink(legacyPatchPath); unlink(legacyRestoredPath); + unlink(bsdiff40PatchPath); + unlink(convertedPatchPath); + unlink(convertedRestoredPath); + unlink(nonprogressPatchPath); CHECK(rmdir(directory) == 0, "temporary directory cleanup failed"); printf("native operation controls: ok\n"); return 0; diff --git a/docs/README.md b/docs/README.md index 9a535e2..3662551 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,14 +18,19 @@ The [Chinese documentation](./zh-CN/README.md) mirrors the same public guides. ## Guides +- [Web and desktop WebView SDK](./web-sdk.md) — explicit ESM `/web` and + `/toolkit` entries, Vite/Tauri resources, lifecycle, limits, CSP, and + packaging checks. - [Getting started](./getting-started.md) — installation and a first native or Web round trip. - [API reference](./api-reference.md) — signatures, inputs, outputs, and errors. - [Production recipes](./recipes.md) — integrity, cleanup, downloads, and cross-runtime workflows. +- [Verified Delta Pipeline](./verified-delta-pipeline.md) — Node CLI, + manifests, multi-baseline bundles, release selection, and GitHub Actions. - [Platform support](./platform-support.md) — architecture and bundler behavior. - [Architecture](./architecture.md) — execution paths and patch compatibility. - [Controllable native operations](./native-operations-v03.md) — resource limits, cancellation, progress, and atomic output contract. -- [Large-file roadmap](./large-files-v04.md) — memory baselines, honest progress, +- [Large-file roadmap](./large-files-roadmap.md) — memory baselines, honest progress, and streaming feasibility for the next architecture iteration. - [Troubleshooting](./troubleshooting.md) — common integration failures. - [Development](./development.md) — local builds, tests, WebAssembly, and release checks. diff --git a/docs/api-reference.md b/docs/api-reference.md index 19b66d4..49c0877 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1,7 +1,11 @@ # API reference -The package exposes two platform-specific API families from the same import -path. Native runtimes use absolute paths; Web uses in-memory binary values. +The package exposes two platform-specific API families. Existing shared code +may import the root package and rely on its React Native/browser conditions; +standalone browser and desktop WebView consumers should use the explicit ESM +entry `react-native-bs-diff-patch/web`. Native runtimes use absolute paths; Web +uses in-memory binary values. The platform-neutral manifest helpers live in +the ESM entry `react-native-bs-diff-patch/toolkit`. ```ts import { @@ -9,10 +13,13 @@ import { patch, startDiff, startPatch, + startDiffBytes, + startPatchBytes, diffBytes, patchBytes, inspectPatch, verifyPatch, + classifyPatchError, type BinaryInput, type BinaryOperationOptions, type PatchMetadata, @@ -58,7 +65,7 @@ Reconstructs the target file at `outputFile`. Available on Android and iOS. - Resolves to `0` on success. - Rejects rather than overwriting an existing `outputFile`. -## `startDiff` and `startPatch` +## Native `startDiff` and `startPatch` ```ts interface NativeOperationOptions { @@ -115,6 +122,7 @@ interface BinaryOperationOptions { signal?: AbortSignal; maxInputBytes?: number; maxOutputBytes?: number; + onProgress?: (event: BinaryOperationProgress) => void; } function diffBytes( @@ -127,7 +135,10 @@ function diffBytes( Creates a binary patch in a Web Worker. Available on Web. - Accepts `ArrayBuffer`, any typed-array or `DataView`, and `Blob`. -- Copies inputs, so buffers owned by the caller are not detached. +- Zero-byte binary inputs are valid; native path APIs separately reject empty + path strings. +- Preserves caller-owned buffers. `Blob` and `File` inputs are mounted + read-only through WORKERFS instead of being copied in full on the main thread. - Resolves to a new `Uint8Array` containing an `ENDSLEY/BSDIFF43` patch. - Checks each input against `maxInputBytes` and the generated patch against `maxOutputBytes` when those limits are configured. @@ -146,7 +157,9 @@ Applies a compatible patch in a Web Worker and resolves to the reconstructed bytes. Available on Web. - Validates the patch header before invoking the WebAssembly core. -- Copies inputs and resolves to a new `Uint8Array`. +- An empty baseline or target buffer is valid when the patch format permits it; + malformed patch input is rejected. +- Preserves inputs and resolves to a new `Uint8Array`. - Does not mutate `oldData` or `patchData`. - Rejects before allocating the declared output when the patch header exceeds `maxOutputBytes`. @@ -236,6 +249,8 @@ byte-for-byte. dedicated Worker so aborting it cannot interrupt another request. - `maxInputBytes` limits each supplied binary input, not their sum. - `maxOutputBytes` limits the generated patch or restored output. +- `onProgress` receives real `reading`, `processing`, and `writing` checkpoints + emitted by the C core. - Limits must be non-negative safe integers. Invalid limits reject with `EINVAL`; exceeded limits reject with `ERESOURCE`. @@ -243,13 +258,44 @@ The binary APIs accept the options argument on native only to keep shared wrappers source-compatible, then reject with `EUNSUPPORTED` as usual. Native path operations use `startDiff` or `startPatch` for equivalent controls. +## Web jobs + +On Web, `startDiff()` and `startPatch()` accept binary inputs and return a job +whose result is `Promise`. `startDiffBytes()` and +`startPatchBytes()` are explicit aliases for shared cross-platform wrappers. + +```ts +const job = startPatchBytes(oldFile, patchFile, { + maxOutputBytes: 128 * 1024 * 1024, +}); + +const unsubscribe = job.onProgress(({ phase, progress }) => { + renderProgress(phase, progress); +}); + +try { + const restored = await job.result; + // await job.cancel(); +} finally { + unsubscribe(); +} +``` + +Job cancellation terminates only its dedicated Worker. `result` rejects with +`EABORTED`, while `cancel()` resolves after that result reaches a terminal +state and the Worker/listener cleanup has completed. Calling `cancel()` again +after completion is safe and does not change the settled result. Progress is +sourced from the C/WASM operation and never simulated. + ## Availability behavior All functions remain exported so shared code has one stable import shape. Calling `diffBytes` or `patchBytes` on native rejects with `EUNSUPPORTED`. -Calling `diff`, `patch`, `startDiff`, or `startPatch` on Web behaves the same -way. `inspectPatch` and `verifyPatch` are available on every platform, but they -require native paths on Android/iOS and binary values on Web. +Calling `diff` or `patch` on Web rejects with `EUNSUPPORTED`; binary +`startDiff` and `startPatch` are available. `startDiffBytes` and +`startPatchBytes` reject on native. `inspectPatch` and `verifyPatch` are +available on every platform, but require native paths on Android/iOS and binary +values on Web. Importing the Web entry during server-side rendering does not start a Worker. Calling a binary API without browser Worker support rejects with @@ -266,7 +312,7 @@ type PatchError = Error & { code?: string }; | Code | Meaning | | ------------------- | ----------------------------------------------------------- | -| `EINVAL` | Empty, duplicate, or invalid input. | +| `EINVAL` | Native empty/duplicate paths or invalid input/options; zero-byte binary inputs are valid. | | `ENOENT` | A required native file does not exist. | | `EEXIST` | A native output path already exists. | | `EUNSUPPORTED` | The selected API is not available on the current platform. | @@ -284,6 +330,11 @@ type PatchError = Error & { code?: string }; Treat error messages as diagnostic text rather than a stable machine-readable contract. Branch on `code` when recovery behavior differs. +`classifyPatchError(error)` normalizes platform-specific codes into +`ABORTED`, `RESOURCE`, `INVALID_ARGUMENT`, `INVALID_PATCH`, `VERIFICATION`, +`DESTINATION`, `UNSUPPORTED`, or `RUNTIME`, while preserving the original +code and message. + Native validation stops before entering the C core. Web failures related to Worker startup, patch validation, or WebAssembly execution use `EWEBASSEMBLY` unless a more specific code is available. @@ -300,5 +351,10 @@ for large browser inputs. ## Patch format -All operations read or write `ENDSLEY/BSDIFF43` patches. Other bsdiff -variants, such as patches beginning with `BSDIFF40`, are not interchangeable. +Runtime operations read or write `ENDSLEY/BSDIFF43` patches. Other bsdiff +variants are not accepted automatically. Convert existing `BSDIFF40` files +offline with the Node CLI, then verify them before release: + +```sh +npx react-native-bs-diff-patch convert legacy.patch -o compatible.patch +``` diff --git a/docs/architecture.md b/docs/architecture.md index b1965ba..5fb220b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,7 +17,7 @@ React Native Web -> typed public API -> shared or cancellation-scoped module Web Worker -> Emscripten MEMFS - -> the same bsdiff + bzip2 C sources compiled to WebAssembly + -> the same bsdiff + bzip2 C sources compiled to browser WebAssembly ``` The worker boundaries keep expensive binary work away from the JavaScript/UI @@ -45,9 +45,10 @@ Patches begin with a 24-byte header: | `16..23` | Signed 64-bit target size in the format's byte order | | `24..` | bzip2-compressed control, diff, and extra data | -The Web adapter validates the header and signature before entering the C patch -function. Native and Web operations use the same checked-in bsdiff and bzip2 -sources, preserving cross-platform patch compatibility. +The Web adapter validates the header magic and declared target size before +entering the C patch function. Native and Web operations use the same +checked-in bsdiff and bzip2 sources, preserving cross-platform patch +compatibility. The format identifies the patch implementation, but not the intended baseline or release. Applications should carry baseline and target digests in a trusted @@ -55,16 +56,17 @@ manifest when distributing patches. ## WebAssembly packaging -`scripts/build-web-wasm.sh` invokes Emscripten with: +`scripts/build-web-wasm.sh` invokes Emscripten twice with the same C sources: -- an ES module factory; -- a single-file embedded WebAssembly payload; -- memory growth enabled; -- MEMFS and the `FS`/`ccall` runtime methods; -- exported `bsDiffFile` and `bsPatchFile` functions. +- `web/bsdiffpatch.mjs`: Node-compatible ES module factory with NODEFS for the + `/node` entry and CLI; +- `web/bsdiffpatch.browser.mjs`: Node-free browser/Worker ES module factory; +- both builds use a single-file embedded WebAssembly payload, memory growth, + MEMFS, the `FS`/`ccall` runtime methods, and the patch operation exports. -The generated `web/bsdiffpatch.mjs` is published with the package. Consumers do -not need Emscripten. +Both generated modules are published with the package. The `/web` resource +graph reaches only the browser module, while `/node` retains the Node module; +consumers do not need Emscripten. ## Compatibility verification @@ -113,9 +115,10 @@ runner family. The checked-in record is ## Memory model Native operations read the old and target files into process memory. Web calls -copy inputs before transferring them to a Worker, then copy results out of -MEMFS. Peak memory can therefore be several times larger than the input or -output size. The native reference reaches roughly nineteen times the input size +copy ArrayBuffer and typed-array inputs into Worker MEMFS; Blob and File inputs +use a read-only WORKERFS mount. Results are copied out of MEMFS. Peak memory can +therefore be several times larger than the input or output size. The native +reference reaches roughly nineteen times the input size for this highly similar 50 MiB fixture, primarily because of the suffix array and simultaneous file buffers. diff --git a/docs/development.md b/docs/development.md index ae2bd8a..67e5869 100644 --- a/docs/development.md +++ b/docs/development.md @@ -34,6 +34,7 @@ yarn test:web yarn test:web:browser yarn test:web:metro yarn test:package +yarn test:sdk ``` - `test:web` checks the WebAssembly round trip and patch magic. @@ -42,6 +43,9 @@ yarn test:package TurboModule facade. - `test:package` installs the real tarball into a clean consumer and verifies browser, ESM, CommonJS, TypeScript, and optional-peer behavior. +- `test:sdk` installs the prepared tarball into an isolated Vite consumer and + verifies the explicit `/web` and `/toolkit` ESM entries, production resource + loading, and real byte round trips. ## Native robustness and compatibility @@ -88,7 +92,7 @@ The large profile uses 16, 64, and 128 MiB fixtures and can consume several gigabytes of memory. It is intentionally not a pull-request gate. A manual `Native Core Benchmark` run accepts a comma-separated size list when a shared runner baseline is useful. Interpret the numbers with the scope and acceptance -criteria in the [large-file roadmap](./large-files-v04.md). +criteria in the [large-file roadmap](./large-files-roadmap.md). The published-package canaries install directly from npm and intentionally use current Vite and Expo toolchains. They are scheduled CI checks, not release @@ -135,7 +139,11 @@ yarn test:web yarn test:web:browser ``` -Commit the regenerated `web/bsdiffpatch.mjs` with the C source change. +Commit both generated modules with the C source change. The Node-compatible +`web/bsdiffpatch.mjs` includes NODEFS for `/node` and the CLI; the dedicated +browser `web/bsdiffpatch.browser.mjs` excludes Node runtime branches for the +`/web` Worker graph. Do not replace one with the other to hide a bundler +warning. ## Native verification @@ -156,11 +164,14 @@ For local example commands, see [CONTRIBUTING.md](../CONTRIBUTING.md). ## Publishing checklist 1. Run the core, Web, and site gates. -2. Run `yarn test:package` and inspect `npm pack --dry-run --ignore-scripts`. +2. Run `yarn test:package`, `yarn test:sdk`, and inspect `npm pack --dry-run`. + The pack command runs the `prepack` contract check; use + `node scripts/check-package-contract.mjs` for a direct check. 3. Confirm public docs match the exported TypeScript declarations. 4. Confirm English and Chinese public guides describe the same behavior. -5. Use `yarn release` to create the version, tag, and GitHub Release. It does not - publish directly to npm. +5. When the prepared `package.json` version is final, use + `yarn release --no-increment` to create the release commit, tag, and GitHub + Release. Run it only with explicit maintainer authorization. 6. Publishing the GitHub Release starts `npm-publish.yml`. The workflow checks that the tag matches `package.json`, runs the release gates, publishes through npm Trusted Publishing, and verifies the provenance attestation. diff --git a/docs/getting-started.md b/docs/getting-started.md index 3196649..846f8ce 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -19,6 +19,10 @@ React Native autolinking handles Android and iOS registration. Rebuild the native application after installation; reloading Metro does not change the native modules inside an already-installed binary. +For a Vite app or desktop WebView, use the dedicated [Web and desktop WebView +SDK](./web-sdk.md) guide. It uses the explicit ESM `/web` entry and does not +require React Native or a Node sidecar. + ## Choose the API for the runtime | Runtime | Use | Do not use | diff --git a/docs/large-files-v04.md b/docs/large-files-roadmap.md similarity index 64% rename from docs/large-files-v04.md rename to docs/large-files-roadmap.md index 223789d..1a5ee10 100644 --- a/docs/large-files-v04.md +++ b/docs/large-files-roadmap.md @@ -1,4 +1,4 @@ -# Large-file roadmap (v0.4) +# Large-file roadmap This document defines how the project will evaluate larger inputs, expose honest progress, and investigate streaming without weakening patch @@ -10,12 +10,14 @@ every browser or mobile device can process a particular file size. The current diff algorithm needs random access to the complete old and new inputs while building and traversing its suffix array. Native calls therefore operate on file paths but still allocate memory proportional to the input. The -Web implementation additionally moves complete buffers between JavaScript, a -Worker, and WebAssembly linear memory. +Web implementation still transfers typed-array inputs into a Worker and +WebAssembly linear memory; `Blob`/`File` inputs avoid the extra main-thread copy +through WORKERFS. Node release tools mount host paths through NODEFS. -Patch application is less demanding than diff generation, but the current C -and Web boundaries still materialize the complete operation state. Resource -limits prevent unbounded work; they do not make the algorithm streaming. +Patch application now reads the old file and compressed patch in 64 KiB +chunks and writes a sibling temporary output incrementally. Web still returns a +complete `Uint8Array`, so the browser boundary materializes the final result +even though the C core no longer allocates complete old and output buffers. ## Measurement matrix @@ -45,26 +47,24 @@ on lower-memory devices. The initial Apple M3 Pro / Node 22 record is checked in under `benchmarks/`. Native completed 128 MiB with approximately 2.37 GiB peak RSS. Web completed -64 MiB with approximately 2.09 GiB peak RSS, while 128 MiB returned the generic -`EWEBASSEMBLY` error. That generic failure remains an error-taxonomy gap and -means the project does not currently claim 128 MiB Web diff support. +64 MiB with approximately 2.09 GiB peak RSS, while 128 MiB exhausted the +WebAssembly memory budget. Web classifies that failure as `ERESOURCE`; the +project still does not claim 128 MiB Web diff support. ## Progress semantics Progress must be produced by real algorithm checkpoints, never a timer or an -animation that guesses completion. A future cross-platform operation can use -the existing stages: +animation that guesses completion. Cross-platform jobs use these stages: - `reading`: validating inputs and loading the data required by the core. - `processing`: suffix-array/diff work or patch reconstruction. - `writing`: persisting and atomically committing native output; Web completes this stage when the result buffer is ready to transfer. -Native jobs already expose these stages. Web parity requires Worker messages -emitted from instrumented C/WebAssembly boundaries. Until those checkpoints -exist, Web should report only start, cancellation, and completion rather than -synthetic percentages. The public callback remains optional and must not change -the result or error behavior when it is absent. +Native and Web jobs expose these stages. Web progress travels from instrumented +C checkpoints through WebAssembly and Worker messages; no timer or synthetic +percentage is used. The public callback remains optional and does not change +result or error behavior when absent. ## Streaming feasibility @@ -73,21 +73,20 @@ algorithm: suffix-array construction and matching require global random access to both inputs. Supporting it would mean selecting a different algorithm or a new patch format, with an explicit compatibility and migration decision. -Patch application is a better candidate for incremental work. A prototype can -read the old file and compressed control/diff/extra streams in bounded chunks, -write a temporary destination, and retain the current `ENDSLEY/BSDIFF43` -contract. Browser support should start with `Blob`/`File` and an internal -bounded reader; writable file handles can remain a progressive enhancement. +Patch application uses a bounded C implementation that reads the old file and +compressed control/diff/extra stream in chunks, writes a temporary destination, +and retains the `ENDSLEY/BSDIFF43` contract. `Blob`/`File` inputs use read-only +WORKERFS mounts in browsers. Direct browser file-handle output remains a +progressive enhancement because the public API still returns a complete buffer. ## Delivery sequence 1. Keep 16/64/128 MiB time and peak-memory baselines for native and Web. -2. Instrument core checkpoints and add truthful Web progress events without - changing the existing `diff`, `patch`, or `startPatch` contracts. -3. Prototype file-backed, incremental patch application and prove cancellation, - resource limits, temporary cleanup, and byte-for-byte compatibility. -4. Decide whether the measured benefit justifies a new public API. Treat a - streaming diff algorithm or new patch format as a separate proposal. +2. Measure the completed C/WASM progress and bounded patch path on comparable + devices, including peak memory before and after the change. +3. Evaluate direct browser writable-file output without weakening cancellation, + resource limits, cleanup, or byte-for-byte compatibility. +4. Treat a streaming diff algorithm or new patch format as a separate proposal. Any production API must preserve deterministic output validation, reject sizes outside configured limits before large allocations where possible, clean up on diff --git a/docs/native-operations-v03.md b/docs/native-operations-v03.md index 072be28..d954d52 100644 --- a/docs/native-operations-v03.md +++ b/docs/native-operations-v03.md @@ -65,9 +65,11 @@ output. Existing `diff` and `patch` keep their established behavior. ## Platform behavior -The job API is available on Android and iOS. React Native Web uses the binary -`diffBytes` and `patchBytes` APIs with an `AbortSignal` and byte limits instead; -calling `startDiff` or `startPatch` on Web rejects with `EUNSUPPORTED`. +Android and iOS jobs accept file paths and resolve to `0`. React Native Web +jobs accept binary inputs and resolve to `Uint8Array`; `startDiffBytes` and +`startPatchBytes` are explicit aliases. Web cancellation terminates the job's +dedicated Worker with `EABORTED`, while progress comes from the same C-core +checkpoints. The patch wire format remains `ENDSLEY/BSDIFF43`. Operation control changes execution behavior, not patch compatibility. diff --git a/docs/platform-support.md b/docs/platform-support.md index 68968f6..e5ea86e 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -55,6 +55,13 @@ file under the system temporary directory and removes it on every exit path. ## React Native Web +Standalone browser and desktop WebView applications should import the +explicit ESM entry `react-native-bs-diff-patch/web`. Its Worker graph uses the +Node-free `web/bsdiffpatch.browser.mjs`; the `/toolkit` entry is also ESM-only. +The root package's `browser` condition remains available for existing React +Native Web consumers. See [Web and desktop WebView SDK](./web-sdk.md) for +resource and CSP requirements. + The package has two Web entry mechanisms: - `browser` points standard browser-aware bundlers to `web/index.mjs`. @@ -73,15 +80,17 @@ Webpack and Vite understand the standard `new Worker(new URL(..., import.meta.url), { type: 'module' })` pattern. A Metro Web setup must preserve module-worker URLs in its Web serializer. -The Web entry is browser-oriented rather than a Node.js filesystem adapter. It -does not make the native file-path APIs available in Node.js. -Native job functions remain exported for a stable import shape but reject with -`EUNSUPPORTED` on Web. +The Web entry is browser-oriented rather than a Node.js filesystem adapter. +It does not make native file-path APIs available in the browser. `startDiff` +and `startPatch` use binary inputs on Web; the separate package `./node` entry +provides release-side filesystem operations. Calls without an `AbortSignal` share a module Worker and initialized WebAssembly module. Calls with a signal receive a dedicated Worker so cancellation is isolated to that operation. Both paths serialize work inside their Worker; callers should still enforce an application memory budget. +`Blob` and `File` inputs use read-only WORKERFS mounts to avoid a full +main-thread copy. `inspectPatch` does not start a Worker. `verifyPatch` first validates metadata, then uses the same Worker path as `patchBytes` and discards the restored buffer after comparing it with the expected input. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 0e40da9..517c31d 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -69,9 +69,9 @@ Confirm the bundler emits module-worker assets and that the deployed server serves `.mjs` files as JavaScript. Strict Content Security Policy deployments must permit same-origin workers and WebAssembly execution. -Open the browser network panel and confirm `worker.mjs`, `operations.mjs`, and -`bsdiffpatch.mjs` are returned with successful status codes rather than the -application HTML fallback. +Open the browser network panel and confirm `worker.browser.mjs`, +`operations.browser.mjs`, and `bsdiffpatch.browser.mjs` are returned with +successful status codes rather than the application HTML fallback. ## `EPATCH`, `EWEBASSEMBLY`, or corrupt patch diff --git a/docs/verified-delta-pipeline.md b/docs/verified-delta-pipeline.md new file mode 100644 index 0000000..fb6f935 --- /dev/null +++ b/docs/verified-delta-pipeline.md @@ -0,0 +1,141 @@ +# Verified Delta Pipeline + +The package can be used as a client runtime, a release-side Node tool, or both. +The shared manifest and bundle schema connects patch generation, CDN selection, +and verified restore without owning transport or private signing keys. + +## Node CLI + +Install the package in a release workspace or run it through `npx`: + +```sh +npx react-native-bs-diff-patch diff old.bin new.bin -o update.patch +npx react-native-bs-diff-patch inspect update.patch --json +npx react-native-bs-diff-patch verify old.bin update.patch new.bin +npx react-native-bs-diff-patch manifest \ + old.bin update.patch new.bin -o patch-manifest.json +``` + +The CLI refuses to overwrite existing output files. Node runs the same +WebAssembly core shipped for Web and mounts host paths through NODEFS, avoiding +an additional full-file copy in JavaScript. Diff generation still indexes +complete inputs inside the C/WASM core; patch application and verification use +the bounded streaming file path. + +## Verified patch manifest + +`createPatchManifest()` and `validatePatchManifest()` are available from the +environment-neutral toolkit entry: + +```ts +import { + canonicalJson, + createPatchManifest, + signingPayload, +} from 'react-native-bs-diff-patch/toolkit'; + +const manifest = createPatchManifest({ + baseline: { bytes: 1000, sha256: baselineSha256 }, + patch: { bytes: 120, sha256: patchSha256, url: 'update.patch' }, + target: { bytes: 1100, sha256: targetSha256, url: 'app.bin' }, +}); + +const bytesToSign = new TextEncoder().encode(signingPayload(manifest)); +const canonical = canonicalJson(manifest); +``` + +The library canonicalizes JSON, validates SHA-256 descriptors, and carries +detached-signature metadata. It never loads, stores, or manages a private key. +Authenticate the canonical manifest through the signing system already used by +your release pipeline. + +## Verified restore in Node + +The Node entry validates the baseline and patch before applying, then validates +the restored target before keeping it: + +```ts +import { + createFilePatchManifest, + restoreVerified, +} from 'react-native-bs-diff-patch/node'; + +const manifest = await createFilePatchManifest( + 'old.bin', + 'update.patch', + 'new.bin' +); + +await restoreVerified('old.bin', 'update.patch', 'restored.bin', manifest); +``` + +A baseline, patch, or target mismatch rejects with a verification error and +does not leave the requested output behind. + +## Multi-baseline bundle + +Generate one target release from every regular file in a baseline directory: + +```sh +npx react-native-bs-diff-patch bundle \ + --from releases/ \ + --to dist/app.bin \ + --out dist/update-bundle \ + --max-ratio 0.85 \ + --release-id v1.5.0 +``` + +The output contains: + +- the full target fallback; +- one `ENDSLEY/BSDIFF43` patch for each cost-effective baseline; +- `bundle-manifest.json` for humans and CDNs; +- `bundle-manifest.canonical.json` for signing; +- a decision report showing patch or full-file selection. + +At runtime, `selectPatch()` matches the trusted baseline SHA-256 and applies +optional patch-byte or patch-ratio budgets. A missing baseline or an +uneconomical patch selects the full artifact explicitly. + +## GitHub Action + +The repository includes a dependency-free action for one baseline: + +```yaml +- uses: JimmyDaddy/react-native-bs-diff-patch@v0.5.0 + id: delta + with: + old-file: releases/v1.bin + new-file: dist/app.bin + patch-file: dist/update.patch + manifest-file: dist/patch-manifest.json + max-patch-ratio: '0.85' + +- run: echo "strategy=${{ steps.delta.outputs.strategy }}" +``` + +Use the CLI `bundle` command when the release needs several baselines. The +action exposes patch size, target size, ratio, savings, and the selected +`patch` or `full` strategy as outputs. + +## BSDIFF40 migration + +The runtime continues to accept only `ENDSLEY/BSDIFF43`. Existing `BSDIFF40` +files can be converted offline without the baseline file: + +```sh +npx react-native-bs-diff-patch convert legacy.patch -o compatible.patch +npx react-native-bs-diff-patch inspect compatible.patch +``` + +Conversion validates the three BSDIFF40 compressed blocks, rewrites them into +the interleaved BSDIFF43 stream, and refuses malformed or existing outputs. +Verify the converted patch against its known baseline and target before +publishing it. + +## Browser Release Planner + +The [Release Planner](https://bs-dff-patch.corerobin.com/planner/) generates a +multi-baseline matrix and the same bundle manifest entirely in the browser. It +is intended for evaluation and debugging; use the CLI to reproduce production +artifacts in CI. Files selected in the planner are not uploaded. diff --git a/docs/web-sdk.md b/docs/web-sdk.md new file mode 100644 index 0000000..3904a38 --- /dev/null +++ b/docs/web-sdk.md @@ -0,0 +1,333 @@ +# Web and desktop WebView SDK + +This guide is for a browser, Tauri 2 WebView, or another TypeScript +application that needs the binary patch engine without installing React Native +or starting a Node sidecar. The SDK operates on bytes. A desktop application +still owns file dialogs, permissions, file reads and writes, temporary paths, +job policy, and final replacement in its Rust or platform layer. + +## Use the public entries + +The package keeps the React Native root entry for existing applications and +adds explicit ESM entries for browser consumers: + +| Import | Module format | Use | +| --- | --- | --- | +| `react-native-bs-diff-patch/web` | ESM | Browser and WebView byte APIs, Worker jobs, metadata inspection and verification | +| `react-native-bs-diff-patch/toolkit` | ESM | Platform-neutral manifest, bundle and patch-header helpers | +| `react-native-bs-diff-patch` | Conditional | Existing React Native API; browser bundlers may select its browser condition | +| `react-native-bs-diff-patch/node` | ESM | Node filesystem operations and release tooling | + +`/web` and `/toolkit` intentionally do not expose a separate CommonJS +`require` entry. Use a bundler or a native ESM import. The root package keeps +its existing CommonJS build for consumers that already depend on it; that +compatibility path does not make path-based native APIs available in a WebView. + +The browser resource graph is part of the published package. The `/web` +entry loads the browser WASM module from `web/bsdiffpatch.browser.mjs` through +the module Worker graph. The Node entry keeps using `web/bsdiffpatch.mjs`, +which includes the Node filesystem support needed by `/node` and the CLI. Do +not alias one artifact to the other, import repository source paths, or add a +CDN fallback. Vite and other standard ESM bundlers should retain the +`new Worker(new URL('./worker.browser.mjs', import.meta.url), { type: 'module' })` +relationship. + +## Minimal Vite or Tauri round trip + +Install the package in the application that owns the WebView: + +```sh +# Main install path for the 0.5.0 Web SDK: +npm install react-native-bs-diff-patch@^0.5.0 +``` + +For pre-release verification of a locally prepared package, substitute its +tarball: + +```sh +npm install ./react-native-bs-diff-patch-0.5.0.tgz +``` + +The registry's 0.4.x package predates the `/web` and `/toolkit` subpaths. Do not +use an unversioned registry install as a pre-release verification of those +entries. + +The following code imports only the public Web entry and performs a real +byte-to-byte round trip. It does not read a path and does not require React, +React Native, Node, or a server endpoint: + +```ts +import { + diffBytes, + inspectPatch, + patchBytes, + verifyPatch, +} from 'react-native-bs-diff-patch/web'; + +const encoder = new TextEncoder(); +const baseline = encoder.encode('release=1\nfeature=native\n'); +const target = encoder.encode('release=2\nfeature=native,web\n'); + +const controller = new AbortController(); +const patch = await diffBytes(baseline, target, { + signal: controller.signal, + maxInputBytes: 32 * 1024 * 1024, + maxOutputBytes: 32 * 1024 * 1024, + onProgress: ({ phase, progress }) => { + console.log(phase, progress); + }, +}); + +const metadata = await inspectPatch(patch); +if (!metadata.valid || metadata.format !== 'ENDSLEY/BSDIFF43') { + throw new Error('unsupported patch header'); +} + +const restored = await patchBytes(baseline, patch, { + maxOutputBytes: 32 * 1024 * 1024, +}); +const verification = await verifyPatch(baseline, patch, target, { + maxInputBytes: 32 * 1024 * 1024, + maxOutputBytes: 32 * 1024 * 1024, +}); + +if (!verification.verified || restored.length !== target.length) { + throw new Error('restored bytes do not match the target'); +} +``` + +The same functions accept an `ArrayBuffer`, any `ArrayBufferView` (including a +`DataView`), or a `Blob`/`File`. A file selected by the browser can +therefore be passed directly: + +```ts +const patch = await diffBytes(oldFile, newFile); +const patchBlob = new Blob([patch.slice().buffer as ArrayBuffer]); +const restored = await patchBytes(oldFile, patchBlob); +``` + +The result is a new `Uint8Array`. Typed-array offsets and lengths are honored, +and the input buffers remain usable after the call. The Worker does not take +ownership of caller buffers. For `Blob` and `File`, the Worker uses a +read-only WORKERFS mount while the C core reads the object; this avoids making +a full additional main-thread copy before the operation starts. Keep patch +bytes as binary data when storing or sending them; UTF-8 conversion corrupts +arbitrary patch bytes. + +## Jobs, cancellation, and cleanup + +Use a binary job when a UI needs progress, an explicit Cancel action, or a +separate operation lifecycle: + +```ts +import { startPatchBytes } from 'react-native-bs-diff-patch/web'; + +const job = startPatchBytes(oldFile, patchFile, { + maxInputBytes: 64 * 1024 * 1024, + maxOutputBytes: 128 * 1024 * 1024, + onProgress: renderProgress, +}); +const unsubscribe = job.onProgress(renderProgress); + +cancelButton.onclick = () => void job.cancel(); +try { + const restored = await job.result; + consume(restored); +} catch (error) { + if ((error as { code?: string }).code !== 'EABORTED') throw error; +} finally { + unsubscribe(); +} +``` + +`startDiff`, `startPatch`, `startDiffBytes`, and `startPatchBytes` are +binary job APIs in the Web entry. Their `result` resolves to a new +`Uint8Array`, and `cancel()` is operation-local. A job cancellation +terminates its dedicated Worker and rejects with `EABORTED`; it is not a +Promise timeout and does not interrupt another job. `cancel()` resolves only +after `result` reaches its terminal state and job cleanup has run. The `result` +promise itself rejects with `EABORTED`. Repeated cancellation is safe; +cancelling an already completed job does not change its settled result. A +cancellation or failure does not return a partial result. + +The library removes operation-owned MEMFS files and listeners when a Worker +operation settles. There is no public `dispose()` call for the shared Worker: +calls without a signal reuse a module Worker and a cached WASM module, while a +call with a signal uses a dedicated Worker that is terminated after settle. +Applications still need to revoke their own `URL.createObjectURL()` URLs and +release references to returned buffers when those values are no longer needed. + +Calls without a signal share one serialized Worker queue. Calls with a signal, +including the `start*` job wrappers, use dedicated Workers so cancellation is +isolated. The SDK does not enforce an aggregate application memory or +concurrency budget; cap concurrent jobs in the application before starting +large operations. + +## Limits and memory behavior + +`maxInputBytes` and `maxOutputBytes` are optional per-operation guards: + +- `maxInputBytes` applies separately to every supplied input. It is not a + total-memory or combined-input limit. +- `maxOutputBytes` applies to a generated patch or reconstructed output. For a + patch operation, the declared target size is checked before decompression and + output allocation; the produced result is checked as well. +- Limits must be non-negative safe integers. An invalid value rejects with + `EINVAL`; an exceeded byte limit rejects with `ERESOURCE`. + +The algorithm and WebAssembly adapter can use several times the input or +output size. The generated browser WASM build currently retains Emscripten's +configured maximum linear-memory setting of 2 GiB. This is a build setting, +not a limit from the WebAssembly standard or a universal hard ceiling for +every engine. It is not a promise that every browser, Tauri WebView, or device +can allocate that much; a host can fail earlier because of its WebAssembly +linear-memory or tab budget; +detectable allocation and memory-access failures are classified as +`ERESOURCE`, while other Worker or WebAssembly failures use +`EWEBASSEMBLY`. Treat the host's measured ceiling as an environment +constraint and record the tested input sizes for the target WebView. Do not +present `maxInputBytes` as a guarantee about total process memory. Recheck this +ceiling when the toolchain or generated WASM build changes. + +## Errors and trust boundaries + +Errors are ordinary `Error` values with a best-effort string `code`. Branch +on the code, not diagnostic message text: + +| Code | Meaning | +| --- | --- | +| `EINVAL` | Malformed or unsupported input type, or invalid option (native empty or duplicate paths are also invalid; zero-byte binary inputs are valid) | +| `EUNSUPPORTED` | Web Worker or the selected platform API is unavailable | +| `EABORTED` | A Web signal or job was cancelled | +| `ERESOURCE` | An input/output bound or detectable runtime allocation limit was exceeded | +| `EPATCH` | The patch header or patch payload is malformed or unsupported | +| `EWEBASSEMBLY` | Worker startup, resource loading, or an unclassified WASM failure | + +`inspectPatch()` is a cheap header inspection. It reads at most the 24-byte +header from a binary input and does not apply or authenticate the patch. +`inspectPatchHeader()` from `/toolkit` has the same header-only purpose for a +caller's `Uint8Array`. `valid: true` means that the magic and declared +target-size header fields are structurally acceptable; it does not prove that +compressed payload blocks are intact, that the baseline is correct, or that a +signature is valid. Use `verifyPatch()` and a trusted digest/signature policy +before replacing application data. + +Runtime generation and application support `ENDSLEY/BSDIFF43`. A +`BSDIFF40` input is recognized by header inspection as `format: +'BSDIFF40'` with `valid: false` and `issue: 'LEGACY_FORMAT'`; it is not +silently applied. The existing Node converter remains available for offline +migration: + +```sh +npx react-native-bs-diff-patch convert legacy.patch -o compatible.patch +``` + +Verify the converted patch against its exact baseline and target before +shipping it. A patch's format does not identify its intended baseline. + +## Toolkit manifests and candidate selection + +The toolkit has no filesystem, network, or private-key access. It validates and +normalizes data supplied by the caller: + +```ts +import { + canonicalJson, + createPatchBundle, + createPatchManifest, + selectPatch, + signingPayload, +} from 'react-native-bs-diff-patch/toolkit'; + +const manifest = createPatchManifest({ + baseline: { bytes: 1000, sha256: baselineSha256 }, + patch: { bytes: 120, sha256: patchSha256, url: 'release.patch' }, + target: { bytes: 1100, sha256: targetSha256, url: 'app.bin' }, +}); +const bytesToSign = new TextEncoder().encode(signingPayload(manifest)); +const canonical = canonicalJson(manifest); +``` + +`validatePatchManifest()` and `validatePatchBundle()` check structure, byte +counts, SHA-256-shaped strings, format, and bundle target consistency. They do +not read the named URLs, download artifacts, calculate hashes, verify a +signature, or prove that the bytes match the descriptors. Unknown fields are +discarded from the normalized return value, so keep application-specific data +outside the validated schema or explicitly preserve it in your own envelope. + +`canonicalJson()` sorts object keys and omits `undefined` object properties. +`signingPayload()` returns canonical JSON with the manifest's detached +signature metadata removed. Neither function signs data: canonical JSON and a +signing payload are inputs to an external cryptographic signing system, not a +digital signature. + +`selectPatch()` first validates the bundle and the selection options, then: + +1. Lowercases the requested 64-character baseline SHA-256 and finds the first + candidate with that exact digest. +2. Returns the full artifact with `BASELINE_NOT_FOUND` if no candidate matches. +3. Returns the full artifact with `PATCH_BYTES_EXCEEDED` when + `maxPatchBytes` is exceeded. +4. Returns the full artifact with `PATCH_RATIO_EXCEEDED` when + `candidate.patch.bytes / max(1, full.bytes)` is greater than + `maxPatchRatio`. +5. Otherwise returns that first matching candidate with `BASELINE_MATCH` and + strategy `patch`. + +The helper does not search for the smallest patch or perform a restore. The +application must authenticate the manifest, download the selected artifact, +verify its digest, and then run `patchBytes()` and `verifyPatch()` as needed. + +## Vite and Tauri packaging checklist + +Use the package name in application source and let the bundler follow its +exports. A production build should contain the `/web` entry's module Worker +and browser WASM resource graph. With the single-file WASM build, the binary +payload is embedded in the generated browser module; consumers do not need to +copy an independent `.wasm` file or install Emscripten. + +Before shipping, inspect the production bundle and run it from the built +assets, including with network access disabled. Confirm that: + +- `react-native-bs-diff-patch/web` and `/toolkit` resolve from the installed + tarball, with no workspace link, source alias, or private deep import; +- the Worker URL resolves to a packaged same-origin asset; +- browser WASM loads from the package resource graph, not a CDN; +- the production app can generate, apply and verify a patch after the network + is disconnected; +- the target WebView's CSP permits the Worker and WebAssembly execution; +- Rust or another desktop layer owns file authorization and persistence, while + JavaScript passes bytes to the SDK. + +The minimum CSP additions for this SDK are: + +```text +script-src 'self' 'wasm-unsafe-eval'; +worker-src 'self'; +``` + +Merge those sources into the application's existing policy. Do not add +ordinary `unsafe-eval`, load the engine from a CDN, or loosen the policy as a +fallback. This guide documents the required policy; actual Tauri WebView +acceptance remains a downstream application test. + +## Verification commands + +From the repository, the relevant local checks are: + +```sh +yarn test:web +yarn test:web:browser +yarn test:web:metro +yarn test:toolkit +yarn test:sdk +yarn typecheck +yarn site:build +yarn site:test +``` + +`yarn test:sdk` installs a prepared package tarball into an isolated consumer +and checks the public `/web` and `/toolkit` ESM entries, production Vite +resource loading and byte round trips. Registry smoke checks are a separate +post-release check. These checks do not constitute a Tauri device acceptance +test or a claim that registry smoke has passed. diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md index 50233fd..0ea3393 100644 --- a/docs/zh-CN/README.md +++ b/docs/zh-CN/README.md @@ -18,14 +18,18 @@ ## 指南 +- [Web 与桌面 WebView SDK](./web-sdk.md) — `/web` 与 `/toolkit` ESM 入口、Vite/Tauri + 资源、生命周期、限制、CSP 与打包检查。 - [快速开始](/docs/zh-CN/getting-started/) — 安装并完成第一次原生端或 Web 往返。 - [API 参考](/docs/zh-CN/api-reference/) — 签名、输入、输出和错误码。 - [生产实践](/docs/zh-CN/recipes/) — 完整性、清理、下载与跨运行时流程。 +- [可验证增量发布工具链](/docs/zh-CN/verified-delta-pipeline/) — Node CLI、 + manifest、多基线 bundle、发布选择与 GitHub Actions。 - [平台支持](/docs/zh-CN/platform-support/) — 架构与打包器行为。 - [架构](/docs/zh-CN/architecture/) — 执行路径与补丁兼容性。 - [可控制的原生操作](/docs/zh-CN/native-operations-v03/) — 资源限制、取消、进度与 原子输出约定。 -- [大文件演进路线](/docs/zh-CN/large-files-v04/) — 下一阶段的内存基线、真实进度和 +- [大文件演进路线](/docs/zh-CN/large-files-roadmap/) — 下一阶段的内存基线、真实进度和 流式能力可行性。 - [常见问题与排障](/docs/zh-CN/troubleshooting/) — 常见集成失败。 - [开发与验证](/docs/zh-CN/development/) — 本地构建、测试、WASM 与发布检查。 diff --git a/docs/zh-CN/api-reference.md b/docs/zh-CN/api-reference.md index d3dbd2f..2b11c99 100644 --- a/docs/zh-CN/api-reference.md +++ b/docs/zh-CN/api-reference.md @@ -1,7 +1,9 @@ # API 参考 -包从同一个入口导出两组平台专用 API。原生运行时使用绝对路径,Web 使用内存中的 -二进制值。 +包提供两组平台专用 API。已有共享代码可以导入根包并依赖其 React Native/browser +条件解析;独立浏览器和桌面 WebView 消费者应使用明确的 +`react-native-bs-diff-patch/web` ESM 入口。原生运行时使用绝对路径,Web 使用内存中的 +二进制值。与平台无关的 manifest 工具位于 `react-native-bs-diff-patch/toolkit` ESM 入口。 ```ts import { @@ -9,10 +11,13 @@ import { patch, startDiff, startPatch, + startDiffBytes, + startPatchBytes, diffBytes, patchBytes, inspectPatch, verifyPatch, + classifyPatchError, type BinaryInput, type BinaryOperationOptions, type PatchMetadata, @@ -55,7 +60,7 @@ function patch( - `patchFile`:已存在且兼容的补丁路径。 - 成功时返回 `0`,不会覆盖已有输出文件。 -## `startDiff` 与 `startPatch` +## 原生 `startDiff` 与 `startPatch` ```ts interface NativeOperationOptions { @@ -109,6 +114,7 @@ interface BinaryOperationOptions { signal?: AbortSignal; maxInputBytes?: number; maxOutputBytes?: number; + onProgress?: (event: BinaryOperationProgress) => void; } function diffBytes( @@ -121,7 +127,9 @@ function diffBytes( 在 Web Worker 中生成补丁,仅 Web 可用。 - 接受 `ArrayBuffer`、任意 TypedArray、`DataView` 和 `Blob`。 -- 会复制输入,不会让调用方缓冲区失效。 +- 零字节二进制输入有效;原生路径 API 另行拒绝空路径字符串。 +- 保留调用方缓冲区;`Blob` 与 `File` 通过 WORKERFS 只读挂载,不会先在主线程 + 生成完整副本。 - 返回包含 `ENDSLEY/BSDIFF43` 补丁的新 `Uint8Array`。 - 配置上限后,分别用 `maxInputBytes` 检查每个输入,并用 `maxOutputBytes` 检查生成补丁。 @@ -139,7 +147,8 @@ function patchBytes( 在 Web Worker 中应用兼容补丁,并返回还原后的字节。 - 进入 WebAssembly 核心前会校验补丁头。 -- 复制输入并返回新的 `Uint8Array`。 +- 空基线或空目标缓冲区在补丁格式允许时有效;损坏的补丁输入会被拒绝。 +- 保留输入并返回新的 `Uint8Array`。 - 不会修改 `oldData` 或 `patchData`。 - 当补丁头声明的输出超过 `maxOutputBytes` 时,会在分配输出前拒绝。 @@ -222,18 +231,48 @@ function verifyPatch( 其他请求。 - `maxInputBytes` 分别限制每个二进制输入,而不是输入之和。 - `maxOutputBytes` 限制生成补丁或还原输出。 +- `onProgress` 接收 C 核心产生的真实 `reading`、`processing` 和 `writing` + 检查点。 - 上限必须是非负安全整数;非法上限以 `EINVAL` 拒绝,超过上限以 `ERESOURCE` 拒绝。 原生端的二进制 API 接受 options 参数只是为了让共享封装保持源码兼容,随后仍会以 `EUNSUPPORTED` 拒绝。原生路径操作通过 `startDiff`、`startPatch` 获得同类控制。 +## Web job + +Web 端的 `startDiff()` 与 `startPatch()` 接收二进制输入,并返回 +`Promise` 结果的 job。`startDiffBytes()` 与 `startPatchBytes()` 是供 +跨平台封装明确使用的别名。 + +```ts +const job = startPatchBytes(oldFile, patchFile, { + maxOutputBytes: 128 * 1024 * 1024, +}); + +const unsubscribe = job.onProgress(({ phase, progress }) => { + renderProgress(phase, progress); +}); + +try { + const restored = await job.result; + // await job.cancel(); +} finally { + unsubscribe(); +} +``` + +取消 job 只会终止它自己的专用 Worker。`result` 会以 `EABORTED` 拒绝;`cancel()` 会在 +该 result 到达终态且 Worker/监听器清理完成后才 resolve。完成后再次调用 `cancel()` 是 +安全的,不会改变已确定的结果。进度来自 C/WASM 操作,不使用模拟百分比。 + ## 平台不可用时的行为 -所有函数始终导出,以便共享代码保持稳定导入形式。在原生端调用 `diffBytes` 或 -`patchBytes`,以及在 Web 调用 `diff`、`patch`、`startDiff` 或 `startPatch`, -都会以 `EUNSUPPORTED` 拒绝。`inspectPatch` 与 `verifyPatch` 在所有平台可用,但 -Android/iOS 必须传文件路径,Web 必须传二进制值。 +所有函数始终导出,以便共享代码保持稳定导入形式。在原生端调用 `diffBytes`、 +`patchBytes`、`startDiffBytes` 或 `startPatchBytes` 会以 `EUNSUPPORTED` 拒绝。 +Web 端 `diff`、`patch` 不可用,但二进制 `startDiff` 与 `startPatch` 可用。 +`inspectPatch` 与 `verifyPatch` 在所有平台可用,但 Android/iOS 必须传文件路径, +Web 必须传二进制值。 SSR 阶段导入 Web 入口不会启动 Worker;在没有浏览器 Worker 的环境调用二进制 API 会以 `EUNSUPPORTED` 拒绝。 @@ -248,7 +287,7 @@ type PatchError = Error & { code?: string }; | 错误码 | 含义 | | ------------------- | ------------------------------------------ | -| `EINVAL` | 输入为空、重复或类型无效。 | +| `EINVAL` | 原生空路径/重复路径或输入与选项无效;零字节二进制输入有效。 | | `ENOENT` | 原生端所需文件不存在。 | | `EEXIST` | 原生端输出路径已经存在。 | | `EUNSUPPORTED` | 当前平台不支持所选 API。 | @@ -265,6 +304,10 @@ type PatchError = Error & { code?: string }; 错误消息仅用于诊断,不是稳定的机器可读约定。恢复策略不同时应根据 `code` 分支。 +`classifyPatchError(error)` 会把平台专用错误统一归类为 `ABORTED`、`RESOURCE`、 +`INVALID_ARGUMENT`、`INVALID_PATCH`、`VERIFICATION`、`DESTINATION`、 +`UNSUPPORTED` 或 `RUNTIME`,同时保留原始 code 与消息。 + ## 并发与顺序 每个原生平台的 Promise 与 job 操作共用库内部串行队列。取消排队任务会阻止它进入 @@ -274,5 +317,9 @@ C 核心;运行中的任务会协作式观察取消。不带 signal 的 Web ## 补丁格式 -所有操作都读写 `ENDSLEY/BSDIFF43` 补丁。以 `BSDIFF40` 开头的其他 bsdiff -变体不能互换。 +运行时操作只读写 `ENDSLEY/BSDIFF43`,不会自动接受其他 bsdiff 变体。已有 +`BSDIFF40` 可以通过 Node CLI 离线转换,并在发布前验证: + +```sh +npx react-native-bs-diff-patch convert legacy.patch -o compatible.patch +``` diff --git a/docs/zh-CN/architecture.md b/docs/zh-CN/architecture.md index 417091d..20bcf78 100644 --- a/docs/zh-CN/architecture.md +++ b/docs/zh-CN/architecture.md @@ -16,7 +16,7 @@ React Native Web -> 强类型公开 API -> 共享或取消任务专用的模块 Web Worker -> Emscripten MEMFS - -> 由同一套 bsdiff + bzip2 C 源码编译的 WebAssembly + -> 由同一套 bsdiff + bzip2 C 源码编译的浏览器 WebAssembly ``` Worker 边界让高开销二进制计算离开 JavaScript / UI 线程,但不会消除算法成本。 @@ -40,23 +40,24 @@ MEMFS 临时文件。 | `16..23` | 该格式字节序下的有符号 64 位目标大小 | | `24..` | bzip2 压缩的控制、差分和附加数据 | -Web 适配器进入 C patch 函数前会校验头和签名。原生与 Web 使用同一份已检入的 -bsdiff 和 bzip2 源码,从而保持跨平台兼容。 +Web 适配器进入 C patch 函数前会校验头部 magic 与声明的目标大小。原生与 Web 使用同一份 +已检入的 bsdiff 和 bzip2 源码,从而保持跨平台兼容。 格式能标识补丁实现,但不标识预期基线或发布版本。分发补丁时,应用应在可信清单中 携带基线和目标摘要。 ## WebAssembly 打包 -`scripts/build-web-wasm.sh` 使用 Emscripten 生成: +`scripts/build-web-wasm.sh` 使用同一套 C 源码调用 Emscripten 两次,生成: -- ES module 工厂; -- 单文件内嵌 WebAssembly payload; -- 可增长内存; -- MEMFS 以及 `FS` / `ccall` 运行时方法; -- 导出的 `bsDiffFile` 和 `bsPatchFile` 函数。 +- `web/bsdiffpatch.mjs`:带 NODEFS 的 Node 兼容 ES module 工厂,供 `/node` 入口和 + CLI 使用; +- `web/bsdiffpatch.browser.mjs`:不包含 Node 分支的浏览器/Worker ES module 工厂; +- 两者都使用单文件内嵌 WebAssembly payload、可增长内存、MEMFS、`FS` / `ccall` + 运行时方法和补丁操作导出。 -生成的 `web/bsdiffpatch.mjs` 随 npm 包发布,消费者无需安装 Emscripten。 +两个生成模块都会随 npm 包发布。`/web` 资源图只会到达浏览器模块,`/node` 保留 +Node 模块;消费者无需安装 Emscripten。 ## 兼容性验证 @@ -98,8 +99,9 @@ bsdiff 和 bzip2 源码,从而保持跨平台兼容。 ## 内存模型 -原生操作会把旧文件与目标文件读入进程内存。Web 调用先复制输入再传给 Worker, -之后从 MEMFS 复制结果,因此峰值内存可能达到输入或输出大小的数倍。在这组高度相似 +原生操作会把旧文件与目标文件读入进程内存。Web 的 ArrayBuffer 和 TypedArray 输入会 +复制到 Worker 的 MEMFS;Blob 与 File 使用只读 WORKERFS 挂载;结果再从 MEMFS 复制出, +因此峰值内存可能达到输入或输出大小的数倍。在这组高度相似 的 50 MiB fixture 中,原生参考峰值约为输入的十九倍,主要来自后缀数组和同时存在的 文件缓冲区。 diff --git a/docs/zh-CN/development.md b/docs/zh-CN/development.md index fa8fef7..0892220 100644 --- a/docs/zh-CN/development.md +++ b/docs/zh-CN/development.md @@ -34,6 +34,7 @@ yarn test:web yarn test:web:browser yarn test:web:metro yarn test:package +yarn test:sdk ``` - `test:web` 检查 WebAssembly 往返和补丁 magic。 @@ -41,6 +42,8 @@ yarn test:package - `test:web:metro` 证明 Metro 选择 `.web` 入口,而不是原生 TurboModule facade。 - `test:package` 将真实 tarball 安装到干净消费者,验证 browser、ESM、CommonJS、 TypeScript 与可选 peer 行为。 +- `test:sdk` 将准备好的 tarball 安装到隔离 Vite 消费者,验证明确的 `/web` 与 + `/toolkit` ESM 入口、生产资源加载和真实字节往返。 ## 原生健壮性与兼容性 @@ -82,7 +85,7 @@ BENCHMARK_OUTPUT=/tmp/native-large.json yarn benchmark:large:native 大文件 profiling 使用 16、64、128 MiB fixture,可能消耗数 GiB 内存,因此不会作为 pull request 门禁。手动运行 `Native Core Benchmark` 时可以传入逗号分隔的尺寸列表, -以获得共享 Runner 基线。解读结果时应遵循[大文件演进路线](/docs/zh-CN/large-files-v04/) +以获得共享 Runner 基线。解读结果时应遵循[大文件演进路线](/docs/zh-CN/large-files-roadmap/) 中的范围和验收标准。 发布包 canary 会直接从 npm 安装,并有意使用当前 Vite 与 Expo 工具链;它们是定时 @@ -126,7 +129,10 @@ yarn test:web yarn test:web:browser ``` -将重新生成的 `web/bsdiffpatch.mjs` 与 C 源码改动一起提交。 +将两个重新生成的模块与 C 源码改动一起提交。Node 兼容的 +`web/bsdiffpatch.mjs` 为 `/node` 和 CLI 保留 NODEFS;专用浏览器 +`web/bsdiffpatch.browser.mjs` 为 `/web` Worker 图排除 Node runtime 分支。不要为了 +隐藏打包器警告而互相替换两者。 ## 原生验证 @@ -144,10 +150,13 @@ RN 0.86 新架构;React Native 0.82 及以上已不再提供旧架构运行时 ## 发布检查清单 1. 执行核心、Web 和站点门禁。 -2. 运行 `yarn test:package`,并检查 `npm pack --dry-run --ignore-scripts`。 +2. 运行 `yarn test:package`、`yarn test:sdk`,并检查 `npm pack --dry-run`。 + pack 命令会运行 `prepack` contract 检查;也可直接运行 + `node scripts/check-package-contract.mjs`。 3. 确认公开文档与导出的 TypeScript 声明一致。 4. 确认中英文指南描述同一套公开行为。 -5. 运行 `yarn release` 创建版本、tag 和 GitHub Release;该命令不直接发布 npm。 +5. 准备好的 `package.json` 版本确定后,使用 `yarn release --no-increment` 创建 + release commit、tag 和 GitHub Release;仅在维护者明确授权时运行。 6. GitHub Release 发布后会触发 `npm-publish.yml`。工作流校验 tag 与 `package.json` 版本一致,执行发布门禁,通过 npm Trusted Publishing 发布, 并验证 provenance 证明。 diff --git a/docs/zh-CN/getting-started.md b/docs/zh-CN/getting-started.md index da55899..9fcbcd8 100644 --- a/docs/zh-CN/getting-started.md +++ b/docs/zh-CN/getting-started.md @@ -17,6 +17,9 @@ npx pod-install React Native autolinking 会完成 Android 与 iOS 注册。安装后必须重新构建原生应用; 刷新 Metro 不会改变已经安装的应用二进制中包含的原生模块。 +如果使用 Vite 应用或桌面 WebView,请阅读[Web 与桌面 WebView SDK](./web-sdk.md)。 +它使用明确的 `/web` ESM 入口,不需要 React Native 或 Node sidecar。 + ## 按运行时选择 API | 运行时 | 应使用 | 不应使用 | diff --git a/docs/zh-CN/large-files-v04.md b/docs/zh-CN/large-files-roadmap.md similarity index 65% rename from docs/zh-CN/large-files-v04.md rename to docs/zh-CN/large-files-roadmap.md index d4e85bb..acf9926 100644 --- a/docs/zh-CN/large-files-v04.md +++ b/docs/zh-CN/large-files-roadmap.md @@ -1,4 +1,4 @@ -# 大文件演进路线(v0.4) +# 大文件演进路线 本文定义项目如何评估更大输入、提供可信进度并研究流式处理,同时不削弱补丁兼容性。 它是一份可行性与测量计划,不承诺所有浏览器或移动设备都能处理某个固定尺寸。 @@ -6,11 +6,13 @@ ## 当前约束 当前 diff 算法在构建和遍历后缀数组时,需要随机访问完整的旧、新输入。原生调用虽然 -接收文件路径,内存使用仍然与输入规模成比例。Web 实现还需要在 JavaScript、Worker -与 WebAssembly 线性内存之间传递完整 buffer。 +接收文件路径,内存使用仍然与输入规模成比例。Web 的 TypedArray 输入仍需要传入 +Worker 与 WebAssembly 线性内存;`Blob`/`File` 通过 WORKERFS 避免主线程中的额外 +完整副本。Node 发布工具则通过 NODEFS 挂载宿主路径。 -应用补丁比生成 diff 的要求低,但当前 C 和 Web 边界仍会把完整操作状态放入内存。 -资源限制可以阻止无界工作,但不会让算法自动变成流式处理。 +应用补丁现在会以 64 KiB 分块读取旧文件和压缩补丁,并增量写入同目录临时输出。 +Web 仍返回完整 `Uint8Array`,因此浏览器边界会保留最终结果,但 C 核心不再同时分配 +完整旧文件与输出缓冲区。 ## 测量矩阵 @@ -34,39 +36,39 @@ artifact,不作为 PR 阈值。 结果不代表更大尺寸或更低内存设备也获得支持。 首份 Apple M3 Pro / Node 22 记录已检入 `benchmarks/`。原生端以约 2.37 GiB 峰值 RSS -完成 128 MiB;Web 端以约 2.09 GiB 峰值 RSS 完成 64 MiB,但 128 MiB 返回通用 -`EWEBASSEMBLY`。这一通用失败仍是错误分类缺口,因此项目目前不宣称 Web diff 支持 -128 MiB。 +完成 128 MiB;Web 端以约 2.09 GiB 峰值 RSS 完成 64 MiB,但 128 MiB 会耗尽 +WebAssembly 内存预算并被归类为 `ERESOURCE`;项目仍不宣称 Web diff 支持 128 MiB。 ## 进度语义 -进度必须来自真实算法检查点,不能使用定时器或动画猜测完成比例。未来的跨平台操作 -可以沿用现有阶段: +进度必须来自真实算法检查点,不能使用定时器或动画猜测完成比例。跨平台 job 使用 +以下阶段: - `reading`:验证输入并加载核心需要的数据。 - `processing`:执行后缀数组/diff 工作或重建补丁。 - `writing`:原生端持久化并原子提交输出;Web 端在结果 buffer 可以传输时完成此阶段。 -原生 job 已暴露这些阶段。Web 对齐需要从插桩后的 C/WebAssembly 边界发出 Worker -消息。在这些检查点出现之前,Web 只应报告开始、取消和完成,不能提供虚假百分比。 -公开 callback 保持可选,未传入时不得改变结果或错误行为。 +原生与 Web job 都已暴露这些阶段。Web 进度从插桩后的 C 检查点经 WebAssembly 和 +Worker 消息传递,不使用定时器或模拟百分比。公开 callback 保持可选,未传入时不会 +改变结果或错误行为。 ## 流式处理可行性 真正的流式 diff 不是当前 BSDiff 算法的兼容优化:后缀数组构建和匹配需要全局随机 访问两份输入。要实现它,必须选择另一种算法或新补丁格式,并明确兼容与迁移策略。 -补丁应用更适合增量处理。原型可以按有界 chunk 读取旧文件和压缩后的 -control/diff/extra 流,写入临时目标,同时保留 `ENDSLEY/BSDIFF43` 约定。浏览器应先 -支持 `Blob`/`File` 和内部有界 reader;可写文件句柄继续作为渐进增强能力。 +补丁应用已经使用有界 C 实现:按块读取旧文件与压缩后的 control/diff/extra 流, +写入临时目标,同时保留 `ENDSLEY/BSDIFF43` 约定。浏览器 `Blob`/`File` 使用只读 +WORKERFS 挂载;公开 API 仍返回完整 buffer,因此直接写浏览器文件句柄继续作为渐进 +增强能力。 ## 实施顺序 1. 保持原生与 Web 的 16/64/128 MiB 耗时和峰值内存基线。 -2. 为核心检查点插桩,增加真实的 Web 进度事件,不改变现有 `diff`、`patch` 或 - `startPatch` 约定。 -3. 验证文件后备、增量应用补丁的原型,并证明取消、资源限制、临时清理和逐字节兼容。 -4. 根据实测收益决定是否值得新增公开 API;流式 diff 算法或新补丁格式另立提案。 +2. 在可比设备上测量已经完成的 C/WASM 进度与有界 patch 路径,比较改造前后的 + 峰值内存。 +3. 评估直接写入浏览器文件句柄,同时保持取消、资源限制、清理与逐字节兼容。 +4. 流式 diff 算法或新补丁格式继续作为独立提案。 所有生产 API 都必须保持确定性输出验证,在大额分配前尽可能拒绝超限尺寸,在取消和 失败时清理资源,并通过 Android API 24、iOS Simulator、浏览器与跨平台 golden patch diff --git a/docs/zh-CN/native-operations-v03.md b/docs/zh-CN/native-operations-v03.md index 1b5451c..018f560 100644 --- a/docs/zh-CN/native-operations-v03.md +++ b/docs/zh-CN/native-operations-v03.md @@ -60,9 +60,10 @@ job 操作会独占创建同目录临时文件,完成写入、刷新与校验 ## 平台差异 -job API 仅用于 Android 与 iOS。React Native Web 应使用二进制 `diffBytes`、 -`patchBytes` API,并通过 `AbortSignal` 与字节限制控制任务;Web 调用 `startDiff` -或 `startPatch` 会以 `EUNSUPPORTED` 拒绝。 +Android 与 iOS job 接收文件路径并返回 `0`;React Native Web job 接收二进制输入 +并返回 `Uint8Array`,也可以使用明确的 `startDiffBytes`、`startPatchBytes` 别名。 +Web 取消会终止当前 job 的专用 Worker 并以 `EABORTED` 拒绝,进度来自同一套 C 核心 +检查点。 补丁格式仍是 `ENDSLEY/BSDIFF43`。0.3 改变的是操作控制,不是补丁兼容性。 diff --git a/docs/zh-CN/platform-support.md b/docs/zh-CN/platform-support.md index f29cef0..834eb16 100644 --- a/docs/zh-CN/platform-support.md +++ b/docs/zh-CN/platform-support.md @@ -48,6 +48,11 @@ TurboModule 实例。 ## React Native Web +独立浏览器和桌面 WebView 应用应导入明确的 +`react-native-bs-diff-patch/web` ESM 入口。它的 Worker 资源图使用不含 Node 分支的 +`web/bsdiffpatch.browser.mjs`;`/toolkit` 入口同样仅支持 ESM。根包的 `browser` 条件 +继续供已有 React Native Web 消费者使用。资源与 CSP 要求见[Web 与桌面 WebView SDK](./web-sdk.md)。 + 包提供两种 Web 入口机制: - `browser` 字段让标准浏览器感知型打包器选择 `web/index.mjs`。 @@ -66,13 +71,13 @@ Webpack 与 Vite 能识别标准的 `new Worker(new URL(..., import.meta.url), { type: 'module' })` 模式。Metro Web 配置需要在 Web serializer 中保留模块 Worker URL。 -Web 入口面向浏览器,不是 Node.js 文件系统适配器;它不会在 Node.js 中提供原生 -文件路径 API。 -原生 job 函数仍会导出以保持统一导入形式,但在 Web 上以 `EUNSUPPORTED` 拒绝。 +Web 入口面向浏览器,不会在浏览器中提供原生文件路径 API。Web 的 `startDiff`、 +`startPatch` 使用二进制输入;单独的 `./node` 包入口提供发布端文件系统操作。 未传 `AbortSignal` 的调用共用模块 Worker 与已初始化的 WebAssembly 模块;带 signal 的调用使用专用 Worker,保证取消只影响当前任务。两种路径都会在各自 Worker 内串行执行,但调用方仍应设置应用级内存预算。 +`Blob` 与 `File` 会通过只读 WORKERFS 挂载,避免在主线程生成完整副本。 `inspectPatch` 不会启动 Worker。`verifyPatch` 先验证元数据,再复用 `patchBytes` 的 Worker 路径,并在与预期输入比较后丢弃还原缓冲区。 diff --git a/docs/zh-CN/troubleshooting.md b/docs/zh-CN/troubleshooting.md index cd66fc8..e2dcfe8 100644 --- a/docs/zh-CN/troubleshooting.md +++ b/docs/zh-CN/troubleshooting.md @@ -62,8 +62,8 @@ 确认打包器输出了模块 Worker 资源,并且服务器将 `.mjs` 作为 JavaScript 提供。 严格 CSP 需要允许同源 Worker 和 WebAssembly 执行。 -在浏览器网络面板中确认 `worker.mjs`、`operations.mjs` 和 `bsdiffpatch.mjs` -返回成功状态,而不是应用 HTML fallback。 +在浏览器网络面板中确认 `worker.browser.mjs`、`operations.browser.mjs` 和 +`bsdiffpatch.browser.mjs` 返回成功状态,而不是应用 HTML fallback。 ## `EPATCH`、`EWEBASSEMBLY` 或补丁损坏 diff --git a/docs/zh-CN/verified-delta-pipeline.md b/docs/zh-CN/verified-delta-pipeline.md new file mode 100644 index 0000000..fa4be48 --- /dev/null +++ b/docs/zh-CN/verified-delta-pipeline.md @@ -0,0 +1,130 @@ +# 可验证增量发布工具链 + +这个包既可以作为客户端运行时,也可以作为发布端 Node 工具,或者同时承担两种角色。 +共享的 manifest 与 bundle schema 将补丁生成、CDN 选择和验证还原连接起来,但不会 +接管传输系统或私钥。 + +## Node CLI + +可以在发布工作区安装包,也可以直接通过 `npx` 运行: + +```sh +npx react-native-bs-diff-patch diff old.bin new.bin -o update.patch +npx react-native-bs-diff-patch inspect update.patch --json +npx react-native-bs-diff-patch verify old.bin update.patch new.bin +npx react-native-bs-diff-patch manifest \ + old.bin update.patch new.bin -o patch-manifest.json +``` + +CLI 不会覆盖已有输出。Node 复用 Web 发布的同一份 WebAssembly 核心,并通过 +NODEFS 直接挂载宿主路径,避免在 JavaScript 中再产生一份完整文件副本。diff +生成仍会在 C/WASM 核心内为完整输入建立索引;patch 应用与验证走有界流式文件路径。 + +## 可验证补丁 manifest + +环境无关的 toolkit 入口提供 `createPatchManifest()` 与 +`validatePatchManifest()`: + +```ts +import { + canonicalJson, + createPatchManifest, + signingPayload, +} from 'react-native-bs-diff-patch/toolkit'; + +const manifest = createPatchManifest({ + baseline: { bytes: 1000, sha256: baselineSha256 }, + patch: { bytes: 120, sha256: patchSha256, url: 'update.patch' }, + target: { bytes: 1100, sha256: targetSha256, url: 'app.bin' }, +}); + +const bytesToSign = new TextEncoder().encode(signingPayload(manifest)); +const canonical = canonicalJson(manifest); +``` + +库负责 canonical JSON、SHA-256 描述校验和 detached-signature 元数据,但不会 +加载、保存或管理私钥。请通过现有发布签名系统认证 canonical manifest。 + +## Node 验证还原 + +Node 入口会先验证基线与补丁,应用后再验证目标;只有全部一致才保留输出: + +```ts +import { + createFilePatchManifest, + restoreVerified, +} from 'react-native-bs-diff-patch/node'; + +const manifest = await createFilePatchManifest( + 'old.bin', + 'update.patch', + 'new.bin' +); + +await restoreVerified('old.bin', 'update.patch', 'restored.bin', manifest); +``` + +基线、补丁或目标不匹配时会以验证错误拒绝,并删除请求的输出。 + +## 多基线 bundle + +从基线目录内的每个普通文件生成到同一目标的发布计划: + +```sh +npx react-native-bs-diff-patch bundle \ + --from releases/ \ + --to dist/app.bin \ + --out dist/update-bundle \ + --max-ratio 0.85 \ + --release-id v1.5.0 +``` + +输出内容包括: + +- 完整目标文件回退; +- 每个高收益基线对应的 `ENDSLEY/BSDIFF43` 补丁; +- 面向人工和 CDN 的 `bundle-manifest.json`; +- 面向签名的 `bundle-manifest.canonical.json`; +- 显示补丁或完整文件选择结果的决策报告。 + +运行时通过 `selectPatch()` 匹配可信基线 SHA-256,并可附加补丁字节数或比例预算。 +基线不存在或补丁不划算时会明确选择完整文件。 + +## GitHub Action + +仓库提供了一个零依赖的单基线 Action: + +```yaml +- uses: JimmyDaddy/react-native-bs-diff-patch@v0.5.0 + id: delta + with: + old-file: releases/v1.bin + new-file: dist/app.bin + patch-file: dist/update.patch + manifest-file: dist/patch-manifest.json + max-patch-ratio: '0.85' + +- run: echo "strategy=${{ steps.delta.outputs.strategy }}" +``` + +多基线发布使用 CLI 的 `bundle` 命令。Action 会输出补丁大小、目标大小、比例、节省量 +以及最终 `patch` 或 `full` 策略。 + +## BSDIFF40 迁移 + +运行时继续只接受 `ENDSLEY/BSDIFF43`。已有 `BSDIFF40` 可以在不读取基线文件的 +情况下离线转换: + +```sh +npx react-native-bs-diff-patch convert legacy.patch -o compatible.patch +npx react-native-bs-diff-patch inspect compatible.patch +``` + +转换器会校验 BSDIFF40 的三个压缩块,并改写为交错的 BSDIFF43 数据流;损坏输入或 +已有输出都会被拒绝。发布前仍应使用已知基线和目标验证转换后的补丁。 + +## 浏览器 Release Planner + +[发布规划器](https://bs-dff-patch.corerobin.com/zh-CN/planner/) 可以完全在浏览器 +本地生成多基线矩阵与相同的 bundle manifest,适合评估和调试。生产产物应通过 CLI +在 CI 中复现;规划器不会上传用户选择的文件。 diff --git a/docs/zh-CN/web-sdk.md b/docs/zh-CN/web-sdk.md new file mode 100644 index 0000000..7725578 --- /dev/null +++ b/docs/zh-CN/web-sdk.md @@ -0,0 +1,281 @@ +# Web 与桌面 WebView SDK + +本指南面向浏览器、Tauri 2 WebView 或其他 TypeScript 应用:在不安装 +React Native、也不启动 Node sidecar 的情况下使用二进制补丁引擎。SDK 处理的是 +字节;桌面应用仍负责文件选择、权限、读写文件、临时路径、任务策略,以及由 Rust +或平台层执行最终替换。 + +## 使用公开入口 + +包保留现有 React Native 根入口,并为浏览器消费者提供明确的 ESM 入口: + +| 导入路径 | 模块格式 | 用途 | +| --- | --- | --- | +| `react-native-bs-diff-patch/web` | ESM | 浏览器与 WebView 字节 API、Worker job、元数据检查与验证 | +| `react-native-bs-diff-patch/toolkit` | ESM | 与平台无关的 manifest、bundle 与补丁头工具 | +| `react-native-bs-diff-patch` | 条件入口 | 既有 React Native API;浏览器打包器可以选择 browser 条件 | +| `react-native-bs-diff-patch/node` | ESM | Node 文件系统操作和发布工具 | + +`/web` 与 `/toolkit` 有意不提供单独的 CommonJS `require` 入口。请使用打包器或原生 +ESM 导入。根包继续保留已有 CommonJS 构建,供依赖该路径的消费者使用;这条兼容路径 +不会让 WebView 获得基于路径的原生 API。 + +浏览器资源图属于发布包的一部分。`/web` 入口通过模块 Worker 图加载 +`web/bsdiffpatch.browser.mjs` 中的浏览器 WASM 模块。Node 入口继续使用 +`web/bsdiffpatch.mjs`,为 `/node` 与 CLI 提供所需的 Node 文件系统支持。不要把两份 +产物互相 alias,不要导入仓库源码路径,也不要添加 CDN fallback。Vite 和其他标准 ESM +打包器应保留 +`new Worker(new URL('./worker.browser.mjs', import.meta.url), { type: 'module' })` 关系。 + +## 最小 Vite 或 Tauri 往返 + +在拥有 WebView 的应用中安装包: + +```sh +# 0.5.0 Web SDK 的主安装路径: +npm install react-native-bs-diff-patch@^0.5.0 +``` + +发布前验证本地准备的包时,可以将其替换为 tarball: + +```sh +npm install ./react-native-bs-diff-patch-0.5.0.tgz +``` + +registry 中的 0.4.x 包尚未包含 `/web` 和 `/toolkit` 子路径。发布前验证这些入口时,不要 +使用未带版本的 registry 安装命令作为验证依据。 + +下面的代码只导入公开 Web 入口,执行真实的逐字节往返。它不读取路径,也不需要 +React、React Native、Node 或服务器接口: + +```ts +import { + diffBytes, + inspectPatch, + patchBytes, + verifyPatch, +} from 'react-native-bs-diff-patch/web'; + +const encoder = new TextEncoder(); +const baseline = encoder.encode('release=1\nfeature=native\n'); +const target = encoder.encode('release=2\nfeature=native,web\n'); + +const controller = new AbortController(); +const patch = await diffBytes(baseline, target, { + signal: controller.signal, + maxInputBytes: 32 * 1024 * 1024, + maxOutputBytes: 32 * 1024 * 1024, + onProgress: ({ phase, progress }) => { + console.log(phase, progress); + }, +}); + +const metadata = await inspectPatch(patch); +if (!metadata.valid || metadata.format !== 'ENDSLEY/BSDIFF43') { + throw new Error('unsupported patch header'); +} + +const restored = await patchBytes(baseline, patch, { + maxOutputBytes: 32 * 1024 * 1024, +}); +const verification = await verifyPatch(baseline, patch, target, { + maxInputBytes: 32 * 1024 * 1024, + maxOutputBytes: 32 * 1024 * 1024, +}); + +if (!verification.verified || restored.length !== target.length) { + throw new Error('restored bytes do not match the target'); +} +``` + +相同函数也接受 `ArrayBuffer`、任意 `ArrayBufferView`(包括 `DataView`)或 +`Blob`/`File`。浏览器选择的文件可以直接传入: + +```ts +const patch = await diffBytes(oldFile, newFile); +const patchBlob = new Blob([patch.slice().buffer as ArrayBuffer]); +const restored = await patchBytes(oldFile, patchBlob); +``` + +结果是新的 `Uint8Array`。TypedArray 的 offset 和长度会被保留,调用结束后调用方的 +输入缓冲区仍可使用。Worker 不会接管调用方缓冲区的所有权。`Blob` 与 `File` 会在 +Worker 内通过只读 WORKERFS 挂载供 C 核心读取,开始操作前不会在主线程额外生成完整副本。 +保存或传输补丁时应保持二进制形式;通过 UTF-8 转换会破坏任意补丁字节。 + +## Job、取消与清理 + +需要界面进度、明确的取消操作或独立任务生命周期时,使用二进制 job: + +```ts +import { startPatchBytes } from 'react-native-bs-diff-patch/web'; + +const job = startPatchBytes(oldFile, patchFile, { + maxInputBytes: 64 * 1024 * 1024, + maxOutputBytes: 128 * 1024 * 1024, + onProgress: renderProgress, +}); +const unsubscribe = job.onProgress(renderProgress); + +cancelButton.onclick = () => void job.cancel(); +try { + const restored = await job.result; + consume(restored); +} catch (error) { + if ((error as { code?: string }).code !== 'EABORTED') throw error; +} finally { + unsubscribe(); +} +``` + +Web 入口的 `startDiff`、`startPatch`、`startDiffBytes` 与 `startPatchBytes` 都是二进制 +job API。`result` 返回新的 `Uint8Array`,`cancel()` 只影响当前操作。取消会终止当前 +专用 Worker,并以 `EABORTED` 拒绝;它不是用 Promise 超时冒充中断,也不会打断其他 job。 +`cancel()` 只有在 `result` 到达终态且 job 清理完成后才会 resolve;`result` 本身会以 +`EABORTED` 拒绝。重复取消是安全的;已完成 job 再次取消不会改变其已确定的结果。取消或 +失败不会返回半成品结果。 + +Worker 操作结束时,库会删除由操作拥有的 MEMFS 文件和监听器。共享 Worker 没有公开的 +`dispose()`:不带 signal 的调用复用模块 Worker 和缓存的 WASM 模块,带 signal 的调用 +使用专用 Worker,并在结束后终止。应用仍需在不再使用时撤销自己的 +`URL.createObjectURL()` URL,并释放返回缓冲区的引用。 + +不带 signal 的调用共享一个串行 Worker 队列。带 signal 的调用(包括 `start*` job +封装)使用专用 Worker,从而隔离取消。SDK 不设置应用级总内存或并发预算;大任务开始 +前应由应用限制并发数。 + +## 限制与内存行为 + +`maxInputBytes` 与 `maxOutputBytes` 是每个操作可选的保护边界: + +- `maxInputBytes` 分别作用于每个输入,不是总内存或输入之和的上限。 +- `maxOutputBytes` 作用于生成的补丁或还原输出。应用补丁时,会在解压和分配输出前 + 检查补丁声明的目标大小,并再次检查实际结果。 +- 限制必须是非负安全整数。非法值以 `EINVAL` 拒绝;超过字节限制以 `ERESOURCE` + 拒绝。 + +算法和 WebAssembly 适配器的峰值内存可能是输入或输出的数倍。当前生成的浏览器 WASM +构建保留 Emscripten 配置的 2 GiB 最大线性内存设置。这是构建设置,不是 WebAssembly +标准规定的限制,也不是所有引擎的统一硬上限;不保证所有浏览器、Tauri WebView 或设备 +都能分配这么多,宿主可能因为自身的 WebAssembly 线性内存或标签页预算更早失败。可识别的分配和内存访问失败归类为 +`ERESOURCE`,其他 Worker 或 WebAssembly 失败使用 `EWEBASSEMBLY`。应把目标 WebView +实测的上限作为环境约束,记录已经验证的输入尺寸。不要把 `maxInputBytes` 宣传成总进程 +内存保证。工具链或生成的 WASM 构建变化后应重新确认该上限。 + +## 错误与信任边界 + +错误是带有尽力分类字符串 `code` 的普通 `Error`。需要分支时使用 code,不要依赖错误 +消息文本: + +| Code | 含义 | +| --- | --- | +| `EINVAL` | 类型格式错误、不支持的输入类型或非法选项(原生空路径或重复路径也无效;零字节二进制输入有效) | +| `EUNSUPPORTED` | Web Worker 或选择的平台 API 不可用 | +| `EABORTED` | Web signal 或 job 被取消 | +| `ERESOURCE` | 超过输入/输出边界,或可识别的运行时分配限制 | +| `EPATCH` | 补丁头或补丁 payload 损坏或不支持 | +| `EWEBASSEMBLY` | Worker 启动、资源加载或未分类的 WASM 失败 | + +`inspectPatch()` 是低成本的头部检查。它从二进制输入最多读取 24 字节头,不应用也不 +认证补丁。`/toolkit` 的 `inspectPatchHeader()` 对调用方提供的 `Uint8Array` 具有相同 +的只检查头部目的。`valid: true` 只表示 magic 和声明的目标大小头字段在结构上可接受; +不表示压缩 payload 完整、不表示基线正确,也不表示签名有效。替换应用数据前应结合 +`verifyPatch()` 与可信摘要/签名策略。 + +运行时生成和应用支持 `ENDSLEY/BSDIFF43`。`BSDIFF40` 输入会在头部检查时识别为 +`format: 'BSDIFF40'`、`valid: false`、`issue: 'LEGACY_FORMAT'`,不会静默应用。已有 +Node 转换器仍可用于离线迁移: + +```sh +npx react-native-bs-diff-patch convert legacy.patch -o compatible.patch +``` + +发布前请用准确的基线和目标验证转换后的补丁。补丁格式本身不会标识预期基线。 + +## Toolkit manifest 与候选选择 + +Toolkit 不访问文件系统、网络或私钥,只校验和规范化调用方传入的数据: + +```ts +import { + canonicalJson, + createPatchBundle, + createPatchManifest, + selectPatch, + signingPayload, +} from 'react-native-bs-diff-patch/toolkit'; + +const manifest = createPatchManifest({ + baseline: { bytes: 1000, sha256: baselineSha256 }, + patch: { bytes: 120, sha256: patchSha256, url: 'release.patch' }, + target: { bytes: 1100, sha256: targetSha256, url: 'app.bin' }, +}); +const bytesToSign = new TextEncoder().encode(signingPayload(manifest)); +const canonical = canonicalJson(manifest); +``` + +`validatePatchManifest()` 和 `validatePatchBundle()` 检查结构、字节数、SHA-256 形状、 +格式与 bundle 目标一致性。但它们不会读取 URL、下载 artifact、计算哈希、验证签名,也 +不会证明字节与描述匹配。未知字段会从规范化返回值中丢弃;应用自有字段应放在自己的 +外层 envelope 中,或明确使用 schema 支持的 `releaseId`/`signature`。 + +`canonicalJson()` 会排序对象键并省略对象属性中的 `undefined`。`signingPayload()` 返回 +移除 detached signature 元数据后的 canonical JSON。二者都不会签名:canonical JSON 与 +signing payload 是外部密码学签名系统的输入,不是数字签名本身。 + +`selectPatch()` 会先验证 bundle 和选择选项,然后: + +1. 将传入的 64 字符 baseline SHA-256 转小写,寻找 digest 完全相同的第一个候选。 +2. 没有候选时返回完整 artifact,原因是 `BASELINE_NOT_FOUND`。 +3. 超过 `maxPatchBytes` 时返回完整 artifact,原因是 `PATCH_BYTES_EXCEEDED`。 +4. 当 `candidate.patch.bytes / max(1, full.bytes)` 大于 `maxPatchRatio` 时返回完整 + artifact,原因是 `PATCH_RATIO_EXCEEDED`。 +5. 其他情况返回该第一个匹配候选,原因是 `BASELINE_MATCH`,策略为 `patch`。 + +该工具不会搜索最小补丁,也不会执行还原。应用必须先认证 manifest,再下载并校验所选 +artifact 的摘要,然后按需要运行 `patchBytes()` 和 `verifyPatch()`。 + +## Vite 与 Tauri 打包检查清单 + +在应用源码中使用包名,让打包器依据 exports 解析。生产构建必须包含 `/web` 入口的 +模块 Worker 与浏览器 WASM 资源图。当前采用单文件 WASM 构建时,二进制 payload 嵌入 +生成的浏览器模块;消费者不需要复制独立 `.wasm` 文件,也不需要安装 Emscripten。 + +发布前应检查生产 bundle,并在断网条件下从构建产物运行,至少确认: + +- `react-native-bs-diff-patch/web` 与 `/toolkit` 从安装的 tarball 解析,不使用 workspace + link、源码 alias 或私有深路径; +- Worker URL 解析到随包发布的同源资源; +- 浏览器 WASM 从包资源图加载,而不是 CDN; +- 断网后生产应用仍能生成、应用和验证补丁; +- 目标 WebView 的 CSP 允许 Worker 与 WebAssembly 执行; +- Rust 或其他桌面层负责文件授权和持久化,JavaScript 只向 SDK 传递字节。 + +SDK 所需的最小 CSP 增量为: + +```text +script-src 'self' 'wasm-unsafe-eval'; +worker-src 'self'; +``` + +请将这些 source 合并到应用现有策略中。不要添加普通 `unsafe-eval`,不要从 CDN 加载 +引擎,也不要以放宽策略作为 fallback。本指南只记录所需策略;真实 Tauri WebView 的 +接受情况仍由下游应用验收。 + +## 验证命令 + +仓库内相关本地检查如下: + +```sh +yarn test:web +yarn test:web:browser +yarn test:web:metro +yarn test:toolkit +yarn test:sdk +yarn typecheck +yarn site:build +yarn site:test +``` + +其中 `yarn test:sdk` 会把准备好的 tarball 安装到隔离消费者中,检查公开 `/web`、 +`/toolkit` ESM 入口、生产 Vite 资源加载和字节往返。Registry smoke 属于独立的发布后 +检查;这些检查不等于 Tauri 真机验收,也不宣称 registry smoke 已通过。 diff --git a/node/index.d.ts b/node/index.d.ts new file mode 100644 index 0000000..7dbe78d --- /dev/null +++ b/node/index.d.ts @@ -0,0 +1,76 @@ +import type { PatchArtifact, PatchManifest } from '../toolkit/index.js'; + +export interface NodeOperationOptions { + maxInputBytes?: number; + maxOutputBytes?: number; + onProgress?: (event: { + operation: 'diff' | 'patch'; + phase: 'reading' | 'processing' | 'writing'; + progress: number; + }) => void; +} + +export interface NodeOperationResult { + bytes: number; + outputPath: string; + sha256: string; +} + +export function sha256Bytes(data: ArrayBufferView): string; +export function sha256File(filePath: string): Promise; +export function describeFile( + filePath: string, + options?: { name?: string; url?: string } +): Promise; +export function inspectPatchFile( + patchPath: string +): Promise; +export function diffFiles( + oldPath: string, + newPath: string, + outputPath: string, + options?: NodeOperationOptions +): Promise; +export function patchFiles( + oldPath: string, + patchPath: string, + outputPath: string, + options?: NodeOperationOptions +): Promise; +export function verifyPatchFiles( + oldPath: string, + patchPath: string, + expectedPath: string +): Promise<{ + expectedBytes: number; + expectedSha256: string; + restoredBytes: number; + restoredSha256: string; + verified: boolean; +}>; +export function createFilePatchManifest( + oldPath: string, + patchPath: string, + targetPath: string, + options?: { + baselineName?: string; + baselineUrl?: string; + patchName?: string; + patchUrl?: string; + targetName?: string; + targetUrl?: string; + releaseId?: string; + signature?: PatchManifest['signature']; + } +): Promise; +export function restoreVerified( + oldPath: string, + patchPath: string, + outputPath: string, + manifest: PatchManifest, + options?: NodeOperationOptions +): Promise; +export function convertBsdiff40File( + inputPath: string, + outputPath: string +): Promise; diff --git a/node/index.mjs b/node/index.mjs new file mode 100644 index 0000000..b3f28e9 --- /dev/null +++ b/node/index.mjs @@ -0,0 +1,473 @@ +import { createHash } from 'node:crypto'; +import { access, link, mkdir, mkdtemp, open, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import createBsDiffPatchModule from '../web/bsdiffpatch.mjs'; +import { + createPatchManifest, + inspectPatchHeader, + validatePatchManifest, +} from '../toolkit/index.mjs'; + +let wasmOperationQueue = Promise.resolve(); +let nodeModulePromise; +const PHASE_NAMES = ['reading', 'processing', 'writing']; + +function runSerialized(operation) { + const result = wasmOperationQueue.then(operation, operation); + wasmOperationQueue = result.catch(() => {}); + return result; +} + +function createNodeError(code, message) { + const error = new Error(message); + error.code = code; + return error; +} + +function getNodeModule() { + if (!nodeModulePromise) { + const pendingModule = createBsDiffPatchModule({ + print: () => {}, + printErr: () => {}, + }); + nodeModulePromise = pendingModule; + pendingModule.catch(() => { + if (nodeModulePromise === pendingModule) { + nodeModulePromise = undefined; + } + }); + } + return nodeModulePromise; +} + +function validateLimit(value, fieldName) { + if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) { + throw createNodeError( + 'EINVAL', + `${fieldName} must be a non-negative safe integer` + ); + } +} + +async function assertRegularFile(filePath) { + const fileStat = await stat(filePath); + if (!fileStat.isFile()) { + throw createNodeError('EINVAL', `not a regular file: ${filePath}`); + } + return fileStat; +} + +async function enforceInputLimits(filePaths, maximumBytes) { + validateLimit(maximumBytes, 'maxInputBytes'); + const stats = await Promise.all(filePaths.map(assertRegularFile)); + if (maximumBytes !== undefined) { + const oversizedIndex = stats.findIndex( + (fileStat) => fileStat.size > maximumBytes + ); + if (oversizedIndex >= 0) { + throw createNodeError( + 'ERESOURCE', + `${filePaths[oversizedIndex]} is ${stats[oversizedIndex].size} bytes and exceeds the ${maximumBytes} byte limit` + ); + } + } + return stats; +} + +function operationResultError(result) { + const codes = new Map([ + [-2, 'ERESOURCE'], + [-3, 'ERESOURCE'], + [-4, 'EABORTED'], + [-5, 'EDESTEXISTS'], + ]); + return createNodeError( + codes.get(result) || 'EWEBASSEMBLY', + `native function returned ${result}` + ); +} + +function ensureVirtualDirectory(module, mountPath) { + if (!module.FS.analyzePath(mountPath).exists) { + module.FS.mkdir(mountPath); + } +} + +function mountHostFile(module, mountName, hostPath) { + const absolutePath = path.resolve(hostPath); + const mountPath = `/node-${mountName}`; + ensureVirtualDirectory(module, mountPath); + module.FS.mount( + module.NODEFS, + { root: path.dirname(absolutePath) }, + mountPath + ); + return { + mountPath, + virtualPath: `${mountPath}/${path.basename(absolutePath)}`, + }; +} + +async function runHostFileOperation( + operation, + inputPaths, + outputPath, + options = {} +) { + const module = await getNodeModule(); + if (!module.NODEFS) { + throw createNodeError( + 'EUNSUPPORTED', + 'the WebAssembly bundle does not include NODEFS' + ); + } + + const mounts = []; + try { + const virtualInputs = inputPaths.map((inputPath, index) => { + const mount = mountHostFile(module, `input-${index}`, inputPath); + mounts.push(mount); + return mount.virtualPath; + }); + const outputMount = mountHostFile(module, 'output', outputPath); + mounts.push(outputMount); + module.onProgress = (phase, progress) => { + options.onProgress?.({ + operation, + phase: PHASE_NAMES[phase] || 'processing', + progress: Math.max(0, Math.min(1, progress)), + }); + }; + + const functionName = + operation === 'diff' + ? 'bsDiffFileWithProgressAndLimits' + : 'bsPatchFileWithProgressAndLimits'; + const fileArgs = + operation === 'diff' + ? [...virtualInputs, outputMount.virtualPath] + : [virtualInputs[0], outputMount.virtualPath, virtualInputs[1]]; + const args = [ + ...fileArgs, + options.maxInputBytes ?? -1, + options.maxOutputBytes ?? -1, + ]; + const result = module.ccall( + functionName, + 'number', + ['string', 'string', 'string', 'number', 'number'], + args + ); + if (result !== 0) { + throw operationResultError(result); + } + } finally { + module.onProgress = undefined; + for (const mount of mounts.reverse()) { + try { + module.FS.unmount(mount.mountPath); + } catch { + // A failed native operation may already have invalidated a mount. + } + } + } +} + +async function runHostConverter(inputPath, outputPath) { + const module = await getNodeModule(); + if (!module.NODEFS) { + throw createNodeError( + 'EUNSUPPORTED', + 'the WebAssembly bundle does not include NODEFS' + ); + } + const mounts = []; + try { + const inputMount = mountHostFile(module, 'convert-input', inputPath); + const outputMount = mountHostFile(module, 'convert-output', outputPath); + mounts.push(inputMount, outputMount); + const result = module.ccall( + 'bsConvertBsdiff40File', + 'number', + ['string', 'string'], + [inputMount.virtualPath, outputMount.virtualPath] + ); + if (result !== 0) { + throw createNodeError( + 'ELEGACYFORMAT', + `BSDIFF40 converter returned ${result}` + ); + } + } finally { + for (const mount of mounts.reverse()) { + try { + module.FS.unmount(mount.mountPath); + } catch { + // Preserve the converter error if cleanup also fails. + } + } + } +} + +async function assertOutputAvailable(outputPath) { + try { + await access(outputPath); + } catch (error) { + if (error && error.code === 'ENOENT') { + return; + } + throw error; + } + throw createNodeError('EDESTEXISTS', `output already exists: ${outputPath}`); +} + +export function sha256Bytes(data) { + return createHash('sha256').update(data).digest('hex'); +} + +export async function sha256File(filePath) { + const file = await open(filePath, 'r'); + const hash = createHash('sha256'); + try { + for await (const chunk of file.createReadStream({ autoClose: false })) { + hash.update(chunk); + } + } finally { + await file.close(); + } + return hash.digest('hex'); +} + +export async function describeFile(filePath, options = {}) { + const fileStat = await assertRegularFile(filePath); + return { + bytes: fileStat.size, + sha256: await sha256File(filePath), + ...(options.name === undefined ? {} : { name: options.name }), + ...(options.url === undefined ? {} : { url: options.url }), + }; +} + +export async function inspectPatchFile(patchPath) { + const file = await open(patchPath, 'r'); + try { + const fileStat = await file.stat(); + const header = new Uint8Array(Math.min(24, fileStat.size)); + if (header.byteLength > 0) { + await file.read(header, 0, header.byteLength, 0); + } + return inspectPatchHeader(header, fileStat.size); + } finally { + await file.close(); + } +} + +export async function diffFiles(oldPath, newPath, outputPath, options = {}) { + validateLimit(options.maxOutputBytes, 'maxOutputBytes'); + if (options.maxOutputBytes === 0) { + throw createNodeError( + 'ERESOURCE', + 'a BSDIFF43 patch cannot fit within a zero-byte output limit' + ); + } + await assertOutputAvailable(outputPath); + await enforceInputLimits([oldPath, newPath], options.maxInputBytes); + await mkdir(path.dirname(path.resolve(outputPath)), { recursive: true }); + await runSerialized(() => + runHostFileOperation('diff', [oldPath, newPath], outputPath, options) + ); + const output = await describeFile(outputPath); + return { + bytes: output.bytes, + outputPath, + sha256: output.sha256, + }; +} + +export async function patchFiles(oldPath, patchPath, outputPath, options = {}) { + validateLimit(options.maxOutputBytes, 'maxOutputBytes'); + await assertOutputAvailable(outputPath); + await enforceInputLimits([oldPath, patchPath], options.maxInputBytes); + const metadata = await inspectPatchFile(patchPath); + if (!metadata.valid) { + throw createNodeError( + metadata.issue === 'LEGACY_FORMAT' ? 'ELEGACYFORMAT' : 'EPATCH', + `cannot apply patch: ${metadata.issue || 'invalid patch'}` + ); + } + if ( + options.maxOutputBytes !== undefined && + BigInt(metadata.declaredTargetBytes) > BigInt(options.maxOutputBytes) + ) { + throw createNodeError( + 'ERESOURCE', + `output exceeds the configured ${options.maxOutputBytes} byte limit` + ); + } + await mkdir(path.dirname(path.resolve(outputPath)), { recursive: true }); + await runSerialized(() => + runHostFileOperation('patch', [oldPath, patchPath], outputPath, options) + ); + const output = await describeFile(outputPath); + return { + bytes: output.bytes, + outputPath, + sha256: output.sha256, + }; +} + +export async function verifyPatchFiles(oldPath, patchPath, expectedPath) { + const temporaryDirectory = await mkdtemp( + path.join(tmpdir(), 'react-native-bs-diff-patch-verify-') + ); + const restoredPath = path.join(temporaryDirectory, 'restored.bin'); + try { + const expected = await describeFile(expectedPath); + const restored = await patchFiles(oldPath, patchPath, restoredPath); + return { + expectedBytes: expected.bytes, + expectedSha256: expected.sha256, + restoredBytes: restored.bytes, + restoredSha256: restored.sha256, + verified: + restored.bytes === expected.bytes && + restored.sha256 === expected.sha256, + }; + } finally { + await rm(temporaryDirectory, { force: true, recursive: true }); + } +} + +export async function createFilePatchManifest( + oldPath, + patchPath, + targetPath, + options = {} +) { + const [baseline, patch, target, metadata] = await Promise.all([ + describeFile(oldPath, { + name: options.baselineName ?? path.basename(oldPath), + url: options.baselineUrl, + }), + describeFile(patchPath, { + name: options.patchName ?? path.basename(patchPath), + url: options.patchUrl, + }), + describeFile(targetPath, { + name: options.targetName ?? path.basename(targetPath), + url: options.targetUrl, + }), + inspectPatchFile(patchPath), + ]); + if (!metadata.valid) { + throw createNodeError( + metadata.issue === 'LEGACY_FORMAT' ? 'ELEGACYFORMAT' : 'EPATCH', + `cannot create manifest for patch: ${metadata.issue || 'invalid patch'}` + ); + } + if (metadata.declaredTargetBytes !== String(target.bytes)) { + throw createNodeError( + 'ETARGETMISMATCH', + 'patch header target size does not match the target artifact' + ); + } + return createPatchManifest({ + baseline, + patch, + target, + releaseId: options.releaseId, + signature: options.signature, + }); +} + +export async function restoreVerified( + oldPath, + patchPath, + outputPath, + manifestValue, + options = {} +) { + const manifest = validatePatchManifest(manifestValue); + + const [baseline, patch] = await Promise.all([ + describeFile(oldPath), + describeFile(patchPath), + ]); + if ( + baseline.bytes !== manifest.baseline.bytes || + baseline.sha256 !== manifest.baseline.sha256 + ) { + throw createNodeError( + 'EBASELINEMISMATCH', + 'baseline does not match the verified patch manifest' + ); + } + if ( + patch.bytes !== manifest.patch.bytes || + patch.sha256 !== manifest.patch.sha256 + ) { + throw createNodeError( + 'EPATCHMISMATCH', + 'patch does not match the verified patch manifest' + ); + } + + await assertOutputAvailable(outputPath); + const outputDirectory = path.dirname(path.resolve(outputPath)); + await mkdir(outputDirectory, { recursive: true }); + const temporaryDirectory = await mkdtemp( + path.join(outputDirectory, '.bsdiffpatch-verified-') + ); + const temporaryOutputPath = path.join(temporaryDirectory, 'restored.bin'); + try { + const result = await patchFiles(oldPath, patchPath, temporaryOutputPath, { + ...options, + maxOutputBytes: Math.min( + options.maxOutputBytes ?? Number.MAX_SAFE_INTEGER, + manifest.target.bytes + ), + }); + if ( + result.bytes !== manifest.target.bytes || + result.sha256 !== manifest.target.sha256 + ) { + throw createNodeError( + 'ETARGETMISMATCH', + 'restored output does not match the verified patch manifest' + ); + } + try { + await link(temporaryOutputPath, outputPath); + } catch (error) { + if (error && error.code === 'EEXIST') { + throw createNodeError( + 'EDESTEXISTS', + `output already exists: ${outputPath}` + ); + } + throw error; + } + return { ...result, outputPath }; + } finally { + await rm(temporaryDirectory, { force: true, recursive: true }); + } +} + +export async function convertBsdiff40File(inputPath, outputPath) { + await assertOutputAvailable(outputPath); + await assertRegularFile(inputPath); + const metadata = await inspectPatchFile(inputPath); + if (metadata.format !== 'BSDIFF40') { + throw createNodeError('ELEGACYFORMAT', 'input is not a BSDIFF40 patch'); + } + await mkdir(path.dirname(path.resolve(outputPath)), { recursive: true }); + await runSerialized(() => runHostConverter(inputPath, outputPath)); + const converted = await describeFile(outputPath); + return { + bytes: converted.bytes, + outputPath, + sha256: converted.sha256, + }; +} diff --git a/package.json b/package.json index dff441f..c027d43 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,51 @@ { "name": "react-native-bs-diff-patch", - "version": "0.4.0", + "version": "0.5.0", "description": "Create and apply compact binary patches across React Native Android, iOS, and Web", "main": "lib/commonjs/index", "module": "lib/module/index", "browser": "web/index.mjs", "types": "lib/typescript/src/index.d.ts", + "bin": "./bin/react-native-bs-diff-patch.mjs", "react-native": "src/index", "source": "src/index", "exports": { ".": { - "types": "./lib/typescript/src/index.d.ts", + "types": { + "react-native": "./lib/typescript/src/index.d.ts", + "browser": "./web/index.d.mts", + "default": "./lib/typescript/src/index.d.ts" + }, "react-native": "./src/index.ts", "browser": "./web/index.mjs", "import": "./lib/module/index.js", "require": "./lib/commonjs/index.js", "default": "./lib/commonjs/index.js" }, + "./web": { + "types": "./web/index.d.mts", + "import": "./web/index.mjs", + "default": "./web/index.mjs" + }, + "./node": { + "types": "./node/index.d.ts", + "node": "./node/index.mjs", + "import": "./node/index.mjs", + "default": "./node/index.mjs" + }, + "./toolkit": { + "types": "./toolkit/index.d.ts", + "import": "./toolkit/index.mjs", + "default": "./toolkit/index.mjs" + }, "./package.json": "./package.json" }, "files": [ + "bin", + "action", + "action.yml", + "node", + "toolkit", "src", "lib", "android", @@ -36,6 +62,8 @@ "!lib/commonjs/index.web.js*", "!lib/module/index.web.js*", "!lib/typescript/src/index.web.d.ts*", + "!web/*.c", + "!web/*-pre.js", "!**/__tests__", "!**/__fixtures__", "!**/__mocks__", @@ -51,6 +79,10 @@ "test:web:browser": "node scripts/test-web-browser.mjs", "test:web:metro": "node scripts/test-web-metro.mjs", "test:package": "node scripts/test-package-consumers.mjs", + "test:sdk": "node scripts/test-sdk-consumers.mjs", + "test:node": "node scripts/test-node-cli.mjs", + "test:toolkit": "node scripts/test-toolkit.mjs", + "test:action": "node scripts/test-action.mjs", "test:registry:vite": "node scripts/test-registry-consumers.mjs vite", "test:registry:expo": "node scripts/test-registry-consumers.mjs expo", "test:fuzz": "sh scripts/test-native-fuzz.sh", @@ -66,7 +98,8 @@ "typecheck": "tsc --noEmit", "lint": "eslint \"**/*.{js,ts,tsx}\"", "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", - "prepare": "bob build && node scripts/prepare-package.mjs", + "prepare": "bob build && node scripts/prepare-package.mjs && node scripts/check-package-contract.mjs", + "prepack": "node scripts/check-package-contract.mjs", "build:web": "bash scripts/build-web-wasm.sh", "release": "release-it" }, @@ -130,6 +163,9 @@ "@react-native-community/cli-config/joi": "17.13.4", "@react-native-community/cli-doctor/yaml": "2.9.0", "@react-native-community/cli-types/joi": "17.13.4", + "brace-expansion@^1.1.7": "1.1.17", + "brace-expansion@^2.0.1": "2.1.3", + "brace-expansion@^5.0.5": "5.0.8", "cosmiconfig/yaml": "1.10.3", "eslint/ajv": "6.15.0", "fast-xml-parser": "5.10.1", @@ -232,7 +268,8 @@ [ "typescript", { - "project": "tsconfig.build.json" + "project": "tsconfig.build.json", + "tsc": "node_modules/typescript/bin/tsc" } ] ] diff --git a/scripts/benchmark-native.mjs b/scripts/benchmark-native.mjs index 50afea0..a133153 100644 --- a/scripts/benchmark-native.mjs +++ b/scripts/benchmark-native.mjs @@ -57,6 +57,7 @@ try { path.join(repositoryDirectory, 'cpp', 'benchmark', 'native_benchmark.c'), path.join(repositoryDirectory, 'cpp', 'bsdiff.c'), path.join(repositoryDirectory, 'cpp', 'bspatch.c'), + path.join(repositoryDirectory, 'cpp', 'bspatch_streaming.c'), ...bzip2Sources, '-o', executable, diff --git a/scripts/build-site.mjs b/scripts/build-site.mjs index 40571d6..4546b8d 100644 --- a/scripts/build-site.mjs +++ b/scripts/build-site.mjs @@ -16,6 +16,13 @@ const pages = [ 'Install the package and complete your first native or Web patch round trip.', file: 'getting-started.md', }, + { + slug: 'web-sdk', + title: 'Web and desktop SDK', + description: + 'Use public Web and toolkit exports in Vite and desktop WebViews without React Native or a Node sidecar.', + file: 'web-sdk.md', + }, { slug: 'api-reference', title: 'API reference', @@ -30,6 +37,13 @@ const pages = [ 'Integrity checks, temporary files, downloads, resource limits, and cross-runtime workflows.', file: 'recipes.md', }, + { + slug: 'verified-delta-pipeline', + title: 'Verified Delta Pipeline', + description: + 'Node CLI, verified manifests, multi-baseline bundles, release selection, and GitHub Actions.', + file: 'verified-delta-pipeline.md', + }, { slug: 'platform-support', title: 'Platform support', @@ -52,11 +66,11 @@ const pages = [ file: 'native-operations-v03.md', }, { - slug: 'large-files-v04', + slug: 'large-files-roadmap', title: 'Large-file roadmap', description: 'Memory baselines, honest progress semantics, and streaming feasibility for larger inputs.', - file: 'large-files-v04.md', + file: 'large-files-roadmap.md', }, { slug: 'troubleshooting', @@ -81,6 +95,13 @@ const chinesePages = [ description: '安装依赖,并完成第一次原生端或 Web 补丁往返。', file: 'getting-started.md', }, + { + slug: 'web-sdk', + title: 'Web 与桌面 SDK', + description: + '通过公开 Web 与 toolkit 入口接入 Vite 和桌面 WebView,无需 React Native 或 Node sidecar。', + file: 'web-sdk.md', + }, { slug: 'api-reference', title: 'API 参考', @@ -93,6 +114,13 @@ const chinesePages = [ description: '补丁完整性、临时文件、下载、资源限制和跨运行时流程。', file: 'recipes.md', }, + { + slug: 'verified-delta-pipeline', + title: '可验证增量发布工具链', + description: + 'Node CLI、可验证 manifest、多基线 bundle、发布选择与 GitHub Actions。', + file: 'verified-delta-pipeline.md', + }, { slug: 'platform-support', title: '平台支持', @@ -112,10 +140,10 @@ const chinesePages = [ file: 'native-operations-v03.md', }, { - slug: 'large-files-v04', + slug: 'large-files-roadmap', title: '大文件演进路线', description: '面向更大输入的内存基线、真实进度语义与流式处理可行性。', - file: 'large-files-v04.md', + file: 'large-files-roadmap.md', }, { slug: 'troubleshooting', @@ -314,6 +342,8 @@ const englishUi = { skipLabel: 'Skip to documentation', primaryNavigationLabel: 'Primary navigation', playgroundLabel: 'Playground', + plannerLabel: 'Release Planner', + plannerPath: '/planner/', toolsLabel: 'Tools', toolsPath: '/tools/', docsLabel: 'Docs', @@ -340,6 +370,8 @@ const chineseUi = { skipLabel: '跳到文档正文', primaryNavigationLabel: '主导航', playgroundLabel: '在线实验', + plannerLabel: '发布规划', + plannerPath: '/zh-CN/planner/', toolsLabel: '工具', toolsPath: '/zh-CN/tools/', docsLabel: '中文文档', @@ -419,6 +451,7 @@ function documentationLayout({ slug, title, description, content, items, ui }) {