diff --git a/.github/actions/e2e-android-setup/action.yml b/.github/actions/e2e-android-setup/action.yml index 5163ad68..259efa09 100644 --- a/.github/actions/e2e-android-setup/action.yml +++ b/.github/actions/e2e-android-setup/action.yml @@ -1,8 +1,9 @@ name: E2E Android setup description: >- - Shared setup for the Android Detox jobs: toolchains, caches, KVM, the e2e - project's dependencies and the local react-native-update link. Runs after - the react-native-update checkout (this action lives in it). + Shared setup for the Android Detox jobs: Java (+ Gradle cache) and KVM, then + the platform-neutral e2e setup (../e2e-common: toolchains, Bun cache, the + e2e project's dependencies and the local react-native-update link). Runs + after the react-native-update checkout (this action lives in it). inputs: project-dir: description: E2E app directory, relative to the workspace. @@ -32,32 +33,6 @@ inputs: runs: using: composite steps: - - name: Checkout react-native-update-cli - if: inputs.checkout-cli == 'true' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: reactnativecn/react-native-update-cli - path: react-native-update-cli - - - name: Setup Node.js - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: 24 - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: 1.4.0 - - - name: Cache Bun dependencies - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-e2e${{ inputs.bun-cache-scope }}-${{ hashFiles('Example/e2etest/bun.lock', 'react-native-update-cli/bun.lock') }}${{ inputs.bun-cache-key-suffix }} - restore-keys: | - ${{ runner.os }}-bun-e2e${{ inputs.bun-cache-scope }}- - ${{ runner.os }}-bun-e2e- - - name: Setup Java uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: @@ -73,45 +48,12 @@ runs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - - name: Create e2e project - if: inputs.create-project != '' - shell: bash - env: - CREATE_PROJECT: ${{ inputs.create-project }} - run: eval "$CREATE_PROJECT" - - - name: Install e2e project dependencies - shell: bash - working-directory: ${{ inputs.project-dir }} - env: - FROZEN: ${{ inputs.frozen-lockfile }} - run: | - if [ "$FROZEN" = 'true' ]; then - bun install --frozen-lockfile - else - bun install - fi - - - name: Link local react-native-update - # Mirrors what an npm install ships (package.json `files`), plus cpp/ - # for the iOS/Android native builds; scripts/ is dev-only and unused - # by any build step. - shell: bash - env: - E2E_DIR: ${{ inputs.project-dir }} - run: | - set -euo pipefail - export LOCAL_RNU_VERSION="$(node -p "require('./${E2E_DIR}/node_modules/react-native-update/package.json').version")" - export LOCAL_RNU_DIR="$PWD/$E2E_DIR/.e2e-artifacts/local-react-native-update" - rm -rf "$LOCAL_RNU_DIR" "$E2E_DIR/node_modules/react-native-update" - mkdir -p "$LOCAL_RNU_DIR" - cp package.json react-native-update.podspec react-native.config.js expo-module.config.json "$LOCAL_RNU_DIR"/ - rsync -a --exclude='.git' src ios android cpp "$LOCAL_RNU_DIR"/ - node -e "const fs = require('fs'); const file = process.env.LOCAL_RNU_DIR + '/package.json'; const pkg = JSON.parse(fs.readFileSync(file, 'utf8')); pkg.version = process.env.LOCAL_RNU_VERSION; fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + '\n');" - ln -s ../.e2e-artifacts/local-react-native-update "$E2E_DIR/node_modules/react-native-update" - - - name: Install react-native-update-cli dependencies - if: inputs.checkout-cli == 'true' - shell: bash - working-directory: react-native-update-cli - run: bun install --frozen-lockfile && bun run build + - name: Common e2e setup + uses: ./.github/actions/e2e-common + with: + project-dir: ${{ inputs.project-dir }} + checkout-cli: ${{ inputs.checkout-cli }} + bun-cache-scope: ${{ inputs.bun-cache-scope }} + bun-cache-key-suffix: ${{ inputs.bun-cache-key-suffix }} + create-project: ${{ inputs.create-project }} + frozen-lockfile: ${{ inputs.frozen-lockfile }} diff --git a/.github/actions/e2e-common/action.yml b/.github/actions/e2e-common/action.yml new file mode 100644 index 00000000..ae33999f --- /dev/null +++ b/.github/actions/e2e-common/action.yml @@ -0,0 +1,98 @@ +name: E2E common setup +description: >- + Platform-neutral part of the Detox e2e setup: Node/Bun toolchains, the Bun + cache, the e2e project's dependencies, the local react-native-update link + and (optionally) a built react-native-update-cli. Runs after the + react-native-update checkout (this action lives in it); platform toolchains + (Java/KVM, Xcode/Homebrew) are set up by the caller. +inputs: + project-dir: + description: E2E app directory, relative to the workspace. + default: Example/e2etest + checkout-cli: + description: Check out and build react-native-update-cli (needed to prepare update artifacts). + default: 'true' + bun-cache-scope: + description: Extra bun cache key segment for jobs whose dependency set differs. + default: '' + bun-cache-key-suffix: + description: Extra hash appended to the bun cache key. + default: '' + create-project: + description: Command that generates the e2e project before its dependencies are installed. + default: '' + frozen-lockfile: + description: Install with --frozen-lockfile (false only for generated projects that have no lockfile). + default: 'true' +runs: + using: composite + steps: + - name: Checkout react-native-update-cli + if: inputs.checkout-cli == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: reactnativecn/react-native-update-cli + path: react-native-update-cli + + - name: Setup Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.0 + + - name: Cache Bun dependencies + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-e2e${{ inputs.bun-cache-scope }}-${{ hashFiles('Example/e2etest/bun.lock', 'react-native-update-cli/bun.lock') }}${{ inputs.bun-cache-key-suffix }} + restore-keys: | + ${{ runner.os }}-bun-e2e${{ inputs.bun-cache-scope }}- + ${{ runner.os }}-bun-e2e- + + - name: Create e2e project + if: inputs.create-project != '' + shell: bash + env: + CREATE_PROJECT: ${{ inputs.create-project }} + run: eval "$CREATE_PROJECT" + + - name: Install e2e project dependencies + shell: bash + working-directory: ${{ inputs.project-dir }} + env: + FROZEN: ${{ inputs.frozen-lockfile }} + run: | + if [ "$FROZEN" = 'true' ]; then + bun install --frozen-lockfile + else + bun install + fi + + - name: Link local react-native-update + # Mirrors what an npm install ships (package.json `files`), plus cpp/ + # for the iOS/Android native builds; scripts/ is dev-only and unused + # by any build step. + shell: bash + env: + E2E_DIR: ${{ inputs.project-dir }} + run: | + set -euo pipefail + LOCAL_RNU_VERSION="$(node -p "require('./${E2E_DIR}/node_modules/react-native-update/package.json').version")" + LOCAL_RNU_DIR="$PWD/$E2E_DIR/.e2e-artifacts/local-react-native-update" + export LOCAL_RNU_VERSION LOCAL_RNU_DIR + rm -rf "$LOCAL_RNU_DIR" "$E2E_DIR/node_modules/react-native-update" + mkdir -p "$LOCAL_RNU_DIR" + cp package.json react-native-update.podspec react-native.config.js expo-module.config.json "$LOCAL_RNU_DIR"/ + rsync -a --exclude='.git' src ios android cpp "$LOCAL_RNU_DIR"/ + node -e "const fs = require('fs'); const file = process.env.LOCAL_RNU_DIR + '/package.json'; const pkg = JSON.parse(fs.readFileSync(file, 'utf8')); pkg.version = process.env.LOCAL_RNU_VERSION; fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + '\n');" + ln -s ../.e2e-artifacts/local-react-native-update "$E2E_DIR/node_modules/react-native-update" + + - name: Install react-native-update-cli dependencies + if: inputs.checkout-cli == 'true' + shell: bash + working-directory: react-native-update-cli + run: bun install --frozen-lockfile && bun run build diff --git a/.github/actions/publish-npm/action.yml b/.github/actions/publish-npm/action.yml new file mode 100644 index 00000000..27886876 --- /dev/null +++ b/.github/actions/publish-npm/action.yml @@ -0,0 +1,118 @@ +name: Publish to npm +description: >- + Shared body of the two publish jobs in publish.yml: toolchains, the CI-built + Android native libraries, the Harmony HAR (rebuilt, or reused from the + previous published release), the pre-publish checks and npm publish. Runs + after the react-native-update checkout (this action lives in it). +inputs: + harmony-har: + description: "'build' rebuilds harmony/pushy.har from source; 'previous' reuses the one in the previous-tag release on npm." + required: true + previous-tag: + description: Last release tag published to npm (with harmony-har 'previous'). + default: '' +runs: + using: composite + steps: + - name: Check inputs + shell: bash + env: + HARMONY_HAR: ${{ inputs.harmony-har }} + PREVIOUS_TAG: ${{ inputs.previous-tag }} + run: | + case "$HARMONY_HAR" in + build) ;; + previous) + if [ -z "$PREVIOUS_TAG" ]; then + echo "::error::harmony-har 'previous' needs previous-tag" + exit 1 + fi + ;; + *) echo "::error::unknown harmony-har '$HARMONY_HAR'"; exit 1 ;; + esac + + - name: Configure git safe.directory + shell: bash + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + # $toplevel/$sm_path are expanded by git submodule foreach, not here. + # shellcheck disable=SC2016 + git submodule foreach --recursive 'git config --global --add safe.directory "$toplevel/$sm_path"' + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.0 + + # Setup .npmrc file to publish to npm + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + shell: bash + run: bun install --frozen-lockfile + + # Drop the committed binaries first: the artifact must fully replace + # android/lib, so any layout regression makes the verify step fail loudly + # instead of silently passing against stale committed .so files. + - name: Remove committed Android native libraries + shell: bash + run: rm -rf android/lib + + - name: Use CI-built Android native libraries + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: android-so + path: android/lib + + - name: Verify Android native libraries + shell: bash + run: node scripts/verify-android-so.js + + - name: Build Harmony HAR + if: inputs.harmony-har == 'build' + shell: bash + run: npm run build:harmony-har -- --build-mode release + + # Fail closed: the package must always ship harmony/pushy.har + # (package.json `files` lists it and check-packlist.js --release + # requires it). If the previous release can't be fetched, fix the cause + # or re-run — never publish a HAR-less package. + - name: Fetch previous Harmony HAR + if: inputs.harmony-har == 'previous' + shell: bash + env: + PREVIOUS_TAG: ${{ inputs.previous-tag }} + run: | + PREV_VERSION="${PREVIOUS_TAG#v}" + echo "Fetching harmony/pushy.har from react-native-update@$PREV_VERSION" + npm pack "react-native-update@$PREV_VERSION" + tar -xzf "react-native-update-${PREV_VERSION}.tgz" package/harmony/pushy.har + mkdir -p harmony + cp package/harmony/pushy.har harmony/pushy.har + # Clean up temporary pack and extraction files to prevent publishing them + rm -rf "react-native-update-${PREV_VERSION}.tgz" package + + - name: Verify Harmony HAR artifact + shell: bash + run: test -f harmony/pushy.har + + - name: Verify release tag matches package version + shell: bash + run: node scripts/check-release-version.js + + - name: Verify publish file list + shell: bash + run: node scripts/check-packlist.js --release + + - name: Publish to npm + shell: bash + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + if [[ "$RELEASE_TAG" == *"beta"* ]]; then + npm publish --provenance --access public --tag beta + else + npm publish --provenance --access public + fi diff --git a/.github/workflows/e2e_android.yml b/.github/workflows/e2e_android.yml index 0a220f4b..7b5e1383 100644 --- a/.github/workflows/e2e_android.yml +++ b/.github/workflows/e2e_android.yml @@ -7,6 +7,7 @@ on: paths: &e2e-android-paths - '.github/workflows/e2e_android.yml' - '.github/actions/e2e-android-setup/**' + - '.github/actions/e2e-common/**' - '.gitmodules' # codegenConfig is copied into the linked package and drives codegen - 'package.json' @@ -26,6 +27,12 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +# Read by .detoxrc.js (detox build/test only): the ABI to build and the AVD +# the emulator steps below create. +env: + DETOX_ANDROID_ARCHS: x86_64 + DETOX_AVD_NAME: api34 + jobs: e2e-android: runs-on: ubuntu-latest @@ -40,9 +47,6 @@ jobs: uses: ./.github/actions/e2e-android-setup - name: Detox build (android.emu.release) - env: - DETOX_ANDROID_ARCHS: x86_64 - DETOX_AVD_NAME: api34 run: cd Example/e2etest && E2E_PLATFORM=android bunx detox build --configuration android.emu.release - name: Prepare local update artifacts @@ -54,11 +58,18 @@ jobs: - name: Detox test (android.emu.release) uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 env: - DETOX_ANDROID_ARCHS: x86_64 RNU_CLI_ROOT: ${{ github.workspace }}/react-native-update-cli RNU_E2E_SKIP_PREPARE: 'true' - DETOX_AVD_NAME: api34 - with: + # 每条命令自带 cd:android-emulator-runner 逐行起独立 shell, + # 单独一行的 cd 不会保持到下一条(实测 detox 会在仓库根找不到 jest) + EMULATOR_SCRIPT: | + cd Example/e2etest && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 + # 原生冷启动检测有自己的 runner 配置(见 e2e/native/jest.config.js); + # Android 这个 job 有预算,不必像 iOS 那样拆成独立 job + cd Example/e2etest && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --config e2e/native/jest.config.js + # Shared by every emulator step in this workflow; each step supplies + # its own commands through the EMULATOR_SCRIPT env var. + with: &emulator api-level: 34 target: google_apis arch: x86_64 @@ -71,13 +82,7 @@ jobs: # worker. Unpin once a run proves 37.x stable. emulator-build: 15507667 emulator-boot-timeout: 900 - # 每条命令自带 cd:android-emulator-runner 逐行起独立 shell, - # 单独一行的 cd 不会保持到下一条(实测 detox 会在仓库根找不到 jest) - script: | - cd Example/e2etest && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 - # 原生冷启动检测有自己的 runner 配置(见 e2e/native/jest.config.js); - # Android 这个 job 有预算,不必像 iOS 那样拆成独立 job - cd Example/e2etest && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --config e2e/native/jest.config.js + script: ${{ env.EMULATOR_SCRIPT }} - name: Upload Detox artifacts # cancelled() 覆盖 job 超时被杀的场景,否则超时时拿不到现场 @@ -108,9 +113,6 @@ jobs: checkout-cli: 'false' - name: Detox build (android.emu.debug) - env: - DETOX_ANDROID_ARCHS: x86_64 - DETOX_AVD_NAME: api34 run: cd Example/e2etest && bunx detox build --configuration android.emu.debug - name: Start Metro in background @@ -125,22 +127,13 @@ jobs: - name: Detox test (android.emu.debug boot smoke) uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 env: - DETOX_ANDROID_ARCHS: x86_64 - DETOX_AVD_NAME: api34 - with: - api-level: 34 - target: google_apis - arch: x86_64 - avd-name: api34 - # Pinned while stabilizing e2e; see the e2e-android job. - emulator-build: 15507667 - emulator-boot-timeout: 900 # 注意:emulator-runner 把 script 的每一行单独用 sh -c 执行, # cd 不会跨行生效,所以每行必须自包含。 - script: | + EMULATOR_SCRIPT: | for i in $(seq 1 90); do curl -sf http://localhost:8081/status >/dev/null && break; sleep 2; done curl -sf --max-time 420 -o /dev/null "http://localhost:8081/index.bundle?platform=android&dev=true&minify=false" || { echo '::error::Metro dev bundle failed to build'; tail -n 100 Example/e2etest/.e2e-artifacts/metro.log; exit 1; } cd Example/e2etest && bunx detox test --configuration android.emu.debug --config e2e/smoke/jest.config.js --no-start --headless --record-logs all --retries 1 + with: *emulator - name: Upload Detox artifacts # cancelled() 覆盖 job 超时被杀的场景,否则超时时拿不到现场 @@ -183,9 +176,6 @@ jobs: frozen-lockfile: 'false' - name: Detox build (RN 0.77.3 old arch android.emu.release) - env: - DETOX_ANDROID_ARCHS: x86_64 - DETOX_AVD_NAME: api34 run: cd .e2e-rn077-oldarch/AwesomeProject && E2E_PLATFORM=android bunx detox build --configuration android.emu.release - name: Prepare local update artifacts @@ -197,22 +187,13 @@ jobs: - name: Detox test (RN 0.77.3 old arch android.emu.release) uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 env: - DETOX_ANDROID_ARCHS: x86_64 RNU_CLI_ROOT: ${{ github.workspace }}/react-native-update-cli RNU_E2E_SKIP_PREPARE: 'true' - DETOX_AVD_NAME: api34 - with: - api-level: 34 - target: google_apis - arch: x86_64 - avd-name: api34 - # Pinned while stabilizing e2e; see the e2e-android job. - emulator-build: 15507667 - emulator-boot-timeout: 900 # 每条命令自带 cd,理由同上 - script: | + EMULATOR_SCRIPT: | cd .e2e-rn077-oldarch/AwesomeProject && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 cd .e2e-rn077-oldarch/AwesomeProject && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --config e2e/native/jest.config.js + with: *emulator - name: Upload Detox artifacts # cancelled() 覆盖 job 超时被杀的场景,否则超时时拿不到现场 diff --git a/.github/workflows/e2e_ios.yml b/.github/workflows/e2e_ios.yml index 433d937d..0a4d7200 100644 --- a/.github/workflows/e2e_ios.yml +++ b/.github/workflows/e2e_ios.yml @@ -7,6 +7,7 @@ on: paths: &e2e-ios-paths - '.github/workflows/e2e_ios.yml' - '.github/actions/e2e-cache/**' + - '.github/actions/e2e-common/**' - '.gitmodules' # codegenConfig is copied into the linked package and drives codegen - 'package.json' @@ -27,6 +28,10 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + # Also part of every cache key/scope below: a new Xcode means new binaries. + XCODE_VERSION: '26.4' + jobs: # Builds the release .app once (pod install + xcodebuild, or the cached app # with a refreshed JS bundle) and prepares the local update artifacts; both @@ -41,34 +46,10 @@ jobs: with: submodules: recursive - - name: Checkout react-native-update-cli - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: reactnativecn/react-native-update-cli - path: react-native-update-cli - - - name: Setup Node.js - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: 22 - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: 1.4.0 - - - name: Cache Bun dependencies - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-e2e-${{ hashFiles('Example/e2etest/bun.lock', 'react-native-update-cli/bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun-e2e- - - name: Setup Xcode uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: - xcode-version: '26.4' + xcode-version: ${{ env.XCODE_VERSION }} - name: Cache Homebrew downloads # Bottle content addressing still controls reuse; only this job's @@ -78,7 +59,7 @@ jobs: uses: ./.github/actions/e2e-cache with: path: ~/Library/Caches/Homebrew - scope: brew-xcode-26.4 + scope: brew-xcode-${{ env.XCODE_VERSION }} write: 'true' - name: Install applesimutils and ccache @@ -90,36 +71,20 @@ jobs: uses: ./.github/actions/e2e-cache with: path: ~/Library/Caches/ccache - scope: ccache-xcode-26.4 + scope: ccache-xcode-${{ env.XCODE_VERSION }} write: 'true' - - name: Install e2etest dependencies - run: cd Example/e2etest && bun install --frozen-lockfile - - - name: Link local react-native-update - # Mirrors what an npm install ships (package.json `files`), plus cpp/ - # for the native build; scripts/ is dev-only and unused by any build - # step. - run: | - set -euo pipefail - export LOCAL_RNU_VERSION="$(node -p "require('./Example/e2etest/node_modules/react-native-update/package.json').version")" - LOCAL_RNU_DIR="$PWD/Example/e2etest/.e2e-artifacts/local-react-native-update" - rm -rf "$LOCAL_RNU_DIR" Example/e2etest/node_modules/react-native-update - mkdir -p "$LOCAL_RNU_DIR" - cp package.json react-native-update.podspec react-native.config.js expo-module.config.json "$LOCAL_RNU_DIR"/ - rsync -a --exclude='.git' src ios android cpp "$LOCAL_RNU_DIR"/ - node -e "const fs = require('fs'); const file = 'Example/e2etest/.e2e-artifacts/local-react-native-update/package.json'; const pkg = JSON.parse(fs.readFileSync(file, 'utf8')); pkg.version = process.env.LOCAL_RNU_VERSION; fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + '\n');" - ln -s ../.e2e-artifacts/local-react-native-update Example/e2etest/node_modules/react-native-update - - - name: Install react-native-update-cli dependencies - run: cd react-native-update-cli && bun install --frozen-lockfile && bun run build + - name: Set up e2e environment + # Node/Bun, e2etest dependencies, the local react-native-update link + # and react-native-update-cli (shared with the Android jobs). + uses: ./.github/actions/e2e-common - name: Cache Metro transform cache # Metro validates its own content keys; stale snapshots only recompile. uses: ./.github/actions/e2e-cache with: path: Example/e2etest/.metro-cache - scope: metro-xcode-26.4 + scope: metro-xcode-${{ env.XCODE_VERSION }} write: 'true' - name: Prepare local update artifacts @@ -149,7 +114,7 @@ jobs: path: | Example/e2etest/ios/build/Build/Products/Release-iphonesimulator/AwesomeProject.app Example/e2etest/ios/build/hermesc - key: ${{ runner.os }}-e2e-app-xcode-26.4-${{ hashFiles('Example/e2etest/ios/**', 'Example/e2etest/package.json', 'Example/e2etest/bun.lock', 'react-native-update.podspec', 'react-native.config.js', 'expo-module.config.json', 'ios/**', 'cpp/**', 'android/jni/**', 'src/Native*.ts', 'package.json') }} + key: ${{ runner.os }}-e2e-app-xcode-${{ env.XCODE_VERSION }}-${{ hashFiles('Example/e2etest/ios/**', 'Example/e2etest/package.json', 'Example/e2etest/bun.lock', 'react-native-update.podspec', 'react-native.config.js', 'expo-module.config.json', 'ios/**', 'cpp/**', 'android/jni/**', 'src/Native*.ts', 'package.json') }} - name: Install CocoaPods dependencies if: steps.cache-ios-app.outputs.cache-hit != 'true' @@ -215,7 +180,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 22 + node-version: 24 - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 @@ -233,7 +198,7 @@ jobs: - name: Setup Xcode uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: - xcode-version: '26.4' + xcode-version: ${{ env.XCODE_VERSION }} - name: Boot iOS simulator in background # 模拟器冷启动要 2-5 分钟,提前异步 boot,让它和依赖安装/下载并行。 @@ -253,7 +218,7 @@ jobs: uses: ./.github/actions/e2e-cache with: path: ~/Library/Caches/Homebrew - scope: brew-xcode-26.4 + scope: brew-xcode-${{ env.XCODE_VERSION }} - name: Install applesimutils run: brew trust wix/brew && HOMEBREW_NO_AUTO_UPDATE=1 brew install wix/brew/applesimutils @@ -274,9 +239,9 @@ jobs: uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/Library/Detox - key: ${{ runner.os }}-detox-ios-framework-xcode-26.4-${{ hashFiles('Example/e2etest/bun.lock') }} + key: ${{ runner.os }}-detox-ios-framework-xcode-${{ env.XCODE_VERSION }}-${{ hashFiles('Example/e2etest/bun.lock') }} restore-keys: | - ${{ runner.os }}-detox-ios-framework-xcode-26.4- + ${{ runner.os }}-detox-ios-framework-xcode-${{ env.XCODE_VERSION }}- - name: Build Detox iOS framework cache if: steps.cache-detox-ios-framework.outputs.cache-hit != 'true' diff --git a/.github/workflows/harmony-build.yml b/.github/workflows/harmony-build.yml index bc166c13..1968a8ad 100644 --- a/.github/workflows/harmony-build.yml +++ b/.github/workflows/harmony-build.yml @@ -7,11 +7,14 @@ name: harmony-build # first seen when publishing. assembleHar runs the ArkTS compiler over the # .ets/.ts sources, which is the real type check; check-harmony-types.js # adds a strict tsc pass over the .ts sources and must not skip here. +# +# The JS <-> native method parity and constant-mirror checks run in +# `bun lint` (lint.yml) on every PR, so they are not repeated here. on: pull_request: branches: - master - paths: + paths: &harmony-paths - '.github/workflows/harmony-build.yml' - '.gitmodules' - 'package.json' @@ -21,23 +24,10 @@ on: - 'android/jni/**' - 'scripts/build-harmony-har.js' - 'scripts/check-harmony-types.js' - - 'scripts/check-native-spec-parity.js' - - 'src/NativePushy.ts' push: branches: - master - paths: - - '.github/workflows/harmony-build.yml' - - '.gitmodules' - - 'package.json' - - 'bun.lock' - - 'harmony/**' - - 'cpp/**' - - 'android/jni/**' - - 'scripts/build-harmony-har.js' - - 'scripts/check-harmony-types.js' - - 'scripts/check-native-spec-parity.js' - - 'src/NativePushy.ts' + paths: *harmony-paths concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -67,12 +57,6 @@ jobs: node-version: 24 - run: bun install --frozen-lockfile - - name: Verify JS <-> native method parity - run: node scripts/check-native-spec-parity.js - - - name: Verify constant mirrors match the C++ headers - run: node harmony/pushy/src/test/check-constant-parity.js - - name: Build Harmony HAR (release) run: npm run build:harmony-har -- --build-mode release @@ -88,30 +72,9 @@ jobs: test "${#libs[@]}" -gt 0 node scripts/check-binary-strings.js "${libs[@]}" + # Required: the script also searches the container's install roots for + # the OpenHarmony SDK declarations and fails if it finds none. - name: Type-check Harmony TS sources (required) - shell: bash - run: | - # The container's SDK is not laid out like a DevEco install, so - # locate the ets declaration root (the directory holding api/ and - # kits/) directly and hand it to the script. - ets_dir="${HARMONY_SDK_ETS_DIR:-}" - if [ -z "$ets_dir" ]; then - for root in "${DEVECO_SDK_HOME:-}" /opt /usr/local /usr/lib /root /home /github/home; do - [ -n "$root" ] && [ -d "$root" ] || continue - # The @ohos.*/@kit.* declarations live under the OpenHarmony SDK - # (sdk//openharmony/ets); the sibling hms/ets tree only - # holds HMS kits and must not win. - api_dir="$(find "$root" -maxdepth 8 -type d -path '*/openharmony/ets/api' 2>/dev/null \ - | sort | head -n 1)" - if [ -n "$api_dir" ]; then - ets_dir="$(dirname "$api_dir")" - break - fi - done - fi - echo "HARMONY_SDK_ETS_DIR=${ets_dir:-}" - if [ -z "$ets_dir" ]; then - echo "SDK candidates under /opt:"; ls -la /opt 2>/dev/null || true - fi - HARMONY_SDK_ETS_DIR="$ets_dir" HARMONY_TYPECHECK_REQUIRED=1 \ - node scripts/check-harmony-types.js + run: node scripts/check-harmony-types.js + env: + HARMONY_TYPECHECK_REQUIRED: '1' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1b4ad709..0d090c94 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -28,9 +28,11 @@ jobs: - name: Install Dependency run: bun install --frozen-lockfile - # `bun lint` ends with check-native-spec-parity.js: every + # `bun lint` ends with check-native-spec-parity.js (every # src/NativePushy.ts method must be exported by all four native - # bindings, with the right sync/async kind on Harmony. + # bindings, with the right sync/async kind on Harmony) and + # check-constant-parity.js (the C++ header constants must match their + # Java/ArkTS/JS mirrors). - name: Run lint run: bun lint diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 04da5855..4cc53a1e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -75,7 +75,7 @@ jobs: fi echo "Previous tag is $PREV_TAG" # The HAR embeds librnpushy.so compiled from cpp/patch_core, - # cpp/update_flow_core, android/jni/hpatch.c and the HDiffPatch/lzma + # cpp/update_flow_core, cpp/hpatch and the HDiffPatch/lzma # submodules (harmony/pushy/src/main/cpp/CMakeLists.txt), so any of # those changing must rebuild it too — reusing the previous HAR # would ship Harmony with an older native core than Android/iOS. @@ -115,6 +115,9 @@ jobs: path: android/lib/ if-no-files-found: error + # The two publish jobs differ only in where harmony/pushy.har comes from; + # everything else lives in .github/actions/publish-npm. They stay separate + # jobs: the HAR build needs the HarmonyOS container, the reuse path does not. publish_with_harmony: needs: [check_changes, build_android_so] if: needs.check_changes.outputs.harmony_changed == 'true' @@ -126,47 +129,10 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: recursive - - name: Configure git safe.directory - run: | - git config --global --add safe.directory "$GITHUB_WORKSPACE" - git submodule foreach --recursive 'git config --global --add safe.directory "$toplevel/$sm_path"' - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: 1.4.0 - # Setup .npmrc file to publish to npm - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: 24 - registry-url: 'https://registry.npmjs.org' - - run: bun install --frozen-lockfile - # Drop the committed binaries first: the artifact must fully replace - # android/lib, so any layout regression makes the verify step fail loudly - # instead of silently passing against stale committed .so files. - - name: Remove committed Android native libraries - run: rm -rf android/lib - - name: Use CI-built Android native libraries - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + - name: Build Harmony HAR and publish + uses: ./.github/actions/publish-npm with: - name: android-so - path: android/lib - - name: Verify Android native libraries - run: node scripts/verify-android-so.js - - name: Build Harmony HAR - run: npm run build:harmony-har -- --build-mode release - - name: Verify Harmony HAR artifact - run: test -f harmony/pushy.har - - name: Verify release tag matches package version - run: node scripts/check-release-version.js - - name: Verify publish file list - run: node scripts/check-packlist.js --release - - name: Publish to npm - shell: bash - run: | - if [[ "${{ github.event.release.tag_name }}" == *"beta"* ]]; then - npm publish --provenance --access public --tag beta - else - npm publish --provenance --access public - fi + harmony-har: build publish_without_harmony: needs: [check_changes, build_android_so] @@ -177,58 +143,8 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: recursive - - name: Configure git safe.directory - run: | - git config --global --add safe.directory "$GITHUB_WORKSPACE" - git submodule foreach --recursive 'git config --global --add safe.directory "$toplevel/$sm_path"' - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + - name: Reuse the previous Harmony HAR and publish + uses: ./.github/actions/publish-npm with: - bun-version: 1.4.0 - # Setup .npmrc file to publish to npm - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: 24 - registry-url: 'https://registry.npmjs.org' - - run: bun install --frozen-lockfile - # Drop the committed binaries first: the artifact must fully replace - # android/lib, so any layout regression makes the verify step fail loudly - # instead of silently passing against stale committed .so files. - - name: Remove committed Android native libraries - run: rm -rf android/lib - - name: Use CI-built Android native libraries - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: android-so - path: android/lib - - name: Verify Android native libraries - run: node scripts/verify-android-so.js - # Fail closed: the package must always ship harmony/pushy.har - # (package.json `files` lists it and check-packlist.js --release - # requires it). If the previous release can't - # be fetched, fix the cause or re-run — never publish a HAR-less package. - - name: Fetch previous Harmony HAR - shell: bash - run: | - PREV_VERSION="${{ needs.check_changes.outputs.prev_tag }}" - PREV_VERSION="${PREV_VERSION#v}" - echo "Fetching harmony/pushy.har from react-native-update@$PREV_VERSION" - npm pack "react-native-update@$PREV_VERSION" - tar -xzf "react-native-update-${PREV_VERSION}.tgz" package/harmony/pushy.har - mkdir -p harmony - cp package/harmony/pushy.har harmony/pushy.har - # Clean up temporary pack and extraction files to prevent publishing them - rm -rf "react-native-update-${PREV_VERSION}.tgz" package - - name: Verify Harmony HAR artifact - run: test -f harmony/pushy.har - - name: Verify release tag matches package version - run: node scripts/check-release-version.js - - name: Verify publish file list - run: node scripts/check-packlist.js --release - - name: Publish to npm - shell: bash - run: | - if [[ "${{ github.event.release.tag_name }}" == *"beta"* ]]; then - npm publish --provenance --access public --tag beta - else - npm publish --provenance --access public - fi + harmony-har: previous + previous-tag: ${{ needs.check_changes.outputs.prev_tag }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 72618738..7d0bd193 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,6 +40,7 @@ jobs: - 'cpp/**' - 'android/jni/**' - 'scripts/test-*' + - 'scripts/lib/**' # TS reference the C++ golden vectors are generated from. - 'src/updateFlowCore.ts' - 'scripts/generate-flow-vectors.ts' @@ -80,7 +81,8 @@ jobs: submodules: recursive - name: Install build dependencies - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev libbz2-dev + # clang (+ libFuzzer runtime) is for the bounded fuzz step below. + run: sudo apt-get update && sudo apt-get install -y zlib1g-dev libbz2-dev clang - name: Build HDiffPatch library working-directory: android/jni/HDiffPatch @@ -113,8 +115,6 @@ jobs: # libFuzzer is unavailable on the runner. - name: Fuzz parsers (bounded) run: | - sudo apt-get update - sudo apt-get install -y clang FUZZ=1 ./scripts/test-update-flow-core.sh FUZZ=1 ./scripts/test-patch-core.sh diff --git a/.gitignore b/.gitignore index b5ac7c6f..b48aa296 100644 --- a/.gitignore +++ b/.gitignore @@ -65,12 +65,13 @@ harmony/pushy.har **/oh_modules harmony/pushy/.preview harmony/pushy/.cxx +# Regenerated by hvigor on every HAR build. +harmony/pushy/BuildProfile.ets harmony/.tsconfig.harmony.json Example/harmony_use_pushy/harmony/entry/src/main/resources/rawfile/meta.json **/.hvigor Example/harmony_use_pushy/harmony/entry/src/main/cpp/generated Example/testHotUpdate/.e2e-artifacts -harmony/pushy/src/main/cpp/android-generated **/ios/.xcode.env.local .bun-cache .ohpm-cache diff --git a/CODE_AUDIT.md b/CODE_AUDIT.md index b2823a8b..1b49ad6b 100644 --- a/CODE_AUDIT.md +++ b/CODE_AUDIT.md @@ -1,332 +1,196 @@ # 全面审计(2026-09-02,基线 10.55.1 / b15223c) -> 范围:JS/TS 层、Android、iOS、共享 C++ 内核、鸿蒙、CI/发布/打包。 -> 上一轮安全加固(6e9ae76,CODE_AUDIT §13)已落地的项不再重复;本文只记录**仍有改进空间**的项。 -> 每条都经源码核实,标注文件:行号;严重级别 P1 真实缺陷 / P2 很可能的缺陷或明显风险 / P3 质量与性能 / P4 小项。 -> 本地基线:JS 单测 222 通过,biome/tsc 通过,patch-core 29 项 + flow-core 93 向量(含 ASan/UBSan)通过。 - -## 0. 总览:优先处理的 12 项 - -| # | 级别 | 层 | 问题 | 位置 | -|---|---|---|---|---| -| 1 | P1 | C++ | `IsSafeRelativePath` 不拒绝 NUL/控制字节,鸿蒙 manifest 路径可逃出 staging 目录 | `cpp/patch_core/patch_core.cpp:638` | -| 2 | P1 | 发布 | 只改 `cpp/` 或 `android/jni/` 时 publish 复用上一版 HAR,鸿蒙带着旧内核发版 | `.github/workflows/publish.yml:48-54` | -| 3 | P1 | 鸿蒙 | `setUuid`/`setNeedUpdate`/`markSuccess` 在 C++ 方法表注册为 SYNC,ArkTS 实现却是 async,拒绝到不了 JS | `harmony/pushy/src/main/cpp/PushyTurboModule.cpp:51-53` | -| 4 | P2 | JS | web 平台所有"常量"是函数:`isRolledBack===true`,每次加载上报一条假 rollback | `src/core.ts:15-37,76-79` | -| 5 | P2 | Android | 状态持久化四个写入方无统一锁,原生轮次的激活可被 JS `markSuccess` 覆盖回滚 | `UpdateContext.java:439,485,654,712` | -| 6 | P2 | Android | 旧架构 `setNeedUpdate/markSuccess/setUuid/setLocalHashInfo` 无 Promise,持久化失败静默 | `android/src/oldarch/.../UpdateModule.java:69-109` | -| 7 | P2 | CI | `patch_core` 测试(处理不可信补丁字节的代码)在 CI 里完全不跑;鸿蒙在 PR 上既不编译也不做类型检查 | `.github/workflows/test.yml` | -| 8 | P2 | C++ | 递归删目录/合并目录无深度上限,深嵌套归档可栈溢出崩溃 | `patch_core.cpp:235-274,453-512` | -| 9 | P2 | JS | 每次 `setOptions` 都重新触发检查、重置 autoMarkSuccess 计时器、重订阅 AppState | `src/provider.tsx:339-398` | -| 10 | P2 | Android | APK 安装路径无完整性校验,`hash` 只是进度键 | `UpdateModuleImpl.java:70-78`、`DownloadTask.java:797` | -| 11 | P2 | iOS | 无 PrivacyInfo.xcprivacy,且 10.53+ 新增了 DiskSpace required-reason API 调用,集成方上传会遇 ITMS-91053 | `ios/RCTPushy/RCTPushy.mm:186-195`、podspec | -| 12 | P2 | iOS | SSZipArchive 会物化归档里的 symlink 条目,可写到沙箱内任意位置;畸形 `__diff.json` 直接崩 app | `RCTPushy.mm:1714-1723,518-552` | - -其余按层展开如下。 +> 范围:JS/TS 层、Android、iOS、共享 C++ 内核、鸿蒙、CI/发布/打包。严重级别 P1 真实缺陷 / +> P2 很可能的缺陷或明显风险 / P3 质量与性能 / P4 小项。代码注释按编号引用本文(如 +> `CODE_AUDIT 2.12`),**编号保持不变**。 +> +> **处理状态(2026-10-12 按当前代码复核)**:修复随 10.56.0 发布,master 提交为 +> `a60809b3`(JS,§1/6.3/6.8)、`092f544a`(Android,§2)、`77b5488c`(iOS,§3)、 +> `90f8d58e`(C++,§4)、`6a7f4163`(鸿蒙,§5)、`1e46ee06`(CI/发布,§6),评审跟进 +> `5b0b9d29`。已修项每条压缩为一行;**仍开放 / 部分修复的项保留完整描述**,汇总如下: +> +> | 项 | 状态 | +> |---|---| +> | 1.10 | 部分:`require('../package.json')` 仍在 `src/core.ts`(收益小,有意保留) | +> | 2.6 | 部分:https-only + 安装后删除已做;APK sha256 校验未做 | +> | 2.13 / 单测建议 | 部分:两份 `UpdateModule.java`、`buildToolsVersion`;若干纯函数未覆盖 | +> | 3.10 | 部分:构建脚本仍写 pod 源码目录 | +> | 3.13 / 3.14 | 部分 / 未做:`RCTPushy.mm` 拆分、纯决策下沉 C++ + XCTest | +> | 4.4 | 部分:Android 已兜住;鸿蒙 NAPI 与 iOS 入口无 C++ catch | +> | 4.12 | 部分:`buildCheckFingerprint` 无 C++ 移植 | +> | §4 P4 | 开放:鸿蒙 NAPI 结果对象省略空串字段(Android 设 null) | +> | 5.7 | 部分:RCP 重定向控制(HMS-only)未做 | +> | 5.8 | 部分:`e2e_harmony` 不在门内 | +> | 5.15 / §5 P4 | 部分:若干纯函数未覆盖;`transferArchive` 未拆;`codelinter` 未接入 | +> | 6.4 | 未做(流程决策):`.so` 仍提交在 `android/lib/` | +> +> 本文提到的 `librnupdate.so` 现名 `librnpushy.so`;`NativeCheckOrchestrator` 现名 +> `SyncCoordinator`(b8bfe1fd)。原文第 7 节"建议的执行顺序"已全部执行,删去。 --- ## 1. JS / TS 层(src/) -### P2 - -**1.1 web 平台常量全是函数,产生假 rollback 上报** -`src/core.ts:15-37,76-79`、`src/utils.ts:46-51`、`src/client.ts:239-246`。 -web 上 `PushyModule = emptyModule`(Proxy,任何属性都返回 `noop`),`PushyConstants` 就是同一个 Proxy,所以 `packageVersion / currentVersion / isFirstTime / rolledBackVersion / buildTime / uuid / currentVersionInfo` 全是 `() => {}`。实测(mock `Platform.OS='web'` 导入 core):`isRolledBack === true`、`isFirstTime` 是函数、`cInfo.uuid` 是函数、`JSON.parse(noop)` 抛错并打印 `error_parse_version_info`。后果:构造函数每次都 `report({type:'rollback'})`,生产 web 每次加载向 `/report/{appKey}` POST 一条 `hash:"() => {}"` 的 rollback;`attachToSentry` 打的 tag 也是函数字符串。 -修法:`core.ts` 里 `Platform.OS === 'web'` 时用显式常量对象(空串/false),不要从 Proxy 上取;补一个 web 用例断言 `isRolledBack===false` 且无 telemetry 请求。 - -**1.2 `setOptions` 触发整套副作用重跑** -`src/provider.tsx:339-398`。主 effect 依赖 `optionsVersion`(每次 `setOptions` 自增),重跑时无条件执行 `checkUpdate()`(`onAppStart/both`)、清掉并重新 arm `markSuccessTimer`、重订阅 AppState。README 里的 `client.setOptions({disableErrorReporting:true})`、`setOptions({logger})`、示例 app 的策略切换,每次都多一次网络检查(客户端去重只覆盖 5s 同指纹),`alwaysAlert` 下会立即再弹一次"发现新版本";反复改 options 会无限推迟 `markSuccess/healthCheck`。 -修法:拆成三个 effect,各自只依赖读到的原始值:AppState 订阅依赖 `[client, options.checkStrategy]`;计时器依赖 `[client, options.autoMarkSuccess, options.autoMarkSuccessDelayMs]`;启动检查只在挂载时跑一次(`useRef` 标记)。补测试:挂载后 `setOptions` 一次,断言 `checkUpdate` 只调用一次。 - -**1.3 `setOptions({locale})` 被静默忽略** -`src/client.ts:225-227,269-291`。`i18n.setLocale` 只在构造函数里调用;fast-refresh 的"同 client 重建"路径也走 `setOptions`,语言切换永远不生效。 -修法:`setOptions` 里 `key==='locale' && value` 时调用 `i18n.setLocale`,加单测。 - -### P3 - -**1.4 `getRemoteEndpoints` 取的是第一个"settled"而非第一个"成功"** -`src/client.ts:847-874`、`src/utils.ts:23-42,188-206`。`promiseAny(queryUrls.map(fetchWithTimeout))` 在任一 fetch resolve 时就返回,而 `fetch` 对 404/403/5xx 也 resolve,`enhancedFetch` 不检查 `ok`。Pushy 预设里 gitee 与 jsdelivr 竞速:gitee 秒回 404/反爬 HTML 时 `resp.json()` 抛错,好的 jsdelivr 列表被丢弃——而这正是主端点挂掉时才会走的路径。 -修法:每个查询 fetch 先 `if(!r.ok) throw` 再 `r.json()`,让 `promiseAny` 竞速解析后的数组;补一个 non-ok + ok 的用例。 - -**1.5 原生检查缓存复用绕过了响应 schema 门** -`src/client.ts:980-985`。`readNativeCheckCache` 只判 `typeof result==='object'`,网络路径却强制 `isValidCheckResult`。JS 可热更到旧原生上(10.51–10.53 的原生有 `getNativeCheckCache` 但没有 R2 schema 门),缓存里的 `{"error":...}` 200 会变成"无更新"并触发 `markJsCheckCompleted`。 -修法:加一行 `if(!isValidCheckResult(result)) return undefined`。 - -**1.6 重试循环会重复下载并重新应用已经确定性失败(`PATCH_FAILED`)的策略** -`src/client.ts:1386-1449`、`src/updateFlowCore.ts:258-260`。`isMirrorRetryableCode` 只跳出镜像循环;外层 `attempt`(默认 3 次重试)把整个 `attempts` 再跑一遍,坏 diff 最多下载并打补丁 4 次、每次间隔退避,才轮到 full。 -修法:记录已以不可重试码结束的策略集合,后续 attempt 跳过;补测试断言 `downloadPatchFromPpk` 在 `PATCH_FAILED` 后只调用一次。 - -**1.7 `applyingUpdate` 在 `reloadUpdate` 成功 resolve 后永不复位** -`src/client.ts:1020-1056,1618-1662`。`switchVersion` 在 `applyingUpdate` 为真时静默返回(无日志、返回 undefined)。89c638e 记录过 iOS `reloadUpdate` resolve 但没重启的真实案例;这种情况下后续所有 `switchVersion` 都被无声吞掉。`resetToPackagedBundle` 也不清 `applyingUpdate/apkStatus`。 -修法:至少在跳过时打日志、在 `resetToPackagedBundle` 里复位;更好的是 resolve 后 arm 一个几秒的计时器,进程仍活着就复位并上报 `errorSwitchVersion`;返回 boolean 区分"已切换"与"被忽略"。 - -**1.8 `Linking.openURL(downloadUrl)` 未 catch** -`src/provider.tsx:281,292`。`silentAndNow` 分支和弹窗按钮都直接调用,URL 由服务端控制,无处理器/格式错误时是 unhandled rejection。修法:抽出 `openExpiredDownload(url)`(两处是逐字重复的 apk/openURL 分支),失败走 `setLastError/log`。 - -**1.9 类型与导出契约** -- `src/index.ts` 没有从 `./type` 导出任何类型:`ClientOptions / CheckResult / ProgressData / EventType / EventData / UpdateEventsLogger / UpdateCheckState / UpdateServerConfig / BeforeReloadContext / UpdateTestPayload / VersionInfo`,用户只能深层 import `react-native-update/src/type`。 -- `src/context.ts:26-55` 与 provider 实际塞进去的值不一致:`markSuccess` 实际返回 Promise;`checkUpdate` 隐藏了 `{extra}` 参数;`switchVersion/switchVersionLater/downloadUpdate` 隐藏了可选 `info` 参数;`parseTestQrCode` 实际接受 `string | UpdateTestPayload`;`progress` 不在类型里但 `useUpdate()` 返回它。 -修法:`index.ts` 补 `export type {...} from './type'`;context 类型从 provider 的 value 推导。 - -**1.10 依赖与包体** -- `react-native-url-polyfill` 只为 `provider.tsx:500-504` 两个 `searchParams.get()`,却把 `whatwg-url-without-unicode`(84KB)+ `buffer` polyfill 带进每个 app 的 bundle,是这个 SDK 给宿主增加的最大一块。用 10 行 query 解析替换并删除依赖。 -- `src/core.ts:140` `require('../package.json')` 把整个 manifest(scripts、devDependencies)打进 bundle,prepublish 时生成 `version.ts` 即可。 -- `nanoid/non-secure` 仅用于 5 行随机 id,可内联。 - -**1.11 release 下无条件 `console.log`,包含完整检查请求体(含 uuid)与响应** -`src/utils.ts:4-18`、`src/client.ts:656,1157-1160,1176`。`options.debug` 存在但不管日志。修法:`log/info` 以 `__DEV__ || options.debug` 门控(保留 warn/error),或走用户 logger。 - -**1.12 测试基建让 provider↔client 契约无覆盖** -`src/__tests__/provider.render.test.tsx:28-79` 用手写假 client(没有 `optionsVersion / onOptionsChange / claimProviderMount`),provider 里的 `client.claimProviderMount?.()`、`client.optionsVersion ?? 0` 等 `?.` 纯粹是为了让假 client 活着。因此未覆盖:1.2 的重跑行为、单例 claim、卸载清理(AppState/计时器)、`checkStrategy:'both'`、深链解析、expired+openURL、`dismissErrorAfter` 重排。`setupDownloadMocks` 整体替换 `../utils`,`rankDownloadUrls` 的排序从未真正执行。 -修法:用真实 `Pushy`(mock `../core`)渲染 provider 做契约套件;去掉 `?.`;用 `emitAppStateChange` 断言卸载。 - -### P4 - -- `src/client.ts:1191-1195`:失败的检查无条件清空 `lastRespJson`,会清掉另一个在途检查的去重槽;只在 `this.lastRespJson === respJsonPromise` 时清。 -- `src/client.ts:1460-1471`:`downloadFallback` 的 `code` 取最后一个失败(diff→PATCH_FAILED、pdiff→DOWNLOAD_FAILED、full 成功 ⇒ 记成 DOWNLOAD_FAILED),patch-health 信号丢失;单独跟踪"任一 attempt 以 PATCH_FAILED 结束"。 -- 死代码:`EventType` 的 `rejectStoragePermission/errorStoragePermission` 与 `STORAGE_PERMISSION_*` 错误码全仓库无发射点;18 个 locale key 从未引用;`utils.ts:44 emptyObj`;`updateFlowCore.ts:358` 与其测试名仍说 devNoop 是"立即成功"(9ad95fb 已改为返回 undefined)。 -- `src/client.ts:1424-1426` catch 里 `e.message` 没有 `?.`,非 Error 拒绝会逃出 catch 并泄漏进度监听。 -- `src/client.ts:887-895` 非 2xx 的完整 `resp.text()`(常是 HTML 错误页)直接成为弹窗文案;截断到 ~200 字。 -- `src/utils.ts:153` 挂在调用方 signal 上的 abort 监听从不移除;`client.ts:664-667` 没有 logger 时每次 `report()` 都留一个未清理的 10s 计时器。 -- `src/client.ts:229-236` appKey 检查只在 ios/android 生效;鸿蒙空 appKey 得到 `/checkUpdate/` 404(HTTP_STATUS)而非 `APPKEY_REQUIRED`,且 `getNativeConfig` 会持久化 `{"disabled":true}`。 -- `src/endpoint.ts:149` `'No endpoints configured'` 未走 i18n。 -- `src/provider.tsx:308-311` 服务端不带 `name` 时弹窗里留着字面 `{{name}}`。 -- NATIVE_CHECK_FOLLOWUPS 第四轮"后续小版本"第 7/8/9 项在代码中仍未变(`!info.expired` 守卫、`?? NATIVE_CONFIG_DISABLED_JSON`、`noArtifact` 上报位置);"JS 配置同步回退竞态"实际已修,文档可关闭。 +- **1.1(P2)** web 平台常量全是 Proxy 函数、每次加载上报假 rollback — 已修 a60809b3 +- **1.2(P2)** `setOptions` 触发整套副作用重跑 — 已修 a60809b3(拆成三个 effect,启动检查只跑一次) +- **1.3(P2)** `setOptions({locale})` 被忽略 — 已修 a60809b3 +- **1.4(P3)** `getRemoteEndpoints` 取第一个 settled 而非第一个成功 — 已修 a60809b3 +- **1.5(P3)** 原生检查缓存复用绕过响应 schema 门 — 已修 a60809b3 +- **1.6(P3)** 重试循环重复下载已确定性失败(`PATCH_FAILED`)的策略 — 已修 a60809b3 +- **1.7(P3)** `applyingUpdate` 在 reload resolve 后永不复位 — 已修 a60809b3(跳过时打日志、reload 看门狗、reset 复位) +- **1.8(P3)** `Linking.openURL` 未 catch — 已修 a60809b3(`openExpiredDownload`) +- **1.9(P3)** 类型未导出、context 类型与实际不符 — 已修 a60809b3 +- **1.11(P3)** release 下无条件 `console.log` — 已修 a60809b3(`__DEV__ || options.debug` 门控) +- **1.12(P3)** provider↔client 契约无测试 — 已修 a60809b3(`provider.contract.test.tsx` 用真实 client) +- **P4**(`lastRespJson` 去重槽、`downloadFallback` 失败码、死代码/死 locale key、非 2xx 文案截断、abort 监听与计时器泄漏、鸿蒙 appKey 检查、i18n、`{{name}}` 占位)— 全部已修 a60809b3 + +### 仍开放 + +**1.10(P3)依赖与包体**(部分修复) +已做:`react-native-url-polyfill` 换成 `parseQueryParams`,`nanoid` 换成内联 `createNonSecureId`,运行时依赖清零。 +剩余:`src/core.ts` 的 `require('../package.json')` 把整个 manifest 打进 bundle;可在 prepublish 时生成 `version.ts`。收益小,§8 时有意保留。 --- ## 2. Android 层(android/) -### P2 - -**2.1 状态持久化四个写入方无统一互斥,read-modify-write 会丢激活或丢 markSuccess** -`UpdateContext.java:439`(switchVersion)、`:485`(markSuccess)、`:654`(getBundleUrl 的 resolve-launch 提交)、`:712`(commitNativeCheckResult)。所有状态操作都是 `getStateSnapshot()` → JNI 纯函数 → `applyState(editor)` → `commit()`;只有 `commitNativeCheckResult` 和 `resetToPackagedBundle` 拿 `commitLock`。`switchVersion/markSuccess/clearRollbackMark` 在 `pushy-state-serial` 线程,`restartApp` 在 **UI 线程**(`ReactReloadManager.java:27,32`)跑 switchVersion+getBundleUrl,getConstants 在 JS 线程跑 `consumeFirstLoadMarker/clearRollbackMark`。具体交错:原生轮次在 `commitLock` 下提交 `current=H,firstTime=true`,而 JS `markSuccess`(状态线程,快照里 `current=A`)最后提交 → H 的激活被静默撤销;在 crash-rescue 路径上这就是救砖被丢。 -修法:让 `commitLock` 成为所有 snapshot→commit 序列的唯一互斥(含 `switchVersion/markSuccess/clearFirstTime/clearRollbackMark/rollBack`、`getBundleUrl` 内提交、`consumeFirstLoadMarker`),或全部经 `StateSerialRunner`、编排器把提交也 submit 进去。 - -**2.2 `reloadUpdate/restartApp` 在 UI 线程做整包 SHA-256 + 两次同步 commit** -`UpdateModuleImpl.java:152` → `UiThreadRunner` → `ReactReloadManager.java:27` `switchVersion` → `UpdateContext.java:425` `InstallRecord.verifyForActivation` → `sha256Hex(bundleFile)`;然后 `:32` `getBundleUrl()` 再 `commit()`(`:654`)。这是 JS `switchVersion` 的路径,低端机上多 MB Hermes 包 = 数十到数百 ms 主线程卡顿,且它就是 2.1 里的 UI 线程写入方。 -修法:`restartApp` 拆成 (a) 状态串行线程做 switchVersion + getBundleUrl,(b) 只把 `recreateReactContext/reload` post 到 UI 线程。 - -**2.3 旧架构模块丢弃持久化失败,违反 TS Promise 契约** -`android/src/oldarch/.../UpdateModule.java:69 setNeedUpdate`、`:74 markSuccess`、`:89 setUuid`、`:109 setLocalHashInfo` 没有 `Promise` 参数(newarch 有);`src/NativePushy.ts` 全部声明为 `Promise`,`src/client.ts:1065` `await PushyModule.setNeedUpdate(...)`。旧架构下 await 立即 resolve,`commit()` 失败只被 `StateSerialRunner` `Log.e`,JS 报告 `switchVersionLater` 成功——6e9ae76 的"持久化失败拒绝 promise"在旧架构 app 上不成立;`switchVersionLater()` 后紧接 `restartApp()` 还会与仍在队列里的 switch 竞争。 -修法:oldarch 加带 `Promise` 的重载(旧架构支持尾参 Promise),删掉 `UpdateModuleImpl` 里无 promise 的重载(`:180,204,312,365`)。 - -**2.4 JNI 构造的结果类没有硬 keep 规则** -`android/proguard.pro:4` `-keepnames class cn.reactnative.modules.update.** { *; }` 等于 `-keep,allowshrinking`。`ArchivePatchPlanResult`、`CopyGroupResult` 从不在 Java 里 `new`(只有 `StateCoreResult` 是),只经 `FindClass + GetMethodID("") + NewObject` 构造(`cpp/patch_core/update_core_android.cpp:274-280,299-304`),字段按名查找。R8 收缩掉无引用的无参构造或字段后,`GetMethodID/GetFieldID` 留下 pending `NoSuchMethodError`,所有 diff/pdiff 安装在混淆 release 包里失败。三个示例 app 都 `enableProguardInReleaseBuilds=false`,仓库自己测不到。同时消费者规则里的 `-keepnames class com.facebook.react.** { *; }` 与 `-keepclassmembers class com.facebook.react.ReactActivity { *; }` 过宽,影响所有宿主 app 的 R8 效果。 -修法:显式 `-keep class ...StateCoreResult { (); ; }`(三个结果类同样)+ `-keepclasseswithmembernames class cn.reactnative.modules.update.** { native ; }`;删掉对 `com.facebook.react.**` 的整体 keepnames;用一次 `minifyEnabled true` 的 release 构建验证。 - -**2.5 清理 keep 集合漏掉正在运行的版本目录(`launchVersion`)** -`UpdateContext.java:778-784` `cleanUp()` 只传 `currentVersion/lastVersion`;C++ `CleanupOldEntries`(`patch_core.cpp:612-627`)删除不在这两个名字里、mtime 超过 3 天的所有非点开头条目。`SwitchVersion`(`state_core.cpp:28-38`)会 `last=current`,两次切换不重启(JS `switchVersionLater(B)` 后原生轮次激活 C)就把运行中的 A 挤出 keep 集合;之后任何时候可调用的 `markSuccess()/clearRollbackMark()` 会删掉 A 的目录,而按需资源(图片/字体)还在从 A 读——正是 `resetToPackagedBundle` 用 `launchVersion` 防的那种故障。 -修法:`cleanupOldEntries` 增加第三个 keep 名(或传 `String[]`),始终包含 `launchVersion`。 - -**2.6 APK 安装路径无完整性校验** -`UpdateModuleImpl.java:70-78` 把 `hash` 传给 `downloadFile` 然后无条件安装 `params.targetFile`;`DownloadTask.java:797` `TASK_TYPE_PLAIN_DOWNLOAD` 什么都不校验;JS 传的 `hash` 是进度键 `'downloadingApk'`(`src/client.ts:1568-1571`)。唯一保护是 `rejectProtocolDowngrade`,对服务端直接下发 `http://` APK(`usesCleartextTraffic=true` 或 targetSdk<28)无效。安装未验证 APK = 代码执行。 -修法:要求服务端提供 sha256(或至少拒绝非 https APK),安装前 `sha256Hex(apk)` 校验,`session.commit` 后删除 APK。 - -### P3 - -- **2.7** `NativeCheckOrchestrator.java:473` `response.body().string()` 无大小上限(checkUpdate 与远程 queryUrls 列表都走这里),劫持的端点可 OOM 后台线程 → 崩溃 → crash handler 又去 hold 进程。`:475` 吞掉所有异常且无日志。修法:`body.source()` 限 1MiB,超出视为端点失败;加一行异常类名日志。 -- **2.8** 冷启动关键路径冗余:`UpdateContext.java:81-108` 构造函数(主线程,`getJSBundleFile/getReactHost` 调用)多次 `getPackageVersion()`(Binder IPC)、`computeBundleHash` 两次;`getConstants`(TurboModule 下 JS 线程同步)做两次独立 `commit()`(`consumeFirstLoadMarker:23`、`clearRollbackMark:29`)+ 一次文件读。缓存 `PackageInfo`,两次提交合成一个 editor。 -- **2.9** `ReactReloadManager.java:51,55` 旧架构反射用字面 `mBundleLoader/mJSBundleFile` 无回退;RN 把 `ReactInstanceManager` 转 Kotlin 后两处都失败,外层 catch 走 `currentActivity.recreate()`(`:83`)加载**旧** bundle 却 resolve 成功。改用 `getCompatibleField(...,"bundleLoader")`,注入失败应 reject `RESTART_FAILED`。 -- **2.10** `DownloadTask.java:586-590` `promoteStaging` 删除 `unzipDirectory` 再 rename;`hasCompletedPatchDirectory()` 只认 `.pushy-complete`,10.5x 之前安装的当前运行 hash 无 marker,重下同 hash 会在进程中抹掉活目录。`params.hash.equals(launchVersion)` 时拒绝/跳过。 -- **2.11** `UpdateModuleImpl.java:34,96,143,170` 的 `options.getString(...)` 不在 try/catch 内(`downloadAndInstallApk/downloadPatchFromPpk` 却包了),缺 key 时在 native-modules 线程抛异常直接崩 app。统一 `readRequiredString(options,key,promise)` reject `INVALID_OPTIONS`。 -- **2.12** `NativeCheckOrchestrator.performAttempts:604-655` 阶段超时后 `break` 到下一 attempt 并在同一单线程 executor 上再排一个任务,前一个可能仍在传输;full 阶段又是新的 600s 窗口。超时应 `cancel()` 在途 OkHttp Call,或编排器用独立 executor。 -- **2.13** 重复与死代码:两份 `UpdateModule.java` 各 ~120 行几乎相同(只差基类与 promise 签名);`UpdateContext.clearFirstTime(:506)`、`isFirstTime()(:467)`、`cn...ReactNativeHostHandler` 接口未用;minSdk 21 下的 `LOLLIPOP` 守卫(`ApkInstaller.java:58`、`BundledResourceCopier.java:365`)与不可达的 `UNSUPPORTED_PLATFORM` 分支;`build.gradle:150` `java/expo/modules/pushy` 路径不存在;`:128-132` `compileSdk 31 / targetSdk 27` 回退值过旧;`repositories { maven "$rootDir/../node_modules/react-native/android" }` 对 RN≥0.71 是死路径;`lintOptions/buildToolsVersion` 已废弃;`versionName "1.81.4"` 陈旧;`resolveInstalledExpoMajorVersion` 每次 Gradle 配置都 spawn `node`。 +- **2.1** 状态持久化写入方无统一互斥 — 已修 092f544a(`UpdateContext.commitLock` 为所有 snapshot→commit 序列的唯一互斥) +- **2.2** `restartApp` 在 UI 线程做整包 SHA-256 + 同步 commit — 已修 092f544a(`prepareRestart` 走 `StateSerialRunner`,只有 reload 上 UI 线程) +- **2.3** 旧架构模块无 Promise、持久化失败静默 — 已修 092f544a +- **2.4** JNI 结果类无硬 keep、消费者规则过宽 — 已修 092f544a;10.56.0 发版前用 minify release 构建 + dexdump 核对,并修正 `reload` 返回类型匹配(583801ab) +- **2.5** cleanup keep 集合漏掉 `launchVersion` — 已修 092f544a(`CleanupKeepNames.select`,有 JVM 单测) +- **2.7** 检查响应体无大小上限 — 已修 092f544a(`SyncCoordinator.readBoundedBody`,1 MiB) +- **2.8** 冷启动关键路径冗余 IPC/commit — 已修 092f544a +- **2.9** 旧架构反射字段名无回退、失败时 recreate 加载旧 bundle — 已修 092f544a(`getCompatibleField`,失败 reject `RESTART_FAILED`) +- **2.10** 重下运行中 hash 会抹掉活目录 — 已修 092f544a(`ensureNotReinstallingRunningVersion`) +- **2.11** `options.getString` 缺 key 崩溃 — 已修 092f544a(`readRequiredString` → `INVALID_OPTIONS`) +- **2.12** 编排下载阶段超时不取消在途传输 — 已修 092f544a(`DownloadTaskParams.cancel` 取消 OkHttp Call,共享绝对 deadline) +- **P4**(无名 executor、`volatile`、`printStackTrace`、目录 fsync 等)— 已修 092f544a;`UpdatePackage` 仍继承 `TurboReactPackage`(替代 API 需 RN 0.74,peer 下限 0.59,已注明) -### P4 +### 仍开放 -`UpdateContext.java:83` 无名 executor;`:32` `reactInstanceManager` 在 `sLock` 下写但无同步读(加 `volatile`);`:123` `e.printStackTrace()`;`UpdatePackage` 用已废弃的 `TurboReactPackage` 与 7 参 `ReactModuleInfo`;`InstallRecord.write` fsync 了文件但 rename 前没 fsync 目录(API 26+ 可 `FileChannel.open(dir).force(true)`)。 +**2.6(P2)APK 安装路径无内容校验**(部分修复) +已做:`downloadAndInstallApk` 拒绝非 https URL(`INVALID_OPTIONS`),`ApkInstaller.install` 在 session 拿到字节或失败后删除 APK。 +剩余:安装前没有 sha256 校验(`ApkInstaller.java` 的 `TODO(CODE_AUDIT 2.6)`)。JS 传给 `downloadAndInstallApk` 的 `hash` 仍是进度键 `'downloadingApk'`,不是摘要;需要服务端下发 APK sha256 + JS/原生透传后才能校验。在此之前 https-only 是唯一的完整性边界。 -### 已核实正常 -JNI 契约(签名与 `verify-android-so.js` 符号表一致,字符串/数组全部释放,异常经 `ThrowRuntimeException` 上抛,`update_flow_jni.cpp` 正确避开非 BMP 的 `NewStringUTF`);`PackageInstallerStatusReceiver` manifest 声明 + `exported=false`;`CrashRescue` 链式调用前任 handler、每进程一次、主线程 3.5s 上限、全部 `catch Throwable`;`SafeZipFile` 的穿越/保留名/限额检查;流全部 try-with-resources;Range/If-Range/416/Content-Range 续传逻辑正确;`ErrorCodes.java ↔ error_codes.h ↔ src/error.ts` 同步;16KB 对齐由脚本强制。 +**2.13(P3)重复与死代码**(部分修复) +已删:`clearFirstTime/isFirstTime`、`ReactNativeHostHandler`、`LOLLIPOP` 守卫、`build.gradle` 的陈旧路径/`repositories`/`versionName`,SDK 回退值更新到 34,`node` 只作最后手段。 +剩余:两份 `UpdateModule.java`(oldarch/newarch,均已是薄转发层,只差基类与注解);`buildToolsVersion` 仍设置。 -### 单测建议(纯 JUnit,零 Android 依赖) -`UpdateContext.isSafePathComponent`、`DownloadTask.parseContentRange`、`SafeZipFile.isReservedEntryName/inspect()/unzipToPath`(构造内存 zip:穿越、炸弹比、条目数、保留名)、`InstallRecord.build/read/isComplete/verifyForActivation`、`ArchiveLimits.ensureFreeSpace`、`NativeCheckOrchestrator.normalizeEndpointBase` 与 `runCheckRequest` 的次数上限(注入 HTTP 函数)、`BundledResourceCopier.normalizeResPath/extractResourceName/parseDensityQualifier`。 +**单测建议**(部分) +已有 `android/src/test` 5 个 JVM 测试类(CleanupKeepNames、HttpUtils、InstallRecord、SafeZipFile、UpdateFileUtils;CI 不跑,本地 `gradle testReleaseUnitTest`)。归档计数规则不在 JVM 测(`cpp/patch_core/tests/archive-limits-vectors.json`)。未覆盖:`ArchiveLimits.ensureFreeSpace`、`runCheckRequest` 次数上限、`BundledResourceCopier` 的路径/密度解析。 --- ## 3. iOS 层(ios/) -NATIVE_CHECK_FOLLOWUPS 中两项已在代码里修复、可关闭:"iOS 合流下载的两个语义代价"(`PushyReportDownloadProgress` 进度广播 `RCTPushy.mm:378-391`,异类型请求 deferred `:351-361`)与"启动状态解析抛异常时救援检测永不调度"(`@finally` `:881-886`)。 - -### P2 - -**3.1 缺少隐私清单(PrivacyInfo.xcprivacy),且 SDK 新增了磁盘空间这一 required-reason API** -`RCTPushy.mm:186-195`(`NSFileSystemFreeSize`)、`:773-781`(`fileModificationDate`)、`:88-90`(`systemUptime`)、`:446-448`(`NSUserDefaults`);`podspec:98` 只随包携带 `pushy_build_time.txt`,`ios/` 下无任何 `*.xcprivacy`。 -6e9ae76 加入的 `PushyFreeDiskSpaceForPath` 读 `NSFileSystemFreeSize`,属于 `NSPrivacyAccessedAPICategoryDiskSpace`。RN 模板 app 的清单(见 `Example/testHotUpdate/ios/.../PrivacyInfo.xcprivacy`)只声明了 FileTimestamp C617.1、SystemBootTime 35F9.1、UserDefaults CA92.1,所以每个升级过 10.53 的集成方都会静默获得一个未声明的 API 使用,上传时 ITMS-91053(2024-05 起是审核阻断)。其余三类目前靠宿主模板"恰好"覆盖。 -修法:`s.resource_bundles = { 'react-native-update_privacy' => 'ios/PrivacyInfo.xcprivacy' }`,声明 DiskSpace `E174.1`、FileTimestamp `C617.1`、SystemBootTime `35F9.1`、UserDefaults `CA92.1`,`NSPrivacyTracking=false`;CHANGELOG 说明。 - -**3.2 `packageVersion` / `buildTime` 以无前缀 key 存在 `standardUserDefaults` 顶层,撞 key 时每次启动都抹掉热更新** -`RCTPushy.mm:38-39`(`paramPackageVersion=@"packageVersion"`、`paramBuildTime=@"buildTime"`),`:555-556` `stringForKey:` 顶层读取(已核实),`:571-572` 写入,`:817-827` 消费;`state_core.cpp:12-24`。其他 key 都是 `REACTNATIVECN_PUSHY_*`,唯独这两个二进制身份 key 用了宿主 app 与其他 SDK 常用的通用名。宿主若把 `buildTime` 存成 NSNumber,`stringForKey:` 返回 nil → `""` → `SyncBinaryVersion` 判定 `changed` → 清空 `current/last_version` → 已装更新被丢弃并触发 `clearInvalidFiles`;Pushy 再写回字符串、宿主再覆盖,每次启动循环。Android 用私有 SharedPreferences 文件,仅 iOS 有此问题。 -修法:改名 `REACTNATIVECN_PUSHY_PACKAGEVERSION/_BUILDTIME`,`PushyStateFromDefaults` 里一次性迁移(读新 key、回退旧 key、首次写入后删旧 key);长期迁到 `initWithSuiteName:@"cn.reactnative.pushy"`。 - -**3.3(安全)归档可含 symlink 条目,SSZipArchive 无条件按任意目标物化** -`RCTPushy.mm:1714-1723`(`unzipFileAtPath … preserveAttributes:YES … delegate:guard`),`PushyUnzipGuard :232-259`;SSZipArchive 2.4.3 的 symlink 分支不受 `preserveAttributes` 门控。`_sanitizedPath` 只中和条目名里的 `../`;一个 `assets -> ../../../Documents` 的 symlink 条目再跟一个 `assets/x`,就能写到沙箱内任意位置(Preferences plist、Documents、另一版本目录);`index.bundlejs` 做成 symlink 会让安装记录 digest 与 bundle 加载指向版本目录之外。Android `SafeZipFile`(java.util.zip)不支持 symlink,仅 iOS 有此缺口。 -修法:`zipArchiveShouldUnzipFileAtIndex:` 里拒绝 `((external_fa >> 16) & S_IFMT) == S_IFLNK` 的条目(顺带拒绝 `.pushy-` 前缀名),记录违规使其成为硬 `PATCH_FAILED`;`preserveAttributes:NO`。 - -**3.4 畸形 `__diff.json` 在 `_fileQueue` 上抛 ObjC 异常直接杀 app,而非 `PATCH_FAILED`** -`RCTPushy.mm:518-552`(`PushyPatchManifestFromJson`,从 `:1449` 的 GCD block 调用)。`copies` 未做类型检查:`for (NSString *to in copies)` + `copies[to]` 遇 NSArray/NSString 值 → unrecognized selector;`from.length` 遇 NSNumber 同样;`deletes` 的值未检查类型就 `[value UTF8String]`。只有 `copiesCrc` 与 `hbcTransform` 有守卫。dispatch block 里未捕获异常是致命的(还会顺带触发 crash-rescue)。Android(`DownloadTask.java:450-475`)用 `optJSONObject/getString`,`JSONException` 在 `:804` 被接住变成补丁失败。 -修法:把 manifest 解析搬进 C++(用 `flowjson` 解析成 `PatchManifest` 并返回 `Status`),同时消掉 Java/ObjC 两份解析器(见 3.13);短期在 `copies`、每个 `from`、`deletes` 及每个 key 上加 `isKindOfClass:`。 - -**3.5(对等)iOS 的归档大小上限与剩余空间检查在整个 body 落盘之后才执行** -`RCTPushyDownloader.mm:301-410`(`didReceiveResponse` 无 Content-Length 上限、无磁盘检查),`:412-430`(`didReceiveData` 无界追加);上限在 `RCTPushy.mm:1697-1703` 事后应用。Android `DownloadTask.java:349-353` 流式前比对 Content-Length 与 `MAX_ARCHIVE_BYTES` 并 `ensureFreeSpace`,`:385-388` 有流式字节兜底。`archive_limits.h` 注释说的"Content-Length up front, streamed bytes as backstop"iOS 两样都没实现,恶意服务端可在 `timeoutIntervalForResource`(10 分钟)内把卷写满,续传 partial 还留在盘上。 -修法:`didReceiveResponse` 里 `expectedTotal > kMaxArchiveBytes` 即拒绝并 `PushyEnsureFreeSpace(savePath, expectedTotal - baseOffset)`;`didReceiveData` 里 `baseOffset + received > kMaxArchiveBytes` 即失败并 cancel。 - -**3.6(很可能)`use_frameworks!` 动态链接下 `buildTime` 资源在 framework bundle 里,`[NSBundle mainBundle]` 找不到** -`RCTPushy.mm:1822-1836`;`podspec:98-99`。CocoaPods 对动态 framework 把 `s.resource` 放进 framework bundle。此时 `pathForResource:` 为 nil → `buildTime` 空 → `SyncBinaryVersion` 以 `""` 运行(同 `CFBundleShortVersionString` 的原生重建不再被检测,旧热更新在 App Store 更新后存活),检查请求的 `buildTime` 也为空。89c638e 证明动态 framework 构建在野。本环境无法跑 CocoaPods,请用 `:linkage => :dynamic` 验证;修法很便宜。 -修法:`[NSBundle mainBundle]` 找不到时回退 `[NSBundle bundleForClass:[RCTPushy class]]`,两者都失败时打 warning。 - -### P3 - -- **3.7** 重载路径:`RCTPushy.mm:1263-1273` 先 `RCTReloadCommandSetBundleURL([[self class] bundleURL])` 跑 `ResolveLaunchState`(消费 `first_time`、置 `keyFirstLoadMarked`、`ignoreRollback=true`)再 `RCTTriggerReloadCommandListeners`。RN 自己会重新解析 URL(`RCTHost.didReceiveReloadCommand` 调 `_bundleURLProvider()`,`RCTBridge.setUp` 重问 `sourceURLForBridge:`),这个设置没有读者。若无监听器触发(bridgeless 下的 `[self.bridge reload]` 回退、无 RCTHost/RCTBridge 的宿主、未来 RN 变更),被消费的首载状态让**下次冷启动**把新版本回滚——正是 89c638e 的症状,仍可经 `#else` 分支到达。`restartApp` 无论如何都 resolve `@true`;Android 会 reject `RESTART_FAILED`。修法:去掉 `RCTReloadCommandSetBundleURL(...)`,`#else` 分支 `#error` 或在 `self.bridge == nil` 时 reject `kRestartFailed`。 -- **3.8** 主线程预算档的 crash rescue 可能把 3.5s 花在哈希内嵌 bundle 上:`:2154-2155` 轮次总是调 `PushyBundleHashSync()`,二进制新装后首启缓存未命中时在救援窗口里对多 MB 文件做 SHA-256;且 +5s 的 utility 队列与 JS `getBundleHash`(`_fileQueue`,`:1140`)可能双重哈希。救援期间只用缓存值(miss 发 `""`),非救援期算一次共享。 -- **3.9(对等)** iOS 从不发出 `MARK_SUCCESS_FAILED / RESET_FAILED / RESTART_FAILED`:`markSuccess(:1146-1166)`、`resetToPackagedBundle(:1168-1223)` 吞掉所有失败(`CleanupOldEntries` 失败只 `RCTLogWarn` 且在 `resolve(@true)` 之后)。按码聚合的遥测在 iOS 上对这些操作是盲的。用 `@try` 包住并以对应码 reject;reset 的清理同步在 `_fileQueue` 上跑完再 resolve。 -- **3.10** 构建时脚本写入 pod 源码目录:`podspec:98-99` `date +%s > "#{podspec_dir}/ios/pushy_build_time.txt"` 在 `before_compile` 执行;`:path` pod(示例、monorepo)每次构建弄脏 checkout,git 源 pod 改写 CocoaPods 缓存;配合 Xcode build cache 时 `buildTime` 取决于哪次运行最后碰了文件,不可复现。`:95` `USER_HEADER_SEARCH_PATHS` 嵌入绝对路径 `#{podspec_dir}/ios`,xcconfig 输出机器相关。已提交的 `ios/pushy_build_time.txt` 是陈旧占位(`1680488830`)。修法:写到 `$(DERIVED_FILE_DIR)` 并声明 `:output_files`,或干脆运行时从 `CFBundleVersion`/`Info.plist` mtime 派生 `buildTime` 删掉脚本;去掉绝对搜索路径。 -- **3.11** `podspec:103` `s.dependency 'SSZipArchive'` 无版本约束;`_sanitizedPath` 与 guard 依赖的 delegate 方法只在 ≥2.2 存在,未来 3.x 可静默改契约。改 `'~> 2.4'`。 -- **3.12(P4)** `isUsingBundleUrl` Spec 声明但 iOS 未导出(Android/鸿蒙在 `getBundleUrl` 被调用后置 true);JS 无人读,无害。要么 Spec 标可选,要么 iOS 在 `+bundleURL` 顶部置一个静态标记(两行)。 -- **3.13(结构)** 重复:续传 sidecar 后缀 `@".resume"` 硬编码在 `RCTPushy.mm:1707,1729`,重复 `RCTPushyResumeSidecarPath`(`RCTPushyDownloader.mm:13-15`);`PushyFileSizeAtPath(:178-182)` == `RCTPushyFileSize(Downloader:38-43)`;`runCheckRequest` 同一段 15 行"归一→去重→预算→POST→校验"出现两次(`:2338-2358`、`:2380-2400`);安装记录校验在 `PushyHasCompletedVersionAtPath(:119-135)` 与 `PushyVerifyInstallForActivation(:139-152)` 各写一遍(Java/ArkTS 又各一份);`setNeedUpdate/reloadUpdate` 共享主体(`:1092-1117`)。死/误导注释:`:277 //TASK_TYPE_PLAIN_DOWNLOAD=4?`;`:794` `zipExtension default:` 不可达;`:1694-1696` 说 SSZipArchive 拿不到中央目录,其实 `zipArchiveWillUnzipArchiveAtPath:zipInfo:` 给了 `number_entry`(`:225` 已用);`Downloader:5-8` 仍在解释已被替换的 NSURLSessionDownloadTask 设计。拆分建议:`PushyKeys.h`(`:37-90,437-516`)、`PushyInstallRecord.mm`(`:94-175`)、`PushyArchiveGuard.mm`(`:178-262` + `unzipFileAtPath`)、`PushyDownloadRegistry.mm`(`:292-412`)、`PushyState.mm`(`:414-609`)、`PushyCrashRescue.mm`(`:665-763`)、`RCTPushyOrchestrator.mm`(`:1848-2479`),`RCTPushy.mm` 留模块表面 + 管线约 900 行。 -- **3.14(可测试性)** 应下沉到 `cpp/` 的纯决策(已有 `state_core/CleanupOldEntries` 的 harness):`__diff.json` → `PatchManifest` 解析含类型校验(替代 `PushyPatchManifestFromJson` 与 `DownloadTask.appendManifestEntries`);安装记录解析/校验 `install_record::Validate(json, hash)` 三端共用;归档限额累加器 `archive_limits::EntryAccumulator::Accept(size, compressed, isSymlink)`;下载注册表 join/defer 规则(`:337-349`)与 `Content-Range` 解析(`Downloader:47-69`,Java 又一份);crash-rescue 门控(`:706-716`)与检查端点尝试策略(`:2332-2403`);`PushyIsSafePathComponent(:483-497)` 放到 `IsSafeRelativePath` 旁边。另建 `ios/Tests` XCTest target 由 `xcodebuild test` 跑:`RCTPushyDownloader` 对本地 `NSURLProtocol` stub(416/206/redirect/encoded)与 `unzipFileAtPath` 对夹具 zip(symlink、保留名、比值炸弹)。 - -### P4(线程,未发现缺陷) -`PushyWithStateLock(:419-428)` 是不可重入的 `os_unfair_lock`,当前无嵌套但无守卫,DEBUG 下加 `os_unfair_lock_assert_not_owner`;`switchVersion` 在锁内哈希整包(`:1620`),包变大时移到锁外再在锁内复验记录;`PushyHttpRequest(:1853-1895)` 让共享 session 跟随 https→http 重定向后才拒绝响应,POST 体(appKey、uuid、版本)已明文发出一次——ATS 默认拦截,仅 `NSAllowsArbitraryLoads` 的 app 受影响,可像 downloader 一样用 delegate session。冷启动成本正常(`+bundleURL` 只做 defaults 读、一次 `stat`、一次小文件读);无观察者/计时器泄漏;downloader 每条完成路径都 invalidate session;不持久化绝对路径(容器迁移安全)。 +- **3.1** 缺 PrivacyInfo.xcprivacy — 已修 77b5488c(`ios/PrivacyInfo.xcprivacy` 经 `s.resource_bundles` 发布) +- **3.2** `packageVersion/buildTime` 无前缀 defaults key — 已修 77b5488c(`REACTNATIVECN_PUSHY_*` + 旧 key 回退迁移;未改用 suite) +- **3.3** 归档 symlink 条目被物化 — 已修 77b5488c(unzip guard 拒绝 `S_IFLNK` 与 `.pushy-` 名,`preserveAttributes:NO`) +- **3.4** 畸形 `__diff.json` 抛 ObjC 异常 — 已修 77b5488c(`PushyPatchManifestFromJson` 全量 `isKindOfClass:` 校验,返回原因);现由共享 C++ 解析器取代,见 3.14 +- **3.5** 归档大小/剩余空间检查在落盘之后 — 已修 77b5488c(`didReceiveResponse` 前置检查 + `didReceiveData` 流式上限) +- **3.6** `use_frameworks!` 下 `buildTime` 资源找不到 — 已修 77b5488c(mainBundle → `bundleForClass:` → Info.plist mtime 回退) +- **3.7** 重载路径预消费首载状态、失败仍 resolve — 已修 77b5488c(去掉 `RCTReloadCommandSetBundleURL`,无可用 bridge 时 reject `RESTART_FAILED`) +- **3.8** 救援窗口里哈希内嵌 bundle — 已修 77b5488c(救援期只用缓存值,与 JS `getBundleHash` 单飞共享) +- **3.9** iOS 从不发出 `MARK_SUCCESS_FAILED/RESET_FAILED/RESTART_FAILED` — 已修 77b5488c +- **3.11** SSZipArchive 无版本约束 — 已修 77b5488c(`'~> 2.4'`) +- **3.12** `isUsingBundleUrl` 未导出 — 已修 77b5488c +- **P4**(锁断言、锁外哈希、`PushyHttpRequest` 重定向)— 已修 77b5488c + +### 仍开放 + +**3.10(P3)构建时脚本写入 pod 源码目录**(部分修复) +已做:去掉 `USER_HEADER_SEARCH_PATHS` 里的绝对路径;script phase 声明了 output files。 +剩余:脚本仍在 pod 源码目录可写时写 `ios/pushy_build_time.txt`(`:path` pod 每次构建弄脏 checkout,git 源 pod 改写 CocoaPods 缓存),而不是 `$(DERIVED_FILE_DIR)`;脚本内嵌 `podspec_dir` 作回退。 + +**3.13(P3,结构)`RCTPushy.mm` 拆分**(部分) +已做:重复实现已合并(`.resume` 后缀、文件大小、`runCheckRequest` 单一 `queryEndpoints`);拆出 `RCTPushyOrchestrator.mm` 与共享的 `RCTPushyInternal.h`。 +剩余:`RCTPushy.mm` 仍约 2150 行(目标约 900);`PushyInstallRecord` / `PushyArchiveGuard` / `PushyDownloadRegistry` / `PushyState` / `PushyCrashRescue` 未拆。 + +**3.14(P3,可测试性)纯决策下沉 C++ + XCTest**(部分) +已做:`__diff.json` → `PatchManifest` 解析含类型校验下沉 `cpp/patch_core/patch_manifest.h`(`ParsePatchManifest`,向量 `cpp/patch_core/tests/patch-manifest-vectors.json` + fuzz),三端经 `archive_patch::ApplyArchivePatch` 直接传 manifest 原文,`PushyPatchManifestFromJson`、Java `appendManifestEntries`、Harmony `parseManifestToArrays` 已删。完成记录的解析/写入/激活校验在 `cpp/patch_core/install_record.cpp`(三端共用)。归档限额计数在 `cpp/patch_core/archive_limits.cpp`(`EntryAccumulator`、`CheckUncompressedSize`、`CheckExtractedDirectory`、`PlanUnknownLengthFreeSpaceProbe`,向量 `archive-limits-vectors.json`):iOS unzip guard 直接调用,Android `SafeZipFile.inspect` 每归档一次 JNI,鸿蒙解压前整包校验 + 解压后遍历经 NAPI;三端规则漂移取最严(精确比值、整包比值、任意层 `.pushy-` 保留名、拒绝 symlink 与未知大小条目)。 +剩余应下沉到 `cpp/` 的纯决策:下载注册表 join/defer 规则与 `Content-Range` 解析(Java/ObjC 各一份);crash-rescue 门控与检查端点尝试策略;`PushyIsSafePathComponent` 放到 `IsSafeRelativePath` 旁边。另建 `ios/Tests` XCTest target:`RCTPushyDownloader` 对 `NSURLProtocol` stub(416/206/redirect/encoded)、`unzipFileAtPath` 对夹具 zip(symlink、保留名、比值炸弹)。 --- ## 4. 共享 C++ 内核(cpp/) -### P1 - -**4.1 `IsSafeRelativePath` 不拒绝嵌入 NUL → 鸿蒙上 staging 沙箱逃逸** -`cpp/patch_core/patch_core.cpp:638-652`。只按 `/` 切段并拒绝空段、`.`、`..`,不检查 NUL 或其他控制字节。manifest `to` 为 `"..\0x"` 时 `!= ".."`,通过;下游全部经 `.c_str()`(`CopyFile/JoinPath → fopen/stat/link`)在 NUL 处截断,目标变成 `..`。编译 PoC 验证:`IsSafeRelativePath("..\0x")` 返回 1,`ApplyPatchFromFileSource`(根 `root/v2.staging`)把拷贝目标解析到 `root/`,即存放其他已安装版本目录的父目录。可达性按字符串桥而异:**鸿蒙可达**(`napi_get_value_string_utf8` 返回含原始 `\0` 的 UTF-8,`pushy.cpp:79-86` `GetString` 通过 `resize(written)` 保留它);iOS 侥幸安全(`[v UTF8String]` 在 NUL 处停止,目标变成 `..` 被拒);Android 安全(JNI modified UTF-8 把 `\0` 编成 `C0 80`)。manifest 是来自下载归档的攻击者可控输入。 -修法:在内核层拒绝任一段里 `< 0x20` 的字节(含 NUL),三端在边界上一并受保护。 +- **4.1(P1)** `IsSafeRelativePath` 不拒绝 NUL/控制字节(鸿蒙 staging 逃逸)— 已修 90f8d58e +- **4.2(P2)** 递归删目录/合并目录无深度上限 — 已修 90f8d58e(`kMaxDirectoryDepth`) +- **4.3(P2)** `patch_core` 测试不在 CI — 已修 1e46ee06(`test.yml` 跑 `SANITIZE=1 ./scripts/test-patch-core.sh`) +- **4.5(P3)** `flowjson::Parse` 无输入/节点上限 — 已修 90f8d58e(1 MiB / 65536 节点) +- **4.6(P3)** 数值解析/输出受 `LC_NUMERIC` 影响 — 已修 90f8d58e(手写十进制解析) +- **4.7(P3)** Murmur3 对非 ASCII key 与 TS 不一致 — 已修 90f8d58e(向量含非 ASCII) +- **4.8(P3)** 孤立代理项转义输出非法 UTF-8 — 已修 90f8d58e(归一到 U+FFFD) +- **4.9(P3)** HBC 变换路径整包读入内存两次 — 已修 90f8d58e(mmap 临时文件原地变换) +- **4.10(P3)** LZMA 字典大小无上限 — 已修 90f8d58e(`hpatch.c` `kMaxLzmaDictSize`) +- **4.11(P3)** payload 写入后 rename 前不 fsync — 已修 90f8d58e +- **4.13(P3)** 不可信输入解析器无 fuzz — 已修 90f8d58e(`fuzz_flow_json.cpp`、`fuzz_hbc_transform_wire.cpp`,CI `FUZZ=1`) +- **4.14(P3)** `ftell` 在 32 位 ABI 上截断 — 已修 90f8d58e(`fstat`) +- **P4** 测试脚本无 `-Werror` — 已修(`scripts/lib/cpp-test-common.sh` `CORE_WARNINGS`;vendored C 源仍只 `-Wall -Wextra`) -### P2 +### 仍开放 -**4.2 `RemovePathRecursively` / `MergeDirectoryRecursively` 递归无深度上限** -`patch_core.cpp:235-274,453-512`。ASan 下 PoC 已把 `RemovePathRecursively` 驱动到栈溢出 abort。独立于 4.1,恶意归档可放几千层嵌套目录(远在 20000 条目上限内),第一次 `CleanupOldEntries`/merge/removal 就栈溢出崩 app。修法:显式深度上限(对齐 flow parser 的 `kMaxDepth`)或用显式工作栈迭代。 +**4.4(P2)STL 分配抛出的 C++ 异常穿越 JNI/NAPI/ObjC 边界**(部分修复) +Android 已兜住:`Application.mk` 改 `-fexceptions`,每个 JNI 入口经 `jni_util::GuardJni` / catch-all 转成 Java 异常或 null。 +剩余:鸿蒙 `harmony/pushy/src/main/cpp/pushy.cpp` 的 NAPI 回调无 C++ `try/catch`,`bad_alloc/length_error` 会 unwind 进 NAPI;iOS `.mm` 只有 `@try/@catch(NSException*)`,抓不到 C++ 异常。4.5 的输入上限压低了可达性,但入口仍应 `catch (...)` 转成干净错误。另:`Application.mk` 仍带 `-fno-unwind-tables -fno-asynchronous-unwind-tables`,宜确认与 `-fexceptions` 同时使用时 unwind 正常。 -**4.3 `patch_core` 测试套件在 CI 完全不跑** -`.github/workflows/test.yml`:`cpp-test` 只构建 HDiffPatch 自带 `unit_test` 并跑 `SANITIZE=1 ./scripts/test-update-flow-core.sh`,从不调用 `./scripts/test-patch-core.sh`。`patch_core.cpp / archive_patch_core.cpp / hbc_transform*.cpp / digest.cpp / state_core.cpp` 及其 29 项测试**零 CI 覆盖**,而这正是消费不可信补丁/归档字节的代码。本地 `SANITIZE=1 ./scripts/test-patch-core.sh` 已通过,直接加一步即可。 +**4.12(P3)对等性:`buildCheckFingerprint` 无 C++ 移植**(部分修复) +`IsMirrorRetryableCode` 已移植并进向量,`paths:null` 已有向量。`buildCheckFingerprint`(`src/updateFlowCore.ts`)仍只有 TS 实现,各原生编排器各自重实现了等价的去重逻辑——对等性框架抓不到这类漂移。 -**4.4 Android 以 `-fno-exceptions` 编译,但 STL 分配可能抛 → `std::terminate` 穿越 JNI/NAPI/ObjC 边界** -`android/jni/Application.mk:43`。`cpp/` 内无任何 `try/catch`,flow parser 与 `std::string/vector` 按输入大小自由分配。Android 上 `bad_alloc/length_error` 在 `-fno-exceptions` 下直接 terminate(硬崩溃,无 Java 异常、无 promise 拒绝);鸿蒙 CMake 没设 `-fno-exceptions`,C++ 异常会 unwind 进无处理的 NAPI 回调;iOS 的 `@try/@catch(NSException*)` 抓不到 C++ 异常。结合 4.5 可达。修法:每个 JNI/NAPI/ObjC 入口包 `try/catch(...)` 转成干净错误(这些 TU 去掉 `-fno-exceptions`),或保证分配有界。 - -### P3 - -- **4.5** `flowjson::Parse`(`flow_json.cpp:621-624`)无总输入长度/节点数上限(HBC wire parser 有 64KB 上限)。`sizeof(Value)=96`,实测 1MB `[0,0,...]` 膨胀到 ~100MB RSS;Android 编排器 `response.body().string()` 无限长直接喂给它(见 2.7)。加 max-input-bytes 守卫。 -- **4.6** `flow_json.cpp:605 strtod` / `:244 "%.17g"` 受 `LC_NUMERIC` 影响:宿主进程 `setlocale` 到逗号小数区域后 `strtod("1.5")` 停在 `.` → 整个合法响应被拒;`%.17g` 会输出 `1,5`。用 `strtod_l/snprintf_l` 固定 C locale 或手写十进制。 -- **4.7** Murmur3 在 C++ 里哈希 UTF-8 字节(`update_flow_core.cpp:106-112`),TS 参考实现哈希 `charCodeAt & 0xff`(`updateFlowCore.ts:29-32`);非 ASCII key 下 `IsInRollout` 分桶不一致。今天 key 是 SDK 生成的 ASCII uuid,属潜在项,但金标向量只测 ASCII。按契约限定 ASCII 并断言,或让 C++ 读解码后码点低字节。 -- **4.8** 孤立代理项 `\u` 转义"成功"解析并输出非法 UTF-8(`flow_json.cpp:481-495`),与 `JSON.stringify` 行为不一致,向量未覆盖;且把非法 UTF-8 推进 `napi_create_string_utf8`。跟踪"曾是代理项"状态,拒绝或归一到 U+FFFD。 -- **4.9** HBC 变换补丁路径把整个 bundle 读进内存两次(`patch_core.cpp:554-599`:`ReadFileBytes(origin)` → 变换 → 写临时 → hpatch → `ReadFileBytes(temp_patched)` → 变换 → 写),非变换路径是端到端流式。10–50MB 包在低内存设备上是真实开销;考虑 mmap 临时文件原地变换。 -- **4.10** LZMA 解码器无字典大小上限:`decompress_plugin_demo.h` `_lzma_open/_lzma2_open` → `LzmaDec_Allocate(props)` 按补丁流里攻击者可控的 `props` 分配;`hpatch.c` 只限旧文件内存加载(8MB),字典可声明到 4GB。对 `dicSize` 加合理上限(如旧包大小的几倍)。 -- **4.11** bundle/资产写入在两阶段 rename 前不 fsync(`patch_core.cpp:304-319 WriteFileBytes`、`CopyFile`)。安装记录已由平台层 fsync,但 payload 数据块没有;掉电后可能出现"看起来完整"但 `index.bundlejs` 截断的目录。有 `bundleSha256` 激活复验兜底(变成拒绝激活+回滚),仍建议记录写入前 fsync payload。 -- **4.12** 对等性:`updateFlowCore.ts` 的 `buildCheckFingerprint(235-249)` 与 `isMirrorRetryableCode(258-260)` 没有 C++ 移植也不在向量里,各原生编排器各自重实现了等价的重试/去重逻辑——这是对等性框架抓不到的漂移。另 `paths: null` 时 TS `const {paths=[]}` 只默认 `undefined`,`null.map` 抛错;C++ `DecideDownload(update_flow_core.cpp:335-337)` 遍历空 elements 返回 `noArtifact`。补 `paths:null` 与非字符串端点条目的向量。 -- **4.13** 两个不可信输入解析器无 fuzz:`flowjson::Parse(const std::string&, bool*)` 与 `pushy::hbc::ParseHbcTransformMeta` 签名干净无副作用,libFuzzer harness 各 ~10 行,可挂到现有 ASan/UBSan 配置。 -- **4.14** `patch_core.cpp:285 const long size = ftell(file)` 在 armeabi-v7a/x86 上 32 位;用 `fseeko/ftello` 或 `fstat`。 - -### P4 -- 鸿蒙 NAPI 结果对象对空串字段直接省略(`pushy.cpp:322-331`),Android 显式设 `null`(`update_core_android.cpp:170-177`);形状不一致。 -- 测试脚本用 `-Wall -Wextra` 但无 `-Werror`;`cpp/` 内核在 CI 里没有以 warnings 编译(见 4.3)。 -- 已核实正常:`TransformHbcInPlace` 逐字节追踪无 OOB;HBC wire parser 严格有界(64KB、深度 8、字段范围检查);C++17 三端一致;`-fvisibility=hidden` + `--exclude-libs,ALL`;NDK r28 固定,16KB LOAD 段对齐由脚本强制;NAPI 异步管线在每条失败路径都 settle promise。 +**P4 鸿蒙 NAPI 结果对象对空串字段直接省略** +`pushy.cpp` `SetStringProperty` 跳过空串,Android(`update_core_android.cpp` `SetStringField`)显式设 `null`;两端结果对象形状不一致。 --- ## 5. 鸿蒙层(harmony/) -### 对 6e9ae76 声明的核实 -| 声明 | 结论 | -|---|---| -| "Harmony single flush" | 提交路径成立(`commitNativeCheckResult` 用 `beginFlushBatch/endFlushBatch` 包住,`UpdateContext.ts:206-244,521-548`)。但见 5.4:`flushSync` 缺失时退化为异步 best-effort;batch 内 `switchVersion` 抛错时内存里已 `setKv` 的 hash 信息仍会被 `finally` 落盘,不是"要么全有要么全无"。 | -| 归档限额 | 部分。归档大小上限在前置和流式兜底里都有;条目数/条目大小/总大小在 `zlib.decompressFile` **之后**才量(见 5.9);`MAX_COMPRESSION_RATIO / RATIO_CHECK_MIN_BYTES`(`ArchiveLimits.ts:10-11`)导出但从未使用。 | -| NAPI 严格响应校验 | 成立(`pushy.cpp:1082-1096` → `IsValidCheckResult`,`NativeCheckOrchestrator.ts:420,458` 逐端点使用)。 | -| FOLLOWUPS 2026-08-13 功能性断裂 | 已修(`PushyTurboModule.cpp:106-111` 注册了全部 10.50+ 方法含 `markJsCheckCompleted`)。仍开放:鸿蒙 e2e 无 native-check 场景;`Logger.ts:52` 仍硬编码 `isDebug=false`。 | - -### P1 - -**5.1 只改 `cpp/` 或 `android/jni/` 时 publish 复用陈旧 HAR** -`.github/workflows/publish.yml:48-54,173-186`。`check_changes` 用 `git diff --name-only $PREV_TAG HEAD | grep -q '^harmony/'` 判定,否则 `publish_without_harmony` 从 npm 上一版取 `pushy.har`。但 HAR 里的 `librnupdate.so` 由 `cpp/patch_core/*`、`cpp/update_flow_core/*`、`android/jni/hpatch.c`、HDiffPatch、lzma 编译(`CMakeLists.txt:31-46`;`build-harmony-har.js:186-250` 正是 staging 这些)。一次只修 state_core / update_flow_core / hpatch 的发版,Android/iOS 拿到修复而鸿蒙带着旧决策逻辑和旧 patcher 出去——正是为 Android 加 `.so` 重建所防的那类静默分叉。 -修法:`grep -qE '^(harmony|cpp|android/jni)/|^scripts/build-harmony-har\.js$'`;submodule 指针变化在 `android/jni/` 下同样以路径形式出现。 - -**5.2 三个 async 方法在 C++ 方法表注册为 SYNC,拒绝到不了 JS** -`PushyTurboModule.cpp:51-53` 用 `PUSHY_SYNC_METHOD` 注册 `setUuid/setNeedUpdate/markSuccess`,ArkTS 实现却是 `async`(`PushyTurboModule.ts:191,246,259`)。同步调用把 ArkTS 返回值(一个 Promise,无自有属性的对象)转成 jsi 值;async 体内的抛错(如 `switchVersion` → "Bundle version X is incomplete." / "bundle digest mismatch.",`UpdateContext.ts:668-678` 的 SR-2 检查)变成 ArkTS 侧 unhandled rejection 而非 JS 异常。JS(`src/client.ts:1063-1072`)于是把 `setNeedUpdate` 当成功,报告切换成功并告诉用户重启后生效——实际什么都没切。运行时验证:`PushyModule.setNeedUpdate({hash:'does-not-exist'})` 应 reject;若 resolve 即确认。 -修法:改为 `PUSHY_ASYNC_METHOD`(JS spec 就是 Promise);加一个不依赖 SDK 的 Node 测试,解析 `NativePushy.ts`、`PushyTurboModule.ts`、`PushyTurboModule.cpp`,断言每个 Spec 方法都在 C++ 表里且 sync/async 种类匹配——该表已经咬过一次(10.51 三个版本冷启动检测全灭)。 - -### P2 - -- **5.3** `DownloadTask.ts:788-796` `readTimeout: 60000`。OpenHarmony netstack 把 `readTimeout` 传给 libcurl 的 `CURLOPT_TIMEOUT_MS`(整次传输总时长)而非空闲超时;代码自己按空闲语义设计(另有 60s 不活动看门狗 `:657-681` 与 10 分钟整轮 deadline `:113,566-568`)。若映射如此,超过 60s 的传输全部失败,续传掩盖了它(每次重试多拿 60s 的字节),但 40–50MB 全量包在慢网下 3 次重试仍会失败。修法:`readTimeout = max(1, deadlineUptimeMs - now)`,靠已有看门狗管卡死;两种语义下都正确。 -- **5.4** 持久化在 `flushSync` 不可用时是 best-effort 异步(`UpdateContext.ts:223-244`),HAR 兼容 `compatibleSdkVersion 5.0.0(12)` 而 `flushSync` 是 API 14 才有;`reloadUpdate`(`PushyTurboModule.ts:223-234`)在 `switchVersion` 后立即 `restartApp` 杀进程,切换可能丢失、重启到旧 bundle。这也是 SR-5 在鸿蒙不成立的原因(Android 是 `persistEditorOrThrow`)。修法:有 promise 的操作在 `flushSync` 缺失时 `await preferences.flush()` 并让失败拒绝 promise;`commitNativeCheckResult` 里把 switch 校验放到第一个 `putSync` 之前。 -- **5.5** 整包/整 bundle SHA-256 在 UI 线程同步执行:`Sha256HexFile` 是普通同步 NAPI(`pushy.cpp:902-916`),从 ArkTS 主线程对下载归档(上限 512MB)调用于 `DownloadTask.ts:976,999,1067`、对最终 bundle `:175`、且每次激活 `verifyInstallForActivation`(`InstallRecord.ts:125` ← `UpdateContext.ts:673`)再来一次。30–50MB 全量包 = 数百 ms 冻结。`applyPatchFromFileSource/cleanupOldEntries` 已因同样原因搬到 `napi_create_async_work`(`pushy.cpp:635-643`)。修法:加 `sha256HexFileAsync`,或在 `transferArchive` 写入队列里边写边算 digest 免去第二遍。 -- **5.6** 鸿蒙原生几乎没有稳定错误码:仅 `DownloadTask.ts:328,989,1057` 设 `PATCH_FAILED`,其余抛裸 `Error`(空 hash `PushyTurboModule.ts:55-60` 应为 `INVALID_OPTIONS`、`downloadAndInstallApk:329` 应为 `UNSUPPORTED_PLATFORM`、`reloadUpdate/setNeedUpdate/resetToPackagedBundle` 重包并丢上下文 `:232,255,287`、`getLocalHashInfo` 应为 `INVALID_HASH_INFO`)。且 RNOH 异步桥以纯字符串 reject,`PATCH_FAILED` 标记很可能也丢,JS `toUpdateError(e,'DOWNLOAD_FAILED')` 把每次 pdiff/diff 应用失败在 patch-health 遥测里记成网络失败。修法:加 `ErrorCodes.ts` 镜像并在每个 throw 点附码;若 RNOH 丢 `code`,消息前缀 `[PATCH_FAILED] …` 并在 JS 鸿蒙分支解析。 -- **5.7** https→http 降级在鸿蒙未拒绝(SR-1 对等缺口):`NativeCheckOrchestrator.ts:347-390`、`DownloadTask.ts:788-796` 用 `@ohos.net.http`,它内部跟随重定向且既无开关也不暴露最终 URL。commit message 把 SR-1 限定在 Android/iOS 不算虚报,但设计文档"no https->http downgrade anywhere"对鸿蒙不成立。修法:至少文档要求消费者 `network_config.json` 设 `cleartextTrafficPermitted:false`;配置端点为 https 时拒绝决策里的 `http:` URL;可接受 HMS-only 的话 RCP(`autoRedirect=false` + effective URL)能完全关闭。 -- **5.8** 必需 CI 门里没有任何鸿蒙检查:`check-harmony-types.js:52-66` 无 DevEco SDK 时 exit 0(托管 runner 上永远如此),即便跑也只含 `.ts` 不含 `.ets`(`:86`);`harmony/pushy/src/test` 的 hypium 测试没有任何 workflow 跑;`e2e_harmony.yml` 仅手动且被 `native-gate.yml` 排除;HAR 只在发布时于固定容器里编译。`.ets` 类型错误、坏的 NAPI 导出、10.51 那类方法表断裂都要到发版才看见。修法:`test.yml` 加 `harmony-build` job,用同一容器 digest 跑 `check-harmony-types.js`(`HARMONY_TYPECHECK_REQUIRED=1` 时失败而非跳过)和 `build:harmony-har -- --build-mode release`(`assembleHar` 里的 ArkTS 编译器才是 `.ets` 的真正类型检查);把纯逻辑测试搬到 Node(5.15)让每个 PR 都跑。 - -### P3 - -- **5.9** 归档限额在解压之后才生效(`DownloadTask.ts:337-349`:`ensureFreeSpace(size*2)` → `zlib.decompressFile` → `measureExtractedDirectory`):20MB 归档 100:1 载荷通过 40MB+64MB 空间检查后先解出 2GB 再被拒。Android 先看中央目录(`SafeZipFile.inspect`)含压缩比。路径穿越依赖平台 zip 读取器拒绝 `..`/绝对名(代码里无断言;manifest `copies/deletes` 路径在 C++ `ValidateManifestImpl` 里验证,所以 `copyFromResource` 的拼接是安全的)。修法:解压前 `zlib.getOriginalSize(archiveFile)`(API 12)超过 `MAX_TOTAL_UNCOMPRESSED_BYTES` 或比值超 `MAX_COMPRESSION_RATIO` 即拒绝。 -- **5.10** `cleanUp()`(`UpdateContext.ts:784-798`,`markSuccess/clearFirstTime/clearRollbackMark/syncStateWithBinaryVersion` 调用)绕过串行任务链直接在 libuv worker 跑 `cleanupOldEntries`,而下载可能正在追加 >3 天的续传 partial 或填充 staging(`CleanupOldEntries` 按 mtime)。Android 与下载同一单线程 executor。修法:`enqueueSerialTask(() => NativePatchCore.cleanupOldEntries(...))`。 -- **5.11** `forceBootRescue` 标记缺失:`NativeCheckOrchestrator.ts:278-293` 只拷 `name/description/metaInfo`;Android(`NativeCheckOrchestrator.java:380-383`)和 iOS(`RCTPushy.mm:2228`)在 `config.forceBoot` 时写 `forceBootRescue:true`,`src/client.ts:1013-1014` 据此在 `markSuccess` 上报回执。鸿蒙的救砖永远不被记为救砖。 -- **5.12** 无可观测性:`Logger.ts:52` 出厂 `isDebug=false`,编排器里程碑除 "skipped" 外全是 `logger.debug`(`NativeCheckOrchestrator.ts:227,250,313-317,547`),Android 是 `Log.i/w`;`UpdateContext.DEBUG(:39)` 硬编码 false 未接 `BuildProfile.DEBUG`。轮次生命周期日志提到 `info/warn`;`DEBUG` 从 `BuildProfile` 派生。 -- **5.13** HAR 版本标识陈旧:`harmony/pushy/oh-package.json5:10` `version: '10.35.1'`,`package.json` 是 10.55.1;`check-release-version.js` 不看它。消费者 `oh-package-lock.json5` 和诊断无法区分装的是哪个 HAR。让 `build-harmony-har.js` 在 `assembleHar` 前从 `package.json` 重写版本,并扩展 `check-release-version.js` 断言相等。 -- **5.14** `hvigor-plugin.ts:7-31` 在 `apply()`(每次 hvigor 调用含 IDE sync)里跑,硬编码 `entry/` 和 `process.cwd()`,用 `Date.now()` 改写消费者源码树里的 `rawfile/meta.json`(git 噪音;模块名不是 entry 时写错位置);debug 构建也拿真实时间戳,每次 debug 构建都像新二进制(Android debug 输出 `0`)。改挂到构建任务、从 hvigor 节点上下文取模块路径、debug 输出 `0`。 -- **5.15** 测试:已覆盖 `parseManifestToArrays/isSafePathComponent/validateHashInfo/getErrorMessage/DownloadTaskParams`(`EventHub` 的 on/off 测试断言为空,因为 `emit` 不看监听器)。未覆盖的纯逻辑:`parseContentRangeTotal`(专为测试导出)、`buildInstallRecord/isInstallComplete/verifyInstallForActivation`、`readString/readBoolean`、`normalizeEndpointBase`、`buildResponseCacheJson`、flush-batch 深度语义、以及最有价值的:`ArchiveLimits.ts/InstallRecord.ts` 与它们声称"手工镜像"的 C++ 头文件之间的漂移。修法:JS 套件里加一个 Node 测试解析 `archive_limits.h/install_record.h/error_codes.h` 与 ArkTS 镜像并断言常量相等。 - -### P4 -- `EventHub.ts:19-34` `listeners` map 从未被 `emit` 使用;`rnInstance: any` 只在构造新 TurboModule 时替换,已销毁的 RNInstance 仍被引用。 -- 死代码/重复:`persistState({clearExisting})`(`UpdateContext.ts:290-292`)无调用且未 await `preferences.clear()`;`downloadFile()`(`:571-583`)无 JS 入口;`VERSION_COMPLETE_FILE_NAME`(`DownloadTask.ts:28`)重复 `INSTALL_RECORD_FILE_NAME`;`removeDirectory(:252-277)` 重实现递归 `fileIo.rmdir`;`transferArchive` ~420 行 15 个闭包捕获的可变局部;`doPatchFromApp*/doPatchFromPpk*` 共享同样的下载→staging→digest→解压→plan 脚手架;`console.*` 与 `logger` 混用。潜在 unhandled rejection:`writeError` 在 `dataEnd` 已触发后才设置(`:863-866`),`dataEndPromise` 链(`:686-712`)无订阅者地 reject。 -- `pushy.cpp` 小项:`Sha256HexFile` 参数非字符串时抛两次(`:910-914`);`FlowIsValidCheckResponse` 在异常 pending 时返回 boolean(`:1090-1095`);`CMakeLists.txt:75-78` `--gc-sections` 没配 `-ffunction-sections -fdata-sections`(无效),无 `-fvisibility=hidden`。 -- `rejectReservedEntries`(`DownloadTask.ts:315-330`)只查顶层精确 `.pushy-complete`,Android 拒绝任意深度 `.pushy-` 前缀。 -- `module.json5` 未声明 `requestPermissions`,`ohos.permission.INTERNET` 需消费者声明但仓库无文档。 -- `.ts` 源大量使用 ArkTS 禁止的写法(解构 `DownloadTask.ts:63,1146`、`Logger.ts:31`;Set 展开 `:1125-1129`;`any`;交叉类型 `UpdateContext.ts:31-33`;动态属性赋值 `error.code=`)——`tsc --strict` 不会告警,迁移到 `.ets`/严格 ArkTS 时全部暴露。在 5.8 的容器里跑 DevEco `codelinter` 能拿到真实清单。 - -已核实正常:回滚/first-time 记账与 Android 镜像(含 `ignoreRollback` 与循环守卫);`resetGeneration` 对在途轮次的围栏;`taskChain` 串行化与开跑时去重;`transferArchive/httpRequest/runWithinDeadline` 的计时器全部清理;服务端控制的路径分量在触盘前已验证;激活时的安装记录 digest 校验存在;不做 crash hold 由"RNOH 未捕获 JS 错误不杀进程"的模拟器实证支撑。 +- **6e9ae76 声明核实**:single flush(激活校验已前移到第一个 `putSync` 之前)、归档限额(解压前检查原始大小与压缩比)、NAPI 严格响应校验、FOLLOWUPS 2026-08-13 方法表断裂与 `Logger isDebug` — 均已修 6a7f4163;鸿蒙 native-check e2e 仍开放,见 `docs/NATIVE_CHECK_FOLLOWUPS.md` +- **5.1(P1)** 只改 `cpp/`/`android/jni/` 时 publish 复用陈旧 HAR — 已修 1e46ee06(`publish.yml` 变更检测含 `cpp|android/jni|build-harmony-har.js|.gitmodules`) +- **5.2(P1)** 三个 async 方法注册为 SYNC — 已修 6a7f4163(`PUSHY_ASYNC_METHOD` + `scripts/check-native-spec-parity.js`) +- **5.3(P2)** `readTimeout` 语义是整次传输 — 已修 6a7f4163(绑定剩余 deadline) +- **5.4(P2)** `flushSync` 缺失时持久化 best-effort — 已修 6a7f4163(await `preferences.flush()`,失败拒绝) +- **5.5(P2)** 整包 SHA-256 在 UI 线程 — 已修 6a7f4163(只剩 `Sha256HexFileAsync`) +- **5.6(P2)** 鸿蒙几乎无稳定错误码 — 已修 6a7f4163(`ErrorCodes.ts` + `[CODE] msg` 前缀,JS `readErrorCode` 解析) +- **5.9(P3)** 归档限额在解压之后才生效 — 已修 6a7f4163(`zlib.getOriginalSize` 前置) +- **5.10(P3)** `cleanUp()` 绕过串行任务链 — 已修 6a7f4163 +- **5.11(P3)** `forceBootRescue` 标记缺失 — 已修 6a7f4163 +- **5.12(P3)** 无可观测性 — 已修 6a7f4163(里程碑 info/warn;DEBUG 取宿主 `isDebugModeEnabled`,HAR 内无法引用 `BuildProfile`) +- **5.13(P3)** HAR 版本标识陈旧 — 已修 6a7f4163(`build-harmony-har.js` 同步版本,`check-release-version.js` 断言) +- **5.14(P3)** `hvigor-plugin.ts` 每次调用改写消费者源码 — 已修 6a7f4163(只在 assemble 时由注册任务写,debug 输出 `0`) +- **P4**(EventHub、死代码、`removeDirectory`、`doPatchFrom*` 重复、`console.*`、`dataEnd` 未处理拒绝、`pushy.cpp` 小项、CMake 段/可见性、`.pushy-` 任意深度、INTERNET 权限文档)— 已修 6a7f4163 + +### 仍开放 + +**5.7(P2)https→http 降级**(部分修复) +已做:配置端点全为 https 时共享轮次内核(`cpp/update_flow_core/native_round.cpp`,三端共用)丢弃非 https 远程端点与 `http:` 产物 URL;`harmony/pushy/src/README.md` 要求消费者 `network_config.json` 设 `cleartextTrafficPermitted:false`。 +剩余:`@ohos.net.http` 内部跟随重定向且不暴露最终 URL,https→http 的重定向降级仍可能发生;可接受 HMS-only 的话 RCP(`autoRedirect=false` + effective URL)能完全关闭。 + +**5.8(P2)鸿蒙 CI 门**(部分修复) +已做:`harmony-build.yml` 在固定容器里做类型检查(`HARMONY_TYPECHECK_REQUIRED=1` 时失败)与 release HAR 编译,并进 `native-gate`;hypium 套件已删除,鸿蒙纯逻辑测试改为 `src/__tests__/harmony*.test.ts` 在 bun 下跑(`test.yml`)。 +剩余:`e2e_harmony.yml` 仅手动,不在门内(无托管鸿蒙 runner)。 + +**5.15(P3)测试**(部分修复) +已覆盖:ContentRange、InstallRecord、ErrorCodes、ArchiveLimits(`getOriginalSize` 门与 NAPI 绑定;计数规则见 `archive-limits-vectors.json`)、NativeConfig、EventHub、原生轮次(请求体、端点查询、响应缓存:`native-round-vectors.json`);C++ 头文件 ↔ ArkTS/Java/JS 镜像的常量对等由 `scripts/check-constant-parity.js`(`bun lint`)断言。 +未覆盖:`readString/readBoolean`、flush-batch 深度语义。 + +**§5 P4 余项** +- `transferArchive`(`DownloadTask.ts`)仍约 470 行、大量闭包捕获的可变局部,未拆。 +- ArkTS 禁止写法已大部分清除,仍有 `Record`(`DownloadTask.ts`);DevEco `codelinter` 未接入 CI。 --- ## 6. CI / 发布 / 打包 / 仓库卫生 -- **6.1(P2)测试覆盖缺口汇总**:`patch_core` 测试不在 CI(4.3);鸿蒙不编译不类型检查(5.8);`native-gate` 的 e2e 只在路径过滤命中时才要求,`e2e-harmony` 永远不在门内。 -- **6.2(P1,同 5.1)** publish 的 HAR 变更检测只看 `harmony/`。 -- **6.3(P3)根 package.json 里的死依赖与死脚本**:`firebase-tools`、`jest`、`ts-jest`、`@types/jest`、`detox`、`pod-install` 在根目录无任何使用(detox/pod-install 由 `Example/e2etest` 自己的 lock 提供;测试跑的是 `bun test`);`tests:emulator:prepare / tests:emulator:start-ci / tests:packager:jet-ci / tests:ios:pod:install` 脚本和整个 `.github/workflows/scripts/`(firebase emulator、functions、rules)是模板残留,仓库里没有任何 workflow 引用;根 `e2e/starter.test.js` 测的是不存在的 welcome/hello 元素。清掉后 `bun install` 少装 ~30MB,lockfile 与 dependabot 面都小。 -- **6.4(P3)仓库里的大二进制**:`Example/harmony_use_pushy/debug.png`(761KB)、`demo.png`(345KB)在 git 历史里;`android/lib/*/librnupdate.so` 每次重建都是 4×~290KB 的新 blob(历史里已有 3 代)。发布已改为 CI 重建(publish.yml),可以考虑不再提交 `.so`(本地开发走 `bun build:so`,或 git LFS)。 -- **6.5(P3)README 与公开 API**:`testChannel / healthCheck / autoMarkSuccessDelayMs / resetToPackagedBundle / getUpdateMetadata / attachToSentry / maxRetries / beforeReload / onPackageExpired / overridePackageVersion / dismissErrorAfter / useUpdateProgress / onError` 在两份 README 里均无提及(只有外站文档链接)。至少在 README 里放一张 options 索引表指向文档页。 -- **6.6(P4)设计文档**:`NATIVE_CHECKUPDATE_DESIGN.md`(36KB)、`NATIVE_CHECK_FOLLOWUPS.md`(31KB)、`BUNDLEHASH_MIGRATION.md`(25KB)都在仓库根;FOLLOWUPS 里大量已关闭项与原始描述并存。建议移到 `docs/`,FOLLOWUPS 只保留开放项表。(2026-10-12:四份设计文档已移到 `docs/`;FOLLOWUPS 精简未做。) -- **6.7(P4)方法表对等性**:本次核对 16 个 Spec 方法在 oldarch/newarch/iOS/鸿蒙 C++/鸿蒙 ArkTS 五处全部注册(好)。但没有自动化守护,鸿蒙已经断过一次;见 5.2 的脚本建议。另 iOS 未导出 Spec 声明的 `isUsingBundleUrl` 常量(Android/鸿蒙有;JS 侧无人使用)——要么 iOS 补上,要么从 Spec 删掉。 -- **6.8(P3)安全模型说明**:三端均无 bundle 签名校验(grep 无 signature/ed25519/publicKey),完整性完全依赖 TLS + 服务端下发的 hash;端点列表又可由 gitee/jsdelivr 上的 `endpoints.json` 远程注入(`getRemoteEndpoints` 只要求是字符串,不要求 https)。这是既定信任模型,但两处低成本加固值得做:远程端点列表只接受 `https://`;长期看可加可选的离线签名(app 内置公钥、服务端签 manifest),对自托管/http 部署尤其有价值。 - ---- - -## 8. 处理状态(2026-09-02,同分支后续提交) - -全部条目已按层落地,提交:`2d21709`(CI/发布/脚本/README/C++ keep 列表)、`604ef60`(JS)、`4efa5f9`(iOS)、`36ddfc2`(Android)、`b56ecd2`(鸿蒙)、`f40d28d`(C++ 内核与 `.so` 重建)。 - -| 层 | 已完成 | 明确未做 / 需人工跟进 | -|---|---|---| -| JS | §1 全部(含 P4)、6.3、6.8、FOLLOWUPS 7/8/9;单测 222 → 273 | `require('../package.json')` 改生成 `version.ts`(收益小,保留) | -| Android | 2.1–2.13 与 P4 全部;新增 25 项纯 JVM JUnit | 无法在本环境跑 Gradle/R8:`build.gradle` 与 `proguard.pro` 需在一次 `minifyEnabled true` 的 release 构建里确认 diff/pdiff 仍可应用 | -| iOS | 3.1–3.12 与 P4 全部;`PrivacyInfo.xcprivacy` 随 pod 发布 | 3.13 文件拆分、3.14 逻辑下沉 C++ 未做(无 Xcode 无法验证大重构);两处改动需真机/模拟器回归:defaults key 迁移、`use_frameworks!` 下 `buildTime` 回退 | -| C++ | 4.1–4.14 与 P4 全部;`.so` 已用 NDK 28.2.13676358 重建 | `buildCheckFingerprint` 未移植到 C++(原生各端未复用它) | -| 鸿蒙 | 5.2–5.15 与 P4 全部;`oh-package.json5` 版本对齐 | 5.7 的 RCP 重定向控制(HMS-only)未做;HAR 内无法引用 hvigor 生成的 `BuildProfile`(首次 CI 编译已证实),DEBUG 只取宿主 `isDebugModeEnabled`;`transferArchive` 大函数未拆 | -| CI/发布 | 4.3、5.1、5.8、6.1、6.2、6.5、6.7 全部;新增 `check-native-spec-parity.js`、`harmony-build.yml`、`FUZZ=1` 步骤 | 6.4(不再提交 `.so` / 迁 LFS)与 6.6(设计文档搬家)未做,属流程决策;`harmony-build` 首次运行时若容器未暴露 `DEVECO_SDK_HOME`,type-check 步骤会失败并提示 | - -发版前提醒:鸿蒙 HAR 由 publish 在容器内重建;Android `.so` 已随本分支提交。iOS 的 `PrivacyInfo.xcprivacy` 与 defaults key 迁移、Android 的 old-arch Promise 签名属于行为变化,发版说明需提及。 - -## 7. 建议的执行顺序 - -**第一批(小改动、高收益,可一个 PR)** -1. `IsSafeRelativePath` 拒绝控制字节(4.1)+ 递归深度上限(4.2)+ `flowjson::Parse` 输入上限(4.5)。 -2. `publish.yml` 变更检测扩到 `cpp/ android/jni/ scripts/build-harmony-har.js`(5.1)。 -3. 鸿蒙三个方法改 ASYNC 注册 + spec/方法表一致性脚本(5.2)。 -4. `test.yml` 加 `SANITIZE=1 ./scripts/test-patch-core.sh`(4.3)。 -5. `core.ts` web 常量(1.1);`readNativeCheckCache` 加 schema 门(1.5);`setOptions` 应用 locale(1.3);`getRemoteEndpoints` 要求 `ok`(1.4)。 -6. iOS:随包发 `PrivacyInfo.xcprivacy`(3.1);`PushyUnzipGuard` 拒绝 symlink(3.3);`__diff.json` 解析加类型检查(3.4);下载前置大小/空间上限(3.5);SSZipArchive 固定 `~> 2.4`(3.11)。 +- **6.1(P2)** 测试覆盖缺口 — 已修 1e46ee06(patch_core + fuzz 进 `test.yml`、`harmony-build` 进门);`e2e_harmony` 仍不在门内,见 5.8 +- **6.2(P1)** publish 的 HAR 变更检测只看 `harmony/` — 已修,同 5.1 +- **6.3(P3)** 根 package.json 死依赖/死脚本、firebase 残留、根 `e2e/` — 已修 a60809b3 +- **6.5(P3)** README 无 options 索引 — 已修 1e46ee06(两份 README 的 Options reference) +- **6.6(P4)** 设计文档在仓库根、FOLLOWUPS 已关闭项与原文并存 — 已修(2026-10-12 移到 `docs/`;FOLLOWUPS 只留开放项) +- **6.7(P4)** 方法表对等性无自动化守护 — 已修 1e46ee06(`scripts/check-native-spec-parity.js`,`bun lint`);iOS 已导出 `isUsingBundleUrl` +- **6.8(P3)** 远程端点列表不要求 https — 已修 a60809b3;可选的离线签名(内置公钥签 manifest)仍是长期建议 -**第二批(并发与契约)** -7. Android:所有 snapshot→commit 走 `commitLock`,`restartApp` 的 hash/切换搬离 UI 线程(2.1、2.2);oldarch 补 Promise 签名(2.3);JNI 结果类硬 keep + 收窄消费者 proguard(2.4);cleanup keep 集合加 `launchVersion`(2.5)。 -8. iOS:无前缀 defaults key 改名并迁移(3.2);`buildTime` 资源 `bundleForClass:` 回退(3.6);重载路径去掉预消费状态、失败 reject(3.7)。 -9. provider 主 effect 拆分(1.2)+ 用真实 client 的契约测试(1.12)。 -10. 鸿蒙:`readTimeout` 绑到剩余 deadline(5.3)、`flush` 真正 await(5.4)、异步 sha256(5.5)、错误码(5.6)。 +### 仍开放 -**第三批(CI 与卫生)** -11. `harmony-build` PR job(5.8);libFuzzer harness(4.13);Android 纯 JUnit(§2 末);manifest/安装记录/限额累加器下沉 C++(3.14);`RCTPushy.mm` 拆分(3.13)。 -12. 删死依赖/死脚本/firebase 残留(6.3);`react-native-url-polyfill` 替换(1.10);README options 索引(6.5);APK 完整性校验(2.6);构建时脚本改写方式(3.10)。 +**6.4(P3)仓库里的大二进制**(未做,流程决策) +`android/lib/*/librnpushy.so` 仍提交,每次重建都是 4 个新 blob;发布已由 `publish.yml` 在 CI 重建,可考虑不再提交 `.so`(本地开发走 `bun build:so`,或 git LFS)。`Example/harmony_use_pushy/debug.png`、`demo.png` 仍在仓库里。 diff --git a/Example/e2etest/e2e/entry.brick.ts b/Example/e2etest/e2e/entry.brick.ts deleted file mode 100644 index a361bd9f..00000000 --- a/Example/e2etest/e2e/entry.brick.ts +++ /dev/null @@ -1,29 +0,0 @@ -// §11 crash-rescue experiment (not part of the regular e2e suites): the first -// healthy launch arms a flag well after markSuccess; every later launch reads -// it and dies a few hundred ms in. -import { PushyModule } from 'react-native-update'; -import { LOCAL_UPDATE_LABELS } from './localUpdateConfig.ts'; - -const bundleLabelGlobal = globalThis as typeof globalThis & { - __RNU_E2E_BUNDLE_LABEL?: string; -}; - -bundleLabelGlobal.__RNU_E2E_BUNDLE_LABEL = `${LOCAL_UPDATE_LABELS.base}_BRICK`; - -PushyModule.getLocalHashInfo('brickflag').then((v: string) => { - if (v && v.includes('armed') && !v.includes('disarmed')) { - setTimeout(() => { - throw new Error('BRICK: crash on launch'); - }, 0); - } else { - setTimeout(() => { - PushyModule.setLocalHashInfo( - 'brickflag', - JSON.stringify({ state: 'armed' }) - ); - console.warn('brick armed for next launch'); - }, 8000); - } -}); - -require('../index'); diff --git a/Example/e2etest/e2e/entry.fix.ts b/Example/e2etest/e2e/entry.fix.ts deleted file mode 100644 index c9e43259..00000000 --- a/Example/e2etest/e2e/entry.fix.ts +++ /dev/null @@ -1,16 +0,0 @@ -// §11 crash-rescue experiment fix bundle: disarm the brick flag immediately. -import { PushyModule } from 'react-native-update'; -import { LOCAL_UPDATE_LABELS } from './localUpdateConfig.ts'; - -const bundleLabelGlobal = globalThis as typeof globalThis & { - __RNU_E2E_BUNDLE_LABEL?: string; -}; - -bundleLabelGlobal.__RNU_E2E_BUNDLE_LABEL = `${LOCAL_UPDATE_LABELS.base}_FIXED`; - -PushyModule.setLocalHashInfo( - 'brickflag', - JSON.stringify({ state: 'disarmed' }) -); - -require('../index'); diff --git a/Example/e2etest/e2e/local-merge.test.ts b/Example/e2etest/e2e/local-merge.test.ts index 4b1407d8..d1d9540a 100644 --- a/Example/e2etest/e2e/local-merge.test.ts +++ b/Example/e2etest/e2e/local-merge.test.ts @@ -175,7 +175,10 @@ describe('Local Update Merge E2E', () => { delete: true, ...getDetoxLaunchArgs(), }); - }); + // simctl uninstall + install alone has taken 5-9 min on slow macOS + // runners; the 300s testTimeout default would fail the suite before a + // single step of it ran. + }, 600000); beforeEach(async () => { await device.launchApp({ diff --git a/Example/e2etest/e2e/native/native-check.test.ts b/Example/e2etest/e2e/native/native-check.test.ts index 49d7e735..7f4e3967 100644 --- a/Example/e2etest/e2e/native/native-check.test.ts +++ b/Example/e2etest/e2e/native/native-check.test.ts @@ -97,6 +97,10 @@ describe('Native cold-start check', () => { await waitForReady(); await waitForBundleLabel(LOCAL_UPDATE_LABELS.base); await waitForHash(''); + // This launch runs its own delayed round (5s after start). Let it finish + // before flipping the directive: a round still in flight would read the + // flipped server and activate one launch early. + await new Promise((resolve) => setTimeout(resolve, NATIVE_CHECK_SETTLE_MS)); // Flip the directive before the launch whose round should honor it. await setForceBoot(true); diff --git a/Example/e2etest/scripts/ios-rescue-server.ts b/Example/e2etest/scripts/ios-rescue-server.ts deleted file mode 100644 index f421f657..00000000 --- a/Example/e2etest/scripts/ios-rescue-server.ts +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env bun -// One-off server for the §11 iOS crash-rescue experiment (not part of the -// regular e2e suites). Update chain: no hash → brick; brick → fix. The /state -// endpoint reports what was served, so the driver can assert the sequence. - -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -declare const Bun: any; - -const moduleDir = path.dirname(fileURLToPath(import.meta.url)); -const artifactsDir = path.resolve(moduleDir, '../.e2e-artifacts/ios-rescue'); -// The app hardcodes this port (localUpdateConfig LOCAL_UPDATE_PORT). -const port = 31337; - -const BRICK_HASH = 'e2e-rescue-brick'; -const FIX_HASH = 'e2e-rescue-fix'; - -const served: string[] = []; - -function json(body: unknown, status = 200) { - return new Response(JSON.stringify(body), { - status, - headers: { - 'Content-Type': 'application/json; charset=utf-8', - 'Cache-Control': 'no-store', - }, - }); -} - -const server = Bun.serve({ - port, - hostname: '0.0.0.0', - async fetch(request: Request) { - const url = new URL(request.url); - - if (url.pathname === '/state') { - return json({ served }); - } - - if (url.pathname.startsWith('/checkUpdate/')) { - const payload = (await request.json().catch(() => ({}))) as { - hash?: unknown; - }; - const currentHash = typeof payload.hash === 'string' ? payload.hash : ''; - const assetBasePath = `${url.origin}/artifacts`; - let response: Record; - if (!currentHash) { - response = { - update: true, - name: 'rescue-brick', - hash: BRICK_HASH, - description: 'brick: crashes on launch after arming', - paths: [assetBasePath], - full: 'brick.ppk', - // Deliver the brick via forceBoot (the app runs checkStrategy:null / - // afterDownload:none, so nothing else would activate it). The FIX - // response deliberately has no forceBoot: its activation must come - // from the crash-rescue window itself. - config: { forceBoot: true }, - }; - } else if (currentHash === BRICK_HASH) { - response = { - update: true, - name: 'rescue-fix', - hash: FIX_HASH, - description: 'fix: disarms the brick flag', - paths: [assetBasePath], - full: 'fix.ppk', - }; - } else { - response = { upToDate: true }; - } - served.push( - `${currentHash || '(base)'} -> ${JSON.stringify(response.hash ?? 'upToDate')}` - ); - console.log( - `[checkUpdate] hash=${currentHash || '(base)'} ->`, - response.hash ?? 'upToDate' - ); - return json(response); - } - - if (url.pathname.startsWith('/artifacts/')) { - const name = path.basename(url.pathname); - const filePath = path.join(artifactsDir, name); - if (!fs.existsSync(filePath)) { - return new Response('not found', { status: 404 }); - } - console.log(`[artifact] ${name}`); - const file = Bun.file(filePath); - return new Response(request.method === 'HEAD' ? null : file, { - headers: { - 'Content-Type': 'application/octet-stream', - 'Content-Length': String(file.size), - 'Cache-Control': 'no-store', - }, - }); - } - - return new Response('not found', { status: 404 }); - }, -}); - -console.log(`ios rescue server listening on ${server.hostname}:${server.port}`); diff --git a/android/build.gradle b/android/build.gradle index 328df88f..f5549e39 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -56,34 +56,17 @@ def parseDeclaredMajorVersion(String declared) { def resolveInstalledExpoMajorVersion(File projectDir) { def nodeArgs = ['node', '--print', "require('expo/package.json').version"] try { - String stdout - if (GradleVersion.current() >= GradleVersion.version('7.5')) { - // providers.exec exists since Gradle 7.5; Project.exec is gone in 9. - def output = providers.exec { - workingDir projectDir - commandLine nodeArgs - ignoreExitValue = true - } - if (output.result.get().exitValue != 0) { - return null - } - stdout = output.standardOutput.asText.get() - } else { - def out = new ByteArrayOutputStream() - def result = project.exec { - workingDir projectDir - commandLine nodeArgs - standardOutput = out - errorOutput = new ByteArrayOutputStream() - ignoreExitValue true - } - if (result.exitValue != 0) { - return null - } - stdout = out.toString() + // providers.exec exists since Gradle 7.5 (Expo SDK 50+ requires + // Gradle 8); Project.exec is gone in 9. + def output = providers.exec { + workingDir projectDir + commandLine nodeArgs + ignoreExitValue = true } - - return parseMajorVersion(stdout.trim()) + if (output.result.get().exitValue != 0) { + return null + } + return parseMajorVersion(output.standardOutput.asText.get().trim()) } catch (Exception ignored) { return null } @@ -154,18 +137,16 @@ if (isNewArchitectureEnabled()) { apply plugin: 'com.facebook.react' } +version = projectVersion if (expoProject) { group = 'expo.modules.pushy' - version = projectVersion def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") apply from: expoModulesCorePlugin applyKotlinExpoModulesCorePlugin() - // useExpoPublishing() useCoreDependencies() } else { group = 'cn.reactnative.modules.update' - version = projectVersion } android { @@ -179,6 +160,9 @@ android { } } compileSdkVersion safeExtGet('compileSdkVersion', 34) + // Follows the app's ext.buildToolsVersion: without it AGP falls back to + // its own default, which hosts that only install the app's build-tools + // then have to download. buildToolsVersion safeExtGet('buildToolsVersion', '34.0.0') defaultConfig { minSdkVersion safeExtGet('minSdkVersion', 21) @@ -186,14 +170,22 @@ android { consumerProguardFiles "proguard.pro" buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString() } + + // The sources use lambdas, which AGP < 4.2 (React Native < 0.65) only + // compiles with an explicit Java 8 level. React Native's Gradle plugin + // (0.73+) raises every module to its own JDK level in finalizeDsl. + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } sourceSets { main { // let gradle pack the shared library into apk jniLibs.srcDirs = ['./lib'] - if (isNewArchitectureEnabled()) { - java.srcDirs += ['src/newarch'] - } else { + // Under the new architecture codegen generates NativePushySpec; + // the old one compiles the hand-written stand-in instead. + if (!isNewArchitectureEnabled()) { java.srcDirs += ['src/oldarch'] } diff --git a/android/jni/Android.mk b/android/jni/Android.mk index 5de674f7..b656e9bc 100644 --- a/android/jni/Android.mk +++ b/android/jni/Android.mk @@ -3,37 +3,28 @@ LOCAL_PATH := $(call my-dir) include $(CLEAR_VARS) LOCAL_MODULE := rnpushy -LOCAL_CPPFLAGS += -std=c++17 -LOCAL_LDFLAGS += -Wl,--exclude-libs,ALL LOCAL_LDLIBS := -llog -LOCAL_C_INCLUDES := \ - $(LOCAL_PATH) \ - $(LOCAL_PATH)/HDiffPatch \ - $(LOCAL_PATH)/HDiffPatch/libHDiffPatch/HPatch \ - $(LOCAL_PATH)/lzma/C \ - $(LOCAL_PATH)/../../cpp/patch_core \ - $(LOCAL_PATH)/../../cpp/update_flow_core +# The shared cores (cpp/patch_core, cpp/update_flow_core, cpp/hpatch) and the +# Android JNI glue (cpp/android_jni) are picked up by directory, so a new +# source file there needs no edit here; tests/ subdirectories are not matched. +# Every include is file-relative (as on iOS), so no include path is needed. +CPP_DIR := $(LOCAL_PATH)/../../cpp +Shared_Files := \ + $(sort $(wildcard $(CPP_DIR)/patch_core/*.cpp)) \ + $(sort $(wildcard $(CPP_DIR)/update_flow_core/*.cpp)) \ + $(sort $(wildcard $(CPP_DIR)/android_jni/*.cpp)) \ + $(sort $(wildcard $(CPP_DIR)/hpatch/*.c)) + +# HDiffPatch / lzma submodules: only the decoder sources hpatch.c uses. Hdp_Files := \ - hpatch.c \ - HDiffPatch/libHDiffPatch/HPatch/patch.c \ + HDiffPatch/libHDiffPatch/HPatch/patch.c \ HDiffPatch/file_for_patch.c \ lzma/C/LzmaDec.c \ - lzma/C/Lzma2Dec.c + lzma/C/Lzma2Dec.c LOCAL_SRC_FILES := \ - ../../cpp/patch_core/archive_patch_core.cpp \ - ../../cpp/patch_core/digest.cpp \ - ../../cpp/patch_core/hbc_transform.cpp \ - ../../cpp/patch_core/hbc_transform_wire.cpp \ - ../../cpp/patch_core/jni_registration.cpp \ - ../../cpp/patch_core/patch_core.cpp \ - ../../cpp/patch_core/patch_core_android.cpp \ - ../../cpp/patch_core/state_core.cpp \ - ../../cpp/patch_core/update_core_android.cpp \ - ../../cpp/update_flow_core/flow_json.cpp \ - ../../cpp/update_flow_core/update_flow_core.cpp \ - ../../cpp/update_flow_core/update_flow_jni.cpp \ + $(patsubst $(LOCAL_PATH)/%,%,$(Shared_Files)) \ $(Hdp_Files) include $(BUILD_SHARED_LIBRARY) diff --git a/android/lib/arm64-v8a/librnpushy.so b/android/lib/arm64-v8a/librnpushy.so index 59a3ea9f..ef78569b 100755 Binary files a/android/lib/arm64-v8a/librnpushy.so and b/android/lib/arm64-v8a/librnpushy.so differ diff --git a/android/lib/armeabi-v7a/librnpushy.so b/android/lib/armeabi-v7a/librnpushy.so index e7e4a628..68c4cac5 100755 Binary files a/android/lib/armeabi-v7a/librnpushy.so and b/android/lib/armeabi-v7a/librnpushy.so differ diff --git a/android/lib/x86/librnpushy.so b/android/lib/x86/librnpushy.so index e6b28b84..0bc21312 100755 Binary files a/android/lib/x86/librnpushy.so and b/android/lib/x86/librnpushy.so differ diff --git a/android/lib/x86_64/librnpushy.so b/android/lib/x86_64/librnpushy.so index 5a7e2da0..1767d630 100755 Binary files a/android/lib/x86_64/librnpushy.so and b/android/lib/x86_64/librnpushy.so differ diff --git a/android/proguard.pro b/android/proguard.pro index 33955d08..e06f4414 100644 --- a/android/proguard.pro +++ b/android/proguard.pro @@ -1,21 +1,23 @@ -# JNI-constructed result classes: cpp/patch_core/update_core_android.cpp +# JNI-constructed result classes: cpp/android_jni/update_core_android.cpp # resolves them by FindClass + GetMethodID("") + GetFieldID, so the # no-arg constructor and every field must survive shrinking with their names # (a -keepnames rule alone lets R8 strip the unreferenced constructor/fields). -keep class cn.reactnative.modules.update.StateCoreResult { (); ; } --keep class cn.reactnative.modules.update.ArchivePatchPlanResult { (); ; } -keep class cn.reactnative.modules.update.CopyGroupResult { (); ; } # Native methods are bound from JNI_OnLoad by class/method name (the encoded -# table in cpp/patch_core/jni_registration.cpp), not by exported symbols, so +# table in cpp/android_jni/jni_registration.cpp), not by exported symbols, so # their classes and names must still survive shrinking unchanged. -keepclasseswithmembernames class cn.reactnative.modules.update.** { native ; } -# Instantiated by React Native / Expo / the system by name. +# Instantiated or called by name: the module's methods by the bridge / +# TurboModule JNI (not every React Native version ships a consumer rule +# keeping NativeModule members), the receiver by the system, Expo's package +# by Expo. +# UpdatePackage needs no rule: hosts construct it directly. -keep class cn.reactnative.modules.update.UpdateModule { *; } --keep class cn.reactnative.modules.update.UpdatePackage { *; } -keep class cn.reactnative.modules.update.PackageInstallerStatusReceiver { *; } -keep class expo.modules.pushy.** { *; } @@ -72,5 +74,3 @@ -keep class **.BuildConfig { public static boolean IS_NEW_ARCHITECTURE_ENABLED; } - --keepnames class expo.modules.ExpoReactHostFactory$ExpoReactHostDelegate { *; } diff --git a/android/src/main/java/cn/reactnative/modules/update/ApkInstaller.java b/android/src/main/java/cn/reactnative/modules/update/ApkInstaller.java index 64c559b9..dd2a62d9 100644 --- a/android/src/main/java/cn/reactnative/modules/update/ApkInstaller.java +++ b/android/src/main/java/cn/reactnative/modules/update/ApkInstaller.java @@ -20,7 +20,6 @@ import java.util.concurrent.ConcurrentHashMap; final class ApkInstaller { - private static final int COPY_BUFFER_SIZE = 64 * 1024; private static final String SESSION_ID_EXTRA = Texts.reveal("3919bac3ab8a6b512c3e08f0c0b6de60452311edfbc8f680624b2d1de38da5a58e65557f3ecaeb8ea3b859661f3dc3f98bab48646c042bd0e19ca546681c2939"); private static final ConcurrentHashMap pendingPromises = @@ -137,7 +136,7 @@ static void install( // before staging it. The `hash` JS passes to downloadAndInstallApk is // the progress-event key ("downloadingApk"), not a digest, so there // is nothing to check against yet; until the server sends one, the - // https-only rule in UpdateModuleImpl is the integrity boundary. + // https-only rule in UpdateModule is the integrity boundary. PackageInstaller packageInstaller = null; int sessionId = -1; boolean committed = false; @@ -177,11 +176,7 @@ static void install( FileInputStream input = new FileInputStream(apkFile); OutputStream output = session.openWrite("base.apk", 0, apkFile.length()) ) { - byte[] buffer = new byte[COPY_BUFFER_SIZE]; - int count; - while ((count = input.read(buffer)) != -1) { - output.write(buffer, 0, count); - } + UpdateFileUtils.copy(input, output); session.fsync(output); } diff --git a/android/src/main/java/cn/reactnative/modules/update/ArchiveLimits.java b/android/src/main/java/cn/reactnative/modules/update/ArchiveLimits.java index 0be982c7..964d1f3e 100644 --- a/android/src/main/java/cn/reactnative/modules/update/ArchiveLimits.java +++ b/android/src/main/java/cn/reactnative/modules/update/ArchiveLimits.java @@ -1,19 +1,19 @@ package cn.reactnative.modules.update; /** - * Java mirror of cpp/patch_core/archive_limits.h — keep the two in sync by - * hand. Damage bounds for a corrupt or hostile update package. + * Damage bounds for a corrupt or hostile update package. The accounting + * itself (entry count, sizes, ratios, reserved names, the unknown-length + * probe plan) is the shared core's (cpp/patch_core/archive_limits.h, over + * JNI); the constants below are the ones this side still compares against + * itself, mirrored by hand (scripts/check-constant-parity.js asserts they + * match the header). */ final class ArchiveLimits { static final long MAX_ARCHIVE_BYTES = 512L * 1024 * 1024; static final long MAX_TOTAL_UNCOMPRESSED_BYTES = 2048L * 1024 * 1024; static final long MAX_ENTRY_BYTES = 512L * 1024 * 1024; - static final long MAX_ENTRIES = 20000; - static final long MAX_COMPRESSION_RATIO = 100; - static final long RATIO_CHECK_MIN_BYTES = 1L * 1024 * 1024; static final long MAX_MANIFEST_BYTES = 16L * 1024 * 1024; static final long FREE_DISK_MARGIN_BYTES = 64L * 1024 * 1024; - static final long UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES = 8L * 1024 * 1024; private ArchiveLimits() { } @@ -42,4 +42,21 @@ static void ensureFreeSpace(java.io.File target, long bytesToWrite) "insufficient disk space: need " + needed + " bytes, have " + usable); } } + + /** + * The central directory (one entry per index) through the shared + * EntryAccumulator. Returns the total uncompressed size; throws with the + * first violation. + */ + static native long checkEntries( + long archiveBytes, String[] names, long[] sizes, long[] compressedSizes) + throws java.io.IOException; + + /** + * Before one chunk of a download is written: {bytes to probe for, 0 when + * no probe is due; the reservation end after the chunk}. writtenBefore + * counts this response's body bytes before the chunk. + */ + static native long[] planFreeSpaceProbe( + long knownTotal, long writtenBefore, long chunkBytes, long reservedUntil); } diff --git a/android/src/main/java/cn/reactnative/modules/update/ArchivePatchPlanResult.java b/android/src/main/java/cn/reactnative/modules/update/ArchivePatchPlanResult.java deleted file mode 100644 index a4477cf8..00000000 --- a/android/src/main/java/cn/reactnative/modules/update/ArchivePatchPlanResult.java +++ /dev/null @@ -1,6 +0,0 @@ -package cn.reactnative.modules.update; - -class ArchivePatchPlanResult { - String mergeSourceSubdir; - boolean enableMerge; -} diff --git a/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java b/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java index 1a8c9caf..5eb656ba 100644 --- a/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java +++ b/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java @@ -1,8 +1,6 @@ package cn.reactnative.modules.update; import android.content.Context; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageManager; import android.content.res.Resources; import android.util.DisplayMetrics; import android.util.Log; @@ -32,9 +30,9 @@ private static final class ResolvedResourceSource { } } - // Holds the exact archive a CRC32 match came from, so the fallback copy - // reads from that archive even if another APK exposes the same entry name - // with different bytes. + // An entry together with the exact archive it came from, so a copy reads + // from that archive even if another APK exposes the same entry name with + // different bytes. private static final class ZipSource { final ZipEntry entry; final SafeZipFile zipFile; @@ -49,14 +47,29 @@ private static final class ZipSource { this.context = context.getApplicationContext(); } - void copyFromResource( - HashMap> resToCopy, - HashMap crcByFrom - ) throws IOException { + /** + * Performs the manifest copies of an APK-based patch (the host share of + * the native applyArchiveDelta): each group's targets, relative to + * targetRoot, receive its source entry. The native manifest parser has + * already validated every path and the copiesCrc rules. + */ + void copyFromResource(File targetRoot, CopyGroupResult[] groups) throws IOException { + HashMap> resToCopy = new HashMap>(); + HashMap crcByFrom = new HashMap(); + for (CopyGroupResult group : groups) { + ArrayList targets = new ArrayList(); + for (String to : group.toPaths) { + targets.add(new File(targetRoot, to)); + } + resToCopy.put(group.from, targets); + if (group.expectedCrc >= 0) { + crcByFrom.put(group.from, group.expectedCrc); + } + } ArrayList apkPaths = collectApkPaths(); - HashMap availableEntries = new HashMap(); - HashMap zipFileMap = new HashMap(); - HashMap entryToZipFileMap = new HashMap(); + ArrayList zipFiles = new ArrayList(); + // Entry name -> first archive (base APK first) that carries it. + HashMap availableEntries = new HashMap(); // Content checksum index: CRC32 -> matched archive source. Lets us // locate a file by content when its origin path is not present verbatim // on device (e.g. APK baseline diff applied on an AAB/split-apk install @@ -66,18 +79,18 @@ void copyFromResource( try { for (String apkPath : apkPaths) { SafeZipFile zipFile = new SafeZipFile(new File(apkPath)); - zipFileMap.put(apkPath, zipFile); + zipFiles.add(zipFile); Enumeration entries = zipFile.entries(); while (entries.hasMoreElements()) { ZipEntry ze = entries.nextElement(); - String entryName = ze.getName(); - if (!availableEntries.containsKey(entryName)) { - availableEntries.put(entryName, ze); - entryToZipFileMap.put(entryName, zipFile); + ZipSource source = new ZipSource(ze, zipFile); + // No Map.putIfAbsent: it is API 24+ and minSdk is 21. + if (!availableEntries.containsKey(ze.getName())) { + availableEntries.put(ze.getName(), source); } long crc = ze.getCrc(); if (crc != -1L && !crcToEntry.containsKey(crc)) { - crcToEntry.put(crc, new ZipSource(ze, zipFile)); + crcToEntry.put(crc, source); } } } @@ -85,10 +98,11 @@ void copyFromResource( HashMap normalizedEntryMap = new HashMap(); for (String entryName : availableEntries.keySet()) { String normalized = normalizeResPath(entryName); - normalizedEntryMap.putIfAbsent(normalized, entryName); + if (!normalizedEntryMap.containsKey(normalized)) { + normalizedEntryMap.put(normalized, entryName); + } } - SafeZipFile baseZipFile = zipFileMap.get(context.getPackageResourcePath()); HashMap> remainingFiles = new HashMap>(resToCopy); // Track copies that were located but failed to write. These are real @@ -109,11 +123,10 @@ void copyFromResource( continue; } - ZipEntry entry = availableEntries.get(fromPath); + ZipSource entry = availableEntries.get(fromPath); String actualSourcePath = fromPath; - SafeZipFile matchedZipFile = null; ResolvedResourceSource resolvedResource = null; - Long wantedCrc = crcByFrom != null ? crcByFrom.get(fromPath) : null; + Long wantedCrc = crcByFrom.get(fromPath); if (entry == null) { String normalizedFrom = normalizeResPath(fromPath); @@ -130,7 +143,8 @@ void copyFromResource( // locate the expected content elsewhere. An entry with an // unknown CRC (-1) cannot be verified and is accepted as-is. if (entry != null && wantedCrc != null - && entry.getCrc() != -1L && entry.getCrc() != wantedCrc.longValue()) { + && entry.entry.getCrc() != -1L + && entry.entry.getCrc() != wantedCrc.longValue()) { entry = null; actualSourcePath = fromPath; } @@ -141,8 +155,7 @@ void copyFromResource( if (entry == null && wantedCrc != null) { ZipSource matched = crcToEntry.get(wantedCrc); if (matched != null) { - entry = matched.entry; - matchedZipFile = matched.zipFile; + entry = matched; actualSourcePath = matched.entry.getName(); } } @@ -166,7 +179,7 @@ void copyFromResource( // archives so the right variant is used. (openRawResource // would re-resolve the id at the current configuration // density and ignore the requested one.) - ZipEntry resolvedEntry = availableEntries.get(actualSourcePath); + ZipSource resolvedEntry = availableEntries.get(actualSourcePath); if (resolvedEntry != null) { entry = resolvedEntry; resolvedResource = null; @@ -184,13 +197,7 @@ void copyFromResource( if (lastTarget != null) { UpdateFileUtils.copyFile(lastTarget, target); } else if (entry != null) { - SafeZipFile sourceZipFile = matchedZipFile != null - ? matchedZipFile - : entryToZipFileMap.get(actualSourcePath); - if (sourceZipFile == null) { - sourceZipFile = baseZipFile; - } - sourceZipFile.unzipToFile(entry, target); + entry.zipFile.unzipToFile(entry.entry, target); } else { InputStream in = openResolvedResourceStream(resolvedResource); UpdateFileUtils.copyInputStreamToFile(in, target); @@ -235,7 +242,7 @@ void copyFromResource( ); } } finally { - closeZipFiles(zipFileMap); + closeZipFiles(zipFiles); } } @@ -354,33 +361,25 @@ private InputStream openResolvedResourceStream(ResolvedResourceSource source) th } } + // The base APK first, then the split APKs of an AAB install. private ArrayList collectApkPaths() { ArrayList apkPaths = new ArrayList(); apkPaths.add(context.getPackageResourcePath()); - - try { - ApplicationInfo appInfo = - context.getPackageManager().getApplicationInfo(context.getPackageName(), 0); - if (appInfo.splitSourceDirs != null) { - for (String splitPath : appInfo.splitSourceDirs) { - apkPaths.add(splitPath); - } - } - } catch (PackageManager.NameNotFoundException e) { - if (UpdateContext.DEBUG) { - Log.w(Texts.LOG_TAG, "Failed to get application info: " + e.getMessage()); + String[] splitSourceDirs = context.getApplicationInfo().splitSourceDirs; + if (splitSourceDirs != null) { + for (String splitPath : splitSourceDirs) { + apkPaths.add(splitPath); } } - return apkPaths; } - private void closeZipFiles(HashMap zipFileMap) { - for (SafeZipFile zipFile : zipFileMap.values()) { + private void closeZipFiles(ArrayList zipFiles) { + for (SafeZipFile zipFile : zipFiles) { try { zipFile.close(); } catch (IOException e) { - if (UpdateContext.DEBUG) { + if (BuildConfig.DEBUG) { Log.w(Texts.LOG_TAG, "Failed to close zip file", e); } } diff --git a/android/src/main/java/cn/reactnative/modules/update/ConfigEquivalence.java b/android/src/main/java/cn/reactnative/modules/update/ConfigEquivalence.java deleted file mode 100644 index a91e97db..00000000 --- a/android/src/main/java/cn/reactnative/modules/update/ConfigEquivalence.java +++ /dev/null @@ -1,134 +0,0 @@ -package cn.reactnative.modules.update; - -import androidx.annotation.Nullable; -import java.math.BigDecimal; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Iterator; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; -import org.json.JSONTokener; - -/** - * Cross-platform canonical equality of native-check config JSON strings - * (shared contract with the JS / iOS / Harmony sides). Two configs are - * equivalent iff their canonical forms deep-equal: - *
    - *
  • absent keys take the defaults {disabled: false, queryUrls: [], rnu: "", - * rn: "", afterDownload: "none"};
  • - *
  • each string in endpoints / queryUrls loses its trailing '/', then - * duplicates are dropped keeping the first occurrence;
  • - *
  • key order is irrelevant.
  • - *
- * Anything that is not a JSON object string is never equivalent to anything. - */ -final class ConfigEquivalence { - private static final Object NULL_VALUE = new Object(); - - private ConfigEquivalence() { - } - - static boolean equivalent(@Nullable String a, @Nullable String b) { - Map left = canonical(a); - if (left == null) { - return false; - } - Map right = canonical(b); - return right != null && left.equals(right); - } - - @Nullable - static Map canonical(@Nullable String json) { - if (json == null) { - return null; - } - Object parsed; - try { - JSONTokener tokener = new JSONTokener(json); - parsed = tokener.nextValue(); - // nextValue() stops after the first value; anything but trailing - // whitespace after it is not JSON (as JSON.parse/NSJSONSerialization). - if (tokener.nextClean() != 0) { - return null; - } - } catch (JSONException e) { - return null; - } - if (!(parsed instanceof JSONObject)) { - return null; - } - @SuppressWarnings("unchecked") - Map result = (Map) toCanonical(parsed); - putDefault(result, "disabled", Boolean.FALSE); - putDefault(result, "queryUrls", new ArrayList()); - putDefault(result, "rnu", ""); - putDefault(result, "rn", ""); - putDefault(result, "afterDownload", "none"); - normalizeUrls(result, "endpoints"); - normalizeUrls(result, "queryUrls"); - return result; - } - - private static void putDefault(Map map, String key, Object value) { - if (!map.containsKey(key)) { - map.put(key, value); - } - } - - private static void normalizeUrls(Map map, String key) { - Object value = map.get(key); - if (!(value instanceof List)) { - return; - } - LinkedHashSet unique = new LinkedHashSet<>(); - for (Object item : (List) value) { - unique.add(item instanceof String ? stripTrailingSlashes((String) item) : item); - } - map.put(key, new ArrayList(unique)); - } - - private static String stripTrailingSlashes(String url) { - int end = url.length(); - while (end > 0 && url.charAt(end - 1) == '/') { - end--; - } - return url.substring(0, end); - } - - // JSONObject/JSONArray have identity equals; convert to Map/List with - // value semantics. Numbers compare by value (1 == 1.0). - private static Object toCanonical(Object value) { - if (value instanceof JSONObject) { - JSONObject object = (JSONObject) value; - Map map = new HashMap<>(); - for (Iterator keys = object.keys(); keys.hasNext();) { - String key = keys.next(); - map.put(key, toCanonical(object.opt(key))); - } - return map; - } - if (value instanceof JSONArray) { - JSONArray array = (JSONArray) value; - List list = new ArrayList<>(array.length()); - for (int i = 0; i < array.length(); i++) { - list.add(toCanonical(array.opt(i))); - } - return list; - } - if (value instanceof Number) { - try { - return new BigDecimal(value.toString()).stripTrailingZeros(); - } catch (NumberFormatException e) { - return value.toString(); - } - } - if (value == null || value == JSONObject.NULL) { - return NULL_VALUE; - } - return value; - } -} diff --git a/android/src/main/java/cn/reactnative/modules/update/CopyGroupResult.java b/android/src/main/java/cn/reactnative/modules/update/CopyGroupResult.java index d732d301..f28de988 100644 --- a/android/src/main/java/cn/reactnative/modules/update/CopyGroupResult.java +++ b/android/src/main/java/cn/reactnative/modules/update/CopyGroupResult.java @@ -1,6 +1,9 @@ package cn.reactnative.modules.update; +/** Built by the native applyArchiveDelta: one copy source and its targets. */ class CopyGroupResult { String from; String[] toPaths; + // CRC32 the source content must match (__diff.json copiesCrc), or -1. + long expectedCrc = -1; } diff --git a/android/src/main/java/cn/reactnative/modules/update/CrashHold.java b/android/src/main/java/cn/reactnative/modules/update/CrashHold.java index 0113f8cb..52d785c7 100644 --- a/android/src/main/java/cn/reactnative/modules/update/CrashHold.java +++ b/android/src/main/java/cn/reactnative/modules/update/CrashHold.java @@ -43,23 +43,20 @@ static void install() { installedAtElapsedRealtime = SystemClock.elapsedRealtime(); final Thread.UncaughtExceptionHandler previous = Thread.getDefaultUncaughtExceptionHandler(); - Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() { - @Override - public void uncaughtException(Thread thread, Throwable error) { - try { - maybeHold(thread); - } catch (Throwable ignored) { - // The dying process owes the previous handler its turn no - // matter what the rescue did. - } - if (previous != null) { - previous.uncaughtException(thread, error); - } else { - // Mirror RuntimeInit's default behaviour so the process - // still dies when no other handler was registered. - Process.killProcess(Process.myPid()); - System.exit(10); - } + Thread.setDefaultUncaughtExceptionHandler((thread, error) -> { + try { + maybeHold(thread); + } catch (Throwable ignored) { + // The dying process owes the previous handler its turn no + // matter what the rescue did. + } + if (previous != null) { + previous.uncaughtException(thread, error); + } else { + // Mirror RuntimeInit's default behaviour so the process + // still dies when no other handler was registered. + Process.killProcess(Process.myPid()); + System.exit(10); } }); } @@ -90,20 +87,15 @@ private static void maybeHold(Thread crashedThread) { + budgetMillis + "ms (uptime " + uptimeMillis + "ms)"); final CountDownLatch done = new CountDownLatch(1); - Thread worker = new Thread(new Runnable() { - @Override - public void run() { - try { - SyncCoordinator.runHoldRound(deadlineNanos); - } catch (Throwable e) { - Log.w(Texts.LOG_TAG, "crash hold failed: " + e); - } finally { - done.countDown(); - } + Threads.startDaemon("pushy-crash-hold", () -> { + try { + SyncCoordinator.runHoldRound(deadlineNanos); + } catch (Throwable e) { + Log.w(Texts.LOG_TAG, "crash hold failed: " + e); + } finally { + done.countDown(); } - }, "pushy-crash-hold"); - worker.setDaemon(true); - worker.start(); + }); try { if (!done.await(budgetMillis, TimeUnit.MILLISECONDS)) { Log.w(Texts.LOG_TAG, "crash hold: budget exhausted, letting go"); diff --git a/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java b/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java index 432536df..9df4ce1a 100644 --- a/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +++ b/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java @@ -6,7 +6,6 @@ import android.util.Log; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.WritableMap; -import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; @@ -16,8 +15,6 @@ import java.security.MessageDigest; import java.util.ArrayList; import java.util.Enumeration; -import java.util.HashMap; -import java.util.Iterator; import java.util.zip.ZipEntry; import okhttp3.Call; import okhttp3.OkHttpClient; @@ -27,7 +24,6 @@ import okio.BufferedSink; import okio.BufferedSource; import okio.Okio; -import org.json.JSONException; import org.json.JSONObject; import org.json.JSONTokener; @@ -36,9 +32,8 @@ class DownloadTask implements Runnable { private static final String BUNDLE_DELTA_ENTRY = Texts.reveal("3319f0d4b6c56a502c3b10fcdca0de7d4b3307e9"); - // Read size per loop iteration (and the buffer for small in-memory - // reads); progress events are throttled separately. - private static final int DOWNLOAD_CHUNK_SIZE = 64 * 1024; + // Resume sidecars are a few hundred bytes of JSON. + private static final long MAX_SIDECAR_BYTES = 64 * 1024; // When the server does not report Content-Length we cannot key progress // events on percentage change, so throttle by bytes to avoid flooding the // bridge (e.g. a 20MB chunked download would otherwise emit ~5000 events). @@ -54,10 +49,6 @@ class DownloadTask implements Runnable { .callTimeout(10, java.util.concurrent.TimeUnit.MINUTES) .build(); - static { - NativeCore.ensureLoaded(); - } - // Two-phase install (cpp/patch_core/install_record.h): all unpack/patch // work happens in .staging; the final directory only ever // appears through an atomic rename after the completion record was @@ -73,37 +64,26 @@ private File stagingDirectory() { // transfer first hashes the prefix already on disk); null when this task // did not stream the whole file itself and it must be hashed from disk. private String streamedArchiveSha256; - // Digest of index.bundlejs computed while a full package was extracted; + // Digest of the bundle computed while a full package was extracted; // null when the bundle was produced some other way (patch tasks). private String stagedBundleSha256; + /** What a staged patch archive declared, as the native core consumes it. */ private static final class PatchArchiveContents { - final ArrayList entryNames = new ArrayList(); - final ArrayList copyFroms = new ArrayList(); - final ArrayList copyTos = new ArrayList(); - final ArrayList deletes = new ArrayList(); - // __diff.json 的 hbcTransform 元数据(按 patch 条目名索引); - // 为空对象/缺失时返回 ""(native 走现状路径) - JSONObject hbcTransform; - - String hbcTransformMetaFor(String patchEntryName) { - if (hbcTransform == null) { - return ""; - } - JSONObject meta = hbcTransform.optJSONObject(patchEntryName); - return meta != null ? meta.toString() : ""; + final String[] entryNames; + // The raw __diff.json bytes (UTF-8): parsed only by the native core + // (cpp/patch_core/patch_manifest.h), never here. + final byte[] manifestJson; + + PatchArchiveContents(ArrayList entryNames, byte[] manifestJson) { + this.entryNames = entryNames.toArray(new String[0]); + this.manifestJson = manifestJson; } - // Maps a copy source path ("from") to the CRC32 of the file content, - // when provided by the manifest ("copiesCrc"). Lets the resource - // copier locate the file by content if the path is not present on - // device (APK baseline -> AAB install path shortening). - final HashMap copyCrcs = new HashMap(); } private final Context context; private final DownloadTaskParams params; private final Handler mainHandler = new Handler(Looper.getMainLooper()); - private final byte[] buffer = new byte[DOWNLOAD_CHUNK_SIZE]; private final BundledResourceCopier bundledResourceCopier; private String hash; // Set once downloadFile() returns: failures after this point in a patch @@ -120,15 +100,12 @@ private void postProgress(final long received, final long total) { // Cross-platform progress contract: unknown length is reported as // total=0, never a raw -1 (OkHttp's contentLength for chunked/gzip). final long normalizedTotal = total > 0 ? total : 0; - mainHandler.post(new Runnable() { - @Override - public void run() { - WritableMap progress = Arguments.createMap(); - progress.putDouble("received", received); - progress.putDouble("total", normalizedTotal); - progress.putString("hash", hash); - UpdateEventEmitter.sendEvent("RCTPushyDownloadProgress", progress); - } + mainHandler.post(() -> { + WritableMap progress = Arguments.createMap(); + progress.putDouble("received", received); + progress.putDouble("total", normalizedTotal); + progress.putString("hash", hash); + UpdateEventEmitter.sendEvent("RCTPushyDownloadProgress", progress); }); } @@ -142,7 +119,7 @@ private static File resumeSidecarFile(File archive) { static void deleteResumeSidecar(File archive) { File sidecar = resumeSidecarFile(archive); - if (sidecar.exists() && !sidecar.delete() && UpdateContext.DEBUG) { + if (sidecar.exists() && !sidecar.delete() && BuildConfig.DEBUG) { Log.w(Texts.LOG_TAG, "Failed to delete resume sidecar " + sidecar); } } @@ -152,9 +129,8 @@ private JSONObject readResumeMeta(File sidecar, String url) { return null; } try { - byte[] bytes = readBytes(new java.io.FileInputStream(sidecar)); JSONObject meta = (JSONObject) new JSONTokener( - new String(bytes, StandardCharsets.UTF_8)).nextValue(); + UpdateFileUtils.readSmallUtf8(sidecar, MAX_SIDECAR_BYTES)).nextValue(); if (url.equals(meta.optString("url"))) { return meta; } @@ -225,19 +201,13 @@ private void downloadFile() throws IOException { } } if (resumeOffset == 0) { - if (writePath.exists() && !writePath.delete()) { - throw new IOException("Failed to replace existing file: " + writePath); - } - deleteResumeSidecar(writePath); + discardPartial(writePath); } if (!transferArchive(url, writePath, sidecar, resumeMeta, resumeOffset)) { // The server rejected the range for a partial that no longer // matches (416): drop it and retry once from zero. - if (writePath.exists() && !writePath.delete()) { - throw new IOException("Failed to replace existing file: " + writePath); - } - deleteResumeSidecar(writePath); + discardPartial(writePath); if (!transferArchive(url, writePath, sidecar, null, 0)) { throw new IOException("Server rejected the download range for " + url); } @@ -245,6 +215,14 @@ private void downloadFile() throws IOException { downloadPhaseCompleted = true; } + /** Drops a partial download and its sidecar; throws when the bytes stay. */ + private static void discardPartial(File archive) throws IOException { + if (archive.exists() && !archive.delete()) { + throw new IOException("Failed to replace existing file: " + archive); + } + deleteResumeSidecar(archive); + } + /** * One HTTP transfer, appending from resumeOffset when the server honours * the range. Returns false only for the retryable stale-partial case @@ -276,27 +254,13 @@ private boolean transferArchive( } } - OkHttpClient requestClient = HTTP_CLIENT; - if (params.deadlineNanos > 0) { - long remainingNanos = params.deadlineNanos - System.nanoTime(); - if (remainingNanos <= 0) { - throw new IOException("Download deadline expired before start"); - } - long remainingMillis = Math.max( - 1L, - java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(remainingNanos) - ); - requestClient = HTTP_CLIENT.newBuilder() - .callTimeout(remainingMillis, java.util.concurrent.TimeUnit.MILLISECONDS) - .build(); - } - - Call call = requestClient.newCall(builder.build()); + Call call = HttpClients.withDeadline(HTTP_CLIENT, params.deadlineNanos) + .newCall(builder.build()); // Registered so an orchestrated phase timeout can cancel it // (CODE_AUDIT 2.12); cleared once the transfer is over. params.attachCall(call); try (Response response = call.execute()) { - rejectProtocolDowngrade(url, response); + HttpClients.rejectProtocolDowngrade(url, response); if (response.code() == 416) { long total = resumeMeta == null ? 0 : resumeMeta.optLong("total", 0); if (total > 0 && writePath.length() == total) { @@ -353,9 +317,7 @@ private boolean transferArchive( // Destroy the old bytes before the sidecar can vouch for // them with the new validators (a crash between the two // writes must never leave a sidecar describing stale bytes). - if (writePath.exists() && !writePath.delete()) { - throw new IOException("Failed to replace existing file: " + writePath); - } + discardPartial(writePath); } if (encodedBody) { // No resume across an encoded transfer. @@ -371,10 +333,9 @@ private boolean transferArchive( long lastPostedBytes = baseOffset; // Unknown length: the response-time check could only reserve the // margin, so the disk is probed before any write that would run - // past the bytes reserved so far, each probe reserving at least - // the next PROBE bytes (more when one chunk is larger) — no write - // can ever eat into the margin (the archive cap alone is far more - // than the margin). A throw keeps the partial for a later attempt. + // past the bytes reserved so far (the shared probe plan, + // ArchiveLimits.planFreeSpaceProbe) — no write can ever eat into + // the margin. A throw keeps the partial for a later attempt. long freeSpaceReservedUntil = 0; // The archive digest is computed as the bytes reach the file, so @@ -388,14 +349,18 @@ private boolean transferArchive( BufferedSink sink = Okio.buffer(Okio.sink(new DigestOutputStream( new FileOutputStream(writePath, append), digest))) ) { - while ((bytesRead = source.read(sink.buffer(), DOWNLOAD_CHUNK_SIZE)) != -1) { + while ((bytesRead = source.read(sink.buffer(), UpdateFileUtils.BUFFER_SIZE)) != -1) { + // A known length was reserved up front: no per-chunk plan + // (and no JNI round trip on this hot loop). + long[] probe = totalAll > 0 ? null : ArchiveLimits.planFreeSpaceProbe( + totalAll, received, bytesRead, freeSpaceReservedUntil); received += bytesRead; - if (totalAll <= 0 && received > freeSpaceReservedUntil) { - long reserve = Math.max( - ArchiveLimits.UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES, bytesRead); - freeSpaceReservedUntil = received - bytesRead + reserve; + if (probe != null) { + freeSpaceReservedUntil = probe[1]; + } + if (probe != null && probe[0] > 0) { try { - ArchiveLimits.ensureFreeSpace(writePath, reserve); + ArchiveLimits.ensureFreeSpace(writePath, probe[0]); } catch (IOException e) { // The chunk is still only buffered; closing the // sink would flush it onto the disk that just @@ -448,136 +413,51 @@ private boolean transferArchive( return true; } - private byte[] readBytes(InputStream input) throws IOException { - return readBytes(input, Long.MAX_VALUE); - } - - private byte[] readBytes(InputStream input, long maxBytes) throws IOException { - try ( - InputStream in = input; - ByteArrayOutputStream out = new ByteArrayOutputStream() - ) { - int count; - long total = 0; - while ((count = in.read(buffer)) != -1) { - total += count; - if (total > maxBytes) { - throw new IOException("content exceeds " + maxBytes + " bytes"); - } - out.write(buffer, 0, count); - } - return out.toByteArray(); - } - } - - private void appendManifestEntries( - JSONObject manifest, - ArrayList copyFroms, - ArrayList copyTos, - ArrayList deletes, - HashMap copyCrcs - ) throws JSONException { - JSONObject copiesCrc = manifest.optJSONObject("copiesCrc"); - - JSONObject copies = manifest.optJSONObject("copies"); - if (copies != null) { - Iterator keys = copies.keys(); - while (keys.hasNext()) { - String to = (String) keys.next(); - String from = copies.getString(to); - if (from.isEmpty()) { - from = to; - } - copyFroms.add(from); - copyTos.add(to); - if (copiesCrc != null && copyCrcs != null && copiesCrc.has(to)) { - // Same content => same crc, so grouping multiple "to" under - // one "from" stays consistent. - copyCrcs.put(from, copiesCrc.getLong(to)); - } - } - } - - JSONObject deleteMap = manifest.optJSONObject("deletes"); - if (deleteMap != null) { - Iterator deleteKeys = deleteMap.keys(); - while (deleteKeys.hasNext()) { - deletes.add((String) deleteKeys.next()); - } - } - } - private void copyBundledAssetToFile(String assetName, File destination) throws IOException { try (InputStream in = context.getAssets().open(assetName)) { UpdateFileUtils.copyInputStreamToFile(in, destination); } } - private HashMap> buildCopyList( - File unzipDirectory, - CopyGroupResult[] groups - ) throws IOException { - HashMap> copyList = new HashMap>(); - if (groups == null) { - return copyList; - } - - String rootPath = unzipDirectory.getCanonicalPath() + File.separator; - for (CopyGroupResult group : groups) { - ArrayList targets = new ArrayList(); - if (group.toPaths != null) { - for (String to : group.toPaths) { - File toFile = new File(unzipDirectory, to); - String canonicalPath = toFile.getCanonicalPath(); - if (!canonicalPath.startsWith(rootPath)) { - throw new SecurityException("Illegal name: " + to); - } - targets.add(toFile); - } - } - copyList.put(group.from, targets); - } - - return copyList; - } - private PatchArchiveContents extractPatchArchive(File archiveFile, File unzipDirectory) - throws IOException, JSONException { + throws IOException { UpdateFileUtils.removeDirectory(unzipDirectory); UpdateFileUtils.ensureDirectory(unzipDirectory); - PatchArchiveContents contents = new PatchArchiveContents(); + ArrayList entryNames = new ArrayList(); + byte[] manifestJson = new byte[0]; try (SafeZipFile zipFile = new SafeZipFile(archiveFile)) { ensureArchiveWithinLimits(archiveFile, zipFile, unzipDirectory); Enumeration entries = zipFile.entries(); while (entries.hasMoreElements()) { ZipEntry entry = entries.nextElement(); String name = entry.getName(); - contents.entryNames.add(name); + entryNames.add(name); if (name.equals("__diff.json")) { if (entry.getSize() > ArchiveLimits.MAX_MANIFEST_BYTES) { throw new IOException("delta manifest too large: " + entry.getSize()); } - byte[] bytes = readBytes( + manifestJson = UpdateFileUtils.readBytes( zipFile.getInputStream(entry), ArchiveLimits.MAX_MANIFEST_BYTES); - String json = new String(bytes, StandardCharsets.UTF_8); - JSONObject manifest = (JSONObject) new JSONTokener(json).nextValue(); - appendManifestEntries( - manifest, - contents.copyFroms, - contents.copyTos, - contents.deletes, - contents.copyCrcs - ); - contents.hbcTransform = manifest.optJSONObject("hbcTransform"); continue; } zipFile.unzipToPath(entry, unzipDirectory); } } - return contents; + return new PatchArchiveContents(entryNames, manifestJson); + } + + /** + * Shared first half of the two patch tasks: download the archive and + * unpack it into the staging directory. The native applyArchiveDelta + * validates what it declared. + */ + private PatchArchiveContents stagePatchArchive() throws IOException { + downloadFile(); + artifactSha256 = archiveSha256(); + return extractPatchArchive(params.targetFile, stagingDirectory()); } private void doFullPatch() throws IOException { @@ -592,7 +472,7 @@ private void doFullPatch() throws IOException { // bytes as they are extracted, instead of re-reading the bundle right // after. Any entry resolving to the bundle path counts; the last write // wins on disk and in the digest alike. - File bundleTarget = new File(work, "index.bundlejs").getCanonicalFile(); + File bundleTarget = InstallRecord.bundleFile(work).getCanonicalFile(); stagedBundleSha256 = null; try (SafeZipFile zipFile = new SafeZipFile(params.targetFile)) { ensureArchiveWithinLimits(params.targetFile, zipFile, work); @@ -623,7 +503,7 @@ private String archiveSha256() throws IOException { * final bundle's digest) goes into the staging directory, which is then * renamed over the version directory in one atomic step. */ - private void promoteStaging() throws IOException, JSONException { + private void promoteStaging() throws IOException { File work = stagingDirectory(); if (targetsRunningVersion()) { // The process switched to this very version while the task ran @@ -633,26 +513,11 @@ private void promoteStaging() throws IOException, JSONException { UpdateFileUtils.removeDirectory(work); return; } - File bundle = new File(work, "index.bundlejs"); - if (!bundle.isFile()) { - throw new IOException("bundle missing after install: " + bundle); - } - String bundleSha256 = stagedBundleSha256 != null - ? stagedBundleSha256 : UpdateFileUtils.sha256Hex(bundle); - InstallRecord.write( - work, InstallRecord.build(params.hash, bundleSha256, artifactSha256)); + InstallRecord.seal(work, params.hash, artifactSha256, stagedBundleSha256); if (params.unzipDirectory.exists()) { UpdateFileUtils.removeDirectory(params.unzipDirectory); } - if (!work.renameTo(params.unzipDirectory)) { - throw new IOException("failed to promote staging directory to " + params.unzipDirectory); - } - // The rename rewrote the versions root's entries; the record's own - // directory was synced by InstallRecord.write, the root was not. - File versionsRoot = params.unzipDirectory.getParentFile(); - if (versionsRoot != null) { - InstallRecord.syncDirectory(versionsRoot); - } + InstallRecord.promote(work, params.unzipDirectory); } /** @@ -668,8 +533,7 @@ private static void ensureArchiveWithinLimits( if (archiveBytes > ArchiveLimits.MAX_ARCHIVE_BYTES) { throw new IOException("archive too large: " + archiveBytes + " bytes"); } - SafeZipFile.Inspection inspection = zipFile.inspect(); - ArchiveLimits.ensureFreeSpace(unzipDirectory, inspection.totalUncompressed); + ArchiveLimits.ensureFreeSpace(unzipDirectory, zipFile.inspect(archiveBytes)); } // The archive and its resume sidecar live and die together: once the @@ -682,78 +546,48 @@ private void deleteConsumedArchive() { deleteResumeSidecar(params.targetFile); } - private void doPatchFromApk() throws IOException, JSONException { - downloadFile(); + private void doPatchFromApk() throws IOException { + PatchArchiveContents contents = stagePatchArchive(); File work = stagingDirectory(); - artifactSha256 = archiveSha256(); - PatchArchiveContents contents = extractPatchArchive(params.targetFile, work); - - buildArchivePlan( - DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK, - contents.entryNames.toArray(new String[0]), - contents.copyFroms.toArray(new String[0]), - contents.copyTos.toArray(new String[0]), - contents.deletes.toArray(new String[0]) - ); - - HashMap> copyList = buildCopyList( - work, - buildCopyGroups( - contents.copyFroms.toArray(new String[0]), - contents.copyTos.toArray(new String[0]) - ) - ); File originBundleFile = new File(work, ".origin.bundle"); copyBundledAssetToFile("index.android.bundle", originBundleFile); + CopyGroupResult[] copies; try { - applyDeltaFromSource( + // The APK's resources are zip entries, not files: the core + // rebuilds the bundle and hands the manifest copies back. + copies = applyArchiveDelta( + DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK, + contents.entryNames, + contents.manifestJson, work.getAbsolutePath(), work.getAbsolutePath(), originBundleFile.getAbsolutePath(), new File(work, BUNDLE_DELTA_ENTRY).getAbsolutePath(), - new File(work, "index.bundlejs").getAbsolutePath(), - "", - false, - new String[0], - new String[0], - new String[0], - contents.hbcTransformMetaFor(BUNDLE_DELTA_ENTRY) + InstallRecord.bundleFile(work).getAbsolutePath(), + true ); } finally { originBundleFile.delete(); } - bundledResourceCopier.copyFromResource(copyList, contents.copyCrcs); + bundledResourceCopier.copyFromResource(work, copies); deleteConsumedArchive(); } - private void doPatchFromPpk() throws IOException, JSONException { - downloadFile(); + private void doPatchFromPpk() throws IOException { + PatchArchiveContents contents = stagePatchArchive(); File work = stagingDirectory(); - artifactSha256 = archiveSha256(); - PatchArchiveContents contents = extractPatchArchive(params.targetFile, work); - - ArchivePatchPlanResult plan = buildArchivePlan( + applyArchiveDelta( DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK, - contents.entryNames.toArray(new String[0]), - contents.copyFroms.toArray(new String[0]), - contents.copyTos.toArray(new String[0]), - contents.deletes.toArray(new String[0]) - ); - - applyDeltaFromSource( + contents.entryNames, + contents.manifestJson, params.originDirectory.getAbsolutePath(), work.getAbsolutePath(), - new File(params.originDirectory, "index.bundlejs").getAbsolutePath(), + InstallRecord.bundleFile(params.originDirectory).getAbsolutePath(), new File(work, BUNDLE_DELTA_ENTRY).getAbsolutePath(), - new File(work, "index.bundlejs").getAbsolutePath(), - plan.mergeSourceSubdir, - plan.enableMerge, - contents.copyFroms.toArray(new String[0]), - contents.copyTos.toArray(new String[0]), - contents.deletes.toArray(new String[0]), - contents.hbcTransformMetaFor(BUNDLE_DELTA_ENTRY) + InstallRecord.bundleFile(work).getAbsolutePath(), + false ); deleteConsumedArchive(); } @@ -766,19 +600,13 @@ private void doCleanUp() { // Phase 1, under the version-files lock: only renames doomed entries // into the trash, so activation is never blocked behind a recursive // delete of several version directories. - UpdateContext.getInstance(context).runCleanupWithLatestState( - new UpdateContext.CleanupAction() { - @Override - public void run(String[] keepNames) { - cleanupOldEntries( - params.unzipDirectory.getAbsolutePath(), - keepNames, - params.maxAgeDays, - trash.getAbsolutePath() - ); - } - } - ); + UpdateContext.getInstance(context).runCleanupWithLatestState(keepNames -> + cleanupOldEntries( + params.unzipDirectory.getAbsolutePath(), + keepNames, + params.maxAgeDays, + trash.getAbsolutePath() + )); // Phase 2, lock released: nothing in the trash is reachable as a // version any more. Also purges leftovers of an interrupted run. // Best effort: phase 1 already succeeded, and a leftover the native @@ -813,37 +641,13 @@ private void cleanUpAfterFailure(int taskType) { } break; case DownloadTaskParams.TASK_TYPE_PLAIN_DOWNLOAD: - if ( - params.targetFile.exists() - && !params.targetFile.delete() - && UpdateContext.DEBUG - ) { - Log.w(Texts.LOG_TAG, "Failed to clean partial download " + params.targetFile); - } - deleteResumeSidecar(params.targetFile); + deleteConsumedArchive(); break; default: break; } } - /** - * An https artifact URL must stay on https through every redirect: the - * package is the supply-chain boundary and TLS is what authenticates - * it. OkHttp follows cross-scheme redirects by default, so the final - * request is checked here (the redirected bytes are discarded). - */ - static void rejectProtocolDowngrade(String requestedUrl, Response response) - throws IOException { - if (requestedUrl != null - && requestedUrl.regionMatches(true, 0, "https:", 0, 6) - && !response.request().url().isHttps()) { - throw new IOException( - "https download redirected to plaintext http: " - + response.request().url()); - } - } - private boolean isPatchTask(int taskType) { return taskType == DownloadTaskParams.TASK_TYPE_PATCH_FULL || taskType == DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK @@ -853,8 +657,7 @@ private boolean isPatchTask(int taskType) { private boolean hasCompletedDeltaDirectory() { return params.unzipDirectory != null && params.hash != null - && new File(params.unzipDirectory, "index.bundlejs").isFile() - && InstallRecord.isComplete(params.unzipDirectory, params.hash); + && InstallRecord.isInstalled(params.unzipDirectory, params.hash); } // True when this task targets the version the process is running from. @@ -873,18 +676,12 @@ private boolean targetsRunningVersion() { * failure rather than overwritten. */ private void ensureNotReinstallingRunningVersion() throws IOException { - File bundle = new File(params.unzipDirectory, "index.bundlejs"); - if (bundle.isFile() && InstallRecord.isComplete(params.unzipDirectory, params.hash)) { - try { - InstallRecord.verifyForActivation(params.unzipDirectory, params.hash, bundle); - return; - } catch (IOException e) { - throw new FileOperationException("Running version " + params.hash - + " cannot be reinstalled in place: " + e.getMessage()); - } + try { + InstallRecord.verifyInstalled(params.unzipDirectory, params.hash, false); + } catch (IOException e) { + throw new FileOperationException("Running version " + params.hash + + " cannot be reinstalled in place: " + e.getMessage()); } - throw new FileOperationException("Running version " + params.hash - + " has no verifiable install record and cannot be reinstalled in place"); } @Override @@ -977,18 +774,21 @@ && isPatchTask(taskType) } } - private static native void applyDeltaFromSource( + // The native half of a patch install (cpp/patch_core/archive_patch_core.h + // ApplyArchivePatch): checks the archive's entries, parses and validates + // manifestJson (__diff.json), rebuilds the bundle and, unless + // hostCopiesResources, performs the copies, deletes and merge. Returns + // the copies the caller still owes (empty unless hostCopiesResources). + private static native CopyGroupResult[] applyArchiveDelta( + int patchType, + String[] entryNames, + byte[] manifestJson, String sourceRoot, String targetRoot, String originBundlePath, String bundlePatchPath, String bundleOutputPath, - String mergeSourceSubdir, - boolean enableMerge, - String[] copyFroms, - String[] copyTos, - String[] deletes, - String hbcTransformMeta + boolean hostCopiesResources ); // trashDir non-empty: move the selected entries there instead of @@ -999,17 +799,4 @@ private static native void cleanupOldEntries( int maxAgeDays, String trashDir ); - - private static native ArchivePatchPlanResult buildArchivePlan( - int patchType, - String[] entryNames, - String[] copyFroms, - String[] copyTos, - String[] deletes - ); - - private static native CopyGroupResult[] buildCopyGroups( - String[] copyFroms, - String[] copyTos - ); } diff --git a/android/src/main/java/cn/reactnative/modules/update/DownloadTaskParams.java b/android/src/main/java/cn/reactnative/modules/update/DownloadTaskParams.java index bbe4a885..f1c3408e 100644 --- a/android/src/main/java/cn/reactnative/modules/update/DownloadTaskParams.java +++ b/android/src/main/java/cn/reactnative/modules/update/DownloadTaskParams.java @@ -13,13 +13,15 @@ class DownloadTaskParams { static final int TASK_TYPE_PATCH_FROM_PPK = 3; static final int TASK_TYPE_PLAIN_DOWNLOAD = 4; + static final int DEFAULT_MAX_AGE_DAYS = 3; + int type; String url; String hash; String originHash; // TASK_TYPE_CLEANUP only: entries younger than this survive; 0 = delete all - int maxAgeDays = 3; + int maxAgeDays = DEFAULT_MAX_AGE_DAYS; // Absolute System.nanoTime deadline for orchestrated cold-start downloads; // 0 keeps the normal public API's 10-minute per-call timeout. long deadlineNanos; diff --git a/android/src/main/java/cn/reactnative/modules/update/ErrorCodes.java b/android/src/main/java/cn/reactnative/modules/update/ErrorCodes.java index 74f70916..7459272e 100644 --- a/android/src/main/java/cn/reactnative/modules/update/ErrorCodes.java +++ b/android/src/main/java/cn/reactnative/modules/update/ErrorCodes.java @@ -25,4 +25,31 @@ final class ErrorCodes { private ErrorCodes() { } + + /** + * Rejection code of a failed download task, from DownloadTask's + * classification: post-download failures (unzip / hdiff / resource copy, + * incl. copiesCrc verification) are PATCH_FAILED so telemetry can + * separate patch health from network health; a local refusal + * (reinstalling the running version in place) is FILE_OPERATION_FAILED. + */ + static String forDownloadFailure(Throwable error) { + if (error instanceof PatchFailedException) { + return DELTA_FAILED; + } + if (error instanceof FileOperationException) { + return FILE_OPERATION_FAILED; + } + return DOWNLOAD_FAILED; + } + + /** + * Whether another mirror of the same artifact can change the outcome + * (mirrors update_flow_core::IsMirrorRetryableCode and the JS + * isMirrorRetryableCode). A patch failure cannot: the bytes arrived and + * every mirror serves the same artifact. + */ + static boolean isMirrorRetryable(String code) { + return !DELTA_FAILED.equals(code); + } } diff --git a/android/src/main/java/cn/reactnative/modules/update/FileOperationException.java b/android/src/main/java/cn/reactnative/modules/update/FileOperationException.java index c610a22f..af79ec92 100644 --- a/android/src/main/java/cn/reactnative/modules/update/FileOperationException.java +++ b/android/src/main/java/cn/reactnative/modules/update/FileOperationException.java @@ -6,7 +6,7 @@ * A download task refused by a local file-system rule rather than by the * network or the patch itself — today: reinstalling the version this process * is running from in place (DownloadTask.ensureNotReinstallingRunningVersion). - * UpdateModuleImpl rejects these with FILE_OPERATION_FAILED. + * UpdateModule rejects these with FILE_OPERATION_FAILED. */ class FileOperationException extends IOException { FileOperationException(String message) { diff --git a/android/src/main/java/cn/reactnative/modules/update/FlowBridge.java b/android/src/main/java/cn/reactnative/modules/update/FlowBridge.java index b04e8d5e..5871487e 100644 --- a/android/src/main/java/cn/reactnative/modules/update/FlowBridge.java +++ b/android/src/main/java/cn/reactnative/modules/update/FlowBridge.java @@ -7,19 +7,43 @@ * A null return means the input did not parse; callers skip the check round. */ final class FlowBridge { - static { - NativeCore.ensureLoaded(); - } - private FlowBridge() { } - static native String buildRequestBody(String inputJson); + // The cold-start round (cpp/update_flow_core/native_round.h), shared with + // iOS and Harmony. envJson describes this launch (see native_round.h). - static native String orderEndpointCandidates(String endpointsJson, double randomSample); + /** {"body": request text, "query": first query state}; null when not runnable. */ + static native String prepareRound(String configJson, String envJson, double randomSample); - static native boolean isValidResponse(String responseText); + /** + * The next endpoint-query state: "url" (+ "post") to request next, + * "found" when the text passed in is the response, neither when the + * query is over. responseText is null for a failed (or no) request. + */ + static native String nextQueryStep(String stateJson, String responseText); + /** The decision with planned attempts, hashInfo and the response-cache text. */ static native String handleResponse( - String responseText, String identityJson, String afterDownload); + String responseText, String configJson, String envJson, String requestBody, + long responseAtSeconds); + + // Persisted-config layer (cpp/update_flow_core/native_config.h), shared + // with iOS and Harmony. + + /** Contract C1: same config in another serialization. Null is never equivalent. */ + static native boolean configsEquivalent(String a, String b); + + /** + * The run gate: {"appKey": trimmed} when the config may run a round, + * otherwise {"status", "reason"}. Null config text = not configured. + */ + static native String runnableConfig(String configJson); + + /** + * Validates host configure() options (a JSON object) into the config to + * persist. Throws IllegalArgumentException("Invalid native configuration: + * ...") for options the host must fix. + */ + static native String normalizeConfiguration(String optionsJson); } diff --git a/android/src/main/java/cn/reactnative/modules/update/HttpClients.java b/android/src/main/java/cn/reactnative/modules/update/HttpClients.java index 0c935bcf..6db8e945 100644 --- a/android/src/main/java/cn/reactnative/modules/update/HttpClients.java +++ b/android/src/main/java/cn/reactnative/modules/update/HttpClients.java @@ -1,6 +1,9 @@ package cn.reactnative.modules.update; +import java.io.IOException; +import java.util.concurrent.TimeUnit; import okhttp3.OkHttpClient; +import okhttp3.Response; /** * The one OkHttpClient the SDK owns. Callers derive their timeout variants @@ -18,4 +21,43 @@ private static final class Holder { static OkHttpClient base() { return Holder.BASE; } + + /** + * {@code client} with its call timeout capped to the time left until the + * absolute System.nanoTime {@code deadlineNanos} (0: no deadline, the + * client as is), so an orchestrated request never outlives its budget. + * Throws when the deadline already passed. + */ + static OkHttpClient withDeadline(OkHttpClient client, long deadlineNanos) + throws IOException { + if (deadlineNanos <= 0) { + return client; + } + long remainingNanos = deadlineNanos - System.nanoTime(); + if (remainingNanos <= 0) { + throw new IOException("Request deadline expired before start"); + } + long remainingMillis = Math.max(1L, TimeUnit.NANOSECONDS.toMillis(remainingNanos)); + int callTimeoutMillis = client.callTimeoutMillis(); + if (callTimeoutMillis > 0 && callTimeoutMillis <= remainingMillis) { + return client; + } + return client.newBuilder() + .callTimeout(remainingMillis, TimeUnit.MILLISECONDS) + .build(); + } + + /** + * An https URL must stay on https through every redirect: TLS is what + * authenticates the package and the check response. OkHttp follows + * cross-scheme redirects by default, so the final request is checked + * here (the redirected bytes are discarded). + */ + static void rejectProtocolDowngrade(String requestedUrl, Response response) + throws IOException { + if (HttpUtils.isHttpsUrl(requestedUrl) && !response.request().url().isHttps()) { + throw new IOException( + "https request redirected to plaintext http: " + response.request().url()); + } + } } diff --git a/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java b/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java index f40a1732..1919eb5f 100644 --- a/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java +++ b/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java @@ -15,17 +15,6 @@ static boolean isHttpsUrl(String url) { return url != null && url.regionMatches(true, 0, "https://", 0, 8); } - /** Request path appended to an endpoint base, kept out of the plain string table. */ - static final String QUERY_PATH = Texts.reveal("7514fcd4ad805d55263e08fc99"); - - /** Strips every trailing slash so "" never doubles one. */ - static String normalizeEndpointBase(String base) { - while (base.endsWith("/")) { - base = base.substring(0, base.length() - 1); - } - return base; - } - // "bytes -/". Returns the total (0 when "*"), or -1 // when the header is missing/malformed, the start does not match the // local partial, or the range and total contradict each other (end diff --git a/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java b/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java index a1cdae09..2aa373d8 100644 --- a/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java +++ b/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java @@ -1,158 +1,101 @@ package cn.reactnative.modules.update; -import android.os.Build; -import android.util.Log; import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; import java.io.IOException; -import java.nio.charset.StandardCharsets; -import org.json.JSONException; -import org.json.JSONObject; /** - * Java mirror of cpp/patch_core/install_record.h — keep in sync by hand. * The completion record written as the last step of a two-phase install and - * read back before a version directory is trusted. + * read back before a version directory is trusted. The logic is the shared + * core's (cpp/patch_core/install_record.h, over JNI); only the paths are + * built here. Every call does file IO and checkActivation hashes the whole + * bundle: callers run on worker threads (download tasks, the state serial + * thread, the sync coordinator), except the launch-time reads of + * isInstalled / recordedBundleSha256, which only stat and read the record. */ final class InstallRecord { - static final int SCHEMA = 1; - static final String FILE_NAME = ".pushy-complete"; + // Mirrors install_record.h kStagingSuffix (scripts/check-constant-parity.js). static final String STAGING_SUFFIX = ".staging"; + /** The JS bundle inside a version directory. */ + static final String BUNDLE_FILE_NAME = "index.bundlejs"; private InstallRecord() { } + static File bundleFile(File versionDir) { + return new File(versionDir, BUNDLE_FILE_NAME); + } + static File stagingDirectoryFor(File versionDir) { return new File(versionDir.getPath() + STAGING_SUFFIX); } /** - * bundleSha256 is mandatory for schema 1: a version whose bundle could - * not be hashed must fail its install, never be recorded unverifiable. + * Launch / dedup presence check: the bundle exists and the record names + * this version (or is the legacy empty marker). No digest work. */ - static String build(String versionHash, String bundleSha256, String artifactSha256) - throws IOException, JSONException { - if (bundleSha256 == null || bundleSha256.isEmpty()) { - throw new IOException("Bundle version " + versionHash + " has no bundle digest."); - } - JSONObject record = new JSONObject(); - record.put("schema", SCHEMA); - record.put("versionHash", versionHash); - record.put("bundleSha256", bundleSha256); - if (artifactSha256 != null && !artifactSha256.isEmpty()) { - record.put("artifactSha256", artifactSha256); - } - return record.toString(); + static boolean isInstalled(File versionDir, String versionHash) { + return isComplete(versionDir.getPath(), versionHash, BUNDLE_FILE_NAME); } /** - * Writes the record and fsyncs it, then the directory holding it: the - * rename that follows must find the record on disk, and the new - * directory entry is only durable once the directory itself is synced. + * Activation check: the bundle exists and its bytes match the record's + * mandatory digest (the legacy empty marker has none). A directory + * without a record passes only when {@code grandfathered} (it is the + * persisted current or last version). Throws with the reason otherwise. */ - static void write(File versionDir, String recordJson) throws IOException { - File file = new File(versionDir, FILE_NAME); - try (FileOutputStream out = new FileOutputStream(file)) { - out.write(recordJson.getBytes(StandardCharsets.UTF_8)); - out.flush(); - out.getFD().sync(); - } - syncDirectory(versionDir); - } - - // java.nio.file (the only way to open a directory for fsync from Java) - // is API 26+ on Android; below that the file fsync above is all there - // is. Best-effort by design: a file system that refuses to open a - // directory for reading must not fail the install. Also called by the - // installer on the versions root after the staging -> version rename: - // that rename changes the parent's entries, which only the parent's own - // sync makes durable. - static void syncDirectory(File dir) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - return; - } - try (java.nio.channels.FileChannel channel = java.nio.channels.FileChannel.open( - dir.toPath(), java.nio.file.StandardOpenOption.READ)) { - channel.force(true); - } catch (Throwable e) { - Log.w("pushy", "Directory fsync skipped for " + dir + ": " + e); + static void verifyInstalled(File versionDir, String versionHash, boolean grandfathered) + throws IOException { + String reason = checkActivation( + versionDir.getPath(), versionHash, BUNDLE_FILE_NAME, grandfathered); + if (reason != null) { + throw new IOException(reason); } } /** - * Parsed record, or null when the file is absent or malformed. An empty - * file (legacy marker) yields an empty JSONObject. + * Writes the record (with the staged bundle's digest: knownBundleSha256 + * when the bundle was hashed while extracted, else hashed now) into the + * staging directory, atomically and durably. */ - static JSONObject read(File versionDir) { - File file = new File(versionDir, FILE_NAME); - if (!file.isFile()) { - return null; - } - if (file.length() == 0) { - return new JSONObject(); - } - if (file.length() > 64 * 1024) { - return null; - } - try (FileInputStream in = new FileInputStream(file)) { - byte[] bytes = new byte[(int) file.length()]; - int offset = 0; - while (offset < bytes.length) { - int read = in.read(bytes, offset, bytes.length - offset); - if (read < 0) { - break; - } - offset += read; - } - return new JSONObject(new String(bytes, 0, offset, StandardCharsets.UTF_8)); - } catch (IOException | JSONException e) { - return null; + static void seal( + File stagingDir, String versionHash, String artifactSha256, String knownBundleSha256 + ) throws IOException { + String reason = sealStaging(stagingDir.getPath(), versionHash, BUNDLE_FILE_NAME, + artifactSha256, knownBundleSha256); + if (reason != null) { + throw new IOException(reason); } } - /** - * Presence check: the record exists and, unless legacy-empty, names this - * version. No digest work — this runs on launch / dedup paths. - */ - static boolean isComplete(File versionDir, String versionHash) { - JSONObject record = read(versionDir); - if (record == null) { - return false; + /** Renames the sealed staging directory to the (absent) version directory. */ + static void promote(File stagingDir, File versionDir) throws IOException { + String reason = promoteStaging(stagingDir.getPath(), versionDir.getPath()); + if (reason != null) { + throw new IOException(reason); } - if (record.length() == 0) { - return true; - } - return record.optInt("schema", 0) == SCHEMA - && versionHash.equals(record.optString("versionHash", "")); } - /** - * Activation check: re-hashes the bundle against the record's digest, - * which schema 1 makes mandatory (only the legacy empty marker has none). - * Throws with the reason when the directory must not be activated. - */ - static void verifyForActivation(File versionDir, String versionHash, File bundleFile) - throws IOException { - JSONObject record = read(versionDir); - if (record == null) { - throw new IOException("Bundle version " + versionHash + " has no valid completion record."); - } - if (record.length() == 0) { - return; - } - if (record.optInt("schema", 0) != SCHEMA - || !versionHash.equals(record.optString("versionHash", ""))) { - throw new IOException("Bundle version " + versionHash + " completion record mismatch."); - } - Object digest = record.opt("bundleSha256"); - String expected = digest instanceof String ? (String) digest : ""; - if (expected.isEmpty()) { - throw new IOException("Bundle version " + versionHash + " completion record mismatch."); - } - String actual = UpdateFileUtils.sha256Hex(bundleFile); - if (!expected.equalsIgnoreCase(actual)) { - throw new IOException("Bundle version " + versionHash + " bundle digest mismatch."); - } + /** The record's bundleSha256 (crash attribution); "" when there is none. */ + static String recordedBundleSha256(File versionDir) { + String digest = bundleSha256Of(versionDir.getPath()); + return digest == null ? "" : digest; } + + private static native boolean isComplete( + String versionDir, String versionHash, String bundleFileName); + + // These return null on success, the reason otherwise. + private static native String checkActivation( + String versionDir, String versionHash, String bundleFileName, boolean grandfathered); + + private static native String sealStaging( + String stagingDir, + String versionHash, + String bundleFileName, + String artifactSha256, + String knownBundleSha256); + + private static native String promoteStaging(String stagingDir, String versionDir); + + private static native String bundleSha256Of(String versionDir); } diff --git a/android/src/main/java/cn/reactnative/modules/update/NativeCore.java b/android/src/main/java/cn/reactnative/modules/update/NativeCore.java index 2b7cf48f..749b0509 100644 --- a/android/src/main/java/cn/reactnative/modules/update/NativeCore.java +++ b/android/src/main/java/cn/reactnative/modules/update/NativeCore.java @@ -1,5 +1,14 @@ package cn.reactnative.modules.update; +/** + * Loads librnpushy.so. Its JNI_OnLoad registers the natives of every class + * (cpp/android_jni/jni_registration.cpp), so the library only has to be + * loaded before the first native call: UpdateContext's static initializer + * does that, and DownloadTask, FlowBridge, InstallRecord and + * supportedDiffVersion are only ever reached through an UpdateContext + * instance — except PushyRuntime.configure, which validates options before + * touching UpdateContext and so loads the library itself. + */ final class NativeCore { private static boolean loaded = false; @@ -30,7 +39,6 @@ static synchronized void ensureLoaded() { * 服务端按能力门控下发。 */ static int supportedDiffVersion() { - ensureLoaded(); return getSupportedDiffVersion(); } diff --git a/android/src/main/java/cn/reactnative/modules/update/PatchFailedException.java b/android/src/main/java/cn/reactnative/modules/update/PatchFailedException.java index 22f713a3..eb2acb06 100644 --- a/android/src/main/java/cn/reactnative/modules/update/PatchFailedException.java +++ b/android/src/main/java/cn/reactnative/modules/update/PatchFailedException.java @@ -5,7 +5,7 @@ /** * A download task failure that happened after the artifact was fully * downloaded — unzip, hdiff apply, or bundled-resource copy (including the - * copiesCrc content verification). UpdateModuleImpl rejects these with + * copiesCrc content verification). UpdateModule rejects these with * PATCH_FAILED instead of DOWNLOAD_FAILED so the JS layer and server-side * telemetry can separate patch health from network health. */ diff --git a/android/src/main/java/cn/reactnative/modules/update/PushyConfiguration.java b/android/src/main/java/cn/reactnative/modules/update/PushyConfiguration.java deleted file mode 100644 index e125a16d..00000000 --- a/android/src/main/java/cn/reactnative/modules/update/PushyConfiguration.java +++ /dev/null @@ -1,104 +0,0 @@ -package cn.reactnative.modules.update; - -import java.util.Arrays; -import java.util.HashSet; -import java.util.Iterator; -import java.util.Set; -import java.util.regex.Pattern; -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - -/** Internal normalizer for the JSONObject accepted by PushyRuntime.configure. */ -final class PushyConfiguration { - private static final Set KEYS = new HashSet<>(Arrays.asList( - "appKey", "endpoints", "queryUrls", "afterDownload", "disabled", - "packageVersion", "rnu", "rn")); - private static final Pattern URL = Pattern.compile( - "^https?://(\\[[0-9a-fA-F:]+\\]|[a-zA-Z0-9.-]+)(:[0-9]+)?([/?#]|$)"); - // Default service addresses, encoded by scripts/encode-native-text.ts. - private static final String[] ENDPOINTS = { - Texts.reveal("3203e0c1bdd1270a372f18f8c2b6de7f4f2607f5b3d5b9817b592947e5cdefbc8a7e"), - Texts.reveal("3203e0c1bdd1270a372f18f8c2b6de7f4f2607f5f0daac9c644a620ae88ca1ad93") - }; - private static final String[] QUERY_URLS = { - Texts.reveal("3203e0c1bdd1270a253608fcd3fd9362476817f4f0d5a1996342631be3c2a3a9d779552507fdcde8928a6f512f5ce2ccbdc869404d2f1de79daa826d562c0913eec4fa9b7d4426"), - Texts.reveal("3203e0c1bdd1270a213b12b7dca09468462e12f3b0d5bd813d482446f4c6a1be8e79552507fdcda68cd06e5c3710e480a4867048483e55e0c2ab8d7d43030d1ce9c3b183214e2601f2f0d5b782601e2719e8ca") - }; - - // afterDownload value that selects the bundle for the next launch, encoded. - static final String POLICY_NEXT_LAUNCH = Texts.reveal("2912e0ffab8e6c70323b1dedd3"); - - private PushyConfiguration() {} - - static String normalize(String json) throws JSONException { - JSONObject options = new JSONObject(json); - for (Iterator keys = options.keys(); keys.hasNext();) { - String key = keys.next(); - if (!KEYS.contains(key)) { - throw invalid("unknown option " + key); - } - } - String appKey = string(options.opt("appKey"), "appKey", false); - boolean customEndpoints = options.has("endpoints"); - JSONArray endpoints = urls(customEndpoints ? options.get("endpoints") - : new JSONArray(Arrays.asList(ENDPOINTS)), "endpoints", true); - JSONArray queryUrls = urls(options.has("queryUrls") ? options.get("queryUrls") - : new JSONArray(Arrays.asList(customEndpoints ? new String[0] : QUERY_URLS)), - "queryUrls", false); - String afterDownload = options.has("afterDownload") - ? string(options.get("afterDownload"), "afterDownload", false) : "none"; - if (!"none".equals(afterDownload) && !POLICY_NEXT_LAUNCH.equals(afterDownload)) { - throw invalid("afterDownload must be none or " + POLICY_NEXT_LAUNCH); - } - Object disabled = options.has("disabled") ? options.get("disabled") : Boolean.FALSE; - if (!(disabled instanceof Boolean)) { - throw invalid("disabled must be a boolean"); - } - JSONObject result = new JSONObject(); - result.put("appKey", appKey); - result.put("endpoints", endpoints); - result.put("queryUrls", queryUrls); - result.put("afterDownload", afterDownload); - result.put("disabled", disabled); - result.put("rnu", options.has("rnu") ? string(options.get("rnu"), "rnu", true) : ""); - result.put("rn", options.has("rn") ? string(options.get("rn"), "rn", true) : ""); - if (options.has("packageVersion")) { - result.put("packageVersion", string(options.get("packageVersion"), "packageVersion", false)); - } - return result.toString(); - } - - private static String string(Object value, String name, boolean allowEmpty) { - if (!(value instanceof String) || (!allowEmpty && ((String) value).trim().isEmpty())) { - throw invalid(name + " must be a string" + (allowEmpty ? "" : " and must not be blank")); - } - return (String) value; - } - - private static JSONArray urls(Object value, String name, boolean base) throws JSONException { - if (!(value instanceof JSONArray) || (base && ((JSONArray) value).length() == 0)) { - throw invalid(name + " must be " + (base ? "a non-empty" : "an") + " array"); - } - JSONArray values = (JSONArray) value; - JSONArray result = new JSONArray(); - Set seen = new HashSet<>(); - for (int i = 0; i < values.length(); i++) { - String url = string(values.get(i), name, false).trim(); - if (!URL.matcher(url).find() || url.matches("(?s).*\\s.*") || url.contains("\\") - || (base && (url.contains("?") || url.contains("#")))) { - throw invalid(name + " requires absolute HTTP(S) URLs without credentials" - + (base ? ", queries or fragments" : "")); - } - String normalized = base ? url.replaceAll("/+$", "") : url; - if (seen.add(normalized)) { - result.put(normalized); - } - } - return result; - } - - private static IllegalArgumentException invalid(String message) { - return new IllegalArgumentException("Invalid native configuration: " + message); - } -} diff --git a/android/src/main/java/cn/reactnative/modules/update/PushyRuntime.java b/android/src/main/java/cn/reactnative/modules/update/PushyRuntime.java index 38123909..7c791380 100644 --- a/android/src/main/java/cn/reactnative/modules/update/PushyRuntime.java +++ b/android/src/main/java/cn/reactnative/modules/update/PushyRuntime.java @@ -7,8 +7,6 @@ import androidx.annotation.Nullable; import org.json.JSONObject; import java.util.concurrent.Executor; -import java.util.concurrent.Executors; -import java.util.concurrent.ThreadFactory; /** Bridge-free native configuration and update APIs. */ public final class PushyRuntime { @@ -19,14 +17,7 @@ public interface Callback { // A single waiting worker, not one thread per caller. The actual round is // shared with cold start and crash rescue by SyncCoordinator. - private static final Executor WORKER = Executors.newSingleThreadExecutor(new ThreadFactory() { - @Override - public Thread newThread(Runnable runnable) { - Thread thread = new Thread(runnable, "pushy-host-sync"); - thread.setDaemon(true); - return thread; - } - }); + private static final Executor WORKER = Threads.single("pushy-host-sync", true); public interface ConfigurationCallback { /** Main thread; null means configuration was persisted successfully. */ @@ -34,14 +25,7 @@ public interface ConfigurationCallback { } // Configuration must not wait behind a network round that it invalidates. - private static final Executor CONFIG_WORKER = Executors.newSingleThreadExecutor(new ThreadFactory() { - @Override - public Thread newThread(Runnable runnable) { - Thread thread = new Thread(runnable, "pushy-host-config"); - thread.setDaemon(true); - return thread; - } - }); + private static final Executor CONFIG_WORKER = Threads.single("pushy-host-config", true); /** * Validate and persist a complete configuration, even before JS or bundle @@ -56,26 +40,22 @@ public static void configure(Context context, JSONObject options, final Configur final Context applicationContext = context.getApplicationContext(); // Snapshot caller-owned JSON before dispatch, not minutes later on a worker. final String snapshot = options.toString(); - CONFIG_WORKER.execute(new Runnable() { - @Override - public void run() { - Exception failure = null; - try { - String config = PushyConfiguration.normalize(snapshot); - UpdateContext.getInstance(applicationContext).setNativeConfig(config); - } catch (Exception e) { - failure = e; - } catch (LinkageError e) { - failure = new IllegalStateException("Native configuration failed", e); + CONFIG_WORKER.execute(() -> { + Exception failure = null; + try { + NativeCore.ensureLoaded(); + String config = FlowBridge.normalizeConfiguration(snapshot); + if (config == null) { + throw new IllegalStateException("Native configuration failed"); } - final Exception error = failure; - new Handler(Looper.getMainLooper()).post(new Runnable() { - @Override - public void run() { - callback.onComplete(error); - } - }); + UpdateContext.getInstance(applicationContext).setNativeConfig(config); + } catch (Exception e) { + failure = e; + } catch (LinkageError e) { + failure = new IllegalStateException("Native configuration failed", e); } + final Exception error = failure; + new Handler(Looper.getMainLooper()).post(() -> callback.onComplete(error)); }); } @@ -96,32 +76,24 @@ public static void prepareBundle(Context context, final Callback callback) { throw new IllegalArgumentException("context and callback are required"); } final Context applicationContext = context.getApplicationContext(); - WORKER.execute(new Runnable() { - @Override - public void run() { - BundlePreparationResult outcome; - try { - if (BuildConfig.DEBUG) { - outcome = BundlePreparationResult.of(BundlePreparationResult.SKIPPED, "debug"); - } else { - outcome = SyncCoordinator.prepareBundle( - UpdateContext.getInstance(applicationContext)); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - outcome = BundlePreparationResult.of(BundlePreparationResult.CANCELLED, "interrupted"); - } catch (Exception | LinkageError e) { - Log.w(Texts.LOG_TAG, "native host sync failed", e); - outcome = BundlePreparationResult.of(BundlePreparationResult.FAILED, "internal_error"); + WORKER.execute(() -> { + BundlePreparationResult outcome; + try { + if (BuildConfig.DEBUG) { + outcome = BundlePreparationResult.of(BundlePreparationResult.SKIPPED, "debug"); + } else { + outcome = SyncCoordinator.prepareBundle( + UpdateContext.getInstance(applicationContext)); } - final BundlePreparationResult result = outcome; - new Handler(Looper.getMainLooper()).post(new Runnable() { - @Override - public void run() { - callback.onComplete(result); - } - }); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + outcome = BundlePreparationResult.of(BundlePreparationResult.CANCELLED, "interrupted"); + } catch (Exception | LinkageError e) { + Log.w(Texts.LOG_TAG, "native host sync failed", e); + outcome = BundlePreparationResult.of(BundlePreparationResult.FAILED, "internal_error"); } + final BundlePreparationResult result = outcome; + new Handler(Looper.getMainLooper()).post(() -> callback.onComplete(result)); }); } } diff --git a/android/src/main/java/cn/reactnative/modules/update/ReactReloadManager.java b/android/src/main/java/cn/reactnative/modules/update/ReactReloadManager.java index fdabed1e..24369acb 100644 --- a/android/src/main/java/cn/reactnative/modules/update/ReactReloadManager.java +++ b/android/src/main/java/cn/reactnative/modules/update/ReactReloadManager.java @@ -19,22 +19,7 @@ private ReactReloadManager() { } /** - * First half of a restart, for the state serial thread: activate the - * requested version (bundle digest + commit) and resolve the bundle to - * load. Nothing here touches React, so nothing here needs the UI thread - * (CODE_AUDIT 2.2). Returns the bundle path, or null for the packaged - * bundle. - */ - @Nullable - static String prepareRestart(UpdateContext updateContext, @Nullable String hash) { - if (hash != null) { - updateContext.switchVersion(hash); - } - return updateContext.getBundleUrl(); - } - - /** - * Second half, for the UI thread: point the running React instance at + * For the UI thread: point the running React instance at * {@code updateBundlePath} and reload it. Throws when no supported * injection worked — the caller rejects RESTART_FAILED. There is * deliberately no Activity.recreate() fallback: it would reload the @@ -241,14 +226,14 @@ private static void reloadReactHost(Object reactHost, JSBundleLoader loader) thr reactHostDelegateField.setAccessible(true); Object reactHostDelegate = reactHostDelegateField.get(reactHost); - String bundleFieldName = "jsBundleLoader"; - if ("expo.modules.ExpoReactHostFactory.ExpoReactHostDelegate".equals( - reactHostDelegate.getClass().getCanonicalName() - )) { - bundleFieldName = "_jsBundleLoader"; + // DefaultReactHostDelegate stores the loader in jsBundleLoader; Expo's + // ExpoReactHostDelegate computes that property from _jsBundleLoader. + Field jsBundleLoaderField; + try { + jsBundleLoaderField = reactHostDelegate.getClass().getDeclaredField("jsBundleLoader"); + } catch (NoSuchFieldException e) { + jsBundleLoaderField = reactHostDelegate.getClass().getDeclaredField("_jsBundleLoader"); } - - Field jsBundleLoaderField = reactHostDelegate.getClass().getDeclaredField(bundleFieldName); jsBundleLoaderField.setAccessible(true); jsBundleLoaderField.set(reactHostDelegate, loader); diff --git a/android/src/main/java/cn/reactnative/modules/update/SafeZipFile.java b/android/src/main/java/cn/reactnative/modules/update/SafeZipFile.java index a6ff9906..ab04803a 100644 --- a/android/src/main/java/cn/reactnative/modules/update/SafeZipFile.java +++ b/android/src/main/java/cn/reactnative/modules/update/SafeZipFile.java @@ -7,6 +7,8 @@ import java.io.OutputStream; import java.security.DigestOutputStream; import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Collections; import java.util.Enumeration; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; @@ -18,73 +20,30 @@ public SafeZipFile(File file) throws IOException { super(file); } - private static final int BUFFER_SIZE = 64 * 1024; - - // Bytes written by unzipToFile across the archive: the sum of the central - // directory's declared sizes is checked up front (inspect), the actual + // Bytes written by unzipToFile across the archive: the central + // directory's declared sizes are checked up front (inspect), the actual // stream is re-checked here — headers can lie. private long totalWritten = 0; - static final class Inspection { - long entries; - long totalUncompressed; - } - /** - * Walks the central directory once, enforcing the archive_limits caps - * (entry count, per-entry size, total size, compression ratio) and the - * reserved-name / traversal rules, before a single byte is extracted. + * Walks the central directory once and hands it to the shared accounting + * (ArchiveLimits.checkEntries: entry count, per-entry and total size, + * compression ratios, reserved `.pushy-` names) before a single byte is + * extracted. Returns the total uncompressed size. */ - Inspection inspect() throws IOException { - Inspection result = new Inspection(); - Enumeration entries = entries(); - while (entries.hasMoreElements()) { - ZipEntry entry = entries.nextElement(); - result.entries++; - if (result.entries > ArchiveLimits.MAX_ENTRIES) { - throw new IOException( - "archive has too many entries (> " + ArchiveLimits.MAX_ENTRIES + ")"); - } - long size = entry.getSize(); - long compressed = entry.getCompressedSize(); - if (size > ArchiveLimits.MAX_ENTRY_BYTES) { - throw new IOException( - "archive entry too large: " + entry.getName() + " (" + size + " bytes)"); - } - if (size > 0) { - result.totalUncompressed += size; - if (result.totalUncompressed > ArchiveLimits.MAX_TOTAL_UNCOMPRESSED_BYTES) { - throw new IOException("archive expands beyond " - + ArchiveLimits.MAX_TOTAL_UNCOMPRESSED_BYTES + " bytes"); - } - if (compressed > 0 && size > ArchiveLimits.RATIO_CHECK_MIN_BYTES - && size / compressed > ArchiveLimits.MAX_COMPRESSION_RATIO) { - throw new IOException( - "archive entry compression ratio too high: " + entry.getName()); - } - } + long inspect(long archiveBytes) throws IOException { + ArrayList entries = Collections.list(entries()); + int count = entries.size(); + String[] names = new String[count]; + long[] sizes = new long[count]; + long[] compressedSizes = new long[count]; + for (int i = 0; i < count; i++) { + ZipEntry entry = entries.get(i); + names[i] = entry.getName(); + sizes[i] = entry.getSize(); + compressedSizes[i] = entry.getCompressedSize(); } - return result; - } - - // Files the SDK itself writes into a version directory to record its own - // decisions (the ".pushy-complete" install marker today). An archive - // must never be able to ship one: a package that unzips its own marker - // next to a bundle would be trusted as a finished install even when the - // patch step that follows fails. - static final String RESERVED_ENTRY_PREFIX = ".pushy-"; - - static boolean isReservedEntryName(String name) { - if (name == null) { - return false; - } - String normalized = name.replace('\\', '/'); - while (normalized.endsWith("/")) { - normalized = normalized.substring(0, normalized.length() - 1); - } - int slash = normalized.lastIndexOf('/'); - String baseName = slash >= 0 ? normalized.substring(slash + 1) : normalized; - return baseName.startsWith(RESERVED_ENTRY_PREFIX); + return ArchiveLimits.checkEntries(archiveBytes, names, sizes, compressedSizes); } @Override @@ -116,9 +75,6 @@ public ZipEntry nextElement() { if (null != name && (name.contains("../") || name.contains("..\\"))) { throw new SecurityException("illegal entry: " + name); } - if (isReservedEntryName(name)) { - throw new SecurityException("reserved entry: " + name); - } } return entry; } @@ -139,10 +95,6 @@ void unzipToPath(ZipEntry ze, File targetPath, MessageDigest digest) throws IOEx if (!canonicalPath.startsWith(targetPath.getCanonicalPath() + File.separator)) { throw new SecurityException("Illegal name: " + name); } - if (isReservedEntryName(name)) { - throw new SecurityException("reserved entry: " + name); - } - if (ze.isDirectory()) { target.mkdirs(); return; @@ -155,10 +107,7 @@ public void unzipToFile(ZipEntry ze, File target) throws IOException { } private void unzipToFile(ZipEntry ze, File target, MessageDigest digest) throws IOException { - File parent = target.getParentFile(); - if (parent != null && !parent.exists() && !parent.mkdirs() && !parent.exists()) { - throw new IOException("Failed to create parent dir for " + target); - } + UpdateFileUtils.ensureParentDirectory(target); // The declared size is the budget when known; otherwise the global // per-entry cap. Either way the stream may not exceed it. @@ -173,9 +122,9 @@ private void unzipToFile(ZipEntry ze, File target, MessageDigest digest) throws try (OutputStream output = digest == null ? new FileOutputStream(target) : new DigestOutputStream(new FileOutputStream(target), digest)) { - byte[] buffer = new byte[BUFFER_SIZE]; + byte[] buffer = new byte[UpdateFileUtils.BUFFER_SIZE]; int n; - while ((n = input.read(buffer, 0, BUFFER_SIZE)) >= 0) { + while ((n = input.read(buffer, 0, buffer.length)) >= 0) { written += n; totalWritten += n; if (written > budget) { diff --git a/android/src/main/java/cn/reactnative/modules/update/StateSerialRunner.java b/android/src/main/java/cn/reactnative/modules/update/StateSerialRunner.java index d00a91cb..9d0a7263 100644 --- a/android/src/main/java/cn/reactnative/modules/update/StateSerialRunner.java +++ b/android/src/main/java/cn/reactnative/modules/update/StateSerialRunner.java @@ -1,64 +1,47 @@ package cn.reactnative.modules.update; -import android.util.Log; -import androidx.annotation.Nullable; import com.facebook.react.bridge.Promise; - +import com.facebook.react.bridge.UiThreadUtil; import java.util.concurrent.Executor; -import java.util.concurrent.Executors; -import java.util.concurrent.ThreadFactory; /** - * Runs state-persistence operations (switchVersion / markSuccess / setUuid / - * setLocalHashInfo) on a dedicated single background thread. - * - * These operations only read/modify SharedPreferences via a synchronous - * commit(); they were previously dispatched to the UI thread purely to - * serialize them. markSuccess in particular runs on every cold start, so doing - * its blocking disk write on the main thread caused jank/ANR on low-end - * devices. A single-thread executor preserves the same serialization guarantee - * while keeping the disk I/O off the UI thread. + * Runs a module operation on a given executor and rejects its promise with + * the operation's error code when it throws; the operation resolves the + * promise itself. * - * Note: reload/restart operations must still run on the UI thread and therefore - * keep using {@link UiThreadRunner}. + * State-persistence operations (switchVersion / markSuccess / setUuid / + * setLocalHashInfo / reset / config) go to {@link #STATE_SERIAL}, a single + * background thread: they only read/modify SharedPreferences via a + * synchronous commit(), and were once dispatched to the UI thread purely to + * serialize them. markSuccess runs on every cold start, so its blocking disk + * write on the main thread caused jank/ANR on low-end devices; one worker + * keeps the serialization in submission order without that. Only the React + * reload itself still hops to {@link #UI_THREAD}. */ final class StateSerialRunner { interface Operation { void run() throws Throwable; } - // Single worker thread -> operations stay serialized in submission order, - // matching the previous UI-thread behavior. The thread is named so it is - // identifiable in thread dumps / ANR traces when diagnosing persistence. - private static final Executor EXECUTOR = Executors.newSingleThreadExecutor( - new ThreadFactory() { - @Override - public Thread newThread(Runnable r) { - return new Thread(r, "pushy-state-serial"); - } - }); + // Named so it is identifiable in thread dumps / ANR traces. + static final Executor STATE_SERIAL = Threads.single("pushy-state-serial", false); + static final Executor UI_THREAD = UiThreadUtil::runOnUiThread; private StateSerialRunner() { } static void run( - @Nullable final Promise promise, + Executor executor, + final Promise promise, final String errorCode, final String operationName, final Operation operation ) { - EXECUTOR.execute(new Runnable() { - @Override - public void run() { - try { - operation.run(); - } catch (Throwable error) { - if (promise != null) { - promise.reject(errorCode, operationName + " failed", error); - } else { - Log.e(Texts.LOG_TAG, operationName + " failed", error); - } - } + executor.execute(() -> { + try { + operation.run(); + } catch (Throwable error) { + promise.reject(errorCode, operationName + " failed", error); } }); } diff --git a/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java index e6ef1d99..4f0f5fd5 100644 --- a/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java +++ b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java @@ -6,10 +6,10 @@ import java.io.IOException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; -import java.util.HashSet; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; @@ -31,9 +31,8 @@ * version blacklisting. */ final class SyncCoordinator { - // Version-info flags read by JS (metadata.ts), stored encoded. + // Version-info flag read by JS (metadata.ts), stored encoded. private static final String INFO_CRASH_HOLD = Texts.reveal("3905f5c2a6b96d56212a19"); - private static final String INFO_FORCE_BOOT = Texts.reveal("3c18e6d2aba9674a360d19ead5a695"); static final String KEY_CONFIG = "nativeConfig"; // Raw response cache for the JS side to reuse (§10.3), scoped to the @@ -44,7 +43,6 @@ final class SyncCoordinator { // next launch means the previous process died mid-round (a crash rescue // was truncated): that launch resumes immediately instead of waiting 5s. static final String KEY_ROUND_INCOMPLETE = "nativeCheckIncomplete"; - private static final int MAX_QUERY_HTTP_ATTEMPTS = 8; private static final long DOWNLOAD_PHASE_TIMEOUT_SECONDS = 600; // The check response (and a remote queryUrls list) is a small JSON // document; anything bigger is a broken or hijacked endpoint. @@ -86,25 +84,25 @@ final class SyncCoordinator { /** Blocking only on the host API's worker; never call on the UI thread. */ static BundlePreparationResult prepareBundle(UpdateContext context) throws InterruptedException { - if (UpdateContext.DEBUG) { + if (BuildConfig.DEBUG) { return BundlePreparationResult.of(BundlePreparationResult.SKIPPED, "debug"); } if (!nativeReady || sContext != context || !context.getIsUsingBundleUrl()) { return BundlePreparationResult.of(BundlePreparationResult.SKIPPED, "not_initialized"); } - RunnableConfig parsed = parseRunnableConfig(context.getKv(KEY_CONFIG)); - if (parsed.rejection != null) { - return parsed.rejection; + String configJson = context.getKv(KEY_CONFIG); + BundlePreparationResult rejection = configRejection(configJson); + if (rejection != null) { + return rejection; } - String configJson = parsed.json; startRound(0); if (!roundStarted.get()) { return BundlePreparationResult.of(BundlePreparationResult.SKIPPED, "config_changed"); } roundDone.await(); if (roundConfigGeneration != UpdateContext.getNativeConfigGeneration() - || !ConfigEquivalence.equivalent(configJson, roundConfigJson) - || !ConfigEquivalence.equivalent(configJson, context.getKv(KEY_CONFIG))) { + || !FlowBridge.configsEquivalent(configJson, roundConfigJson) + || !FlowBridge.configsEquivalent(configJson, context.getKv(KEY_CONFIG))) { return BundlePreparationResult.of(BundlePreparationResult.CANCELLED, "config_changed"); } if (roundGeneration != UpdateContext.getResetGeneration()) { @@ -120,7 +118,7 @@ static void recordJsRound(String config) { /** * True when JS already obtained a valid response in this process for a - * config equivalent (ConfigEquivalence, the cross-platform contract) to + * config equivalent (FlowBridge.configsEquivalent, contract C1) to * the one the native round would use: the delayed round is then a * duplicate request. JS and a host may serialize the same config * differently, so exact string equality would almost never match. Only @@ -130,62 +128,38 @@ static void recordJsRound(String config) { private static boolean hasJsRound(UpdateContext context) { String jsConfig = sJsCompletedConfig; return jsConfig != null - && ConfigEquivalence.equivalent(jsConfig, context.getKv(KEY_CONFIG)); + && FlowBridge.configsEquivalent(jsConfig, context.getKv(KEY_CONFIG)); } /** - * The persisted config, parsed and checked once for every entry point - * (prepareBundle, the automatic round's gate, the round itself), so they - * cannot disagree on what is runnable. Exactly one of {config, rejection} - * is set. + * The shared gate (FlowBridge.runnableConfig, update_flow_core) every + * entry point (prepareBundle, the automatic round's gate, the round + * itself) applies to the persisted config, so they cannot disagree on + * what is runnable: null when a round may run, otherwise the result — + * absent is skipped/not_configured, a non-object failed/invalid_config, a + * truthy disabled skipped/disabled, a blank appKey failed/invalid_config. */ - static final class RunnableConfig { - @Nullable final String json; - @Nullable final JSONObject config; - // Trimmed, non-empty. - @Nullable final String appKey; - @Nullable final BundlePreparationResult rejection; - - private RunnableConfig(String json, JSONObject config, String appKey, - BundlePreparationResult rejection) { - this.json = json; - this.config = config; - this.appKey = appKey; - this.rejection = rejection; - } - - static RunnableConfig rejected(String status, String reason) { - return new RunnableConfig( - null, null, null, BundlePreparationResult.of(status, reason)); - } - } - - static RunnableConfig parseRunnableConfig(@Nullable String json) { - if (json == null || json.isEmpty()) { - return RunnableConfig.rejected(BundlePreparationResult.SKIPPED, "not_configured"); - } - JSONObject config; + @Nullable + static BundlePreparationResult configRejection(@Nullable String json) { try { - config = new JSONObject(json); + String result = FlowBridge.runnableConfig(json); + if (result == null) { + return BundlePreparationResult.of(BundlePreparationResult.FAILED, "internal_error"); + } + JSONObject verdict = new JSONObject(result); + return verdict.has("status") + ? BundlePreparationResult.of(verdict.getString("status"), verdict.getString("reason")) + : null; } catch (JSONException e) { - return RunnableConfig.rejected(BundlePreparationResult.FAILED, "invalid_config"); - } - if (config.optBoolean("disabled", false)) { - return RunnableConfig.rejected(BundlePreparationResult.SKIPPED, "disabled"); - } - Object appKey = config.opt("appKey"); - String trimmed = appKey instanceof String ? ((String) appKey).trim() : ""; - if (trimmed.isEmpty()) { - return RunnableConfig.rejected(BundlePreparationResult.FAILED, "invalid_config"); + return BundlePreparationResult.of(BundlePreparationResult.FAILED, "invalid_config"); } - return new RunnableConfig(json, config, trimmed, null); } private SyncCoordinator() { } static void schedule(final UpdateContext context, final String launchRolledBackVersion) { - if (UpdateContext.DEBUG) { + if (BuildConfig.DEBUG) { return; } if (!scheduled.compareAndSet(false, true)) { @@ -199,47 +173,35 @@ static void schedule(final UpdateContext context, final String launchRolledBackV if (context.getKv(KEY_CONFIG) != null) { CrashHold.install(); } - Thread thread = new Thread(new Runnable() { - @Override - public void run() { - try { - // Keep the check away from the cold-start critical path - // (§7 R5) — unless the previous process died mid-round, - // in which case every launch second counts (§11.4). - if (context.getKv(KEY_ROUND_INCOMPLETE) == null) { - Thread.sleep(5000); - } - if (hasJsRound(context)) { - // Not consuming the round: a later crash rescue may - // still need it. - Log.i(Texts.LOG_TAG, - "native sync skipped: JS already queried in this process"); - return; - } - startRound(0); - } catch (Throwable e) { - // The rescue path must never take the app down with it. - Log.w(Texts.LOG_TAG, "native sync failed: " + e); + Threads.startDaemon("pushy-native-sync", Thread.MIN_PRIORITY + 1, () -> { + try { + // Keep the check away from the cold-start critical path + // (§7 R5) — unless the previous process died mid-round, + // in which case every launch second counts (§11.4). + if (context.getKv(KEY_ROUND_INCOMPLETE) == null) { + Thread.sleep(5000); + } + if (hasJsRound(context)) { + // Not consuming the round: a later crash rescue may + // still need it. + Log.i(Texts.LOG_TAG, + "native sync skipped: JS already queried in this process"); + return; } + startRound(0); + } catch (Throwable e) { + // The rescue path must never take the app down with it. + Log.w(Texts.LOG_TAG, "native sync failed: " + e); } - }, "pushy-native-sync"); - thread.setPriority(Thread.MIN_PRIORITY + 1); - thread.setDaemon(true); - thread.start(); + }); } static boolean isRoundInFlight() { return roundStarted.get() && !roundCompleted; } - /** - * Runs the process's single round on the calling thread if nobody has - * started it yet. deadlineNanos > 0 (crash rescue) caps every HTTP call - * and download phase to the remaining budget. - */ private static boolean hasRunnableConfig(UpdateContext context) { - return context != null - && parseRunnableConfig(context.getKv(KEY_CONFIG)).rejection == null; + return context != null && configRejection(context.getKv(KEY_CONFIG)) == null; } static void onConfigured(UpdateContext context) { @@ -248,6 +210,11 @@ static void onConfigured(UpdateContext context) { } } + /** + * Runs the process's single round on the calling thread if nobody has + * started it yet. deadlineNanos > 0 (crash rescue) caps every HTTP call + * and download phase to the remaining budget. + */ private static void startRound(long deadlineNanos) { // An automatic check before first-run provisioning must not consume // the process's only round. Hosts may configure later in this launch. @@ -341,13 +308,11 @@ private static void runOnce( roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "check_failed"); String configJson = context.getKv(KEY_CONFIG); roundConfigJson = configJson; - RunnableConfig parsed = parseRunnableConfig(configJson); - if (parsed.rejection != null) { - roundResult = parsed.rejection; + BundlePreparationResult rejection = configRejection(configJson); + if (rejection != null) { + roundResult = rejection; return; } - JSONObject config = parsed.config; - String appKey = parsed.appKey; // Keep the existing interrupted-round breadcrumb and reset generation. try { context.setKv(KEY_ROUND_INCOMPLETE, "1"); @@ -355,8 +320,7 @@ private static void runOnce( } try { runConfiguredRound( - context, launchRolledBackVersion, deadlineNanos, - resetGeneration, configJson, config, appKey); + context, launchRolledBackVersion, deadlineNanos, resetGeneration, configJson); } finally { // A committed round already cleared the breadcrumb in its own // commit (UpdateContext.commitSyncResult); only rounds that ended @@ -375,64 +339,37 @@ private static void runConfiguredRound( String launchRolledBackVersion, long deadlineNanos, long resetGeneration, - String configJson, - JSONObject config, - String appKey + String configJson ) throws JSONException { - String packageVersion = config.optString( - "packageVersion", context.getPackageVersion()); - if (packageVersion.isEmpty()) { - packageVersion = context.getPackageVersion(); - } - String currentVersion = context.getCurrentVersion(); + // This launch as the shared core sees it (native_round.h). Absent + // values (null) are left out. + JSONObject env = new JSONObject(); + env.put("packageVersion", context.getPackageVersion()); + // The JS constant's form, so JS can reuse the response cache. + env.put("currentVersion", currentVersion == null ? JSONObject.NULL : currentVersion); + env.put("uuid", context.getKv("uuid")); // Snapshot captured on the launch path before getConstants consumes // the one-shot rollback marker. Reading SharedPreferences here, five // seconds later, would lose the guard and could forceBoot the version // that this very launch just rolled back. - String rolledBackVersion = launchRolledBackVersion; - String uuid = context.getKv("uuid"); - if (uuid == null) { - uuid = ""; - } - - JSONObject identity = new JSONObject(); - identity.put("packageVersion", packageVersion); - identity.put( - "currentVersion", - currentVersion == null ? JSONObject.NULL : currentVersion - ); - identity.put("uuid", uuid); - if (rolledBackVersion != null) { - identity.put("rolledBackVersion", rolledBackVersion); - } - - JSONObject cInfo = new JSONObject(); - cInfo.put("rnu", config.optString("rnu", "")); - cInfo.put("rn", config.optString("rn", "")); + env.put("rolledBackVersion", launchRolledBackVersion); + env.put("buildTime", context.getBuildTime()); // React Native's Platform.Version is the Android SDK integer; use the // same value so this request can be fingerprinted against the JS one. - cInfo.put("os", "android " + Build.VERSION.SDK_INT); - cInfo.put("uuid", uuid); + env.put("os", "android " + Build.VERSION.SDK_INT); + env.put("bundleHash", context.computeBundleHash()); + String envJson = env.toString(); - JSONObject input = new JSONObject(); - input.put("packageVersion", packageVersion); - input.put( - "currentVersion", - currentVersion == null ? JSONObject.NULL : currentVersion - ); - input.put("buildTime", context.getBuildTime()); - input.put("cInfo", cInfo); - input.put("supportedDiffVersion", NativeCore.supportedDiffVersion()); - input.put("bundleHash", context.computeBundleHash()); - - String body = FlowBridge.buildRequestBody(input.toString()); - if (body == null) { + String preparedJson = FlowBridge.prepareRound(configJson, envJson, Math.random()); + if (preparedJson == null) { roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "invalid_request"); return; } - - String responseText = runQueryRequest(config, appKey, body, deadlineNanos); + JSONObject prepared = new JSONObject(preparedJson); + String body = prepared.getString("body"); + String responseText = + runQueryRequest(prepared.getJSONObject("query").toString(), body, deadlineNanos); if (responseText == null) { Log.i(Texts.LOG_TAG, "native sync: no endpoint reachable, giving up until next launch"); @@ -440,19 +377,18 @@ private static void runConfiguredRound( } // Cache freshness is anchored to when the server response arrived, // not to when a potentially long download/patch/activation finished. - final long responseAtSeconds = System.currentTimeMillis() / 1000; - String decisionJson = FlowBridge.handleResponse( - responseText, identity.toString(), config.optString("afterDownload", "")); + responseText, configJson, envJson, body, System.currentTimeMillis() / 1000); if (decisionJson == null) { roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "invalid_response"); return; } JSONObject decision = new JSONObject(decisionJson); + // Built once; every commit below caches it for JS. + final String responseCacheJson = decision.getString("cache"); if (!"download".equals(decision.optString("action"))) { boolean committed = context.commitSyncResult( - resetGeneration, null, null, false, - buildResponseCacheJson(configJson, body, responseText, responseAtSeconds)); + resetGeneration, null, null, false, responseCacheJson); roundResult = committed ? BundlePreparationResult.of(BundlePreparationResult.NO_UPDATE, decision.optString("reason")) : BundlePreparationResult.of(BundlePreparationResult.CANCELLED, "reset"); @@ -469,47 +405,29 @@ private static void runConfiguredRound( boolean downloaded = context.hasCompletedVersion(hash); if (!downloaded) { downloaded = performAttempts( - context, decision.optJSONArray("attempts"), hash, currentVersion, - deadlineNanos); + decision.optJSONArray("attempts"), deadlineNanos, + downloadStarter(context, hash, currentVersion)); } if (!downloaded) { // The native attempt has finished, so JS may safely reuse the // response and retry through its own strategy chain. boolean committed = context.commitSyncResult( - resetGeneration, null, null, false, - buildResponseCacheJson(configJson, body, responseText, responseAtSeconds)); + resetGeneration, null, null, false, responseCacheJson); roundResult = BundlePreparationResult.of( committed ? BundlePreparationResult.FAILED : BundlePreparationResult.CANCELLED, committed ? "download_failed" : "reset"); return; } - // Version info (mirroring the JS side's setLocalHashInfo), the - // activation and the response cache all land in one atomic commit — - // see UpdateContext.commitSyncResult. - String hashInfoJson = null; - JSONObject info = decision.optJSONObject("info"); - if (info != null) { - JSONObject hashInfo = new JSONObject(); - for (String key : new String[] {"name", "description", "metaInfo"}) { - Object value = info.opt(key); - if (value instanceof String) { - hashInfo.put(key, value); - } - } - // A forceBoot activation is the brick-rescue path: mark it in the - // persisted info so JS can report force_boot_rescue when this - // version survives to markSuccess. Only the server-sent directive - // counts — a silent-strategy activation is ordinary delivery. - JSONObject infoConfig = info.optJSONObject("config"); - if (infoConfig != null && infoConfig.optBoolean("forceBoot", false)) { - hashInfo.put(INFO_FORCE_BOOT, true); - } - if (crashHoldActive) { - hashInfo.put(INFO_CRASH_HOLD, true); - } - hashInfoJson = hashInfo.toString(); + // Version info (mirroring the JS side's setLocalHashInfo, the + // forceBoot rescue marked by the core), the activation and the + // response cache all land in one atomic commit — see + // UpdateContext.commitSyncResult. + JSONObject hashInfo = decision.getJSONObject("hashInfo"); + if (crashHoldActive) { + hashInfo.put(INFO_CRASH_HOLD, true); } + String hashInfoJson = hashInfo.toString(); // Silent strategies or a server-marked forceBoot version (per-version // remote override — the brick rescue) activate for the next launch; // otherwise activation stays with the JS side. Unless a crash is @@ -523,7 +441,7 @@ private static void runConfiguredRound( hash, hashInfoJson, activate, - buildResponseCacheJson(configJson, body, responseText, responseAtSeconds)); + responseCacheJson); } catch (Exception e) { Log.w(Texts.LOG_TAG, "native sync: commit failed: " + e); roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "commit_failed"); @@ -548,20 +466,6 @@ private static void runConfiguredRound( : BundlePreparationResult.of(BundlePreparationResult.CANCELLED, "reset"); } - private static String buildResponseCacheJson( - String configJson, - String requestBody, - String responseText, - long responseAtSeconds - ) throws JSONException { - JSONObject cacheEntry = new JSONObject(); - cacheEntry.put("ts", responseAtSeconds); - cacheEntry.put("body", responseText); - cacheEntry.put("request", requestBody); - cacheEntry.put("config", configJson); - return cacheEntry.toString(); - } - private static final OkHttpClient httpClient = HttpClients.base().newBuilder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) @@ -576,25 +480,13 @@ private static String httpRequest(String url, String postBody, long deadlineNano builder.post(RequestBody.create( postBody, MediaType.parse("application/json; charset=utf-8"))); } - OkHttpClient client = httpClient; - if (deadlineNanos > 0) { - // Crash-rescue budget: never let a single request outlive the - // handler's hold window. - long remainingMillis = TimeUnit.NANOSECONDS.toMillis( - deadlineNanos - System.nanoTime()); - if (remainingMillis <= 0) { - return null; - } - if (remainingMillis < 15000) { - client = httpClient.newBuilder() - .callTimeout(remainingMillis, TimeUnit.MILLISECONDS) - .build(); - } - } + // Crash-rescue budget: never let a single request outlive the + // handler's hold window. + OkHttpClient client = HttpClients.withDeadline(httpClient, deadlineNanos); try (Response response = client.newCall(builder.build()).execute()) { // Same rule as the artifact download: an https endpoint that // redirects to plaintext http is a failed endpoint. - DownloadTask.rejectProtocolDowngrade(url, response); + HttpClients.rejectProtocolDowngrade(url, response); ResponseBody body = response.body(); if (!response.isSuccessful() || body == null) { return null; @@ -632,89 +524,32 @@ private static String readBoundedBody(ResponseBody body) throws IOException { return source.readString(charset == null ? StandardCharsets.UTF_8 : charset); } - // Shared schema rule (update_flow_core::IsValidResponse): a 200 with - // `{"error": ...}` is a failed endpoint, not a verdict, and must not stop - // the endpoint fallback. - private static boolean isValidResponse(String responseText) { - return responseText != null && FlowBridge.isValidResponse(responseText); - } - /** - * Sequential fallback over the ordered candidates (§5.1): one request at - * a time with its own timeout; after the configured round fails, - * queryUrls discovery merges remote candidates (excluding the - * already-tried) for one more round. No hedged race on purpose — this + * Sequential fallback over the endpoints (§5.1), one request at a time + * with its own timeout, as the shared core walks it + * (FlowBridge.nextQueryStep: order, list discovery, https-only remote + * bases, budget, response validity). No hedged race on purpose — this * path is latency-insensitive. */ - private static String runQueryRequest( - JSONObject config, String appKey, String body, long deadlineNanos - ) { - JSONArray endpoints = config.optJSONArray("endpoints"); - String orderedJson = FlowBridge.orderEndpointCandidates( - endpoints == null ? "[]" : endpoints.toString(), Math.random()); - JSONArray ordered; - try { - ordered = orderedJson == null ? new JSONArray() : new JSONArray(orderedJson); - } catch (JSONException e) { - return null; - } - HashSet tried = new HashSet<>(); - int httpAttempts = 0; - for (int i = 0; i < ordered.length(); i++) { - String base = HttpUtils.normalizeEndpointBase(ordered.optString(i, "")); - if (base.isEmpty() || !tried.add(base)) { - continue; - } - if (httpAttempts++ >= MAX_QUERY_HTTP_ATTEMPTS) { + @Nullable + private static String runQueryRequest(String state, String body, long deadlineNanos) + throws JSONException { + String text = null; + while (true) { + state = FlowBridge.nextQueryStep(state, text); + if (state == null) { return null; } - String response = httpRequest( - base + HttpUtils.QUERY_PATH + appKey, body, deadlineNanos); - if (isValidResponse(response)) { - return response; + JSONObject step = new JSONObject(state); + if (step.optBoolean("found")) { + return text; } - } - JSONArray queryUrls = config.optJSONArray("queryUrls"); - if (queryUrls == null) { - return null; - } - for (int i = 0; i < queryUrls.length(); i++) { - String listUrl = queryUrls.optString(i, ""); - if (listUrl.isEmpty()) { - continue; - } - if (httpAttempts++ >= MAX_QUERY_HTTP_ATTEMPTS) { + String url = step.optString("url", ""); + if (url.isEmpty()) { return null; } - String listText = httpRequest(listUrl, null, deadlineNanos); - if (listText == null) { - continue; - } - JSONArray remote; - try { - remote = new JSONArray(listText); - } catch (JSONException e) { - continue; - } - for (int j = 0; j < remote.length(); j++) { - String base = HttpUtils.normalizeEndpointBase(remote.optString(j, "")); - if (base.isEmpty() || tried.contains(base)) { - continue; - } - if (httpAttempts++ >= MAX_QUERY_HTTP_ATTEMPTS) { - return null; - } - tried.add(base); - String response = httpRequest( - base + HttpUtils.QUERY_PATH + appKey, body, deadlineNanos); - if (isValidResponse(response)) { - return response; - } - } - // One successfully fetched remote list is enough. - break; + text = httpRequest(url, step.optBoolean("post") ? body : null, deadlineNanos); } - return null; } private static long capToHoldBudget(long phaseDeadlineNanos, long holdDeadlineNanos) { @@ -724,9 +559,35 @@ private static long capToHoldBudget(long phaseDeadlineNanos, long holdDeadlineNa return Math.min(phaseDeadlineNanos, holdDeadlineNanos); } - private static boolean performAttempts( - UpdateContext context, JSONArray attempts, String hash, String originHash, - long holdDeadlineNanos + /** Queues one download attempt; null when it was rejected up front (the listener was told). */ + interface DownloadStarter { + @Nullable + DownloadTaskParams start( + String type, String url, UpdateContext.DownloadFileListener listener, long deadlineNanos); + } + + private static DownloadStarter downloadStarter( + final UpdateContext context, final String hash, final String originHash + ) { + return (type, url, listener, deadlineNanos) -> { + if ("diff".equals(type)) { + return context.downloadPatchFromPpk(url, hash, originHash, listener, deadlineNanos); + } + if ("pdiff".equals(type)) { + return context.downloadPatchFromApk(url, hash, listener, deadlineNanos); + } + return context.downloadFullUpdate(url, hash, listener, deadlineNanos); + }; + } + + /** + * Strategies in the core's order (diff, pdiff, full), each over its + * mirrors, already planned for this device (FlowBridge.handleResponse: + * no diff without a running version, no cleartext URL under an + * https-only config). + */ + static boolean performAttempts( + @Nullable JSONArray attempts, long holdDeadlineNanos, DownloadStarter starter ) { if (attempts == null) { return false; @@ -741,10 +602,6 @@ private static boolean performAttempts( continue; } String type = attempt.optString("type"); - if ("diff".equals(type) && (originHash == null || originHash.isEmpty())) { - // diff patches from the running version; none is running. - continue; - } final boolean isFullAttempt = !"diff".equals(type) && !"pdiff".equals(type); if (isFullAttempt && fullDeadlineNanos == 0) { // Incremental failures must not consume the last-resort full @@ -762,9 +619,6 @@ private static boolean performAttempts( } for (int j = 0; j < urls.length(); j++) { String url = urls.optString(j, ""); - if (url.isEmpty()) { - continue; - } // Check before enqueueing: once the phase budget is gone we // must not launch an orphan download that outlives the round. long remainingNanos = deadlineNanos - System.nanoTime(); @@ -776,6 +630,7 @@ private static boolean performAttempts( } final CountDownLatch latch = new CountDownLatch(1); final AtomicBoolean succeeded = new AtomicBoolean(false); + final AtomicReference failure = new AtomicReference<>(); final String attemptType = type; UpdateContext.DownloadFileListener listener = new UpdateContext.DownloadFileListener() { @@ -789,20 +644,11 @@ public void onDownloadCompleted(DownloadTaskParams params) { public void onDownloadFailed(Throwable error) { Log.i(Texts.LOG_TAG, "native sync: " + attemptType + " attempt failed: " + error); + failure.set(error); latch.countDown(); } }; - DownloadTaskParams task; - if ("diff".equals(type)) { - task = context.downloadPatchFromPpk( - url, hash, originHash, listener, deadlineNanos); - } else if ("pdiff".equals(type)) { - task = context.downloadPatchFromApk( - url, hash, listener, deadlineNanos); - } else { - task = context.downloadFullUpdate( - url, hash, listener, deadlineNanos); - } + DownloadTaskParams task = starter.start(type, url, listener, deadlineNanos); try { if (!latch.await(remainingNanos, TimeUnit.NANOSECONDS)) { Log.w(Texts.LOG_TAG, @@ -829,6 +675,14 @@ public void onDownloadFailed(Throwable error) { if (succeeded.get()) { return true; } + Throwable error = failure.get(); + if (error != null + && !ErrorCodes.isMirrorRetryable(ErrorCodes.forDownloadFailure(error))) { + // The bytes arrived but could not be applied: every + // mirror serves the same artifact, so move on to the + // next strategy instead of downloading it again. + break; + } } } return false; diff --git a/android/src/main/java/cn/reactnative/modules/update/Threads.java b/android/src/main/java/cn/reactnative/modules/update/Threads.java new file mode 100644 index 00000000..dfe615b9 --- /dev/null +++ b/android/src/main/java/cn/reactnative/modules/update/Threads.java @@ -0,0 +1,35 @@ +package cn.reactnative.modules.update; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** The SDK's named worker threads, so they are identifiable in thread dumps / ANR traces. */ +final class Threads { + private Threads() { + } + + /** A single-thread executor: tasks run serialized in submission order. */ + static ExecutorService single(final String name, final boolean daemon) { + return Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, name); + thread.setDaemon(daemon); + return thread; + }); + } + + /** Starts a one-off daemon thread: it never keeps a dying process alive. */ + static Thread startDaemon(String name, Runnable task) { + return startDaemon(name, -1, task); + } + + /** As above, with an explicit priority (negative keeps the inherited one). */ + static Thread startDaemon(String name, int priority, Runnable task) { + Thread thread = new Thread(task, name); + if (priority >= 0) { + thread.setPriority(priority); + } + thread.setDaemon(true); + thread.start(); + return thread; + } +} diff --git a/android/src/main/java/cn/reactnative/modules/update/UiThreadRunner.java b/android/src/main/java/cn/reactnative/modules/update/UiThreadRunner.java deleted file mode 100644 index 7526296d..00000000 --- a/android/src/main/java/cn/reactnative/modules/update/UiThreadRunner.java +++ /dev/null @@ -1,37 +0,0 @@ -package cn.reactnative.modules.update; - -import android.util.Log; -import androidx.annotation.Nullable; -import com.facebook.react.bridge.Promise; -import com.facebook.react.bridge.UiThreadUtil; - -final class UiThreadRunner { - interface Operation { - void run() throws Throwable; - } - - private UiThreadRunner() { - } - - static void run( - @Nullable final Promise promise, - final String errorCode, - final String operationName, - final Operation operation - ) { - UiThreadUtil.runOnUiThread(new Runnable() { - @Override - public void run() { - try { - operation.run(); - } catch (Throwable error) { - if (promise != null) { - promise.reject(errorCode, operationName + " failed", error); - } else { - Log.e(Texts.LOG_TAG, operationName + " failed", error); - } - } - } - }); - } -} diff --git a/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java b/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java index 50bbd2e2..e6b2812c 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java @@ -8,21 +8,17 @@ import androidx.annotation.Nullable; import com.facebook.react.ReactInstanceManager; import java.io.File; -import org.json.JSONObject; import java.io.IOException; import java.io.InputStream; -import java.security.MessageDigest; import java.util.concurrent.Executor; -import java.util.concurrent.Executors; -import java.util.concurrent.ThreadFactory; public class UpdateContext { + // The one place that loads librnpushy.so (see NativeCore). static { NativeCore.ensureLoaded(); } static final String TAG = Texts.LOG_TAG; - static final boolean DEBUG = BuildConfig.DEBUG; private final Context context; private final File rootDir; @@ -68,7 +64,6 @@ public class UpdateContext { private static final int STATE_OP_CLEAR_ROLLBACK_MARK = 5; private static final int STATE_OP_RESOLVE_LAUNCH = 6; private static final String KEY_FIRST_LOAD_MARKED = "firstLoadMarked"; - static final String VERSION_COMPLETE_FILE = InstallRecord.FILE_NAME; // Bumped by resetToPackagedBundle. The cold-start check runs for minutes // and may already hold a decision when the app resets to the packaged // bundle; the orchestrator samples this counter and abandons activation @@ -113,12 +108,7 @@ private static native StateCoreResult runStateCore( private UpdateContext(Context context) { this.context = context.getApplicationContext(); - this.executor = Executors.newSingleThreadExecutor(new ThreadFactory() { - @Override - public Thread newThread(Runnable r) { - return new Thread(r, "pushy-download"); - } - }); + this.executor = Threads.single("pushy-download", false); this.rootDir = new File(this.context.getFilesDir(), Texts.reveal("0502e4d5af9f6d")); @@ -142,7 +132,7 @@ public Thread newThread(Runnable r) { if (nextState.changed) { // Execute cleanUp before clearing SharedPreferences to avoid race condition - this.cleanUp(); + cleanUp(DownloadTaskParams.DEFAULT_MAX_AGE_DAYS); SharedPreferences.Editor editor = this.sp.edit(); editor.clear(); applyState(editor, nextState); @@ -253,26 +243,21 @@ String computeBundleHash() { } private void startBundleHashComputation() { - if (DEBUG) { + if (BuildConfig.DEBUG) { // Metro serves the bundle in debug; mirror the dev buildTime behaviour. publishBundleHash(""); return; } - Thread thread = new Thread(new Runnable() { - @Override - public void run() { - String hash = ""; - try { - hash = loadOrHashBundledAsset(); - } catch (Throwable e) { - Log.w(TAG, "bundle hash failed: " + e); - } finally { - publishBundleHash(hash); - } + Threads.startDaemon("pushy-bundle-hash", () -> { + String hash = ""; + try { + hash = loadOrHashBundledAsset(); + } catch (Throwable e) { + Log.w(TAG, "bundle hash failed: " + e); + } finally { + publishBundleHash(hash); } - }, "pushy-bundle-hash"); - thread.setDaemon(true); - thread.start(); + }); } private void publishBundleHash(String hash) { @@ -315,13 +300,7 @@ private long getPackageLastUpdateTime() { private String sha256OfBundledAsset(String assetName) { try (InputStream in = context.getAssets().open(assetName)) { - MessageDigest md = UpdateFileUtils.newSha256(); - byte[] buffer = new byte[64 * 1024]; - int read; - while ((read = in.read(buffer)) != -1) { - md.update(buffer, 0, read); - } - return UpdateFileUtils.toHex(md.digest()); + return UpdateFileUtils.sha256Hex(in); } catch (IOException e) { // Expected when there is no embedded bundle (custom name, no // release bundle); "unknown" is a valid answer, not an error. @@ -347,16 +326,30 @@ public interface DownloadFileListener { void onDownloadFailed(Throwable error); } - public void downloadFullUpdate(String url, String hash, DownloadFileListener listener) { - downloadFullUpdate(url, hash, listener, 0); + /** + * Params shared by every download task. deadlineNanos is an absolute + * System.nanoTime deadline for orchestrated (native cold-start) tasks; 0 + * keeps the per-call timeout of a JS-initiated download. + */ + private static DownloadTaskParams downloadParams( + int type, String url, String hash, DownloadFileListener listener, long deadlineNanos + ) { + DownloadTaskParams params = new DownloadTaskParams(); + params.type = type; + params.url = url; + params.hash = hash; + params.listener = listener; + params.deadlineNanos = deadlineNanos; + return params; } - /** - * The deadline-taking overloads (native cold-start check) return the - * queued task's params so the orchestrator can cancel the task when its - * phase budget expires (CODE_AUDIT 2.12); null when the request was - * rejected up front (the listener has already been told). + /* + * The patch downloads return the queued task's params so the native + * orchestrator can cancel the task when its phase budget expires + * (CODE_AUDIT 2.12); null when the request was rejected up front (the + * listener has already been told). */ + @Nullable DownloadTaskParams downloadFullUpdate( String url, String hash, DownloadFileListener listener, long deadlineNanos @@ -364,37 +357,25 @@ DownloadTaskParams downloadFullUpdate( if (rejectUnsafeComponent(hash, listener)) { return null; } - DownloadTaskParams params = new DownloadTaskParams(); - params.type = DownloadTaskParams.TASK_TYPE_PATCH_FULL; - params.url = url; - params.hash = hash; - params.listener = listener; - params.deadlineNanos = deadlineNanos; + DownloadTaskParams params = downloadParams( + DownloadTaskParams.TASK_TYPE_PATCH_FULL, url, hash, listener, deadlineNanos); params.targetFile = new File(rootDir, hash + ".ppk"); params.unzipDirectory = new File(rootDir, hash); enqueue(params); return params; } + /** A plain file download into the root dir; hash only keys progress events. */ public void downloadFile(String url, String hash, String fileName, DownloadFileListener listener) { if (rejectUnsafeComponent(fileName, listener)) { return; } - DownloadTaskParams params = new DownloadTaskParams(); - params.type = DownloadTaskParams.TASK_TYPE_PLAIN_DOWNLOAD; - params.url = url; - params.hash = hash; - params.listener = listener; - + DownloadTaskParams params = downloadParams( + DownloadTaskParams.TASK_TYPE_PLAIN_DOWNLOAD, url, hash, listener, 0); params.targetFile = new File(rootDir, fileName); -// params.unzipDirectory = new File(rootDir, hash); enqueue(params); } - public void downloadPatchFromApk(String url, String hash, DownloadFileListener listener) { - downloadPatchFromApk(url, hash, listener, 0); - } - @Nullable DownloadTaskParams downloadPatchFromApk( String url, String hash, DownloadFileListener listener, long deadlineNanos @@ -402,22 +383,14 @@ DownloadTaskParams downloadPatchFromApk( if (rejectUnsafeComponent(hash, listener)) { return null; } - DownloadTaskParams params = new DownloadTaskParams(); - params.type = DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK; - params.url = url; - params.hash = hash; - params.listener = listener; - params.deadlineNanos = deadlineNanos; + DownloadTaskParams params = downloadParams( + DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK, url, hash, listener, deadlineNanos); params.targetFile = new File(rootDir, hash + Texts.reveal("7416e4dae09b69512137")); params.unzipDirectory = new File(rootDir, hash); enqueue(params); return params; } - public void downloadPatchFromPpk(String url, String hash, String originHash, DownloadFileListener listener) { - downloadPatchFromPpk(url, hash, originHash, listener, 0); - } - @Nullable DownloadTaskParams downloadPatchFromPpk( String url, @@ -429,13 +402,9 @@ DownloadTaskParams downloadPatchFromPpk( if (rejectUnsafeComponent(hash, listener) || rejectUnsafeComponent(originHash, listener)) { return null; } - DownloadTaskParams params = new DownloadTaskParams(); - params.type = DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK; - params.url = url; - params.hash = hash; + DownloadTaskParams params = downloadParams( + DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK, url, hash, listener, deadlineNanos); params.originHash = originHash; - params.listener = listener; - params.deadlineNanos = deadlineNanos; params.targetFile = new File(rootDir, originHash + "-" + hash + Texts.reveal("7407e4dae09b69512137")); params.unzipDirectory = new File(rootDir, hash); params.originDirectory = new File(rootDir, originHash); @@ -492,16 +461,6 @@ private boolean persistEditor(SharedPreferences.Editor editor, String reason) { return true; } - /** - * Asynchronous variant for the getConstants path (JS thread): the - * in-memory state changes immediately, the disk write happens on the - * SharedPreferences writer. Losing it to a crash only re-reports the - * one-shot markers on the next launch. - */ - private static void applyEditor(SharedPreferences.Editor editor) { - editor.apply(); - } - /** * Persistence for operations whose result is promised to a caller * (switchVersion / markSuccess / setLocalHashInfo / the cold-start round): @@ -525,29 +484,16 @@ private void verifySwitchTarget(String hash) { if (!UpdateFileUtils.isSafePathComponent(hash)) { throw new IllegalArgumentException("Invalid hash: " + hash); } - File versionDir = new File(rootDir, hash); - File bundleFile = new File(versionDir, "index.bundlejs"); - if (!bundleFile.isFile()) { - throw new IllegalStateException("Bundle version " + hash + " not found."); - } StateCoreResult currentState = getStateSnapshot(); - boolean isLegacyActivatedVersion = hash.equals(currentState.currentVersion) + // Versions activated before completion records existed are + // grandfathered through current/last state, but only while their + // directory has no record; any other directory must carry a record + // whose bundle digest matches the bytes on disk before the next + // launch is pointed at them. + boolean grandfathered = hash.equals(currentState.currentVersion) || hash.equals(currentState.lastVersion); - if (isLegacyActivatedVersion && !new File(versionDir, InstallRecord.FILE_NAME).exists()) { - // Versions activated before completion markers were introduced are - // explicitly grandfathered through current/last state. A directory - // that does carry a record is verified like any other. - return; - } - if (!InstallRecord.isComplete(versionDir, hash)) { - // An arbitrary markerless directory may be a crash-left partial - // install. - throw new IllegalStateException("Bundle version " + hash + " is incomplete."); - } - // The record's bundle digest must match the bytes on disk before - // the next launch is pointed at them. try { - InstallRecord.verifyForActivation(versionDir, hash, bundleFile); + InstallRecord.verifyInstalled(new File(rootDir, hash), hash, grandfathered); } catch (IOException e) { throw new IllegalStateException(e.getMessage(), e); } @@ -559,13 +505,12 @@ private void verifySwitchTarget(String hash) { * holds commitLock and has run verifySwitchTarget. */ private StateCoreResult computeSwitchState(String hash) { - return runStateCore( - STATE_OP_SWITCH_VERSION, - getStateSnapshot(), - hash, - false, - false - ); + return runStateOp(STATE_OP_SWITCH_VERSION, hash); + } + + /** One state-core operation on the current snapshot; caller holds commitLock. */ + private StateCoreResult runStateOp(int operation, @Nullable String hash) { + return runStateCore(operation, getStateSnapshot(), hash, false, false); } public void switchVersion(String hash) { @@ -631,21 +576,18 @@ LaunchMarkers consumeLaunchMarkers() { editor.remove(KEY_FIRST_LOAD_MARKED); } if (rolledBackVersion != null) { - StateCoreResult nextState = runStateCore( - STATE_OP_CLEAR_ROLLBACK_MARK, - getStateSnapshot(), - null, - false, - false - ); - applyState(editor, nextState); + applyState(editor, runStateOp(STATE_OP_CLEAR_ROLLBACK_MARK, null)); } - applyEditor(editor); + // Asynchronous on purpose: the in-memory state changes + // immediately, the disk write happens on the + // SharedPreferences writer. Losing it to a crash only + // re-reports the one-shot markers on the next launch. + editor.apply(); } } if (markers.rolledBackVersion != null) { // The rolled-back version's directory is now eligible for cleanup. - this.cleanUp(); + cleanUp(DownloadTaskParams.DEFAULT_MAX_AGE_DAYS); } return markers; } @@ -659,14 +601,7 @@ public void markSuccess() { return; } synchronized (commitLock) { - StateCoreResult currentState = getStateSnapshot(); - StateCoreResult nextState = runStateCore( - STATE_OP_MARK_SUCCESS, - currentState, - null, - false, - false - ); + StateCoreResult nextState = runStateOp(STATE_OP_MARK_SUCCESS, null); SharedPreferences.Editor editor = sp.edit(); applyState(editor, nextState); if (nextState.staleVersionToDelete != null) { @@ -675,7 +610,7 @@ public void markSuccess() { persistEditorOrThrow(editor, "mark success"); } - this.cleanUp(); + cleanUp(DownloadTaskParams.DEFAULT_MAX_AGE_DAYS); } /** @@ -717,14 +652,7 @@ private void resetToPackagedBundleLocked() { persistEditorOrThrow(editor, "reset to packaged bundle"); ignoreRollback = false; Log.i(TAG, "Reset to packaged bundle"); - - DownloadTaskParams params = new DownloadTaskParams(); - params.type = DownloadTaskParams.TASK_TYPE_CLEANUP; - params.maxAgeDays = 0; - // Keep names are resolved by the queued task at execution time, so a - // later activation is protected too. - params.unzipDirectory = rootDir; - enqueue(params); + cleanUp(0); } public static void setCustomInstanceManager(ReactInstanceManager instanceManager) { @@ -813,7 +741,7 @@ public String getBundleUrl(String defaultAssetsUrl) { // the main thread. java.util.HashSet visitedVersions = new java.util.HashSet<>(); while (currentVersion != null && visitedVersions.add(currentVersion)) { - File bundleFile = new File(rootDir, currentVersion+"/index.bundlejs"); + File bundleFile = InstallRecord.bundleFile(new File(rootDir, currentVersion)); if (!bundleFile.exists()) { Log.e(TAG, "Bundle version " + currentVersion + " not found."); currentVersion = this.rollBack(); @@ -838,7 +766,6 @@ public String getBundleUrl(String defaultAssetsUrl) { } } - /** Sampled/compared by the native check orchestrator; see resetGeneration. */ /** Shared by JS and native hosts. Config replacement invalidates old native decisions. */ private static final java.util.concurrent.atomic.AtomicLong nativeConfigGeneration = new java.util.concurrent.atomic.AtomicLong(0); @@ -849,10 +776,10 @@ static long getNativeConfigGeneration() { void setNativeConfig(String config) { synchronized (commitLock) { - // Canonical comparison (ConfigEquivalence): re-sending the same - // config with another key order or trailing slashes must not - // cancel an in-flight round or drop its cache. - boolean changed = !ConfigEquivalence.equivalent( + // Canonical comparison (C1, FlowBridge.configsEquivalent): + // re-sending the same config with another key order or trailing + // slashes must not cancel an in-flight round or drop its cache. + boolean changed = !FlowBridge.configsEquivalent( config, sp.getString(SyncCoordinator.KEY_CONFIG, null)); SharedPreferences.Editor editor = sp.edit(); if (changed) { @@ -880,7 +807,11 @@ void setNativeConfig(String config) { SyncCoordinator.onConfigured(this); } - // Native-decision generation: bumped by reset AND configuration replacement. + /** + * Native-decision generation, sampled/compared by the native check + * orchestrator: bumped by reset AND configuration replacement (see + * resetGeneration). + */ static long getResetGeneration() { return resetGeneration.get(); } @@ -964,8 +895,7 @@ private String readBundleSha256(String hash) { if (hash == null || hash.isEmpty() || !UpdateFileUtils.isSafePathComponent(hash)) { return ""; } - JSONObject record = InstallRecord.read(new File(rootDir, hash)); - return record == null ? "" : record.optString("bundleSha256", ""); + return InstallRecord.recordedBundleSha256(new File(rootDir, hash)); } /** Raw native check response for JS (§10.3); null when absent. */ @@ -989,22 +919,14 @@ boolean hasCompletedVersion(String hash) { if (!UpdateFileUtils.isSafePathComponent(hash)) { return false; } - File versionDir = new File(rootDir, hash); - return new File(versionDir, "index.bundlejs").isFile() - && InstallRecord.isComplete(versionDir, hash); + return InstallRecord.isInstalled(new File(rootDir, hash), hash); } private String rollBack() { synchronized (commitLock) { - StateCoreResult currentState = getStateSnapshot(); - StateCoreResult nextState = runStateCore( - STATE_OP_ROLLBACK, - currentState, - null, - false, - false - ); - Log.e(TAG, "Rolling back version " + currentState.currentVersion + String rollingBack = getCurrentVersion(); + StateCoreResult nextState = runStateOp(STATE_OP_ROLLBACK, null); + Log.e(TAG, "Rolling back version " + rollingBack + " to " + nextState.currentVersion); SharedPreferences.Editor editor = sp.edit(); applyState(editor, nextState); @@ -1048,9 +970,15 @@ void runCleanupWithLatestState(CleanupAction action) { } } - private void cleanUp() { + /** + * Queues removal of every version entry older than maxAgeDays (0: all) + * except the keep names, which the queued task resolves at execution + * time so a later activation is protected too. + */ + private void cleanUp(int maxAgeDays) { DownloadTaskParams params = new DownloadTaskParams(); params.type = DownloadTaskParams.TASK_TYPE_CLEANUP; + params.maxAgeDays = maxAgeDays; params.unzipDirectory = rootDir; enqueue(params); } diff --git a/android/src/main/java/cn/reactnative/modules/update/UpdateEventEmitter.java b/android/src/main/java/cn/reactnative/modules/update/UpdateEventEmitter.java index 7b8d5740..d7d752e0 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateEventEmitter.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateEventEmitter.java @@ -39,7 +39,7 @@ static void sendEvent(String eventName, WritableMap params) { // emit (this SDK itself triggers reloads); sendEvent runs on the // main thread, so a throw here would crash the app for a lost // progress tick. - Log.w("pushy", "sendEvent " + eventName + " failed: " + e.getMessage()); + Log.w(Texts.LOG_TAG, "sendEvent " + eventName + " failed: " + e.getMessage()); } } diff --git a/android/src/main/java/cn/reactnative/modules/update/UpdateFileUtils.java b/android/src/main/java/cn/reactnative/modules/update/UpdateFileUtils.java index f285b1a4..8f771c1b 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateFileUtils.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateFileUtils.java @@ -1,13 +1,18 @@ package cn.reactnative.modules.update; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; final class UpdateFileUtils { - private static final int BUFFER_SIZE = 64 * 1024; + /** The one IO block size: stream copies, digests, downloads, unzip. */ + static final int BUFFER_SIZE = 64 * 1024; private UpdateFileUtils() { } @@ -57,22 +62,27 @@ static void removeDirectory(File file) throws IOException { } } - static java.security.MessageDigest newSha256() throws IOException { + static MessageDigest newSha256() throws IOException { try { - return java.security.MessageDigest.getInstance("SHA-256"); + return MessageDigest.getInstance("SHA-256"); } catch (java.security.NoSuchAlgorithmException e) { throw new IOException("SHA-256 unavailable", e); } } /** Feeds the whole file into digest (streaming, bounded memory). */ - static void updateDigest(java.security.MessageDigest digest, File file) throws IOException { + static void updateDigest(MessageDigest digest, File file) throws IOException { try (InputStream in = new FileInputStream(file)) { - byte[] buffer = new byte[BUFFER_SIZE]; - int read; - while ((read = in.read(buffer)) != -1) { - digest.update(buffer, 0, read); - } + updateDigest(digest, in); + } + } + + /** Feeds the rest of the stream into digest; the caller closes it. */ + static void updateDigest(MessageDigest digest, InputStream in) throws IOException { + byte[] buffer = new byte[BUFFER_SIZE]; + int read; + while ((read = in.read(buffer)) != -1) { + digest.update(buffer, 0, read); } } @@ -87,8 +97,15 @@ static String toHex(byte[] digest) { /** Streaming SHA-256 of a file, lowercase hex. */ static String sha256Hex(File file) throws IOException { - java.security.MessageDigest md = newSha256(); - updateDigest(md, file); + try (InputStream in = new FileInputStream(file)) { + return sha256Hex(in); + } + } + + /** Streaming SHA-256 of the rest of the stream, lowercase hex; the caller closes it. */ + static String sha256Hex(InputStream in) throws IOException { + MessageDigest md = newSha256(); + updateDigest(md, in); return toHex(md.digest()); } @@ -109,11 +126,38 @@ static void copyInputStreamToFile(InputStream input, File destination) throws IO } } - private static void copy(InputStream in, FileOutputStream out) throws IOException { + /** Copies the rest of the stream; the caller closes both ends. */ + static void copy(InputStream in, OutputStream out) throws IOException { byte[] buffer = new byte[BUFFER_SIZE]; int count; while ((count = in.read(buffer)) != -1) { out.write(buffer, 0, count); } } + + /** + * Reads the whole stream (and closes it), failing once it exceeds + * maxBytes: small server- or archive-provided documents must not be + * able to exhaust memory. + */ + static byte[] readBytes(InputStream input, long maxBytes) throws IOException { + try (InputStream in = input; ByteArrayOutputStream out = new ByteArrayOutputStream()) { + byte[] buffer = new byte[BUFFER_SIZE]; + int count; + long total = 0; + while ((count = in.read(buffer)) != -1) { + total += count; + if (total > maxBytes) { + throw new IOException("content exceeds " + maxBytes + " bytes"); + } + out.write(buffer, 0, count); + } + return out.toByteArray(); + } + } + + /** A small UTF-8 text file (a record, a sidecar), at most maxBytes long. */ + static String readSmallUtf8(File file, long maxBytes) throws IOException { + return new String(readBytes(new FileInputStream(file), maxBytes), StandardCharsets.UTF_8); + } } diff --git a/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java b/android/src/main/java/cn/reactnative/modules/update/UpdateModule.java similarity index 73% rename from android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java rename to android/src/main/java/cn/reactnative/modules/update/UpdateModule.java index f57c6615..3b8e1df7 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateModule.java @@ -1,9 +1,13 @@ package cn.reactnative.modules.update; +import static cn.reactnative.modules.update.StateSerialRunner.STATE_SERIAL; +import static cn.reactnative.modules.update.StateSerialRunner.UI_THREAD; + import android.util.Log; import androidx.annotation.Nullable; import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactMethod; import com.facebook.react.bridge.ReadableMap; import java.util.HashMap; import java.util.Map; @@ -12,32 +16,33 @@ import org.json.JSONTokener; /** - * Everything the "Pushy" native module does, independent of the bridge it is - * registered with. The old-architecture and new-architecture UpdateModule - * classes are thin wrappers that only carry their annotations / spec - * overrides and forward here, so the two cannot drift apart. + * The "Pushy" native module for both architectures. NativePushySpec is the + * codegen spec under the new architecture and the hand-written stand-in in + * src/oldarch otherwise; the old bridge discovers methods through the + * @ReactMethod annotations here, so every spec method carries one. Each + * state-changing method takes a trailing Promise, so a failed persistence + * rejects instead of resolving early (CODE_AUDIT 2.3). */ -public class UpdateModuleImpl { +public class UpdateModule extends NativePushySpec { // Download URL option key, stored encoded. private static final String OPTION_URL = Texts.reveal("2f07f0d0ba8e5d572e"); - - public static final String NAME = "Pushy"; - private final ReactApplicationContext reactContext; private final UpdateContext updateContext; - public UpdateModuleImpl(ReactApplicationContext reactContext) { + public UpdateModule(ReactApplicationContext reactContext) { this(reactContext, UpdateContext.getInstance(reactContext)); } - public UpdateModuleImpl(ReactApplicationContext reactContext, UpdateContext updateContext) { + public UpdateModule(ReactApplicationContext reactContext, UpdateContext updateContext) { + super(reactContext); this.reactContext = reactContext; this.updateContext = updateContext; UpdateEventEmitter.register(reactContext); } - public Map getConstants() { + @Override + protected Map getTypedExportedConstants() { final Map constants = new HashMap(); constants.put("downloadRootDir", updateContext.getRootDir()); constants.put("packageVersion", updateContext.getPackageVersion()); @@ -65,7 +70,7 @@ public Map getConstants() { // A mismatched librnpushy.so (stale manual copy / build cache) // must not crash startup via getConstants; 0 simply means "no v2 // diff track" and the server degrades gracefully. - Log.e("pushy", "supportedDiffVersion missing from the native library", e); + Log.e(Texts.LOG_TAG, "supportedDiffVersion missing from the native library", e); } constants.put("supportedDiffVersion", supportedDiffVersion); return constants; @@ -129,26 +134,13 @@ public void onDownloadCompleted(DownloadTaskParams params) { @Override public void onDownloadFailed(Throwable error) { - promise.reject(downloadErrorCode(error), error); + promise.reject(ErrorCodes.forDownloadFailure(error), error); } }; } - // Post-download failures (unzip / hdiff / resource copy, incl. copiesCrc - // verification) reject as PATCH_FAILED so JS-side telemetry can separate - // patch health from network health; a local refusal (reinstalling the - // running version in place) as FILE_OPERATION_FAILED. DownloadTask - // classifies them. - private static String downloadErrorCode(Throwable error) { - if (error instanceof PatchFailedException) { - return ErrorCodes.DELTA_FAILED; - } - if (error instanceof FileOperationException) { - return ErrorCodes.FILE_OPERATION_FAILED; - } - return ErrorCodes.DOWNLOAD_FAILED; - } - + @ReactMethod + @Override public void downloadFullUpdate(final ReadableMap options, final Promise promise) { String url = readRequiredString(options, OPTION_URL, promise); if (url == null) { @@ -158,9 +150,11 @@ public void downloadFullUpdate(final ReadableMap options, final Promise promise) if (hash == null) { return; } - updateContext.downloadFullUpdate(url, hash, downloadListener(promise)); + updateContext.downloadFullUpdate(url, hash, downloadListener(promise), 0); } + @ReactMethod + @Override public void downloadAndInstallApk(final ReadableMap options, final Promise promise) { // Every failure of this path — a missing REQUEST_INSTALL_PACKAGES // declaration above all — has to come back as a promise rejection, @@ -205,6 +199,8 @@ public void onDownloadFailed(Throwable error) { } } + @ReactMethod + @Override public void downloadPatchFromPackage(final ReadableMap options, final Promise promise) { String url = readRequiredString(options, OPTION_URL, promise); if (url == null) { @@ -214,9 +210,11 @@ public void downloadPatchFromPackage(final ReadableMap options, final Promise pr if (hash == null) { return; } - updateContext.downloadPatchFromApk(url, hash, downloadListener(promise)); + updateContext.downloadPatchFromApk(url, hash, downloadListener(promise), 0); } + @ReactMethod + @Override public void downloadPatchFromPpk(final ReadableMap options, final Promise promise) { String url = readRequiredString(options, OPTION_URL, promise); if (url == null) { @@ -230,79 +228,80 @@ public void downloadPatchFromPpk(final ReadableMap options, final Promise promis if (originHash == null) { return; } - updateContext.downloadPatchFromPpk(url, hash, originHash, downloadListener(promise)); + updateContext.downloadPatchFromPpk(url, hash, originHash, downloadListener(promise), 0); } + @ReactMethod + @Override public void reloadUpdate(final ReadableMap options, final Promise promise) { String hash = readRequiredString(options, "hash", promise); if (hash == null) { return; } - restartApp(hash, promise); + restart(hash, promise); } - public void restartApp(@Nullable final String hash, final Promise promise) { + @ReactMethod + @Override + public void restartApp(Promise promise) { + restart(null, promise); + } + + private void restart(@Nullable final String hash, final Promise promise) { // Two hops (CODE_AUDIT 2.2): the switch (bundle digest + commit) and // the launch resolution run on the state serial thread like every - // other state write; only the React reload itself is posted to the - // UI thread. A failure at either hop rejects with RESTART_FAILED. - StateSerialRunner.run(promise, ErrorCodes.RESTART_FAILED, "restartApp", new StateSerialRunner.Operation() { - @Override - public void run() throws Throwable { - final String bundlePath = ReactReloadManager.prepareRestart(updateContext, hash); - UiThreadRunner.run(promise, ErrorCodes.RESTART_FAILED, "restartApp", new UiThreadRunner.Operation() { - @Override - public void run() throws Throwable { - ReactReloadManager.reload(updateContext, reactContext, bundlePath); - promise.resolve(true); - } - }); + // other state write — nothing there touches React; only the React + // reload itself is posted to the UI thread. A failure at either hop + // rejects with RESTART_FAILED. + StateSerialRunner.run(STATE_SERIAL, promise, ErrorCodes.RESTART_FAILED, "restartApp", () -> { + if (hash != null) { + updateContext.switchVersion(hash); } + // The bundle to load; null for the packaged one. + final String bundlePath = updateContext.getBundleUrl(); + StateSerialRunner.run(UI_THREAD, promise, ErrorCodes.RESTART_FAILED, "restartApp", () -> { + ReactReloadManager.reload(updateContext, reactContext, bundlePath); + promise.resolve(true); + }); }); } + @ReactMethod + @Override public void setNeedUpdate(final ReadableMap options, final Promise promise) { final String hash = readRequiredString(options, "hash", promise); if (hash == null) { return; } - StateSerialRunner.run(promise, ErrorCodes.SWITCH_VERSION_FAILED, "switchVersionLater", new StateSerialRunner.Operation() { - @Override - public void run() { - updateContext.switchVersion(hash); - promise.resolve(true); - } + StateSerialRunner.run(STATE_SERIAL, promise, ErrorCodes.SWITCH_VERSION_FAILED, "switchVersionLater", () -> { + updateContext.switchVersion(hash); + promise.resolve(true); }); } + @ReactMethod + @Override public void markSuccess(final Promise promise) { - StateSerialRunner.run(promise, ErrorCodes.MARK_SUCCESS_FAILED, "markSuccess", new StateSerialRunner.Operation() { - @Override - public void run() { - updateContext.markSuccess(); - promise.resolve(true); - } + StateSerialRunner.run(STATE_SERIAL, promise, ErrorCodes.MARK_SUCCESS_FAILED, "markSuccess", () -> { + updateContext.markSuccess(); + promise.resolve(true); }); } + @ReactMethod + @Override public void getBundleHash(final Promise promise) { // Threading lives in UpdateContext (its bundle-hash thread); resolve is // thread-safe. Never rejects — empty string means "unknown". - updateContext.getBundleHash(new UpdateContext.BundleHashListener() { - @Override - public void onBundleHash(String hash) { - promise.resolve(hash); - } - }); + updateContext.getBundleHash(promise::resolve); } + @ReactMethod + @Override public void resetToPackagedBundle(final Promise promise) { - StateSerialRunner.run(promise, ErrorCodes.RESET_FAILED, "resetToPackagedBundle", new StateSerialRunner.Operation() { - @Override - public void run() { - updateContext.resetToPackagedBundle(); - promise.resolve(true); - } + StateSerialRunner.run(STATE_SERIAL, promise, ErrorCodes.RESET_FAILED, "resetToPackagedBundle", () -> { + updateContext.resetToPackagedBundle(); + promise.resolve(true); }); } @@ -311,18 +310,19 @@ public void run() { * reuse instead of re-checking (§10.3). Empty string when absent; never * rejects. */ + @ReactMethod + @Override public void getNativeCheckCache(final Promise promise) { String cached = updateContext.getNativeCheckCache(); promise.resolve(cached == null ? "" : cached); } /** - * Provisioning for the native cold-start update check - * (NATIVE_CHECKUPDATE_DESIGN §10.1): the raw JSON persists as-is and is - * parsed on read by the orchestrator; absent config = check disabled. - * Validated at write time — a corrupt config would otherwise silently - * disable the native check forever with no signal. + * JS obtained a valid check response for this config in this process; the + * delayed native round then skips its duplicate request (§10.3). */ + @ReactMethod + @Override public void markJsCheckCompleted(final String config, final Promise promise) { if (requireNonEmpty(config, "config", promise) == null) { return; @@ -331,6 +331,15 @@ public void markJsCheckCompleted(final String config, final Promise promise) { promise.resolve(true); } + /** + * Provisioning for the native cold-start update check + * (NATIVE_CHECKUPDATE_DESIGN §10.1): the raw JSON persists as-is and is + * parsed on read by the orchestrator; absent config = check disabled. + * Validated at write time — a corrupt config would otherwise silently + * disable the native check forever with no signal. + */ + @ReactMethod + @Override public void syncNativeConfig(final String config, final Promise promise) { if (requireNonEmpty(config, "config", promise) == null) { return; @@ -341,28 +350,26 @@ public void syncNativeConfig(final String config, final Promise promise) { promise.reject(ErrorCodes.INVALID_OPTIONS, "config must be a JSON object string", e); return; } - StateSerialRunner.run(promise, ErrorCodes.FILE_OPERATION_FAILED, "syncNativeConfig", new StateSerialRunner.Operation() { - @Override - public void run() { - updateContext.setNativeConfig(config); - promise.resolve(true); - } + StateSerialRunner.run(STATE_SERIAL, promise, ErrorCodes.FILE_OPERATION_FAILED, "syncNativeConfig", () -> { + updateContext.setNativeConfig(config); + promise.resolve(true); }); } + @ReactMethod + @Override public void setUuid(final String uuid, final Promise promise) { if (requireNonEmpty(uuid, "uuid", promise) == null) { return; } - StateSerialRunner.run(promise, ErrorCodes.FILE_OPERATION_FAILED, "setUuid", new StateSerialRunner.Operation() { - @Override - public void run() { - updateContext.setKv("uuid", uuid); - promise.resolve(true); - } + StateSerialRunner.run(STATE_SERIAL, promise, ErrorCodes.FILE_OPERATION_FAILED, "setUuid", () -> { + updateContext.setKv("uuid", uuid); + promise.resolve(true); }); } + @ReactMethod + @Override public void setLocalHashInfo(final String hash, final String info, final Promise promise) { if (requireNonEmpty(hash, "hash", promise) == null) { return; @@ -373,15 +380,14 @@ public void setLocalHashInfo(final String hash, final String info, final Promise } // A state write that did not reach disk (SharedPreferences commit // false) is FILE_OPERATION_FAILED, distinct from malformed input. - StateSerialRunner.run(promise, ErrorCodes.FILE_OPERATION_FAILED, "setLocalHashInfo", new StateSerialRunner.Operation() { - @Override - public void run() { - updateContext.setKv("hash_" + hash, info); - promise.resolve(true); - } + StateSerialRunner.run(STATE_SERIAL, promise, ErrorCodes.FILE_OPERATION_FAILED, "setLocalHashInfo", () -> { + updateContext.setKv("hash_" + hash, info); + promise.resolve(true); }); } + @ReactMethod + @Override public void getLocalHashInfo(final String hash, final Promise promise) { if (requireNonEmpty(hash, "hash", promise) == null) { return; @@ -400,4 +406,14 @@ public void getLocalHashInfo(final String hash, final Promise promise) { promise.resolve(value); } + + @ReactMethod + @Override + public void addListener(String eventName) { + } + + @ReactMethod + @Override + public void removeListeners(double count) { + } } diff --git a/android/src/main/java/cn/reactnative/modules/update/UpdatePackage.java b/android/src/main/java/cn/reactnative/modules/update/UpdatePackage.java index 4702e6d2..02288a49 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdatePackage.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdatePackage.java @@ -18,29 +18,26 @@ public class UpdatePackage extends TurboReactPackage { @Nullable @Override public NativeModule getModule(String name, ReactApplicationContext reactContext) { - return name.equals(UpdateModuleImpl.NAME) ? new UpdateModule(reactContext) : null; + return name.equals(UpdateModule.NAME) ? new UpdateModule(reactContext) : null; } @Override public ReactModuleInfoProvider getReactModuleInfoProvider() { - return new ReactModuleInfoProvider() { - @Override - public Map getReactModuleInfos() { - final Map moduleInfos = new HashMap<>(); - boolean isTurboModule = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED; - moduleInfos.put( - UpdateModuleImpl.NAME, - new ReactModuleInfo( - UpdateModuleImpl.NAME, - UpdateModuleImpl.NAME, - false, // canOverrideExistingModule - false, // needsEagerInit - true, // hasConstants - false, // isCxxModule - isTurboModule // isTurboModule - )); - return moduleInfos; - } + return () -> { + final Map moduleInfos = new HashMap<>(); + boolean isTurboModule = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED; + moduleInfos.put( + UpdateModule.NAME, + new ReactModuleInfo( + UpdateModule.NAME, + UpdateModule.NAME, + false, // canOverrideExistingModule + false, // needsEagerInit + true, // hasConstants + false, // isCxxModule + isTurboModule // isTurboModule + )); + return moduleInfos; }; } } diff --git a/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java b/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java deleted file mode 100644 index de6f02d6..00000000 --- a/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java +++ /dev/null @@ -1,121 +0,0 @@ -package cn.reactnative.modules.update; - -import com.facebook.react.bridge.Promise; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.ReadableMap; -import java.util.Map; - -/** - * New-architecture bridge: the codegen spec overrides only, every method - * forwards to UpdateModuleImpl. - */ -public class UpdateModule extends NativePushySpec { - private final UpdateModuleImpl impl; - - public UpdateModule(ReactApplicationContext reactContext, UpdateContext updateContext) { - super(reactContext); - this.impl = new UpdateModuleImpl(reactContext, updateContext); - } - - public UpdateModule(ReactApplicationContext reactContext) { - this(reactContext, UpdateContext.getInstance(reactContext)); - } - - @Override - protected Map getTypedExportedConstants() { - return impl.getConstants(); - } - - @Override - public String getName() { - return UpdateModuleImpl.NAME; - } - - @Override - public void downloadFullUpdate(ReadableMap options, Promise promise) { - impl.downloadFullUpdate(options, promise); - } - - @Override - public void downloadAndInstallApk(ReadableMap options, Promise promise) { - impl.downloadAndInstallApk(options, promise); - } - - @Override - public void downloadPatchFromPackage(ReadableMap options, Promise promise) { - impl.downloadPatchFromPackage(options, promise); - } - - @Override - public void downloadPatchFromPpk(ReadableMap options, Promise promise) { - impl.downloadPatchFromPpk(options, promise); - } - - @Override - public void reloadUpdate(ReadableMap options, Promise promise) { - impl.reloadUpdate(options, promise); - } - - @Override - public void restartApp(Promise promise) { - impl.restartApp(null, promise); - } - - @Override - public void setNeedUpdate(ReadableMap options, Promise promise) { - impl.setNeedUpdate(options, promise); - } - - @Override - public void markSuccess(Promise promise) { - impl.markSuccess(promise); - } - - @Override - public void getBundleHash(Promise promise) { - impl.getBundleHash(promise); - } - - @Override - public void resetToPackagedBundle(Promise promise) { - impl.resetToPackagedBundle(promise); - } - - @Override - public void setUuid(String uuid, Promise promise) { - impl.setUuid(uuid, promise); - } - - @Override - public void syncNativeConfig(String config, Promise promise) { - impl.syncNativeConfig(config, promise); - } - - @Override - public void getNativeCheckCache(Promise promise) { - impl.getNativeCheckCache(promise); - } - - @Override - public void markJsCheckCompleted(String config, Promise promise) { - impl.markJsCheckCompleted(config, promise); - } - - @Override - public void setLocalHashInfo(String hash, String info, Promise promise) { - impl.setLocalHashInfo(hash, info, promise); - } - - @Override - public void getLocalHashInfo(String hash, Promise promise) { - impl.getLocalHashInfo(hash, promise); - } - - @Override - public void addListener(String eventName) { - } - - @Override - public void removeListeners(double count) { - } -} diff --git a/android/src/oldarch/cn/reactnative/modules/update/NativePushySpec.java b/android/src/oldarch/cn/reactnative/modules/update/NativePushySpec.java new file mode 100644 index 00000000..9c329d0e --- /dev/null +++ b/android/src/oldarch/cn/reactnative/modules/update/NativePushySpec.java @@ -0,0 +1,69 @@ +package cn.reactnative.modules.update; + +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReadableMap; +import java.util.Map; + +/** + * Old-architecture stand-in for the NativePushySpec that codegen generates + * from src/NativePushy.ts under the new architecture: same name, methods and + * constants hook, so a single UpdateModule serves both. The bridge reads the + * @ReactMethod annotations from UpdateModule itself. + */ +public abstract class NativePushySpec extends ReactContextBaseJavaModule { + public static final String NAME = "Pushy"; + + public NativePushySpec(ReactApplicationContext reactContext) { + super(reactContext); + } + + @Override + public String getName() { + return NAME; + } + + protected abstract Map getTypedExportedConstants(); + + @Override + public final Map getConstants() { + return getTypedExportedConstants(); + } + + public abstract void setLocalHashInfo(String hash, String info, Promise promise); + + public abstract void getLocalHashInfo(String hash, Promise promise); + + public abstract void setUuid(String uuid, Promise promise); + + public abstract void syncNativeConfig(String config, Promise promise); + + public abstract void getNativeCheckCache(Promise promise); + + public abstract void markJsCheckCompleted(String config, Promise promise); + + public abstract void reloadUpdate(ReadableMap options, Promise promise); + + public abstract void restartApp(Promise promise); + + public abstract void setNeedUpdate(ReadableMap options, Promise promise); + + public abstract void markSuccess(Promise promise); + + public abstract void getBundleHash(Promise promise); + + public abstract void resetToPackagedBundle(Promise promise); + + public abstract void downloadPatchFromPpk(ReadableMap options, Promise promise); + + public abstract void downloadPatchFromPackage(ReadableMap options, Promise promise); + + public abstract void downloadFullUpdate(ReadableMap options, Promise promise); + + public abstract void downloadAndInstallApk(ReadableMap options, Promise promise); + + public abstract void addListener(String eventName); + + public abstract void removeListeners(double count); +} diff --git a/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java b/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java deleted file mode 100644 index addb07f1..00000000 --- a/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java +++ /dev/null @@ -1,125 +0,0 @@ -package cn.reactnative.modules.update; - -import com.facebook.react.bridge.Promise; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.ReactContextBaseJavaModule; -import com.facebook.react.bridge.ReactMethod; -import com.facebook.react.bridge.ReadableMap; -import java.util.Map; - -/** - * Old-architecture bridge: @ReactMethod declarations only, every method - * forwards to UpdateModuleImpl. Each state-changing method takes a trailing - * Promise, like the TurboModule spec, so a failed persistence rejects instead - * of resolving early (CODE_AUDIT 2.3). - */ -public class UpdateModule extends ReactContextBaseJavaModule { - private final UpdateModuleImpl impl; - - public UpdateModule(ReactApplicationContext reactContext, UpdateContext updateContext) { - super(reactContext); - this.impl = new UpdateModuleImpl(reactContext, updateContext); - } - - public UpdateModule(ReactApplicationContext reactContext) { - this(reactContext, UpdateContext.getInstance(reactContext)); - } - - @Override - public Map getConstants() { - return impl.getConstants(); - } - - @Override - public String getName() { - return UpdateModuleImpl.NAME; - } - - @ReactMethod - public void downloadFullUpdate(ReadableMap options, Promise promise) { - impl.downloadFullUpdate(options, promise); - } - - @ReactMethod - public void downloadAndInstallApk(ReadableMap options, Promise promise) { - impl.downloadAndInstallApk(options, promise); - } - - @ReactMethod - public void downloadPatchFromPackage(ReadableMap options, Promise promise) { - impl.downloadPatchFromPackage(options, promise); - } - - @ReactMethod - public void downloadPatchFromPpk(ReadableMap options, Promise promise) { - impl.downloadPatchFromPpk(options, promise); - } - - @ReactMethod - public void reloadUpdate(ReadableMap options, Promise promise) { - impl.reloadUpdate(options, promise); - } - - @ReactMethod - public void restartApp(Promise promise) { - impl.restartApp(null, promise); - } - - @ReactMethod - public void setNeedUpdate(ReadableMap options, Promise promise) { - impl.setNeedUpdate(options, promise); - } - - @ReactMethod - public void markSuccess(Promise promise) { - impl.markSuccess(promise); - } - - @ReactMethod - public void getBundleHash(Promise promise) { - impl.getBundleHash(promise); - } - - @ReactMethod - public void resetToPackagedBundle(Promise promise) { - impl.resetToPackagedBundle(promise); - } - - @ReactMethod - public void setUuid(String uuid, Promise promise) { - impl.setUuid(uuid, promise); - } - - @ReactMethod - public void syncNativeConfig(String config, Promise promise) { - impl.syncNativeConfig(config, promise); - } - - @ReactMethod - public void getNativeCheckCache(Promise promise) { - impl.getNativeCheckCache(promise); - } - - @ReactMethod - public void markJsCheckCompleted(String config, Promise promise) { - impl.markJsCheckCompleted(config, promise); - } - - @ReactMethod - public void setLocalHashInfo(String hash, String info, Promise promise) { - impl.setLocalHashInfo(hash, info, promise); - } - - @ReactMethod - public void getLocalHashInfo(String hash, Promise promise) { - impl.getLocalHashInfo(hash, promise); - } - - @ReactMethod - public void addListener(String eventName) { - } - - @ReactMethod - public void removeListeners(Integer count) { - } -} diff --git a/android/src/test/java/cn/reactnative/modules/update/ConfigEquivalenceTest.java b/android/src/test/java/cn/reactnative/modules/update/ConfigEquivalenceTest.java deleted file mode 100644 index 6508947b..00000000 --- a/android/src/test/java/cn/reactnative/modules/update/ConfigEquivalenceTest.java +++ /dev/null @@ -1,63 +0,0 @@ -package cn.reactnative.modules.update; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import org.json.JSONArray; -import org.json.JSONObject; -import org.junit.Test; - -public class ConfigEquivalenceTest { - private static final String VECTORS = - "cpp/update_flow_core/tests/config-equivalence-vectors.json"; - - // The shared C1 vectors live at the package root (next to android/). Gradle - // runs unit tests from the module directory, which is android/ in this repo - // and node_modules/react-native-update/android in a host app; walk up from - // there so both layouts (and symlinked copies) resolve. - private static File vectorsFile() { - String override = System.getProperty("pushy.configVectors"); - if (override != null) { - return new File(override); - } - for (File dir = new File(System.getProperty("user.dir")).getAbsoluteFile(); - dir != null; dir = dir.getParentFile()) { - File candidate = new File(dir, VECTORS); - if (candidate.isFile()) { - return candidate; - } - } - return null; - } - - @Test - public void sharedVectors() throws Exception { - File file = vectorsFile(); - assertNotNull("cannot locate " + VECTORS + " above " + System.getProperty("user.dir"), file); - JSONArray cases = new JSONObject( - new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8)) - .getJSONArray("cases"); - assertTrue("vector file has cases", cases.length() >= 30); - for (int i = 0; i < cases.length(); i++) { - JSONObject vector = cases.getJSONObject(i); - String name = vector.getString("name"); - String a = vector.getString("a"); - String b = vector.getString("b"); - boolean equivalent = vector.getBoolean("equivalent"); - assertEquals(name, equivalent, ConfigEquivalence.equivalent(a, b)); - assertEquals(name + " (reversed)", equivalent, ConfigEquivalence.equivalent(b, a)); - } - } - - @Test - public void missingConfigIsNeverEquivalent() { - assertFalse(ConfigEquivalence.equivalent(null, null)); - assertFalse(ConfigEquivalence.equivalent("{\"appKey\":\"k\"}", null)); - assertFalse(ConfigEquivalence.equivalent(null, "{\"appKey\":\"k\"}")); - } -} diff --git a/android/src/test/java/cn/reactnative/modules/update/HttpUtilsTest.java b/android/src/test/java/cn/reactnative/modules/update/HttpUtilsTest.java index eaf74b10..de2870d3 100644 --- a/android/src/test/java/cn/reactnative/modules/update/HttpUtilsTest.java +++ b/android/src/test/java/cn/reactnative/modules/update/HttpUtilsTest.java @@ -53,15 +53,6 @@ public void parseContentRangeRejectsMalformedHeaders() { assertEquals(-1, HttpUtils.parseContentRange("items 0-9/10", 0)); } - @Test - public void normalizeEndpointBaseStripsTrailingSlashes() { - assertEquals("https://update.example.com", HttpUtils.normalizeEndpointBase("https://update.example.com/")); - assertEquals("https://update.example.com", HttpUtils.normalizeEndpointBase("https://update.example.com///")); - assertEquals("https://update.example.com/api", HttpUtils.normalizeEndpointBase("https://update.example.com/api")); - assertEquals("", HttpUtils.normalizeEndpointBase("")); - assertEquals("", HttpUtils.normalizeEndpointBase("/")); - } - @Test public void isHttpsUrlIsCaseInsensitiveAndStrict() { assertTrue(HttpUtils.isHttpsUrl("https://example.com/app.apk")); @@ -76,7 +67,7 @@ public void isHttpsUrlIsCaseInsensitiveAndStrict() { @Test public void revealDecodesEncodedText() { // Encoded with scripts/encode-native-text.ts. - assertEquals("/checkUpdate/", HttpUtils.QUERY_PATH); + assertEquals("/checkUpdate/", Texts.reveal("7514fcd4ad805d55263e08fc99")); assertEquals("https://update.react-native.cn/api", Texts.reveal("3203e0c1bdd1270a372f18f8c2b6de7f4f2607f5b3d5b9817b592947e5cdefbc8a7e")); assertEquals("", Texts.reveal("")); diff --git a/android/src/test/java/cn/reactnative/modules/update/InstallRecordTest.java b/android/src/test/java/cn/reactnative/modules/update/InstallRecordTest.java index 6b9d1707..4c263dfb 100644 --- a/android/src/test/java/cn/reactnative/modules/update/InstallRecordTest.java +++ b/android/src/test/java/cn/reactnative/modules/update/InstallRecordTest.java @@ -1,205 +1,28 @@ package cn.reactnative.modules.update; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.Arrays; -import org.json.JSONObject; -import org.junit.Rule; import org.junit.Test; -import org.junit.rules.TemporaryFolder; +/** + * The record logic itself is the shared core's (cpp/patch_core/install_record.h, + * replayed against tests/install-record-vectors.json by the C++ test); the JVM + * cannot load the Android library, so only the paths built here are tested. + */ public class InstallRecordTest { private static final String HASH = "0123456789abcdef"; - @Rule - public TemporaryFolder temp = new TemporaryFolder(); - - private static void writeFile(File file, byte[] bytes) throws IOException { - try (FileOutputStream out = new FileOutputStream(file)) { - out.write(bytes); - } - } - - private static String sha256Hex(byte[] bytes) throws Exception { - byte[] digest = MessageDigest.getInstance("SHA-256").digest(bytes); - StringBuilder hex = new StringBuilder(); - for (byte b : digest) { - hex.append(String.format("%02x", b)); - } - return hex.toString(); - } - - @Test - public void buildWritesSchemaAndDigests() throws Exception { - JSONObject full = new JSONObject(InstallRecord.build(HASH, "bb", "aa")); - assertEquals(InstallRecord.SCHEMA, full.getInt("schema")); - assertEquals(HASH, full.getString("versionHash")); - assertEquals("bb", full.getString("bundleSha256")); - assertEquals("aa", full.getString("artifactSha256")); - - // artifactSha256 is optional diagnostics. - JSONObject noArtifact = new JSONObject(InstallRecord.build(HASH, "bb", null)); - assertEquals("bb", noArtifact.getString("bundleSha256")); - assertFalse(noArtifact.has("artifactSha256")); - } - - @Test - public void buildRefusesARecordWithoutBundleDigest() throws Exception { - for (String missing : new String[] {"", null}) { - try { - InstallRecord.build(HASH, missing, "aa"); - fail("a record without bundleSha256 must not be built"); - } catch (IOException expected) { - assertTrue(expected.getMessage().contains("no bundle digest")); - } - } - } - - @Test - public void writeThenReadRoundTrips() throws Exception { - File dir = temp.newFolder(); - InstallRecord.write(dir, InstallRecord.build(HASH, "bb", "aa")); - assertTrue(new File(dir, InstallRecord.FILE_NAME).isFile()); - - JSONObject record = InstallRecord.read(dir); - assertNotNull(record); - assertEquals(HASH, record.getString("versionHash")); - assertTrue(InstallRecord.isComplete(dir, HASH)); - assertFalse(InstallRecord.isComplete(dir, "someotherhash")); - } - - @Test - public void readHandlesAbsentLegacyAndMalformedRecords() throws Exception { - File dir = temp.newFolder(); - assertNull(InstallRecord.read(dir)); - assertFalse(InstallRecord.isComplete(dir, HASH)); - - // Legacy marker: an empty file counts as complete for any hash. - File marker = new File(dir, InstallRecord.FILE_NAME); - writeFile(marker, new byte[0]); - JSONObject legacy = InstallRecord.read(dir); - assertNotNull(legacy); - assertEquals(0, legacy.length()); - assertTrue(InstallRecord.isComplete(dir, HASH)); - - writeFile(marker, "not json".getBytes(StandardCharsets.UTF_8)); - assertNull(InstallRecord.read(dir)); - assertFalse(InstallRecord.isComplete(dir, HASH)); - - byte[] oversized = new byte[64 * 1024 + 1]; - Arrays.fill(oversized, (byte) ' '); - writeFile(marker, oversized); - assertNull(InstallRecord.read(dir)); - } - - @Test - public void isCompleteRequiresMatchingSchemaAndHash() throws Exception { - File dir = temp.newFolder(); - File marker = new File(dir, InstallRecord.FILE_NAME); - writeFile(marker, ("{\"schema\":" + (InstallRecord.SCHEMA + 1) - + ",\"versionHash\":\"" + HASH + "\"}").getBytes(StandardCharsets.UTF_8)); - assertFalse(InstallRecord.isComplete(dir, HASH)); - - writeFile(marker, ("{\"schema\":" + InstallRecord.SCHEMA - + ",\"versionHash\":\"other\"}").getBytes(StandardCharsets.UTF_8)); - assertFalse(InstallRecord.isComplete(dir, HASH)); - } - - @Test - public void verifyForActivationChecksTheBundleDigest() throws Exception { - File dir = temp.newFolder(); - File bundle = new File(dir, "index.bundlejs"); - byte[] bundleBytes = "var a = 1;".getBytes(StandardCharsets.UTF_8); - writeFile(bundle, bundleBytes); - - InstallRecord.write(dir, InstallRecord.build(HASH, sha256Hex(bundleBytes), "")); - InstallRecord.verifyForActivation(dir, HASH, bundle); - - // Digest stored upper-case still matches. - InstallRecord.write(dir, InstallRecord.build(HASH, sha256Hex(bundleBytes).toUpperCase(), "")); - InstallRecord.verifyForActivation(dir, HASH, bundle); - - writeFile(bundle, "var a = 2;".getBytes(StandardCharsets.UTF_8)); - try { - InstallRecord.verifyForActivation(dir, HASH, bundle); - fail("tampered bundle must not activate"); - } catch (IOException expected) { - assertTrue(expected.getMessage().contains("digest mismatch")); - } - } - - @Test - public void verifyForActivationTrustsLegacyEmptyMarkers() throws Exception { - File dir = temp.newFolder(); - File bundle = new File(dir, "index.bundlejs"); - writeFile(bundle, "x".getBytes(StandardCharsets.UTF_8)); - - writeFile(new File(dir, InstallRecord.FILE_NAME), new byte[0]); - InstallRecord.verifyForActivation(dir, HASH, bundle); - } - - @Test - public void verifyForActivationRejectsSchemaOneRecordsWithoutDigest() throws Exception { - File dir = temp.newFolder(); - File bundle = new File(dir, "index.bundlejs"); - writeFile(bundle, "x".getBytes(StandardCharsets.UTF_8)); - File marker = new File(dir, InstallRecord.FILE_NAME); - String prefix = "{\"schema\":" + InstallRecord.SCHEMA + ",\"versionHash\":\"" + HASH + "\""; - String[] records = { - prefix + "}", - prefix + ",\"bundleSha256\":\"\"}", - prefix + ",\"bundleSha256\":null}", - prefix + ",\"bundleSha256\":123}", - prefix + ",\"artifactSha256\":\"aa\"}", - }; - for (String json : records) { - writeFile(marker, json.getBytes(StandardCharsets.UTF_8)); - // Still present for launch / dedup: only activation is refused. - assertTrue(InstallRecord.isComplete(dir, HASH)); - try { - InstallRecord.verifyForActivation(dir, HASH, bundle); - fail("digest-less record must not activate: " + json); - } catch (IOException expected) { - assertTrue(expected.getMessage().contains("record mismatch")); - } - } - } - - @Test - public void verifyForActivationRejectsMissingOrForeignRecords() throws Exception { - File dir = temp.newFolder(); - File bundle = new File(dir, "index.bundlejs"); - writeFile(bundle, "x".getBytes(StandardCharsets.UTF_8)); - try { - InstallRecord.verifyForActivation(dir, HASH, bundle); - fail("missing record must not activate"); - } catch (IOException expected) { - assertTrue(expected.getMessage().contains("no valid completion record")); - } - - InstallRecord.write(dir, InstallRecord.build("anotherhash", "bb", "")); - try { - InstallRecord.verifyForActivation(dir, HASH, bundle); - fail("record for another version must not activate"); - } catch (IOException expected) { - assertTrue(expected.getMessage().contains("record mismatch")); - } - } - @Test public void stagingDirectoryIsASibling() { File version = new File("/data/_update/" + HASH); assertEquals(new File("/data/_update/" + HASH + InstallRecord.STAGING_SUFFIX), InstallRecord.stagingDirectoryFor(version)); } + + @Test + public void bundleLivesInTheVersionDirectory() { + File version = new File("/data/_update/" + HASH); + assertEquals(new File(version, "index.bundlejs"), InstallRecord.bundleFile(version)); + } } diff --git a/android/src/test/java/cn/reactnative/modules/update/SafeZipFileTest.java b/android/src/test/java/cn/reactnative/modules/update/SafeZipFileTest.java index f66eb876..99025bce 100644 --- a/android/src/test/java/cn/reactnative/modules/update/SafeZipFileTest.java +++ b/android/src/test/java/cn/reactnative/modules/update/SafeZipFileTest.java @@ -48,19 +48,6 @@ private static void drain(SafeZipFile zip) { } } - @Test - public void reservedEntryNamesAreDetectedByBaseName() { - assertTrue(SafeZipFile.isReservedEntryName(".pushy-complete")); - assertTrue(SafeZipFile.isReservedEntryName("sub/dir/.pushy-complete")); - assertTrue(SafeZipFile.isReservedEntryName("sub\\dir\\.pushy-anything")); - assertTrue(SafeZipFile.isReservedEntryName(".pushy-complete/")); - assertFalse(SafeZipFile.isReservedEntryName("pushy-complete")); - assertFalse(SafeZipFile.isReservedEntryName("sub/.pushy/file")); - assertFalse(SafeZipFile.isReservedEntryName("index.bundlejs")); - assertFalse(SafeZipFile.isReservedEntryName("")); - assertFalse(SafeZipFile.isReservedEntryName(null)); - } - @Test public void entriesRejectTraversalNames() throws IOException { Map entries = new LinkedHashMap<>(); @@ -75,20 +62,7 @@ public void entriesRejectTraversalNames() throws IOException { } @Test - public void entriesRejectReservedNames() throws IOException { - Map entries = new LinkedHashMap<>(); - entries.put("index.bundlejs", utf8("bundle")); - entries.put(".pushy-complete", utf8("{}")); - try (SafeZipFile zip = new SafeZipFile(zipOf(entries))) { - drain(zip); - fail("reserved entry must be rejected"); - } catch (SecurityException expected) { - assertTrue(expected.getMessage().contains(".pushy-complete")); - } - } - - @Test - public void unzipToPathRejectsTraversalAndReservedNames() throws IOException { + public void unzipToPathRejectsTraversal() throws IOException { Map entries = new LinkedHashMap<>(); entries.put("ok.txt", utf8("ok")); File target = temp.newFolder(); @@ -99,15 +73,8 @@ public void unzipToPathRejectsTraversalAndReservedNames() throws IOException { } catch (SecurityException expected) { // expected } - try { - zip.unzipToPath(new ZipEntry("nested/.pushy-complete"), target); - fail("reserved name must be rejected before extraction"); - } catch (SecurityException expected) { - // expected - } } assertFalse(new File(temp.getRoot(), "escape.txt").exists()); - assertFalse(new File(target, "nested").exists()); } @Test @@ -118,9 +85,6 @@ public void unzipToPathExtractsFilesAndDirectories() throws IOException { entries.put("index.bundlejs", utf8("console.log(1)")); File target = temp.newFolder(); try (SafeZipFile zip = new SafeZipFile(zipOf(entries))) { - SafeZipFile.Inspection inspection = zip.inspect(); - assertEquals(3, inspection.entries); - assertEquals(3 + "console.log(1)".length(), inspection.totalUncompressed); Enumeration it = zip.entries(); while (it.hasMoreElements()) { zip.unzipToPath(it.nextElement(), target); @@ -133,46 +97,7 @@ public void unzipToPathExtractsFilesAndDirectories() throws IOException { StandardCharsets.UTF_8)); } - @Test - public void inspectRejectsTooManyEntries() throws IOException { - Map entries = new LinkedHashMap<>(); - for (long i = 0; i <= ArchiveLimits.MAX_ENTRIES; i++) { - entries.put("e" + i, null); - } - try (SafeZipFile zip = new SafeZipFile(zipOf(entries))) { - zip.inspect(); - fail("entry count over the cap must be rejected"); - } catch (IOException expected) { - assertTrue(expected.getMessage().contains("too many entries")); - } - } - - @Test - public void inspectRejectsCompressionBombs() throws IOException { - // A megabyte of zeros deflates to about a kilobyte: far beyond the - // allowed ratio once the entry is large enough to be checked. - byte[] zeros = new byte[(int) ArchiveLimits.RATIO_CHECK_MIN_BYTES + 1]; - Map entries = new LinkedHashMap<>(); - entries.put("bomb.bin", zeros); - try (SafeZipFile zip = new SafeZipFile(zipOf(entries))) { - zip.inspect(); - fail("compression bomb must be rejected"); - } catch (IOException expected) { - assertTrue(expected.getMessage().contains("compression ratio")); - } - } - - @Test - public void inspectSkipsRatioCheckForSmallEntries() throws IOException { - // Same ratio, but under RATIO_CHECK_MIN_BYTES: small highly - // compressible files (JSON manifests) are legitimate. - byte[] zeros = new byte[64 * 1024]; - Map entries = new LinkedHashMap<>(); - entries.put("small.bin", zeros); - try (SafeZipFile zip = new SafeZipFile(zipOf(entries))) { - SafeZipFile.Inspection inspection = zip.inspect(); - assertEquals(1, inspection.entries); - assertEquals(zeros.length, inspection.totalUncompressed); - } - } + // The entry accounting (count, sizes, ratios, reserved names) is the + // shared core's: ArchiveLimits.checkEntries over JNI, which the JVM + // cannot load. cpp/patch_core/tests/archive-limits-vectors.json covers it. } diff --git a/android/src/test/java/cn/reactnative/modules/update/SyncCoordinatorTest.java b/android/src/test/java/cn/reactnative/modules/update/SyncCoordinatorTest.java new file mode 100644 index 00000000..413139a0 --- /dev/null +++ b/android/src/test/java/cn/reactnative/modules/update/SyncCoordinatorTest.java @@ -0,0 +1,101 @@ +package cn.reactnative.modules.update; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Test; + +public class SyncCoordinatorTest { + /** Settles every attempt synchronously from a per-url script. */ + private static final class ScriptedStarter implements SyncCoordinator.DownloadStarter { + final List started = new ArrayList<>(); + private final java.util.Map failures = new java.util.HashMap<>(); + + ScriptedStarter fail(String url, Throwable error) { + failures.put(url, error); + return this; + } + + @Override + public DownloadTaskParams start( + String type, String url, UpdateContext.DownloadFileListener listener, long deadlineNanos + ) { + started.add(type + " " + url); + Throwable error = failures.get(url); + if (error != null) { + listener.onDownloadFailed(error); + } else { + listener.onDownloadCompleted(new DownloadTaskParams()); + } + return null; + } + } + + private static JSONArray attempts(Object[]... attempts) throws Exception { + JSONArray result = new JSONArray(); + for (Object[] attempt : attempts) { + JSONObject entry = new JSONObject(); + entry.put("type", attempt[0]); + entry.put("urls", new JSONArray(Arrays.asList(attempt).subList(1, attempt.length))); + result.put(entry); + } + return result; + } + + private static PatchFailedException patchFailed() { + return new PatchFailedException("hpatch failed", null); + } + + @Test + public void patchFailureSkipsTheStrategysRemainingMirrors() throws Exception { + ScriptedStarter starter = new ScriptedStarter() + .fail("https://a/diff", patchFailed()) + .fail("https://a/full", new IOException("reset")); + boolean downloaded = SyncCoordinator.performAttempts( + attempts( + new Object[] {"diff", "https://a/diff", "https://b/diff"}, + new Object[] {"full", "https://a/full", "https://b/full"}), + 0, starter); + + assertTrue(downloaded); + assertEquals( + Arrays.asList("diff https://a/diff", "full https://a/full", "full https://b/full"), + starter.started); + } + + @Test + public void retryableFailuresTryEveryMirror() throws Exception { + ScriptedStarter starter = new ScriptedStarter() + .fail("https://a/full", new IOException("timeout")) + .fail("https://b/full", new FileOperationException("refused")); + boolean downloaded = SyncCoordinator.performAttempts( + attempts(new Object[] {"full", "https://a/full", "https://b/full"}), + 0, starter); + + assertFalse(downloaded); + assertEquals( + Arrays.asList("full https://a/full", "full https://b/full"), starter.started); + } + + // Attempt planning (diff needs a running version, https-only rounds drop + // cleartext URLs) and the https-only rule live in the shared core: + // cpp/update_flow_core/tests/native-round-vectors.json. + + @Test + public void onlyPatchFailuresAreNotMirrorRetryable() { + assertEquals("PATCH_FAILED", ErrorCodes.forDownloadFailure(patchFailed())); + assertEquals("FILE_OPERATION_FAILED", + ErrorCodes.forDownloadFailure(new FileOperationException("x"))); + assertEquals("DOWNLOAD_FAILED", ErrorCodes.forDownloadFailure(new IOException("x"))); + assertFalse(ErrorCodes.isMirrorRetryable("PATCH_FAILED")); + assertTrue(ErrorCodes.isMirrorRetryable("DOWNLOAD_FAILED")); + assertTrue(ErrorCodes.isMirrorRetryable("FILE_OPERATION_FAILED")); + } +} diff --git a/cpp/android_jni/archive_limits_jni.cpp b/cpp/android_jni/archive_limits_jni.cpp new file mode 100644 index 00000000..92c2a966 --- /dev/null +++ b/cpp/android_jni/archive_limits_jni.cpp @@ -0,0 +1,83 @@ +// JNI glue of ArchiveLimits.java over the shared extraction accounting +// (cpp/patch_core/archive_limits.h). +#include + +#include +#include + +#include "jni_natives.h" +#include "jni_util.h" +#include "../patch_core/archive_limits.h" + +namespace { + +using pushy::jni_util::GuardJni; +using pushy::jni_util::JArrayToVector; +using pushy::jni_util::ThrowRuntimeException; + +void ThrowIOException(JNIEnv* env, const std::string& message) { + jclass exception = env->FindClass("java/io/IOException"); + if (exception != nullptr) { + env->ThrowNew(exception, message.c_str()); + } +} + +} // namespace + +// One call per archive: the central directory's names and declared sizes +// through an EntryAccumulator. Returns the total uncompressed size, or throws +// an IOException with the first violation. +jlong pushy::jni_natives::ArchiveCheckEntries( + JNIEnv* env, jclass, jlong archive_bytes, jobjectArray names, + jlongArray sizes, jlongArray compressed_sizes) { + return GuardJni( + env, "Unexpected native exception while checking an archive", [&]() -> jlong { + const jsize count = names == nullptr ? 0 : env->GetArrayLength(names); + if (sizes == nullptr || compressed_sizes == nullptr || + env->GetArrayLength(sizes) != count || + env->GetArrayLength(compressed_sizes) != count) { + ThrowRuntimeException(env, "archive entry arrays differ in length"); + return 0; + } + // Before any name is copied: a hostile central directory may list far + // more entries than the cap. + pushy::delta::Status status = pushy::archive_limits::CheckEntryCount(count); + if (!status.ok) { + ThrowIOException(env, status.message); + return 0; + } + const std::vector entry_names = JArrayToVector(env, names); + std::vector size_values(static_cast(count)); + std::vector compressed_values(static_cast(count)); + if (count > 0) { + env->GetLongArrayRegion(sizes, 0, count, size_values.data()); + env->GetLongArrayRegion(compressed_sizes, 0, count, compressed_values.data()); + } + pushy::archive_limits::EntryAccumulator accumulator(archive_bytes); + for (jsize i = 0; i < count; i++) { + status = accumulator.Accept(size_values[i], compressed_values[i], false, + entry_names[i]); + if (!status.ok) { + ThrowIOException(env, status.message); + return 0; + } + } + return accumulator.total_uncompressed(); + }); +} + +// {bytes to probe for (0: no probe), reservation end} before one chunk of a +// download is written. +jlongArray pushy::jni_natives::ArchivePlanFreeSpaceProbe( + JNIEnv* env, jclass, jlong known_total, jlong written_before, + jlong chunk_bytes, jlong reserved_until) { + const pushy::archive_limits::FreeSpaceProbe plan = + pushy::archive_limits::PlanUnknownLengthFreeSpaceProbe( + known_total, written_before, chunk_bytes, reserved_until); + jlongArray result = env->NewLongArray(2); + if (result != nullptr) { + const jlong values[2] = {plan.probe ? plan.reserve : 0, plan.reserved_until}; + env->SetLongArrayRegion(result, 0, 2, values); + } + return result; +} diff --git a/cpp/android_jni/install_record_jni.cpp b/cpp/android_jni/install_record_jni.cpp new file mode 100644 index 00000000..a014159a --- /dev/null +++ b/cpp/android_jni/install_record_jni.cpp @@ -0,0 +1,89 @@ +// JNI glue of InstallRecord.java over the shared completion record core +// (cpp/patch_core/install_record.h). The jstring-returning natives return +// null on success and the reason otherwise; Java turns a reason into an +// IOException. +#include + +#include + +#include "jni_natives.h" +#include "jni_util.h" +#include "../patch_core/install_record.h" + +namespace { + +using pushy::jni_util::GuardJni; +using pushy::jni_util::JStringToString; +using pushy::jni_util::ToJString; + +jstring ReasonOrNull(JNIEnv* env, const pushy::delta::Status& status) { + return status.ok ? nullptr : ToJString(env, status.message); +} + +} // namespace + +jboolean pushy::jni_natives::InstallRecordIsComplete( + JNIEnv* env, jclass, jstring version_dir, jstring version_hash, + jstring bundle_file_name) { + return GuardJni( + env, "Unexpected native exception while reading a completion record", [&] { + return pushy::install_record::IsComplete( + JStringToString(env, version_dir), + JStringToString(env, version_hash), + JStringToString(env, bundle_file_name)) + ? JNI_TRUE + : JNI_FALSE; + }); +} + +// Hashes the whole bundle: called from worker threads only. +jstring pushy::jni_natives::InstallRecordCheckActivation( + JNIEnv* env, jclass, jstring version_dir, jstring version_hash, + jstring bundle_file_name, jboolean grandfathered) { + return GuardJni( + env, "Unexpected native exception while verifying a version", [&] { + pushy::install_record::Activation activation; + return ReasonOrNull( + env, pushy::install_record::CheckActivation( + JStringToString(env, version_dir), + JStringToString(env, version_hash), + JStringToString(env, bundle_file_name), + grandfathered == JNI_TRUE, &activation)); + }); +} + +jstring pushy::jni_natives::InstallRecordSeal( + JNIEnv* env, jclass, jstring staging_dir, jstring version_hash, + jstring bundle_file_name, jstring artifact_sha256, + jstring known_bundle_sha256) { + return GuardJni( + env, "Unexpected native exception while writing a completion record", [&] { + return ReasonOrNull( + env, pushy::install_record::Seal( + JStringToString(env, staging_dir), + JStringToString(env, version_hash), + JStringToString(env, bundle_file_name), + JStringToString(env, artifact_sha256), + JStringToString(env, known_bundle_sha256)) + .status); + }); +} + +jstring pushy::jni_natives::InstallRecordPromote( + JNIEnv* env, jclass, jstring staging_dir, jstring version_dir) { + return GuardJni( + env, "Unexpected native exception while moving a staged version", [&] { + return ReasonOrNull( + env, pushy::install_record::Promote(JStringToString(env, staging_dir), + JStringToString(env, version_dir))); + }); +} + +jstring pushy::jni_natives::InstallRecordBundleSha256( + JNIEnv* env, jclass, jstring version_dir) { + return GuardJni( + env, "Unexpected native exception while reading a completion record", [&] { + return ToJString(env, pushy::install_record::RecordedBundleSha256( + JStringToString(env, version_dir))); + }); +} diff --git a/cpp/patch_core/jni_log.h b/cpp/android_jni/jni_log.h similarity index 83% rename from cpp/patch_core/jni_log.h rename to cpp/android_jni/jni_log.h index d667f698..f65a3e0f 100644 --- a/cpp/patch_core/jni_log.h +++ b/cpp/android_jni/jni_log.h @@ -1,9 +1,9 @@ -#ifndef PUSHY_PATCH_CORE_JNI_LOG_H_ -#define PUSHY_PATCH_CORE_JNI_LOG_H_ +#ifndef PUSHY_ANDROID_JNI_LOG_H_ +#define PUSHY_ANDROID_JNI_LOG_H_ #include -#include "obscured_text.h" +#include "../patch_core/obscured_text.h" // Keep the core internal to whichever binary links it (see podspec Core). #pragma GCC visibility push(hidden) @@ -28,4 +28,4 @@ inline const char* Tag() { #define PUSHY_JNI_LOGE(...) \ __android_log_print(ANDROID_LOG_ERROR, ::pushy::jni_log::Tag(), __VA_ARGS__) -#endif // PUSHY_PATCH_CORE_JNI_LOG_H_ +#endif // PUSHY_ANDROID_JNI_LOG_H_ diff --git a/cpp/android_jni/jni_natives.h b/cpp/android_jni/jni_natives.h new file mode 100644 index 00000000..6e195a97 --- /dev/null +++ b/cpp/android_jni/jni_natives.h @@ -0,0 +1,40 @@ +#ifndef PUSHY_ANDROID_JNI_NATIVES_H_ +#define PUSHY_ANDROID_JNI_NATIVES_H_ + +#include + +// Android native methods. They are registered by name from JNI_OnLoad +// (jni_registration.cpp) instead of being exported as Java_* symbols, so the +// shared library's dynamic symbol table carries no class or method names. +namespace pushy { +namespace jni_natives { + +// Called once from JNI_OnLoad: caches the JNI result classes (global refs), +// their constructors and field IDs. Returns false (after logging what is +// missing) when any lookup failed; the natives that need the missing class +// then throw a RuntimeException instead of defaulting fields. +bool InitResultClassCache(JNIEnv* env); + +jint SupportedDiffVersion(JNIEnv*, jclass); +jobject SyncStateWithBinaryVersion(JNIEnv* env, jclass, jstring package_version, jstring build_time, jobject state_result); +jobject RunStateCore(JNIEnv* env, jclass, jint operation, jobject state_result, jstring string_arg, jboolean flag_a, jboolean flag_b); +jobjectArray ApplyArchiveDelta(JNIEnv* env, jclass, jint patch_type, jobjectArray entry_names, jbyteArray manifest_json, jstring source_root, jstring target_root, jstring origin_bundle_path, jstring bundle_patch_path, jstring bundle_output_path, jboolean host_copies_resources); +void CleanupOldEntries(JNIEnv* env, jclass, jstring root_dir, jobjectArray keep_names, jint max_age_days, jstring trash_dir); +jstring FlowPrepareRound(JNIEnv* env, jclass, jstring configJson, jstring envJson, jdouble randomSample); +jstring FlowNextQueryStep(JNIEnv* env, jclass, jstring stateJson, jstring responseText); +jstring FlowHandleResponse(JNIEnv* env, jclass, jstring responseText, jstring configJson, jstring envJson, jstring requestBody, jlong responseAtSeconds); +jboolean FlowConfigsEquivalent(JNIEnv* env, jclass, jstring a, jstring b); +jstring FlowRunnableConfig(JNIEnv* env, jclass, jstring configJson); +jstring FlowNormalizeConfiguration(JNIEnv* env, jclass, jstring optionsJson); +jboolean InstallRecordIsComplete(JNIEnv* env, jclass, jstring version_dir, jstring version_hash, jstring bundle_file_name); +jstring InstallRecordCheckActivation(JNIEnv* env, jclass, jstring version_dir, jstring version_hash, jstring bundle_file_name, jboolean grandfathered); +jstring InstallRecordSeal(JNIEnv* env, jclass, jstring staging_dir, jstring version_hash, jstring bundle_file_name, jstring artifact_sha256, jstring known_bundle_sha256); +jstring InstallRecordPromote(JNIEnv* env, jclass, jstring staging_dir, jstring version_dir); +jstring InstallRecordBundleSha256(JNIEnv* env, jclass, jstring version_dir); +jlong ArchiveCheckEntries(JNIEnv* env, jclass, jlong archive_bytes, jobjectArray names, jlongArray sizes, jlongArray compressed_sizes); +jlongArray ArchivePlanFreeSpaceProbe(JNIEnv* env, jclass, jlong known_total, jlong written_before, jlong chunk_bytes, jlong reserved_until); + +} // namespace jni_natives +} // namespace pushy + +#endif // PUSHY_ANDROID_JNI_NATIVES_H_ diff --git a/cpp/patch_core/jni_registration.cpp b/cpp/android_jni/jni_registration.cpp similarity index 56% rename from cpp/patch_core/jni_registration.cpp rename to cpp/android_jni/jni_registration.cpp index 13bca7c4..e5a7546c 100644 --- a/cpp/patch_core/jni_registration.cpp +++ b/cpp/android_jni/jni_registration.cpp @@ -11,7 +11,7 @@ #include "jni_log.h" #include "jni_natives.h" -#include "obscured_text.h" +#include "../patch_core/obscured_text.h" namespace { @@ -42,25 +42,45 @@ const std::vector& NativeClasses() { }}, {"3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c84b28d79583e0feffca49194", // DownloadTask { - {"3802fdddaaaa7a462a360afce6bf9163", "723ecffda48a7e446d331df7d1fca379582e0ae6a5e0949f73592d46eac2aebad544402307e5cffeb9b376582012bfc1ab89630e6d2f0afcdca8d7526a29010bfb98b890604c6736f6edd5b79128190109e98bb3bb9a7b413c0ef8c0b0862f70553301fdcbb8c770525b3d0df39c919f694f2d171bcbd9a191677c2507edf2d8a982784575", - reinterpret_cast(&pushy::jni_natives::BuildArchivePlan)}, - {"3802fdddaaa867553b180ef6c3a383", "722cd8dbaf9d690a2e3e12fe9980847f432903bac5f7b294644e6305e7cda7f2a963463800ec93ecb9b37f577901f5cca9936a404a320ef09da2836d532f050eb5c2a4956f5f2d4ac1f0cca0b1615f381ad5c1b2ab976c0e", - reinterpret_cast(&pushy::jni_natives::BuildCopyGroups)}, - {"3b07e4ddb7af6d49363e3aebd9bea3625f3507e4", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655c7c2a4949e3355371df78299937648503c43d9d8ae9a68092f0113fd9887857c422602b9d3d6b880721f210be9c3ee8d8f6a5c3c08b7e5ac82767c153b15ffc9e4bb715056321eade98ba16046320051f7d9bb95207f3d14eacedae1ac585b2f1de98aaebe927e190004ffc3a9833a45773214e4cee385674d277229e3c6b8806c1309081eead8f99f71432d48d7d5ccb2967209663a", - reinterpret_cast(&pushy::jni_natives::ApplyDeltaFromSource)}, + {"3b07e4ddb7aa7a462a360afcf2b69c794b", "723ecffda48a7e446d331df7d1fca379582e0ae6a5e09ab9784e3a08a9cfa1b39d3867251ce2c6a2d9b376582012bfc1ab89630e6d2f0afcdca8d7454c22161cb5dbb59f69041b11f0f6d2becd5f5a2c1ce68badbf957f1a011bfec0a8843b51503602f081a7896b45100f0de4dabe8a317d6d3a32f8d6fa806a4d2a12edc1c9b381711e2304ecd0aeba8f36432314ecdea2cb42714b2132e0c0b9995446332816e38f", + reinterpret_cast(&pushy::jni_natives::ApplyArchiveDelta)}, {"391bf1d0a09e786a2e3b39f7c2a1996859", "723bfed0b88a274923311bb6e5a7826444205fdad2d1b98373002008e8c4ef8e8e655d3f09b0e189889e6a58791ff1c3adc857554c3216f289e6ba", reinterpret_cast(&pushy::jni_natives::CleanupOldEntries)}, }}, {"3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c86b19560762307efcfa0", // FlowBridge { - {"3802fdddaab96d54373a0fedf4bc9474", "723bfed0b88a274923311bb6e5a7826444205fa8d2d1b98373002008e8c4ef8e8e655d3f09b0", - reinterpret_cast(&pushy::jni_natives::FlowBuildRequestBody)}, - {"3505f0d4bcae6641323015f7c29091634e2e00e0eadeab", "723bfed0b88a274923311bb6e5a7826444205fc5b7f7b294644e6305e7cda7f2a963463800ec93", - reinterpret_cast(&pushy::jni_natives::FlowOrderEndpointCandidates)}, - {"3304c2d0a2826c77272c0cf6d8a095", "723bfed0b88a274923311bb6e5a7826444205fa8c4", - reinterpret_cast(&pushy::jni_natives::FlowIsValidResponse)}, - {"3216fad5a28e5a40312f13f7c5b6", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655c7c2a4949e3355371df78299937648503c43bcfea58d7f476c0c1cf4d0fba27a59210be5a4", + {"2a05f1c1af996d772d2a12fd", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655cf8189889e6a58791ff1c3adc857554c3216f289", + reinterpret_cast(&pushy::jni_natives::FlowPrepareRound)}, + {"3412ecc59f9e6d573b0c08fcc6", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655a2e4af83897d163a12fecae5b4705357351fae", + reinterpret_cast(&pushy::jni_natives::FlowNextQueryStep)}, + {"3216fad5a28e5a40312f13f7c5b6", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655c7c2a4949e3355371df78299937648503c43d9d8ae9a68092f0113fd9887857c422602b9d595959c72462c45ebc5afb9d44b412006e2cefd", reinterpret_cast(&pushy::jni_natives::FlowHandleResponse)}, + {"3918fad7a78c7b60332a15efd7bf95635e", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655a2f2", + reinterpret_cast(&pushy::jni_natives::FlowConfigsEquivalent)}, + {"2802fadfaf896440013012ffdfb4", "723bfed0b88a274923311bb6e5a7826444205fa8d2d1b98373002008e8c4ef8e8e655d3f09b0", + reinterpret_cast(&pushy::jni_natives::FlowRunnableConfig)}, + {"3418e6dcaf87615f271c13f7d0ba9778582610e8f1d5", "723bfed0b88a274923311bb6e5a7826444205fa8d2d1b98373002008e8c4ef8e8e655d3f09b0", + reinterpret_cast(&pushy::jni_natives::FlowNormalizeConfiguration)}, + }}, + {"3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c89b38963553d02d9cda68d8d78", // InstallRecord + { + {"3304d7dea39b6440363a", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655c7c2a4949e3355371df78299937648503c43bce8", + reinterpret_cast(&pushy::jni_natives::InstallRecordIsComplete)}, + {"391ff1d2a5aa6b512b291deddfbc9e", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655c7c2a4949e3355371df78299937648503c43cf9b83866850224f11fbd9b3de5d5f3a0cecf887", + reinterpret_cast(&pushy::jni_natives::InstallRecordCheckActivation)}, + {"2912f5dd9d9f69422b311b", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655c7c2a4949e3355371df78299937648503c43d9d8ae9a68092f0113fd9887857c422602b9d3d6b880721f210be9c3ee8d8f6a5c3c08b7808a89616b5b7818f0c0acc756564d3517f188", + reinterpret_cast(&pushy::jni_natives::InstallRecordSeal)}, + {"2a05fbdca19f6d76363e1bf0d8b4", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655a2e4af83897d163a12fecae5b4705357351fae", + reinterpret_cast(&pushy::jni_natives::InstallRecordPromote)}, + {"3802fad5a28e5b4d236d49aff9b5", "723bfed0b88a274923311bb6e5a7826444205fa8d2d1b98373002008e8c4ef8e8e655d3f09b0", + reinterpret_cast(&pushy::jni_natives::InstallRecordBundleSha256)}, + }}, + {"3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c81af997f5d270bc7c1a88b8b6f", // ArchiveLimits + { + {"391ff1d2a5ae6651303619ea", "723dcffda48a7e446d331df7d1fca379582e0ae6a5e092ae580606", + reinterpret_cast(&pushy::jni_natives::ArchiveCheckEntries)}, + {"2a1bf5df88996d40112f1dfad38382624822", "723ddefb84c2536f", + reinterpret_cast(&pushy::jni_natives::ArchivePlanFreeSpaceProbe)}, }}, }; return classes; diff --git a/cpp/android_jni/jni_util.h b/cpp/android_jni/jni_util.h new file mode 100644 index 00000000..0065f865 --- /dev/null +++ b/cpp/android_jni/jni_util.h @@ -0,0 +1,160 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +// Small JNI helpers shared by every Android glue translation unit +// (update_core_android.cpp and update_flow_jni.cpp). Header-only with inline +// linkage so each can include it without a separate compilation unit. + +namespace pushy { +namespace jni_util { + +inline std::string JStringToString(JNIEnv* env, jstring value) { + if (value == nullptr) { + return std::string(); + } + + const char* chars = env->GetStringUTFChars(value, nullptr); + if (chars == nullptr) { + return std::string(); + } + + std::string result(chars); + env->ReleaseStringUTFChars(value, chars); + return result; +} + +// Standard UTF-8 -> Java string. NewStringUTF expects JNI's *modified* UTF-8 +// and corrupts supplementary code points (4-byte sequences, e.g. emoji), so +// decode explicitly and pass UTF-16 code units to NewString. Encoded +// surrogate code points are accepted too: strings that originated in Java may +// have entered through modified UTF-8 (JStringToString). Invalid bytes become +// U+FFFD. Returns nullptr when the result would not fit a jsize. +inline jstring ToJString(JNIEnv* env, const std::string& value) { + std::vector utf16; + utf16.reserve(value.size()); + size_t i = 0; + while (i < value.size()) { + const unsigned char first = static_cast(value[i]); + uint32_t codePoint = 0xfffd; + size_t width = 1; + if (first < 0x80) { + codePoint = first; + } else if (first >= 0xc2 && first <= 0xdf && i + 1 < value.size()) { + const unsigned char b1 = static_cast(value[i + 1]); + if ((b1 & 0xc0) == 0x80) { + codePoint = ((first & 0x1f) << 6) | (b1 & 0x3f); + width = 2; + } + } else if (first == 0xc0 && i + 1 < value.size() && + static_cast(value[i + 1]) == 0x80) { + codePoint = 0; // modified UTF-8 encoding of U+0000 + width = 2; + } else if (first >= 0xe0 && first <= 0xef && i + 2 < value.size()) { + const unsigned char b1 = static_cast(value[i + 1]); + const unsigned char b2 = static_cast(value[i + 2]); + if ((b1 & 0xc0) == 0x80 && (b2 & 0xc0) == 0x80 && + !(first == 0xe0 && b1 < 0xa0)) { + codePoint = ((first & 0x0f) << 12) | ((b1 & 0x3f) << 6) | + (b2 & 0x3f); + width = 3; + } + } else if (first >= 0xf0 && first <= 0xf4 && i + 3 < value.size()) { + const unsigned char b1 = static_cast(value[i + 1]); + const unsigned char b2 = static_cast(value[i + 2]); + const unsigned char b3 = static_cast(value[i + 3]); + if ((b1 & 0xc0) == 0x80 && (b2 & 0xc0) == 0x80 && + (b3 & 0xc0) == 0x80 && !(first == 0xf0 && b1 < 0x90) && + !(first == 0xf4 && b1 > 0x8f)) { + codePoint = ((first & 0x07) << 18) | ((b1 & 0x3f) << 12) | + ((b2 & 0x3f) << 6) | (b3 & 0x3f); + width = 4; + } + } + i += width; + if (codePoint <= 0xffff) { + utf16.push_back(static_cast(codePoint)); + } else { + codePoint -= 0x10000; + utf16.push_back(static_cast(0xd800 + (codePoint >> 10))); + utf16.push_back(static_cast(0xdc00 + (codePoint & 0x3ff))); + } + } + if (utf16.size() > static_cast(std::numeric_limits::max())) { + return nullptr; + } + return env->NewString(utf16.empty() ? nullptr : utf16.data(), + static_cast(utf16.size())); +} + +inline std::vector JArrayToVector( + JNIEnv* env, + jobjectArray values) { + std::vector result; + if (values == nullptr) { + return result; + } + + const jsize size = env->GetArrayLength(values); + result.reserve(static_cast(size)); + for (jsize index = 0; index < size; ++index) { + auto* item = static_cast(env->GetObjectArrayElement(values, index)); + result.push_back(JStringToString(env, item)); + env->DeleteLocalRef(item); + } + return result; +} + +inline void ThrowRuntimeException(JNIEnv* env, const char* message) { + jclass exception = env->FindClass("java/lang/RuntimeException"); + if (exception != nullptr) { + env->ThrowNew(exception, message); + } +} + +inline void ThrowRuntimeException(JNIEnv* env, const std::string& message) { + ThrowRuntimeException(env, message.c_str()); +} + +// The bytes of a Java byte[] as-is (an empty string for null). +inline std::string JByteArrayToString(JNIEnv* env, jbyteArray values) { + if (values == nullptr) { + return std::string(); + } + const jsize size = env->GetArrayLength(values); + std::string result(static_cast(size), '\0'); + if (size > 0) { + env->GetByteArrayRegion(values, 0, size, + reinterpret_cast(&result[0])); + } + return result; +} + +// Runs a native method body so no C++ exception (bad_alloc/length_error from +// input-proportional STL allocations) ever unwinds through the JNI boundary: +// it becomes a Java RuntimeException like every other failure of the entry +// point, carrying `fallback_message` when the exception has no message of its +// own. Returns a value-initialized Result (nullptr / void) in that case. +template +auto GuardJni(JNIEnv* env, const char* fallback_message, Body&& body) + -> decltype(body()) { + using Result = decltype(body()); + try { + return std::forward(body)(); + } catch (const std::exception& error) { + ThrowRuntimeException(env, error.what()); + } catch (...) { + ThrowRuntimeException(env, fallback_message); + } + return Result(); +} + +} // namespace jni_util +} // namespace pushy diff --git a/cpp/patch_core/update_core_android.cpp b/cpp/android_jni/update_core_android.cpp similarity index 54% rename from cpp/patch_core/update_core_android.cpp rename to cpp/android_jni/update_core_android.cpp index 00a2d684..540813d2 100644 --- a/cpp/patch_core/update_core_android.cpp +++ b/cpp/android_jni/update_core_android.cpp @@ -2,23 +2,26 @@ #include "jni_log.h" #include "jni_natives.h" -#include "obscured_text.h" +#include "../patch_core/obscured_text.h" -#include #include #include -#include "archive_patch_core.h" -#include "hbc_transform_wire.h" +#include "../patch_core/archive_patch_core.h" +#include "../patch_core/hbc_transform_wire.h" +#include "../patch_core/patch_core.h" #include "jni_util.h" -#include "state_core.h" -#include "state_ops.h" +#include "../patch_core/state_core.h" +#include "../patch_core/state_ops.h" namespace { +using pushy::jni_util::GuardJni; using pushy::jni_util::JArrayToVector; +using pushy::jni_util::JByteArrayToString; using pushy::jni_util::JStringToString; using pushy::jni_util::ThrowRuntimeException; +using pushy::jni_util::ToJString; using pushy::state_ops::StateOperation; constexpr const char* kStringSig = "Ljava/lang/String;"; @@ -44,14 +47,6 @@ struct StateResultIds { jfieldID consumed_first_time = nullptr; }; -struct PlanResultIds { - bool ready = false; - jclass clazz = nullptr; - jmethodID constructor = nullptr; - jfieldID merge_source_subdir = nullptr; - jfieldID enable_merge = nullptr; -}; - struct CopyGroupIds { bool ready = false; jclass clazz = nullptr; @@ -59,10 +54,10 @@ struct CopyGroupIds { jmethodID constructor = nullptr; jfieldID from = nullptr; jfieldID to_paths = nullptr; + jfieldID expected_crc = nullptr; }; StateResultIds g_state; -PlanResultIds g_plan; CopyGroupIds g_copy_group; // Resolves a class to a global ref. Logs and clears the pending exception on @@ -135,22 +130,6 @@ bool InitStateResultIds(JNIEnv* env) { return ok; } -bool InitPlanResultIds(JNIEnv* env) { - const std::string name = pushy::text::Reveal( - "3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c81af997f5d270bdbc9b181974c55371dc2c8b9926855"); - PlanResultIds ids; - ids.clazz = FindGlobalClass(env, name); - if (ids.clazz == nullptr) { - return false; - } - bool ok = LookupConstructor(env, ids.clazz, name, &ids.constructor); - ok &= LookupField(env, ids.clazz, name, "mergeSourceSubdir", kStringSig, &ids.merge_source_subdir); - ok &= LookupField(env, ids.clazz, name, "enableMerge", "Z", &ids.enable_merge); - ids.ready = ok; - g_plan = ids; - return ok; -} - bool InitCopyGroupIds(JNIEnv* env) { const std::string name = pushy::text::Reveal( "3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c83b28a6e732301fed897878c695522"); @@ -163,6 +142,7 @@ bool InitCopyGroupIds(JNIEnv* env) { bool ok = LookupConstructor(env, ids.clazz, name, &ids.constructor); ok &= LookupField(env, ids.clazz, name, "from", kStringSig, &ids.from); ok &= LookupField(env, ids.clazz, name, "toPaths", "[Ljava/lang/String;", &ids.to_paths); + ok &= LookupField(env, ids.clazz, name, "expectedCrc", "J", &ids.expected_crc); ids.ready = ok; g_copy_group = ids; return ok; @@ -189,7 +169,7 @@ void SetStringField( return; } - jstring java_value = env->NewStringUTF(value.c_str()); + jstring java_value = ToJString(env, value); env->SetObjectField(target, field, java_value); env->DeleteLocalRef(java_value); } @@ -232,11 +212,11 @@ pushy::state::State ReadStateFromResult(JNIEnv* env, jobject state_result) { jobject NewStateCoreResult( JNIEnv* env, const pushy::state::State& state, - bool changed, - const std::string& stale_version_to_delete, - const std::string& load_version, - bool did_rollback, - bool consumed_first_time) { + bool changed = false, + const std::string& stale_version_to_delete = std::string(), + const std::string& load_version = std::string(), + bool did_rollback = false, + bool consumed_first_time = false) { jobject result = env->NewObject(g_state.clazz, g_state.constructor); if (result == nullptr) { return nullptr; @@ -260,22 +240,6 @@ jobject NewStateCoreResult( return result; } -jobject NewArchivePatchPlanResult( - JNIEnv* env, - const pushy::archive_patch::ArchivePatchPlan& plan) { - if (!RequireReady(env, g_plan.ready, "archive plan result")) { - return nullptr; - } - jobject result = env->NewObject(g_plan.clazz, g_plan.constructor); - if (result == nullptr) { - return nullptr; - } - - SetStringField(env, result, g_plan.merge_source_subdir, plan.merge_source_subdir); - SetBooleanField(env, result, g_plan.enable_merge, plan.enable_merge); - return result; -} - // Caller has checked g_copy_group.ready. jobject NewCopyGroupResult( JNIEnv* env, @@ -292,63 +256,25 @@ jobject NewCopyGroupResult( if (to_paths != nullptr) { for (jsize index = 0; index < static_cast(group.to_paths.size()); ++index) { - jstring value = env->NewStringUTF(group.to_paths[index].c_str()); + jstring value = ToJString(env, group.to_paths[index]); env->SetObjectArrayElement(to_paths, index, value); env->DeleteLocalRef(value); } env->SetObjectField(result, g_copy_group.to_paths, to_paths); env->DeleteLocalRef(to_paths); } + env->SetLongField( + result, g_copy_group.expected_crc, + group.has_expected_crc ? static_cast(group.expected_crc) : -1); return result; } -bool ToArchivePatchType( - jint patch_type, - pushy::archive_patch::ArchivePatchType* out) { - // Delegate to the shared parser so Android and HarmonyOS agree on which - // integer values are valid and neither silently falls back to kFull. - return pushy::archive_patch::TryParseArchivePatchType( - static_cast(patch_type), out); -} - -pushy::delta::PatchManifest BuildManifest( - const std::vector& copy_froms, - const std::vector& copy_tos, - const std::vector& deletes) { - pushy::delta::PatchManifest manifest; - for (size_t index = 0; index < copy_froms.size(); ++index) { - manifest.copies.push_back( - pushy::delta::CopyOperation{copy_froms[index], copy_tos[index]}); - } - manifest.deletes = deletes; - return manifest; -} - -jobject MakeStateResult( - JNIEnv* env, - const pushy::state::State& state, - bool changed = false, - const std::string& stale_version_to_delete = std::string(), - const std::string& load_version = std::string(), - bool did_rollback = false, - bool consumed_first_time = false) { - return NewStateCoreResult( - env, - state, - changed, - stale_version_to_delete, - load_version, - did_rollback, - consumed_first_time); -} - } // namespace bool pushy::jni_natives::InitResultClassCache(JNIEnv* env) { // Every class is attempted so the log names all missing members at once. bool ok = InitStateResultIds(env); - ok &= InitPlanResultIds(env); ok &= InitCopyGroupIds(env); return ok; } @@ -367,25 +293,20 @@ jobject pushy::jni_natives::SyncStateWithBinaryVersion( jstring package_version, jstring build_time, jobject state_result) { - // C++ exceptions (bad_alloc/length_error from input-proportional STL - // allocations) must not unwind through the JNI boundary: convert them to a - // Java RuntimeException like every other failure of these entry points. - try { - if (!RequireReady(env, g_state.ready, "state result")) { - return nullptr; - } - pushy::state::State state = ReadStateFromResult(env, state_result); - pushy::state::BinaryVersionSyncResult result = pushy::state::SyncBinaryVersion( - state, - JStringToString(env, package_version), - JStringToString(env, build_time)); - return MakeStateResult(env, result.state, result.changed); - } catch (const std::exception& error) { - ThrowRuntimeException(env, error.what()); - } catch (...) { - ThrowRuntimeException(env, "Unexpected native exception in syncStateWithBinaryVersion"); - } - return nullptr; + return GuardJni( + env, "Unexpected native exception in syncStateWithBinaryVersion", + [&]() -> jobject { + if (!RequireReady(env, g_state.ready, "state result")) { + return nullptr; + } + pushy::state::State state = ReadStateFromResult(env, state_result); + pushy::state::BinaryVersionSyncResult result = + pushy::state::SyncBinaryVersion( + state, + JStringToString(env, package_version), + JStringToString(env, build_time)); + return NewStateCoreResult(env, result.state, result.changed); + }); } jobject pushy::jni_natives::RunStateCore( @@ -396,143 +317,134 @@ jobject pushy::jni_natives::RunStateCore( jstring string_arg, jboolean flag_a, jboolean flag_b) { - try { - if (!RequireReady(env, g_state.ready, "state result")) { - return nullptr; - } - const pushy::state::State state = ReadStateFromResult(env, state_result); - switch (static_cast(operation)) { - case StateOperation::kSwitchVersion: - return MakeStateResult( - env, - pushy::state::SwitchVersion(state, JStringToString(env, string_arg))); - case StateOperation::kMarkSuccess: { - const pushy::state::MarkSuccessResult result = - pushy::state::MarkSuccess(state); - return MakeStateResult( - env, - result.state, - false, - result.stale_version_to_delete); - } - case StateOperation::kRollback: { - const pushy::state::State next = pushy::state::Rollback(state); - return MakeStateResult( - env, next, false, std::string(), next.current_version, true); - } - case StateOperation::kClearFirstTime: - return MakeStateResult(env, pushy::state::ClearFirstTime(state)); - case StateOperation::kClearRollbackMark: - return MakeStateResult(env, pushy::state::ClearRollbackMark(state)); - case StateOperation::kResolveLaunch: { - const pushy::state::LaunchDecision decision = - pushy::state::ResolveLaunchState( - state, flag_a == JNI_TRUE, flag_b == JNI_TRUE); - return MakeStateResult( - env, - decision.state, - false, - std::string(), - decision.load_version, - decision.did_rollback, - decision.consumed_first_time); - } - } - - ThrowRuntimeException(env, "Unknown state operation"); - } catch (const std::exception& error) { - ThrowRuntimeException(env, error.what()); - } catch (...) { - ThrowRuntimeException(env, "Unexpected native exception in runStateCore"); - } - return nullptr; + return GuardJni( + env, "Unexpected native exception in runStateCore", [&]() -> jobject { + if (!RequireReady(env, g_state.ready, "state result")) { + return nullptr; + } + const pushy::state::State state = ReadStateFromResult(env, state_result); + switch (static_cast(operation)) { + case StateOperation::kSwitchVersion: + return NewStateCoreResult( + env, + pushy::state::SwitchVersion( + state, JStringToString(env, string_arg))); + case StateOperation::kMarkSuccess: { + const pushy::state::MarkSuccessResult result = + pushy::state::MarkSuccess(state); + return NewStateCoreResult( + env, result.state, false, result.stale_version_to_delete); + } + case StateOperation::kRollback: { + const pushy::state::State next = pushy::state::Rollback(state); + return NewStateCoreResult( + env, next, false, std::string(), next.current_version, true); + } + case StateOperation::kClearFirstTime: + return NewStateCoreResult(env, pushy::state::ClearFirstTime(state)); + case StateOperation::kClearRollbackMark: + return NewStateCoreResult( + env, pushy::state::ClearRollbackMark(state)); + case StateOperation::kResolveLaunch: { + const pushy::state::LaunchDecision decision = + pushy::state::ResolveLaunchState( + state, flag_a == JNI_TRUE, flag_b == JNI_TRUE); + return NewStateCoreResult( + env, + decision.state, + false, + std::string(), + decision.load_version, + decision.did_rollback, + decision.consumed_first_time); + } + } + + ThrowRuntimeException(env, "Unknown state operation"); + return nullptr; + }); } -jobject pushy::jni_natives::BuildArchivePlan( +jobjectArray pushy::jni_natives::ApplyArchiveDelta( JNIEnv* env, jclass, jint patch_type, jobjectArray entry_names, - jobjectArray copy_froms, - jobjectArray copy_tos, - jobjectArray deletes) { - try { - const std::vector from_values = JArrayToVector(env, copy_froms); - const std::vector to_values = JArrayToVector(env, copy_tos); - if (from_values.size() != to_values.size()) { - ThrowRuntimeException(env, "copy_froms and copy_tos length mismatch"); - return nullptr; - } - - pushy::delta::PatchManifest manifest = - BuildManifest(from_values, to_values, JArrayToVector(env, deletes)); - pushy::archive_patch::ArchivePatchType archive_type; - if (!ToArchivePatchType(patch_type, &archive_type)) { - ThrowRuntimeException(env, "Unknown archive delta type"); - return nullptr; - } - pushy::archive_patch::ArchivePatchPlan plan; - pushy::delta::Status status = pushy::archive_patch::BuildArchivePatchPlan( - archive_type, - manifest, - JArrayToVector(env, entry_names), - &plan); - if (!status.ok) { - ThrowRuntimeException(env, status.message); - return nullptr; - } - - return NewArchivePatchPlanResult(env, plan); - } catch (const std::exception& error) { - ThrowRuntimeException(env, error.what()); - } catch (...) { - ThrowRuntimeException(env, "Unexpected native exception while planning archive"); - } - return nullptr; + jbyteArray manifest_json, + jstring source_root, + jstring target_root, + jstring origin_bundle_path, + jstring bundle_patch_path, + jstring bundle_output_path, + jboolean host_copies_resources) { + return GuardJni( + env, "Unexpected native exception while applying archive delta", + [&]() -> jobjectArray { + if (!RequireReady(env, g_copy_group.ready, "copy group result")) { + return nullptr; + } + pushy::archive_patch::ArchivePatchRequest request; + // Unknown values never fall back to kFull (which would skip the + // manifest and entry checks). + if (!pushy::archive_patch::TryParseArchivePatchType( + static_cast(patch_type), &request.archive.type)) { + ThrowRuntimeException(env, "Unknown archive delta type"); + return nullptr; + } + request.archive.entry_names = JArrayToVector(env, entry_names); + // The manifest's own UTF-8 bytes: a jstring would come through as + // modified UTF-8 and mangle supplementary characters in paths. + request.archive.manifest_json = JByteArrayToString(env, manifest_json); + request.source_root = JStringToString(env, source_root); + request.target_root = JStringToString(env, target_root); + request.origin_bundle_path = JStringToString(env, origin_bundle_path); + request.bundle_patch_path = JStringToString(env, bundle_patch_path); + request.bundle_output_path = JStringToString(env, bundle_output_path); + request.host_copies_resources = host_copies_resources == JNI_TRUE; + + std::vector groups; + const pushy::delta::Status status = + pushy::archive_patch::ApplyArchivePatch(request, &groups); + if (!status.ok) { + ThrowRuntimeException(env, status.message); + return nullptr; + } + + jobjectArray result = env->NewObjectArray( + static_cast(groups.size()), g_copy_group.clazz, nullptr); + if (result == nullptr) { + return nullptr; + } + for (jsize index = 0; index < static_cast(groups.size()); + ++index) { + jobject group = NewCopyGroupResult(env, groups[index]); + env->SetObjectArrayElement(result, index, group); + env->DeleteLocalRef(group); + } + return result; + }); } -jobjectArray pushy::jni_natives::BuildCopyGroups( +void pushy::jni_natives::CleanupOldEntries( JNIEnv* env, jclass, - jobjectArray copy_froms, - jobjectArray copy_tos) { - try { - const std::vector from_values = JArrayToVector(env, copy_froms); - const std::vector to_values = JArrayToVector(env, copy_tos); - if (from_values.size() != to_values.size()) { - ThrowRuntimeException(env, "copy_froms and copy_tos length mismatch"); - return nullptr; - } - - pushy::delta::PatchManifest manifest = BuildManifest( - from_values, to_values, std::vector()); - std::vector groups; - pushy::delta::Status status = - pushy::archive_patch::BuildCopyGroups(manifest, &groups); + jstring root_dir, + jobjectArray keep_names, + jint max_age_days, + jstring trash_dir) { + GuardJni(env, "Unexpected native exception in cleanupOldEntries", [&] { + const std::string root = JStringToString(env, root_dir); + const std::vector keep = JArrayToVector(env, keep_names); + const std::string trash = JStringToString(env, trash_dir); + // A trash directory turns the delete into renames (cheap enough to run + // under the caller's version-files lock); the caller purges it later. + const pushy::delta::Status status = trash.empty() + ? pushy::delta::CleanupOldEntries( + root, keep, static_cast(max_age_days)) + : pushy::delta::MoveOldEntriesToTrash( + root, keep, static_cast(max_age_days), trash); if (!status.ok) { ThrowRuntimeException(env, status.message); - return nullptr; - } - - if (!RequireReady(env, g_copy_group.ready, "copy group result")) { - return nullptr; } - jobjectArray result = env->NewObjectArray( - static_cast(groups.size()), g_copy_group.clazz, nullptr); - if (result == nullptr) { - return nullptr; - } - - for (jsize index = 0; index < static_cast(groups.size()); ++index) { - jobject group = NewCopyGroupResult(env, groups[index]); - env->SetObjectArrayElement(result, index, group); - env->DeleteLocalRef(group); - } - return result; - } catch (const std::exception& error) { - ThrowRuntimeException(env, error.what()); - } catch (...) { - ThrowRuntimeException(env, "Unexpected native exception in buildCopyGroups"); - } - return nullptr; + }); } diff --git a/cpp/android_jni/update_flow_jni.cpp b/cpp/android_jni/update_flow_jni.cpp new file mode 100644 index 00000000..6592a1ed --- /dev/null +++ b/cpp/android_jni/update_flow_jni.cpp @@ -0,0 +1,133 @@ +// JNI surface of the update-flow decision layer for the Android orchestrator +// (SyncCoordinator.java). Pure string-in/string-out: every payload is +// JSON, matching the decision layer's own boundary. A null return means +// "input did not parse" and the caller skips the check round; a C++ exception +// (bad_alloc/length_error from an oversized body) maps onto that same path — +// it must never unwind through the JNI boundary. +#include + +#include "jni_natives.h" + +#include + +#include "jni_util.h" +#include "../update_flow_core/flow_json.h" +#include "../update_flow_core/native_config.h" +#include "../update_flow_core/native_round.h" + +using pushy::jni_util::ToJString; + +namespace { + +// Parses a JSON object argument; Undefined (-> null result) otherwise. +flowjson::Value ParseObject(JNIEnv* env, jstring json) { + bool ok = false; + flowjson::Value value = + flowjson::Parse(pushy::jni_util::JStringToString(env, json), &ok); + return ok && value.IsObject() ? value : flowjson::Value::Undefined(); +} + +// Stringify, or null for an Undefined result (input the core rejected). +jstring ToJsonOrNull(JNIEnv* env, const flowjson::Value& value) { + return value.IsUndefined() ? nullptr + : ToJString(env, flowjson::Stringify(value)); +} + +} // namespace + +// Native round (update_flow_core/native_round.h). +jstring pushy::jni_natives::FlowPrepareRound(JNIEnv* env, jclass, + jstring configJson, + jstring envJson, + jdouble randomSample) { + try { + return ToJsonOrNull( + env, flowcore::PrepareRound( + pushy::jni_util::JStringToString(env, configJson), + ParseObject(env, envJson), randomSample)); + } catch (...) { + return nullptr; + } +} + +// A null responseText is a failed request (or none made yet). +jstring pushy::jni_natives::FlowNextQueryStep(JNIEnv* env, jclass, + jstring stateJson, + jstring responseText) { + try { + return ToJsonOrNull( + env, flowcore::NextQueryStep( + ParseObject(env, stateJson), + responseText == nullptr + ? flowjson::Value::Undefined() + : flowjson::Value::String(pushy::jni_util::JStringToString( + env, responseText)))); + } catch (...) { + return nullptr; + } +} + +jstring pushy::jni_natives::FlowHandleResponse( + JNIEnv* env, jclass, jstring responseText, jstring configJson, + jstring envJson, jstring requestBody, jlong responseAtSeconds) { + try { + return ToJsonOrNull( + env, flowcore::HandleRoundResponse( + pushy::jni_util::JStringToString(env, responseText), + pushy::jni_util::JStringToString(env, configJson), + ParseObject(env, envJson), + pushy::jni_util::JStringToString(env, requestBody), + static_cast(responseAtSeconds))); + } catch (...) { + return nullptr; + } +} + +// Persisted-config layer (update_flow_core/native_config.h). A null Java +// string is an absent config: never equivalent, not_configured at the gate. +jboolean pushy::jni_natives::FlowConfigsEquivalent( + JNIEnv* env, jclass, jstring a, jstring b) { + if (a == nullptr || b == nullptr) { + return JNI_FALSE; + } + try { + return flowcore::ConfigsEquivalent(pushy::jni_util::JStringToString(env, a), + pushy::jni_util::JStringToString(env, b)) + ? JNI_TRUE + : JNI_FALSE; + } catch (...) { + return JNI_FALSE; + } +} + +jstring pushy::jni_natives::FlowRunnableConfig(JNIEnv* env, jclass, + jstring configJson) { + try { + return ToJString(env, flowcore::RunnableConfigJson( + pushy::jni_util::JStringToString(env, configJson))); + } catch (...) { + return nullptr; + } +} + +// Throws IllegalArgumentException("Invalid native configuration: ...") for +// options the host must fix; null only when memory ran out. +jstring pushy::jni_natives::FlowNormalizeConfiguration(JNIEnv* env, jclass, + jstring optionsJson) { + try { + std::string config; + std::string error; + if (!flowcore::NormalizeConfiguration( + pushy::jni_util::JStringToString(env, optionsJson), &config, + &error)) { + jclass exception = env->FindClass("java/lang/IllegalArgumentException"); + if (exception != nullptr) { + env->ThrowNew(exception, error.c_str()); + } + return nullptr; + } + return ToJString(env, config); + } catch (...) { + return nullptr; + } +} diff --git a/android/jni/hpatch.c b/cpp/hpatch/hpatch.c similarity index 97% rename from android/jni/hpatch.c rename to cpp/hpatch/hpatch.c index 63ed6798..33a5d7d0 100644 --- a/android/jni/hpatch.c +++ b/cpp/hpatch/hpatch.c @@ -1,17 +1,17 @@ // hpatch.c // Copyright 2021 housisong, All rights reserved #include "hpatch.h" -#include "HDiffPatch/libHDiffPatch/HPatch/patch.h" -#include "HDiffPatch/file_for_patch.h" +#include "../../android/jni/HDiffPatch/libHDiffPatch/HPatch/patch.h" +#include "../../android/jni/HDiffPatch/file_for_patch.h" //#define _CompressPlugin_zlib //#define _CompressPlugin_bz2 #define _CompressPlugin_lzma #define _CompressPlugin_lzma2 #define _IsNeedIncludeDefaultCompressHead 0 -#include "lzma/C/LzmaDec.h" -#include "lzma/C/Lzma2Dec.h" -#include "HDiffPatch/decompress_plugin_demo.h" +#include "../../android/jni/lzma/C/LzmaDec.h" +#include "../../android/jni/lzma/C/Lzma2Dec.h" +#include "../../android/jni/HDiffPatch/decompress_plugin_demo.h" #define kMaxLoadMemOldSize ((1<<20)*8) diff --git a/android/jni/hpatch.h b/cpp/hpatch/hpatch.h similarity index 79% rename from android/jni/hpatch.h rename to cpp/hpatch/hpatch.h index cd9488e6..3b74b44c 100644 --- a/android/jni/hpatch.h +++ b/cpp/hpatch/hpatch.h @@ -5,7 +5,10 @@ #ifndef HDIFFPATCH_PATCH_H #define HDIFFPATCH_PATCH_H # include //for uint8_t -#include "HDiffPatch/libHDiffPatch/HPatch/patch_types.h" //for hpatch_singleCompressedDiffInfo +// The HDiffPatch and lzma submodules stay under android/jni; every build +// (iOS without header search paths into the pod) resolves them relative to +// this file. +#include "../../android/jni/HDiffPatch/libHDiffPatch/HPatch/patch_types.h" //for hpatch_singleCompressedDiffInfo #ifdef __cplusplus extern "C" { #endif diff --git a/cpp/patch_core/archive_limits.cpp b/cpp/patch_core/archive_limits.cpp new file mode 100644 index 00000000..2904b3c1 --- /dev/null +++ b/cpp/patch_core/archive_limits.cpp @@ -0,0 +1,194 @@ +#include "archive_limits.h" + +#include +#include + +#include +#include +#include +#include + +#include "install_record.h" + +namespace pushy { +namespace archive_limits { +namespace { + +// expanded > compressed * kMaxCompressionRatio, past kRatioCheckMinBytes. +// A non-positive compressed size bounds nothing. Exact (no integer division +// rounding the ratio down) and overflow-free. +bool ExceedsRatio(long long expanded, long long compressed) { + if (expanded <= kRatioCheckMinBytes) { + return false; + } + if (compressed <= 0) { + return true; + } + if (compressed > std::numeric_limits::max() / kMaxCompressionRatio) { + return false; + } + return expanded > compressed * kMaxCompressionRatio; +} + +// The entry for messages: its name, or its position when the host has none +// yet. +std::string Label(const std::string& name, long long index) { + return name.empty() ? "#" + std::to_string(index) : name; +} + +} // namespace + +bool IsReservedEntryName(const std::string& name) { + const std::string prefix = install_record::kReservedPrefix; + size_t start = 0; + while (start <= name.size()) { + size_t end = name.find_first_of("/\\", start); + if (end == std::string::npos) { + end = name.size(); + } + if (name.compare(start, prefix.size(), prefix) == 0 && + end - start >= prefix.size()) { + return true; + } + start = end + 1; + } + return false; +} + +Status CheckEntryCount(long long entries) { + if (entries > kMaxEntries) { + return Status::Error("archive has too many entries (> " + + std::to_string(kMaxEntries) + ")"); + } + return Status::Ok(); +} + +Status CheckUncompressedSize(long long archive_bytes, long long uncompressed_bytes) { + if (uncompressed_bytes > kMaxTotalUncompressedBytes) { + return Status::Error("archive expands beyond " + + std::to_string(kMaxTotalUncompressedBytes) + " bytes (" + + std::to_string(uncompressed_bytes) + ")"); + } + if (ExceedsRatio(uncompressed_bytes, archive_bytes)) { + return Status::Error("archive compression ratio exceeds " + + std::to_string(kMaxCompressionRatio) + ":1 (" + + std::to_string(uncompressed_bytes) + "/" + + std::to_string(archive_bytes) + ")"); + } + return Status::Ok(); +} + +EntryAccumulator::EntryAccumulator(long long archive_bytes) + : archive_bytes_(archive_bytes) {} + +Status EntryAccumulator::Accept(long long uncompressed_size, + long long compressed_size, + bool is_symlink, + const std::string& name) { + Status status = Add(uncompressed_size, is_symlink, name); + if (status.ok && ExceedsRatio(uncompressed_size, compressed_size)) { + return Status::Error("archive entry compression ratio too high: " + + Label(name, entries_ - 1)); + } + return status; +} + +Status EntryAccumulator::AcceptExtracted(long long size, + bool is_symlink, + const std::string& name) { + return Add(size, is_symlink, name); +} + +Status EntryAccumulator::Add(long long size, + bool is_symlink, + const std::string& name) { + const long long index = entries_++; + Status status = CheckEntryCount(entries_); + if (!status.ok) { + return status; + } + if (IsReservedEntryName(name)) { + return Status::Error("archive contains reserved entry " + name); + } + if (is_symlink) { + return Status::Error("archive entry is a symlink: " + Label(name, index)); + } + if (size < 0) { + return Status::Error("archive entry size unknown: " + Label(name, index)); + } + if (size > kMaxEntryBytes) { + return Status::Error("archive entry too large: " + Label(name, index) + " (" + + std::to_string(size) + " bytes)"); + } + total_uncompressed_ += size; + return CheckUncompressedSize(archive_bytes_, total_uncompressed_); +} + +Status CheckExtractedDirectory(const std::string& dir, long long archive_bytes) { + EntryAccumulator accumulator(archive_bytes); + // Relative paths of the directories still to list; "" is `dir` itself. + std::vector pending(1); + while (!pending.empty()) { + const std::string relative = std::move(pending.back()); + pending.pop_back(); + const std::string path = relative.empty() ? dir : dir + "/" + relative; + DIR* handle = opendir(path.c_str()); + if (handle == nullptr) { + return Status::Error("cannot list extracted directory " + path); + } + Status status = Status::Ok(); + while (status.ok) { + const dirent* item = readdir(handle); + if (item == nullptr) { + break; + } + const std::string name = item->d_name; + if (name == "." || name == "..") { + continue; + } + const std::string child = relative.empty() ? name : relative + "/" + name; + struct stat st; + if (lstat((dir + "/" + child).c_str(), &st) != 0) { + status = Status::Error("cannot stat extracted entry " + child); + break; + } + // Directories are walked but not counted: the zip may omit directory + // entries, and iOS/Android count only what the archive declares. + if (S_ISDIR(st.st_mode)) { + if (IsReservedEntryName(child)) { + status = Status::Error("archive contains reserved entry " + child); + } else { + pending.push_back(child); + } + continue; + } + status = accumulator.AcceptExtracted(static_cast(st.st_size), + S_ISLNK(st.st_mode), child); + } + closedir(handle); + if (!status.ok) { + return status; + } + } + return Status::Ok(); +} + +FreeSpaceProbe PlanUnknownLengthFreeSpaceProbe(long long known_total, + long long written_before, + long long chunk_bytes, + long long reserved_until) { + FreeSpaceProbe plan; + plan.reserve = chunk_bytes > kUnknownLengthFreeSpaceProbeBytes + ? chunk_bytes + : kUnknownLengthFreeSpaceProbeBytes; + plan.reserved_until = reserved_until; + if (known_total > 0 || written_before + chunk_bytes <= reserved_until) { + return plan; + } + plan.probe = true; + plan.reserved_until = written_before + plan.reserve; + return plan; +} + +} // namespace archive_limits +} // namespace pushy diff --git a/cpp/patch_core/archive_limits.h b/cpp/patch_core/archive_limits.h index 1699be4f..10e3bc5f 100644 --- a/cpp/patch_core/archive_limits.h +++ b/cpp/patch_core/archive_limits.h @@ -1,16 +1,31 @@ #ifndef PUSHY_PATCH_CORE_ARCHIVE_LIMITS_H_ #define PUSHY_PATCH_CORE_ARCHIVE_LIMITS_H_ +#include + +#include "patch_core.h" + // Single source of truth for the resource caps applied to an update package -// before and while it is unpacked. A corrupt or hostile archive must be able -// to cost at most a bounded amount of disk, memory and time — never a full -// disk or a zip bomb. The caps are generous for real packages (a large full -// bundle with assets is tens of MB) and exist purely as damage bounds. +// before and while it is unpacked, and for the accounting that enforces them. +// A corrupt or hostile archive must be able to cost at most a bounded amount +// of disk, memory and time — never a full disk or a zip bomb. The caps are +// generous for real packages (a large full bundle with assets is tens of MB) +// and exist purely as damage bounds. // -// Mirrors that cannot include this header MUST stay in sync by hand: -// - android/.../ArchiveLimits.java -// - harmony/pushy/src/main/ets/ArchiveLimits.ts -// iOS (RCTPushy.mm) includes this header directly. +// Every platform runs the rules below (archive_limits.cpp): +// - Android: SafeZipFile.inspect hands the central directory to +// CheckArchiveEntries, and DownloadTask plans its free-space probes, over +// JNI (cpp/android_jni/archive_limits_jni.cpp); +// - iOS: RCTPushyInstaller.mm feeds an EntryAccumulator from the unzip +// delegate, RCTPushyDownloader.mm plans its probes; +// - Harmony: zlib only reports the whole archive's expansion, so +// DownloadTask.ts checks it with CheckUncompressedSize before unpacking +// and CheckExtractedDirectory after, over NAPI (pushy.cpp). +// The constants a host still compares against itself (download caps, the +// manifest cap, the free-space margin, the streamed-bytes backstop) are +// mirrored in android/.../ArchiveLimits.java and +// harmony/pushy/src/main/ets/ArchiveLimits.ts; +// scripts/check-constant-parity.js (part of `bun lint`) asserts they match. // Keep the core internal to whichever binary links it (see podspec Core). #pragma GCC visibility push(hidden) @@ -18,6 +33,8 @@ namespace pushy { namespace archive_limits { +using delta::Status; + // Downloaded archive (Content-Length up front, streamed bytes as backstop). constexpr long long kMaxArchiveBytes = 512LL * 1024 * 1024; // Sum of every entry's uncompressed size. @@ -26,8 +43,9 @@ constexpr long long kMaxTotalUncompressedBytes = 2048LL * 1024 * 1024; constexpr long long kMaxEntryBytes = 512LL * 1024 * 1024; // Number of entries in one archive. constexpr long long kMaxEntries = 20000; -// Per-entry uncompressed/compressed ratio, checked only above -// kRatioCheckMinBytes (tiny highly-compressible files are legitimate). +// Uncompressed/compressed ratio, of each entry and of the archive as a whole, +// checked only above kRatioCheckMinBytes of expansion (tiny highly +// compressible files are legitimate). constexpr long long kMaxCompressionRatio = 100; constexpr long long kRatioCheckMinBytes = 1LL * 1024 * 1024; // __diff.json is parsed fully in memory. @@ -41,6 +59,77 @@ constexpr long long kFreeDiskMarginBytes = 64LL * 1024 * 1024; // reserves its own size), so no write can ever eat into the margin. constexpr long long kUnknownLengthFreeSpaceProbeBytes = 8LL * 1024 * 1024; +// Whether any component of an archive path ('/' or '\\' separated) starts +// with install_record::kReservedPrefix. Those names belong to the SDK (the +// completion record): a package unpacking its own record next to a bundle +// would be trusted as a finished install even if the steps after it fail. +bool IsReservedEntryName(const std::string& name); + +// The entry count of a whole archive (iOS knows it before the first entry). +Status CheckEntryCount(long long entries); + +// The whole archive: its total expansion and its overall ratio. An archive of +// unknown (non-positive) size has no ratio to trust and fails it once the +// expansion is past kRatioCheckMinBytes. +Status CheckUncompressedSize(long long archive_bytes, long long uncompressed_bytes); + +// Per-entry accounting while an archive is walked, before (Android, iOS) or +// right after (Harmony) its bytes land. Each Accept checks, in order: the +// entry count, a reserved name, a symlink, the entry's own size (negative = +// unknown is refused), the running total and whole-archive ratio +// (CheckUncompressedSize), and the entry's ratio. The first failure is the +// verdict; the caller stops there. +class EntryAccumulator { + public: + // archive_bytes: the archive file's size, for the whole-archive ratio. + explicit EntryAccumulator(long long archive_bytes); + + // An entry as the central directory declares it. `name` is the path inside + // the archive; a host that only learns it once the entry is on disk (iOS) + // passes "" and checks the name then with IsReservedEntryName. A + // non-positive compressed size with an expansion past kRatioCheckMinBytes + // is an unbounded ratio. + Status Accept(long long uncompressed_size, + long long compressed_size, + bool is_symlink, + const std::string& name); + + // An entry already on disk whose compressed size the host never saw + // (Harmony): everything but the entry's own ratio; the whole archive's + // ratio still applies. + Status AcceptExtracted(long long size, bool is_symlink, const std::string& name); + + long long entries() const { return entries_; } + long long total_uncompressed() const { return total_uncompressed_; } + + private: + Status Add(long long size, bool is_symlink, const std::string& name); + + long long archive_bytes_; + long long entries_ = 0; + long long total_uncompressed_ = 0; +}; + +// Walks a freshly unpacked directory (iteratively, symlinks not followed) +// through an EntryAccumulator: every file and directory below `dir` is one +// entry named by its path relative to `dir`, a directory weighing 0 bytes. +Status CheckExtractedDirectory(const std::string& dir, long long archive_bytes); + +// Before each chunk of a download is written. `known_total` > 0 means the +// response announced its length (checked once when it arrived: no probe); +// written_before counts this response's body bytes before the chunk. +struct FreeSpaceProbe { + // Probe the disk for `reserve` bytes (plus kFreeDiskMarginBytes) first. + bool probe = false; + long long reserve = 0; + // The reservation after this chunk, relative to the start of the body. + long long reserved_until = 0; +}; +FreeSpaceProbe PlanUnknownLengthFreeSpaceProbe(long long known_total, + long long written_before, + long long chunk_bytes, + long long reserved_until); + } // namespace archive_limits } // namespace pushy diff --git a/cpp/patch_core/archive_patch_core.cpp b/cpp/patch_core/archive_patch_core.cpp index 92712b35..f904b853 100644 --- a/cpp/patch_core/archive_patch_core.cpp +++ b/cpp/patch_core/archive_patch_core.cpp @@ -1,5 +1,8 @@ #include "archive_patch_core.h" +#include +#include + #include "obscured_text.h" namespace pushy { @@ -100,24 +103,105 @@ delta::Status BuildCopyGroups( return manifest_status; } - out_groups->clear(); + // Groups in order of first appearance; the index keeps this linear in the + // number of copies (a package diff copies thousands of resources). + std::vector groups; + std::unordered_map group_by_source; for (const delta::CopyOperation& copy : manifest.copies) { - bool appended = false; - for (CopyGroup& group : *out_groups) { - if (group.from == copy.from) { - group.to_paths.push_back(copy.to); - appended = true; - break; - } - } - if (!appended) { + const auto inserted = group_by_source.emplace(copy.from, groups.size()); + if (inserted.second) { CopyGroup group; group.from = copy.from; - group.to_paths.push_back(copy.to); - out_groups->push_back(group); + groups.push_back(std::move(group)); + } + CopyGroup& group = groups[inserted.first->second]; + group.to_paths.push_back(copy.to); + if (copy.has_expected_crc) { + group.has_expected_crc = true; + group.expected_crc = copy.expected_crc; } } + *out_groups = std::move(groups); + + return delta::Status::Ok(); +} +delta::Status PlanArchivePatch( + const ArchivePatchInput& input, + ArchivePatchPlan* out_plan) { + if (out_plan == nullptr) { + return delta::Status::Error("Archive plan output is required"); + } + if (input.type == ArchivePatchType::kFull) { + return BuildArchivePatchPlan( + input.type, delta::PatchManifest(), input.entry_names, out_plan, + input.bundle_patch_entry_name); + } + // Missing entries first: an archive without __diff.json has no text to + // parse, and the entry error is the one worth reporting. + if (!HasEntry(input.entry_names, kManifestEntryName)) { + return delta::Status::Error("diff.json not found"); + } + if (!HasEntry(input.entry_names, input.bundle_patch_entry_name)) { + return delta::Status::Error("bundle delta not found"); + } + delta::PatchManifestOptions options; + options.bundle_patch_entry_name = input.bundle_patch_entry_name; + options.copy_source_prefix = input.copy_source_prefix; + delta::PatchManifest manifest; + delta::Status status = + delta::ParsePatchManifest(input.manifest_json, options, &manifest); + if (!status.ok) { + return status; + } + return BuildArchivePatchPlan( + input.type, manifest, input.entry_names, out_plan, + input.bundle_patch_entry_name); +} + +delta::Status ApplyArchivePatch( + const ArchivePatchRequest& request, + std::vector* out_host_copies, + const delta::BundleRebuilder& bundle_patcher) { + if (request.archive.type == ArchivePatchType::kFull) { + return delta::Status::Error("A full package has no delta to apply"); + } + if (request.host_copies_resources && out_host_copies == nullptr) { + return delta::Status::Error("Host copy groups output is required"); + } + if (out_host_copies != nullptr) { + out_host_copies->clear(); + } + ArchivePatchPlan plan; + delta::Status status = PlanArchivePatch(request.archive, &plan); + if (!status.ok) { + return status; + } + delta::FileSourcePatchOptions options; + status = BuildFileSourcePatchOptions( + plan, request.source_root, request.target_root, + request.origin_bundle_path, request.bundle_patch_path, + request.bundle_output_path, &options); + if (!status.ok) { + return status; + } + std::vector host_copies; + if (request.host_copies_resources) { + status = BuildCopyGroups(plan.manifest, &host_copies); + if (!status.ok) { + return status; + } + options.manifest.copies.clear(); + options.manifest.deletes.clear(); + options.enable_merge = false; + } + status = delta::ApplyPatchFromFileSource(options, bundle_patcher); + if (!status.ok) { + return status; + } + if (out_host_copies != nullptr) { + *out_host_copies = std::move(host_copies); + } return delta::Status::Ok(); } diff --git a/cpp/patch_core/archive_patch_core.h b/cpp/patch_core/archive_patch_core.h index 646070af..ac852cec 100644 --- a/cpp/patch_core/archive_patch_core.h +++ b/cpp/patch_core/archive_patch_core.h @@ -1,9 +1,11 @@ #pragma once +#include #include #include #include "patch_core.h" +#include "patch_manifest.h" // Keep the core internal to whichever binary links it (see podspec Core). #pragma GCC visibility push(hidden) @@ -22,9 +24,13 @@ enum class EntryAction { kExtract = 1, }; +// Every copy of one source. The manifest parser guarantees that copies of +// one source never declare different CRCs, so a group has at most one. struct CopyGroup { std::string from; std::vector to_paths; + bool has_expected_crc = false; + uint32_t expected_crc = 0; }; struct ArchivePatchPlan { @@ -58,6 +64,49 @@ delta::Status BuildCopyGroups( const delta::PatchManifest& manifest, std::vector* out_groups); +// What a staged patch archive declared: its entry names and the text of its +// __diff.json (empty when the archive has none). +struct ArchivePatchInput { + ArchivePatchType type = ArchivePatchType::kFull; + std::vector entry_names; + std::string manifest_json; + std::string bundle_patch_entry_name = DefaultBundleDeltaEntryName(); + // See PatchManifestOptions::copy_source_prefix (HarmonyOS rawfiles). + std::string copy_source_prefix; +}; + +// The patch types need the manifest and the bundle patch among the entries; +// the manifest is then parsed (ParsePatchManifest) and the merge planned. +// A full package plans nothing and ignores manifest_json. +delta::Status PlanArchivePatch( + const ArchivePatchInput& input, + ArchivePatchPlan* out_plan); + +struct ArchivePatchRequest { + ArchivePatchInput archive; + std::string source_root; + std::string target_root; + std::string origin_bundle_path; + std::string bundle_patch_path; + std::string bundle_output_path; + // The package's resources are not files under source_root (Android APK + // entries, HarmonyOS rawfiles): only the bundle is rebuilt here, there is + // nothing to merge, and the host performs the manifest's copies itself + // from the groups handed back. + bool host_copies_resources = false; +}; + +// PlanArchivePatch + ApplyPatchFromFileSource: the whole native half of a +// patch install, driven by the manifest text so the platforms never parse +// __diff.json themselves. With host_copies_resources, *out_host_copies (which +// must then be non-null) receives the grouped copies the host still owes; +// otherwise it is left empty. Full packages are rejected. +delta::Status ApplyArchivePatch( + const ArchivePatchRequest& request, + std::vector* out_host_copies, + const delta::BundleRebuilder& bundle_patcher = + delta::DefaultBundleRebuilder()); + delta::Status BuildFileSourcePatchOptions( const ArchivePatchPlan& plan, const std::string& source_root, diff --git a/cpp/patch_core/error_codes.h b/cpp/patch_core/error_codes.h index 852338f5..33bd8013 100644 --- a/cpp/patch_core/error_codes.h +++ b/cpp/patch_core/error_codes.h @@ -6,10 +6,12 @@ // so the JS layer (src/error.ts UpdateErrorCode) and user loggers can // aggregate errors across platforms and locales. // -// Mirrors that cannot include this header MUST stay in sync by hand: -// - src/error.ts (UpdateErrorCode union, JS layer) +// Mirrors that cannot include this header MUST stay in sync by hand +// (scripts/check-constant-parity.js, part of `bun lint`, asserts they do): +// - src/error.ts (UpdateErrorCode, JS layer) // - android/.../ErrorCodes.java (Java constants) -// iOS (RCTPushy.mm) includes this header directly. +// - harmony/pushy/src/main/ets/ErrorCodes.ts +// iOS (ios/RCTPushy/*.mm) includes this header directly. // // Human-readable messages are NOT part of this contract: they may differ per // platform and locale. Only the codes are stable. diff --git a/cpp/patch_core/install_record.cpp b/cpp/patch_core/install_record.cpp new file mode 100644 index 00000000..25b02595 --- /dev/null +++ b/cpp/patch_core/install_record.cpp @@ -0,0 +1,430 @@ +#include "install_record.h" + +#include +#include +#include +#include +#include +#include + +#include "digest.h" +// Relative like every other cross-core include (see patch_manifest.cpp): +// use_frameworks! flattens the pod's headers. +#include "../update_flow_core/flow_json.h" + +namespace pushy { +namespace install_record { +namespace { + +// Written next to the record, then renamed over it. Its `.pushy-` prefix is +// reserved like the record's own name, so no archive can ship one. +constexpr const char* kTempSuffix = ".tmp"; +// A record has five members; the cap only bounds a hostile file's tree. +constexpr size_t kMaxRecordNodes = 1024; + +std::string JoinPath(const std::string& dir, const std::string& name) { + return dir + "/" + name; +} + +std::string RecordPath(const std::string& dir) { + return JoinPath(dir, kFileName); +} + +std::string ErrnoText() { + return std::strerror(errno); +} + +bool IsRegularFile(const std::string& path) { + struct stat info; + return stat(path.c_str(), &info) == 0 && S_ISREG(info.st_mode); +} + +// The record file's text and what it is. kPresent only when it parses as a +// JSON object; *text is filled for kPresent alone. +Record::State ReadRecordText(const std::string& dir, std::string* text) { + const std::string path = RecordPath(dir); + struct stat info; + if (stat(path.c_str(), &info) != 0) { + return errno == ENOENT || errno == ENOTDIR ? Record::State::kAbsent + : Record::State::kInvalid; + } + if (!S_ISREG(info.st_mode)) { + return Record::State::kInvalid; + } + if (info.st_size == 0) { + return Record::State::kLegacy; + } + if (info.st_size > kMaxRecordBytes) { + return Record::State::kInvalid; + } + // O_NONBLOCK: a FIFO swapped in after the stat must not hang the caller. + const int fd = open(path.c_str(), O_RDONLY | O_CLOEXEC | O_NONBLOCK); + if (fd < 0) { + return Record::State::kInvalid; + } + std::string content; + char buffer[4096]; + bool failed = false; + for (;;) { + const ssize_t count = read(fd, buffer, sizeof(buffer)); + if (count < 0) { + if (errno == EINTR) { + continue; + } + failed = true; + break; + } + if (count == 0) { + break; + } + content.append(buffer, static_cast(count)); + if (static_cast(content.size()) > kMaxRecordBytes) { + failed = true; + break; + } + } + close(fd); + if (failed) { + return Record::State::kInvalid; + } + // Truncated to zero between the stat and the read: still the legacy shape. + const Record record = Parse(content); + if (record.state == Record::State::kPresent) { + *text = std::move(content); + } + return record.state; +} + +std::string StringMember(const flowjson::Value& object, const char* key) { + const flowjson::Value& value = object.Get(key); + return value.IsString() ? value.AsString() : std::string(); +} + +bool EqualsIgnoringAsciiCase(const std::string& a, const std::string& b) { + if (a.size() != b.size()) { + return false; + } + for (size_t i = 0; i < a.size(); i++) { + char x = a[i]; + char y = b[i]; + if (x >= 'A' && x <= 'Z') { + x = static_cast(x - 'A' + 'a'); + } + if (y >= 'A' && y <= 'Z') { + y = static_cast(y - 'A' + 'a'); + } + if (x != y) { + return false; + } + } + return true; +} + +bool WriteAll(int fd, const std::string& data) { + size_t written = 0; + while (written < data.size()) { + const ssize_t count = write(fd, data.data() + written, data.size() - written); + if (count < 0) { + if (errno == EINTR) { + continue; + } + return false; + } + written += static_cast(count); + } + return true; +} + +// Makes a directory's entries durable (a rename or a new file only is once +// the directory itself is synced). Best-effort by design: some file systems +// refuse to open or fsync a directory, which must not fail an install whose +// files are already synced. +void SyncDirectory(const std::string& dir) { + const int fd = open(dir.c_str(), O_RDONLY | O_CLOEXEC | O_DIRECTORY); + if (fd < 0) { + return; + } + while (fsync(fd) != 0 && errno == EINTR) { + } + close(fd); +} + +std::string ParentDirectory(const std::string& path) { + const size_t slash = path.find_last_of('/'); + if (slash == std::string::npos) { + return "."; + } + return slash == 0 ? "/" : path.substr(0, slash); +} + +Status Rejected(const std::string& version_hash, const char* reason) { + return Status::Error("Bundle version " + version_hash + " " + reason); +} + +Status RecordMismatch(const std::string& version_hash) { + return Rejected(version_hash, "completion record mismatch."); +} + +} // namespace + +Record Parse(const std::string& text) { + Record record; + if (text.empty()) { + record.state = Record::State::kLegacy; + return record; + } + record.state = Record::State::kInvalid; + if (static_cast(text.size()) > kMaxRecordBytes) { + return record; + } + flowjson::ParseLimits limits; + limits.max_input_bytes = static_cast(kMaxRecordBytes); + limits.max_nodes = kMaxRecordNodes; + bool ok = false; + const flowjson::Value object = flowjson::Parse(text, limits, &ok); + if (!ok || !object.IsObject()) { + return record; + } + record.state = Record::State::kPresent; + const flowjson::Value& schema = object.Get("schema"); + record.current_schema = + schema.IsNumber() && schema.AsNumber() == static_cast(kSchema); + record.version_hash = StringMember(object, "versionHash"); + record.bundle_sha256 = StringMember(object, "bundleSha256"); + record.artifact_sha256 = StringMember(object, "artifactSha256"); + return record; +} + +bool Names(const Record& record, const std::string& version_hash) { + return record.state == Record::State::kPresent && record.current_schema && + !version_hash.empty() && record.version_hash == version_hash; +} + +Record Read(const std::string& version_dir) { + std::string text; + const Record::State state = ReadRecordText(version_dir, &text); + if (state == Record::State::kPresent) { + return Parse(text); + } + Record record; + record.state = state; + return record; +} + +bool HasRecordFile(const std::string& version_dir) { + struct stat info; + return stat(RecordPath(version_dir).c_str(), &info) == 0; +} + +Status Build(const std::string& version_hash, + const std::string& bundle_sha256, + const std::string& artifact_sha256, + std::string* out) { + if (bundle_sha256.empty()) { + return Rejected(version_hash, "has no bundle digest."); + } + flowjson::Value record = flowjson::Value::Object(); + record.Set("schema", flowjson::Value::Number(kSchema)); + record.Set("versionHash", flowjson::Value::String(version_hash)); + record.Set("bundleSha256", flowjson::Value::String(bundle_sha256)); + if (!artifact_sha256.empty()) { + record.Set("artifactSha256", flowjson::Value::String(artifact_sha256)); + } + *out = flowjson::Stringify(record); + return Status::Ok(); +} + +Status Write(const std::string& dir, const std::string& record_json) { + const std::string path = RecordPath(dir); + const std::string temp = path + kTempSuffix; + const int fd = open(temp.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0666); + if (fd < 0) { + return Status::Error("cannot create completion record " + temp + ": " + + ErrnoText()); + } + bool ok = WriteAll(fd, record_json); + while (ok && fsync(fd) != 0) { + if (errno != EINTR) { + ok = false; + } + } + std::string error = ok ? std::string() : ErrnoText(); + if (close(fd) != 0 && ok) { + ok = false; + error = ErrnoText(); + } + if (!ok) { + unlink(temp.c_str()); + return Status::Error("cannot write completion record " + temp + ": " + error); + } + if (rename(temp.c_str(), path.c_str()) != 0) { + error = ErrnoText(); + unlink(temp.c_str()); + return Status::Error("cannot place completion record " + path + ": " + error); + } + SyncDirectory(dir); + return Status::Ok(); +} + +SealResult Seal(const std::string& staging_dir, + const std::string& version_hash, + const std::string& bundle_file_name, + const std::string& artifact_sha256, + const std::string& known_bundle_sha256) { + SealResult result; + const std::string bundle = JoinPath(staging_dir, bundle_file_name); + if (!IsRegularFile(bundle)) { + result.status = Status::Error("bundle missing after install: " + bundle); + return result; + } + const std::string bundle_sha256 = known_bundle_sha256.empty() + ? digest::Sha256File(bundle) + : known_bundle_sha256; + std::string record; + result.status = Build(version_hash, bundle_sha256, artifact_sha256, &record); + if (!result.status.ok) { + return result; + } + result.status = Write(staging_dir, record); + result.file_failure = !result.status.ok; + return result; +} + +Status Promote(const std::string& staging_dir, const std::string& version_dir) { + if (rename(staging_dir.c_str(), version_dir.c_str()) != 0) { + return Status::Error("cannot move " + staging_dir + " to " + version_dir + + ": " + ErrnoText()); + } + SyncDirectory(ParentDirectory(version_dir)); + return Status::Ok(); +} + +bool IsComplete(const std::string& version_dir, + const std::string& version_hash, + const std::string& bundle_file_name) { + if (!IsRegularFile(JoinPath(version_dir, bundle_file_name))) { + return false; + } + const Record record = Read(version_dir); + return record.state == Record::State::kLegacy || Names(record, version_hash); +} + +std::string RecordedBundleSha256(const std::string& version_dir) { + return Read(version_dir).bundle_sha256; +} + +bool FileStamp::operator==(const FileStamp& other) const { + return regular == other.regular && device == other.device && + inode == other.inode && size == other.size && + modified_sec == other.modified_sec && + modified_nsec == other.modified_nsec && + changed_sec == other.changed_sec && changed_nsec == other.changed_nsec; +} + +FileStamp StampFile(const std::string& path) { + FileStamp stamp; + struct stat info; + if (stat(path.c_str(), &info) != 0 || !S_ISREG(info.st_mode)) { + return stamp; + } + stamp.regular = true; + stamp.device = static_cast(info.st_dev); + stamp.inode = static_cast(info.st_ino); + stamp.size = static_cast(info.st_size); +#if defined(__APPLE__) + stamp.modified_sec = info.st_mtimespec.tv_sec; + stamp.modified_nsec = info.st_mtimespec.tv_nsec; + stamp.changed_sec = info.st_ctimespec.tv_sec; + stamp.changed_nsec = info.st_ctimespec.tv_nsec; +#else + stamp.modified_sec = info.st_mtim.tv_sec; + stamp.modified_nsec = info.st_mtim.tv_nsec; + stamp.changed_sec = info.st_ctim.tv_sec; + stamp.changed_nsec = info.st_ctim.tv_nsec; +#endif + return stamp; +} + +Status CheckActivation(const std::string& version_dir, + const std::string& version_hash, + const std::string& bundle_file_name, + bool grandfathered, + Activation* out) { + const std::string bundle = JoinPath(version_dir, bundle_file_name); + const FileStamp before = StampFile(bundle); + if (!before.regular) { + return Rejected(version_hash, "not found."); + } + std::string text; + const Record::State state = ReadRecordText(version_dir, &text); + // Versions activated before records existed are grandfathered through + // current/last state, but only while their directory has NO record file: + // a present record is verified like any other. + if (grandfathered && state == Record::State::kAbsent) { + out->basis = Activation::Basis::kGrandfathered; + out->record_text.clear(); + out->bundle = before; + return Status::Ok(); + } + // Any other directory without a record may be a crash-left partial + // install or something else put there. + if (state == Record::State::kAbsent) { + return Rejected(version_hash, "is incomplete."); + } + if (state == Record::State::kInvalid) { + return Rejected(version_hash, "has no valid completion record."); + } + if (state == Record::State::kLegacy) { + out->basis = Activation::Basis::kLegacyMarker; + out->record_text.clear(); + out->bundle = before; + return Status::Ok(); + } + const Record record = Parse(text); + if (!Names(record, version_hash)) { + return RecordMismatch(version_hash); + } + // Mandatory for schema 1: a record without it is never trusted. + if (record.bundle_sha256.empty()) { + return RecordMismatch(version_hash); + } + const std::string actual = digest::Sha256File(bundle); + if (actual.empty()) { + return Rejected(version_hash, "bundle could not be read."); + } + if (StampFile(bundle) != before) { + return Rejected(version_hash, "changed while being verified."); + } + if (!EqualsIgnoringAsciiCase(record.bundle_sha256, actual)) { + return Rejected(version_hash, "bundle digest mismatch."); + } + out->basis = Activation::Basis::kDigest; + out->record_text = text; + out->bundle = before; + return Status::Ok(); +} + +bool Unchanged(const std::string& version_dir, + const std::string& bundle_file_name, + bool grandfathered, + const Activation& passed) { + const FileStamp bundle = StampFile(JoinPath(version_dir, bundle_file_name)); + if (!bundle.regular) { + return false; + } + std::string text; + const Record::State state = ReadRecordText(version_dir, &text); + switch (passed.basis) { + case Activation::Basis::kGrandfathered: + return state == Record::State::kAbsent && grandfathered; + case Activation::Basis::kLegacyMarker: + return state == Record::State::kLegacy && bundle == passed.bundle; + case Activation::Basis::kDigest: + return state == Record::State::kPresent && text == passed.record_text && + bundle == passed.bundle; + } + return false; +} + +} // namespace install_record +} // namespace pushy diff --git a/cpp/patch_core/install_record.h b/cpp/patch_core/install_record.h index 0554dddc..f724ce8f 100644 --- a/cpp/patch_core/install_record.h +++ b/cpp/patch_core/install_record.h @@ -1,6 +1,11 @@ #ifndef PUSHY_PATCH_CORE_INSTALL_RECORD_H_ #define PUSHY_PATCH_CORE_INSTALL_RECORD_H_ +#include +#include + +#include "patch_core.h" + // The completion record every platform writes into a version directory once // an install fully succeeded, and reads back before trusting that directory. // @@ -9,23 +14,26 @@ // // - Written by the SDK only (archives may not ship a `.pushy-*` entry), as // the LAST step of a two-phase install: unpack/patch into -// /.staging, write the record there, then atomically rename +// /.staging, Seal() the record there, then Promote() renames // the staging directory to /. // - versionHash must equal the directory name; bundleSha256 is the digest of -// the final index.bundlejs (bundle.harmony.js on Harmony) and is +// the final bundle (index.bundlejs; bundle.harmony.js on Harmony) and is // re-verified at switchVersion time; artifactSha256 is the digest of the // downloaded archive (diagnostics / CDN corruption attribution, optional). // - bundleSha256 is MANDATORY for schema 1. A writer whose bundle digest is // empty or fails must fail the install (staging dropped, no record); a // verifier must reject a non-empty record without one (record mismatch), // never activate the version unverified. -// - Legacy: an EMPTY file is a completed install written by SDK < 10.53 -// (no digests); it stays trusted for presence, nothing to verify. +// - Legacy: an EMPTY (zero-byte) file is a completed install written by +// SDK < 10.53 (no digests); it stays trusted for presence, nothing to +// verify. Any other content must be a schema-1 record. // -// Mirrors that cannot include this header MUST stay in sync by hand: -// - android/.../InstallRecord.java -// - harmony/pushy/src/main/ets/InstallRecord.ts -// iOS (RCTPushy.mm) includes this header directly. +// This is the only implementation: Android (InstallRecord.java over JNI, +// cpp/android_jni/install_record_jni.cpp), iOS (RCTPushyInstallRecord.mm) +// and Harmony (InstallRecord.ts over NAPI, pushy.cpp) call it. The bundle +// file name is the caller's, since it differs per platform. Constants a +// host still mirrors are listed in scripts/check-constant-parity.js +// (part of `bun lint`), which asserts they match. // Keep the core internal to whichever binary links it (see podspec Core). #pragma GCC visibility push(hidden) @@ -33,9 +41,151 @@ namespace pushy { namespace install_record { +using delta::Status; + constexpr int kSchema = 1; constexpr const char* kFileName = ".pushy-complete"; +// Every name starting with this prefix, at any depth of a version directory, +// belongs to the SDK (the record, its temp file): no archive may ship one +// (archive_limits::IsReservedEntryName). +constexpr const char* kReservedPrefix = ".pushy-"; constexpr const char* kStagingSuffix = ".staging"; +// A record is a few hundred bytes; anything larger is not one. +constexpr long long kMaxRecordBytes = 64 * 1024; + +// A record file's contents. +struct Record { + enum class State { + kAbsent, // no such entry + kInvalid, // not a readable regular file of at most kMaxRecordBytes, or + // not a JSON object + kLegacy, // the zero-byte marker of SDK < 10.53 + kPresent, // a JSON object + }; + State state = State::kAbsent; + // kPresent only. A member that is missing or of another JSON type reads + // as false / empty: records are written by this code alone, so nothing is + // coerced. + bool current_schema = false; // "schema" is the number kSchema + std::string version_hash; + std::string bundle_sha256; + std::string artifact_sha256; +}; + +// The text of a record file: "" is kLegacy, a JSON object kPresent, +// anything else (or more than kMaxRecordBytes) kInvalid. +Record Parse(const std::string& text); + +// Whether a kPresent record is a schema-1 record naming this version. +bool Names(const Record& record, const std::string& version_hash); + +// /.pushy-complete, parsed. +Record Read(const std::string& version_dir); + +// Whether /.pushy-complete exists at all (of any type). Only a +// directory WITHOUT one may be grandfathered through current/last state. +bool HasRecordFile(const std::string& version_dir); + +// The schema-1 record text. Fails when bundle_sha256 is empty: a version +// whose bundle could not be hashed must never be recorded as installed. +// artifact_sha256 is optional and omitted when empty. +Status Build(const std::string& version_hash, + const std::string& bundle_sha256, + const std::string& artifact_sha256, + std::string* out); + +// Writes record_json as /.pushy-complete atomically and durably: a +// temporary sibling is written and fsynced, renamed over the record, and the +// directory is fsynced (best-effort: a file system that refuses a directory +// fsync does not fail the install). +Status Write(const std::string& dir, const std::string& record_json); + +struct SealResult { + Status status = Status::Ok(); + // The record could not be written (a file-system failure), as opposed to + // an install that produced no hashable bundle. + bool file_failure = false; +}; + +// The second phase's first half: hashes / +// (unless the caller already knows its digest, e.g. from extracting it), +// builds the record and writes it into the staging directory. +SealResult Seal(const std::string& staging_dir, + const std::string& version_hash, + const std::string& bundle_file_name, + const std::string& artifact_sha256, + const std::string& known_bundle_sha256); + +// The second phase's second half: renames the sealed staging directory to +// version_dir (which the caller has removed if it existed) and fsyncs the +// parent directory (best-effort, as in Write). +Status Promote(const std::string& staging_dir, const std::string& version_dir); + +// Launch / dedup presence check, no digest work: the bundle is a regular +// file and the record is the legacy marker or names this version (a +// digest-less schema-1 record is still "installed"; only activation refuses +// it). +bool IsComplete(const std::string& version_dir, + const std::string& version_hash, + const std::string& bundle_file_name); + +// bundleSha256 of the version's record (crash attribution); "" when the +// record is absent, legacy, unreadable or has none. +std::string RecordedBundleSha256(const std::string& version_dir); + +// Identity of a file's bytes on disk without reading them: a rewrite or an +// atomic replacement (new inode) changes it. +struct FileStamp { + bool regular = false; + uint64_t device = 0; + uint64_t inode = 0; + int64_t size = 0; + int64_t modified_sec = 0; + int64_t modified_nsec = 0; + int64_t changed_sec = 0; + int64_t changed_nsec = 0; + + bool operator==(const FileStamp& other) const; + bool operator!=(const FileStamp& other) const { return !(*this == other); } +}; + +FileStamp StampFile(const std::string& path); + +// What CheckActivation let through, for Unchanged. +struct Activation { + enum class Basis { + kGrandfathered, // no record file; the version is the current or last one + kLegacyMarker, // the zero-byte marker: nothing to verify + kDigest, // the record's bundleSha256 matched the bundle's bytes + }; + Basis basis = Basis::kDigest; + std::string record_text; // the verified record's exact text + FileStamp bundle; // the bundle as it was hashed +}; + +// The switchVersion gate (slow: re-hashes the bundle; never call it on a UI +// thread or under a lock the UI thread takes). Passes when the bundle is a +// regular file and either +// - the directory has no record file and `grandfathered` (the version is +// the persisted current or last one: activated before records existed), +// - the record is the legacy marker, or +// - the record is a schema-1 record naming this version whose bundleSha256 +// matches the bundle, which did not change while it was hashed. +// Otherwise fails with the reason ("Bundle version ..."), every +// platform's SWITCH_VERSION_FAILED message. *out is set only on success. +Status CheckActivation(const std::string& version_dir, + const std::string& version_hash, + const std::string& bundle_file_name, + bool grandfathered, + Activation* out); + +// Cheap re-check (no hashing; e.g. under the state lock right before the +// switch is committed) that the directory still passes on the same basis: +// same grandfathering, same record text, same bundle file. +bool Unchanged(const std::string& version_dir, + const std::string& bundle_file_name, + bool grandfathered, + const Activation& passed); } // namespace install_record } // namespace pushy diff --git a/cpp/patch_core/jni_natives.h b/cpp/patch_core/jni_natives.h deleted file mode 100644 index cc21aace..00000000 --- a/cpp/patch_core/jni_natives.h +++ /dev/null @@ -1,33 +0,0 @@ -#ifndef PUSHY_PATCH_CORE_JNI_NATIVES_H_ -#define PUSHY_PATCH_CORE_JNI_NATIVES_H_ - -#include - -// Android native methods. They are registered by name from JNI_OnLoad -// (jni_registration.cpp) instead of being exported as Java_* symbols, so the -// shared library's dynamic symbol table carries no class or method names. -namespace pushy { -namespace jni_natives { - -// Called once from JNI_OnLoad: caches the JNI result classes (global refs), -// their constructors and field IDs. Returns false (after logging what is -// missing) when any lookup failed; the natives that need the missing class -// then throw a RuntimeException instead of defaulting fields. -bool InitResultClassCache(JNIEnv* env); - -jint SupportedDiffVersion(JNIEnv*, jclass); -jobject SyncStateWithBinaryVersion(JNIEnv* env, jclass, jstring package_version, jstring build_time, jobject state_result); -jobject RunStateCore(JNIEnv* env, jclass, jint operation, jobject state_result, jstring string_arg, jboolean flag_a, jboolean flag_b); -jobject BuildArchivePlan(JNIEnv* env, jclass, jint patch_type, jobjectArray entry_names, jobjectArray copy_froms, jobjectArray copy_tos, jobjectArray deletes); -jobjectArray BuildCopyGroups(JNIEnv* env, jclass, jobjectArray copy_froms, jobjectArray copy_tos); -void ApplyDeltaFromSource(JNIEnv* env, jclass, jstring source_root, jstring target_root, jstring origin_bundle_path, jstring bundle_patch_path, jstring bundle_output_path, jstring merge_source_subdir, jboolean enable_merge, jobjectArray copy_froms, jobjectArray copy_tos, jobjectArray deletes, jstring hbc_transform_meta); -void CleanupOldEntries(JNIEnv* env, jclass, jstring root_dir, jobjectArray keep_names, jint max_age_days, jstring trash_dir); -jstring FlowBuildRequestBody(JNIEnv* env, jclass, jstring inputJson); -jstring FlowOrderEndpointCandidates(JNIEnv* env, jclass, jstring endpointsJson, jdouble randomSample); -jboolean FlowIsValidResponse(JNIEnv* env, jclass, jstring responseText); -jstring FlowHandleResponse(JNIEnv* env, jclass, jstring responseText, jstring identityJson, jstring afterDownload); - -} // namespace jni_natives -} // namespace pushy - -#endif // PUSHY_PATCH_CORE_JNI_NATIVES_H_ diff --git a/cpp/patch_core/jni_util.h b/cpp/patch_core/jni_util.h deleted file mode 100644 index 123e847b..00000000 --- a/cpp/patch_core/jni_util.h +++ /dev/null @@ -1,60 +0,0 @@ -#pragma once - -#include - -#include -#include - -// Small JNI helpers shared by the Android glue translation units -// (patch_core_android.cpp and update_core_android.cpp). Header-only with inline -// linkage so both can include it without a separate compilation unit. - -namespace pushy { -namespace jni_util { - -inline std::string JStringToString(JNIEnv* env, jstring value) { - if (value == nullptr) { - return std::string(); - } - - const char* chars = env->GetStringUTFChars(value, nullptr); - if (chars == nullptr) { - return std::string(); - } - - std::string result(chars); - env->ReleaseStringUTFChars(value, chars); - return result; -} - -inline std::vector JArrayToVector( - JNIEnv* env, - jobjectArray values) { - std::vector result; - if (values == nullptr) { - return result; - } - - const jsize size = env->GetArrayLength(values); - result.reserve(static_cast(size)); - for (jsize index = 0; index < size; ++index) { - auto* item = static_cast(env->GetObjectArrayElement(values, index)); - result.push_back(JStringToString(env, item)); - env->DeleteLocalRef(item); - } - return result; -} - -inline void ThrowRuntimeException(JNIEnv* env, const char* message) { - jclass exception = env->FindClass("java/lang/RuntimeException"); - if (exception != nullptr) { - env->ThrowNew(exception, message); - } -} - -inline void ThrowRuntimeException(JNIEnv* env, const std::string& message) { - ThrowRuntimeException(env, message.c_str()); -} - -} // namespace jni_util -} // namespace pushy diff --git a/cpp/patch_core/patch_core.cpp b/cpp/patch_core/patch_core.cpp index a2e7a476..27b6c0b8 100644 --- a/cpp/patch_core/patch_core.cpp +++ b/cpp/patch_core/patch_core.cpp @@ -21,7 +21,10 @@ #include "hbc_transform_wire.h" extern "C" { -#include "hpatch.h" +// Relative to this file: under use_frameworks! a bare "hpatch.h" resolves +// to the flattened framework PrivateHeaders copy, whose own HDiffPatch/ +// includes then fail. +#include "../hpatch/hpatch.h" } namespace pushy { @@ -487,14 +490,6 @@ Status MergeDirectoryRecursively( } // namespace -Status Status::Ok() { - return Status{true, std::string()}; -} - -Status Status::Error(const std::string& message) { - return Status{false, message}; -} - Status ValidateManifest(const PatchManifest& manifest) { return ValidateManifestImpl(manifest); } @@ -580,7 +575,7 @@ Status ApplyBundlePatchWithHbcTransform( const FileSourcePatchOptions& options, const BundleRebuilder& bundle_patcher) { hbc::HbcTransformMeta meta; - if (!hbc::ParseHbcTransformMeta(options.bundle_hbc_transform_meta, &meta)) { + if (!hbc::ParseHbcTransformMeta(options.manifest.bundle_hbc_transform_meta, &meta)) { return Status::Error("Invalid hbcTransform metadata"); } if (meta.v != hbc::kHbcTransformSupportedVersion) { @@ -654,7 +649,7 @@ Status ApplyPatchFromFileSource( } Status bundle_status = - options.bundle_hbc_transform_meta.empty() + options.manifest.bundle_hbc_transform_meta.empty() ? bundle_patcher.Apply( options.origin_bundle_path, options.bundle_patch_path, diff --git a/cpp/patch_core/patch_core.h b/cpp/patch_core/patch_core.h index cf69856d..fc6e230d 100644 --- a/cpp/patch_core/patch_core.h +++ b/cpp/patch_core/patch_core.h @@ -17,8 +17,12 @@ struct Status { explicit operator bool() const { return ok; } - static Status Ok(); - static Status Error(const std::string& message); + // Inline so a core that only reports a Status (install_record.cpp) links + // without patch_core.cpp and its hpatch dependencies. + static Status Ok() { return Status{true, std::string()}; } + static Status Error(const std::string& message) { + return Status{false, message}; + } }; struct CopyOperation { @@ -33,9 +37,16 @@ struct CopyOperation { uint32_t expected_crc = 0; }; +// What a patch archive's __diff.json declares (ParsePatchManifest in +// patch_manifest.h is the one parser every platform uses). struct PatchManifest { std::vector copies; std::vector deletes; + // __diff.json 中该 bundle patch 条目的 hbcTransform 元数据(JSON 文本)。 + // 非空时 patch 走变换域:T(origin) → hpatch → T⁻¹。元数据不可解析或 + // 变换规范版本不受支持时返回错误(调用方回退整包),绝不忽略元数据 + // 直接应用——那会产出损坏的 bundle。 + std::string bundle_hbc_transform_meta; }; struct FileSourcePatchOptions { @@ -47,11 +58,6 @@ struct FileSourcePatchOptions { std::string bundle_output_path; std::string merge_source_subdir; bool enable_merge = true; - // __diff.json 中该 bundle patch 条目的 hbcTransform 元数据(原始 JSON)。 - // 非空时 patch 走变换域:T(origin) → hpatch → T⁻¹。元数据不可解析或 - // 变换规范版本不受支持时返回错误(调用方回退整包),绝不忽略元数据 - // 直接应用——那会产出损坏的 bundle。 - std::string bundle_hbc_transform_meta; }; class BundleRebuilder { diff --git a/cpp/patch_core/patch_core_android.cpp b/cpp/patch_core/patch_core_android.cpp deleted file mode 100644 index 89233983..00000000 --- a/cpp/patch_core/patch_core_android.cpp +++ /dev/null @@ -1,102 +0,0 @@ -#include - -#include "jni_natives.h" - -#include -#include -#include - -#include "jni_util.h" -#include "patch_core.h" - -namespace { - -using pushy::jni_util::JArrayToVector; -using pushy::jni_util::JStringToString; -using pushy::jni_util::ThrowRuntimeException; - -} // namespace - -void pushy::jni_natives::ApplyDeltaFromSource( - JNIEnv* env, - jclass, - jstring source_root, - jstring target_root, - jstring origin_bundle_path, - jstring bundle_patch_path, - jstring bundle_output_path, - jstring merge_source_subdir, - jboolean enable_merge, - jobjectArray copy_froms, - jobjectArray copy_tos, - jobjectArray deletes, - jstring hbc_transform_meta) { - // C++ exceptions (bad_alloc/length_error from input-proportional STL - // allocations) must not unwind through the JNI boundary: convert them to a - // Java RuntimeException like every other failure of this entry point. - try { - const std::vector from_values = JArrayToVector(env, copy_froms); - const std::vector to_values = JArrayToVector(env, copy_tos); - - if (from_values.size() != to_values.size()) { - ThrowRuntimeException(env, "copy_froms and copy_tos length mismatch"); - return; - } - - pushy::delta::FileSourcePatchOptions options; - options.source_root = JStringToString(env, source_root); - options.target_root = JStringToString(env, target_root); - options.origin_bundle_path = JStringToString(env, origin_bundle_path); - options.bundle_patch_path = JStringToString(env, bundle_patch_path); - options.bundle_output_path = JStringToString(env, bundle_output_path); - options.merge_source_subdir = JStringToString(env, merge_source_subdir); - options.enable_merge = enable_merge == JNI_TRUE; - options.bundle_hbc_transform_meta = JStringToString(env, hbc_transform_meta); - - for (size_t index = 0; index < from_values.size(); ++index) { - options.manifest.copies.push_back(pushy::delta::CopyOperation{ - from_values[index], - to_values[index], - }); - } - options.manifest.deletes = JArrayToVector(env, deletes); - - const pushy::delta::Status status = - pushy::delta::ApplyPatchFromFileSource(options); - if (!status.ok) { - ThrowRuntimeException(env, status.message); - } - } catch (const std::exception& error) { - ThrowRuntimeException(env, error.what()); - } catch (...) { - ThrowRuntimeException(env, "Unexpected native exception while applying delta"); - } -} - -void pushy::jni_natives::CleanupOldEntries( - JNIEnv* env, - jclass, - jstring root_dir, - jobjectArray keep_names, - jint max_age_days, - jstring trash_dir) { - try { - const std::string root = JStringToString(env, root_dir); - const std::vector keep = JArrayToVector(env, keep_names); - const std::string trash = JStringToString(env, trash_dir); - // A trash directory turns the delete into renames (cheap enough to run - // under the caller's version-files lock); the caller purges it later. - const pushy::delta::Status status = trash.empty() - ? pushy::delta::CleanupOldEntries( - root, keep, static_cast(max_age_days)) - : pushy::delta::MoveOldEntriesToTrash( - root, keep, static_cast(max_age_days), trash); - if (!status.ok) { - ThrowRuntimeException(env, status.message); - } - } catch (const std::exception& error) { - ThrowRuntimeException(env, error.what()); - } catch (...) { - ThrowRuntimeException(env, "Unexpected native exception in cleanupOldEntries"); - } -} diff --git a/cpp/patch_core/patch_manifest.cpp b/cpp/patch_core/patch_manifest.cpp new file mode 100644 index 00000000..bb574253 --- /dev/null +++ b/cpp/patch_core/patch_manifest.cpp @@ -0,0 +1,201 @@ +#include "patch_manifest.h" + +#include +#include +#include +#include + +// Relative like every other cross-core include (see patch_core.cpp's hpatch.h +// include): use_frameworks! flattens the pod's headers. +#include "../update_flow_core/flow_json.h" + +namespace pushy { +namespace delta { +namespace { + +constexpr const char* kErrorPrefix = "delta manifest: "; + +Status ManifestError(const std::string& message) { + return Status::Error(kErrorPrefix + message); +} + +// null counts as absent everywhere (JSON writers emit it for empty maps). +bool IsAbsent(const flowjson::Value& value) { + return value.IsUndefined() || + value.kind() == flowjson::Value::Kind::Null; +} + +// An integer in [0, 2^32): the only CRC value a client can compare. +bool ReadCrc32(const flowjson::Value& value, uint32_t* out) { + if (!value.IsNumber()) { + return false; + } + const double number = value.AsNumber(); + if (!std::isfinite(number) || number < 0 || number > 4294967295.0 || + std::floor(number) != number) { + return false; + } + *out = static_cast(number); + return true; +} + +Status ParseCopies( + const flowjson::Value& copies, + const flowjson::Value& copies_crc, + const PatchManifestOptions& options, + PatchManifest* manifest) { + if (IsAbsent(copies)) { + return Status::Ok(); + } + if (!copies.IsObject()) { + return ManifestError("copies is not an object"); + } + if (!IsAbsent(copies_crc) && !copies_crc.IsObject()) { + return ManifestError("copiesCrc is not an object"); + } + // Target -> declared CRC value; one pass, not a member scan per copy. + std::unordered_map crc_by_target; + if (copies_crc.IsObject()) { + crc_by_target.reserve(copies_crc.members().size()); + for (const auto& member : copies_crc.members()) { + crc_by_target.emplace(member.first, &member.second); + } + } + const std::string& prefix = options.copy_source_prefix; + // Source -> the CRC some copy of it declared, to catch contradictions. + std::unordered_map crc_by_source; + manifest->copies.reserve(copies.members().size()); + for (const auto& member : copies.members()) { + const std::string& to = member.first; + const flowjson::Value& from_value = member.second; + std::string from; + if (from_value.IsString()) { + from = from_value.AsString(); + } else if (!IsAbsent(from_value)) { + return ManifestError("copies entry is not a string: " + to); + } + if (!prefix.empty() && from.compare(0, prefix.size(), prefix) == 0) { + from.erase(0, prefix.size()); + } + if (from.empty()) { + from = to; + } + + CopyOperation copy; + copy.from = std::move(from); + copy.to = to; + const auto declared = crc_by_target.find(to); + if (declared != crc_by_target.end()) { + const flowjson::Value& crc = *declared->second; + if (!IsAbsent(crc)) { + if (!ReadCrc32(crc, ©.expected_crc)) { + return ManifestError("copiesCrc entry is not a CRC32: " + to); + } + copy.has_expected_crc = true; + const auto inserted = + crc_by_source.emplace(copy.from, copy.expected_crc); + if (!inserted.second && + inserted.first->second != copy.expected_crc) { + return ManifestError("conflicting copiesCrc for source " + copy.from); + } + } + } + manifest->copies.push_back(std::move(copy)); + } + return Status::Ok(); +} + +Status ParseDeletes(const flowjson::Value& deletes, PatchManifest* manifest) { + if (IsAbsent(deletes)) { + return Status::Ok(); + } + if (deletes.IsObject()) { + manifest->deletes.reserve(deletes.members().size()); + for (const auto& member : deletes.members()) { + manifest->deletes.push_back(member.first); + } + return Status::Ok(); + } + if (deletes.IsArray()) { + manifest->deletes.reserve(deletes.Size()); + for (const flowjson::Value& path : deletes.elements()) { + if (!path.IsString()) { + return ManifestError("deletes entry is not a string"); + } + manifest->deletes.push_back(path.AsString()); + } + return Status::Ok(); + } + return ManifestError("deletes is not an object or array"); +} + +Status ParseHbcTransform( + const flowjson::Value& hbc_transform, + const PatchManifestOptions& options, + PatchManifest* manifest) { + if (IsAbsent(hbc_transform)) { + return Status::Ok(); + } + if (!hbc_transform.IsObject()) { + return ManifestError("hbcTransform is not an object"); + } + const flowjson::Value& meta = + hbc_transform.Get(options.bundle_patch_entry_name); + if (IsAbsent(meta)) { + return Status::Ok(); + } + if (!meta.IsObject()) { + return ManifestError( + "hbcTransform entry is not an object: " + + options.bundle_patch_entry_name); + } + manifest->bundle_hbc_transform_meta = flowjson::Stringify(meta); + return Status::Ok(); +} + +} // namespace + +Status ParsePatchManifest( + const std::string& json_text, + const PatchManifestOptions& options, + PatchManifest* out) { + if (out == nullptr) { + return Status::Error("Manifest output is required"); + } + flowjson::ParseLimits limits; + limits.max_input_bytes = + static_cast(archive_limits::kMaxManifestBytes); + limits.max_nodes = kMaxManifestNodes; + bool ok = false; + const flowjson::Value root = flowjson::Parse(json_text, limits, &ok); + if (!ok) { + return ManifestError("invalid JSON or over the size limits"); + } + if (!root.IsObject()) { + return ManifestError("not a JSON object"); + } + + PatchManifest manifest; + Status status = + ParseCopies(root.Get("copies"), root.Get("copiesCrc"), options, &manifest); + if (!status) { + return status; + } + status = ParseDeletes(root.Get("deletes"), &manifest); + if (!status) { + return status; + } + status = ParseHbcTransform(root.Get("hbcTransform"), options, &manifest); + if (!status) { + return status; + } + status = ValidateManifest(manifest); + if (!status) { + return status; + } + *out = std::move(manifest); + return Status::Ok(); +} + +} // namespace delta +} // namespace pushy diff --git a/cpp/patch_core/patch_manifest.h b/cpp/patch_core/patch_manifest.h new file mode 100644 index 00000000..012d1f4d --- /dev/null +++ b/cpp/patch_core/patch_manifest.h @@ -0,0 +1,74 @@ +#pragma once + +#include +#include + +#include "archive_limits.h" +#include "patch_core.h" + +// Keep the core internal to whichever binary links it (see podspec Core). +#pragma GCC visibility push(hidden) + +namespace pushy { +namespace delta { + +// __diff.json, the manifest inside every patch archive, as the generators +// emit it (pushy-go internal/taskexecutor/bundle_diff.go and +// react-native-update-cli src/diff.ts): +// +// {"copies": {"": "" | "", ...}, +// "copiesCrc": {"": , ...}, package diffs only +// "deletes": {"": 1, ...}, ppk diffs only +// "hbcTransform": {"": {...}}} hdiffv2 track only +// +// The one parser of that file on Android, iOS and HarmonyOS. The rules are +// the most permissive form that is still safe; anything else fails the +// patch, and the JS strategy chain falls back to the full package: +// - the text is a JSON object (at most archive_limits::kMaxManifestBytes, +// at most kMaxManifestNodes values); unknown members are ignored, and a +// member that is null counts as absent; +// - copies: an object of target -> source strings. An empty (or null) +// source means "same path as the target". Order follows the document; +// - copiesCrc: an object keyed by copy *target* (not source). Each value is +// an integer in [0, 2^32) or null; any other value is an error (a CRC the +// client cannot honour must not silently skip the content check). Keys +// naming no copy are ignored. Two copies of one source that declare +// different CRCs are a self-contradictory manifest and an error; +// - deletes: an object (its keys) or an array of strings; +// - hbcTransform: an object; its member named after the bundle patch entry, +// when present, must be an object and is handed on re-serialized +// (PatchManifest::bundle_hbc_transform_meta). Non-object metadata is an +// error, never ignored: applying a transform-domain patch without the +// transform produces a corrupt bundle; +// - every path passes ValidateManifest. +// Values of the wrong type (a numeric source, a non-string delete, copies as +// an array, ...) are errors on every platform. +struct PatchManifestOptions { + // The archive entry whose hbcTransform metadata is extracted + // (archive_patch::DefaultBundleDeltaEntryName() unless the platform names + // its bundle differently, e.g. HarmonyOS). + std::string bundle_patch_entry_name; + // Stripped once from the start of every copy source that begins with it, + // before the empty-source rule applies. HarmonyOS package diffs name the + // origin entries by their HAP path ("resources/rawfile/assets/a.png"), + // which the resource manager reads back as "assets/a.png". + std::string copy_source_prefix; +}; + +// A package holds at most archive_limits::kMaxEntries entries, and the +// generators emit at most one copy source, one CRC and one delete per entry +// (plus a few hundred values of hbcTransform metadata). The cap bounds the +// parsed tree of a hostile 16 MB manifest to about ten MB of heap. +constexpr size_t kMaxManifestNodes = + 4 * static_cast(archive_limits::kMaxEntries); + +// Fails without touching *out. +Status ParsePatchManifest( + const std::string& json_text, + const PatchManifestOptions& options, + PatchManifest* out); + +} // namespace delta +} // namespace pushy + +#pragma GCC visibility pop diff --git a/cpp/patch_core/state_ops.h b/cpp/patch_core/state_ops.h index 761293bd..735afadf 100644 --- a/cpp/patch_core/state_ops.h +++ b/cpp/patch_core/state_ops.h @@ -1,9 +1,9 @@ #pragma once // Single source of truth for the state-machine operation codes shared across -// the platform glue layers (Android JNI in update_core_android.cpp and -// HarmonyOS NAPI in pushy.cpp). The integer values MUST stay in sync with the -// callers on each platform: +// the platform glue layers (Android JNI in +// cpp/android_jni/update_core_android.cpp and HarmonyOS NAPI in pushy.cpp). +// The integer values MUST stay in sync with the callers on each platform: // - Android: UpdateContext.java (STATE_OP_* constants) // - HarmonyOS: UpdateContext.ts (StateOperation usage) // Do not renumber existing entries; only append new ones. diff --git a/cpp/patch_core/tests/archive-limits-vectors.json b/cpp/patch_core/tests/archive-limits-vectors.json new file mode 100644 index 00000000..a10fd4af --- /dev/null +++ b/cpp/patch_core/tests/archive-limits-vectors.json @@ -0,0 +1,418 @@ +{ + "description": "Archive extraction limits (cpp/patch_core/archive_limits.h), shared by Android, iOS and HarmonyOS. `entries`: an EntryAccumulator(archiveBytes) is fed each entry in order — Accept(size, compressed, symlink, name) when `compressed` is given, AcceptExtracted(size, symlink, name) otherwise; `repeat` feeds the same entry that many times — and must stop at `expect` (a substring of the first rejection) or accept them all (\"ok\", then `count`/`total` when given). `uncompressed`: CheckUncompressedSize(archiveBytes, uncompressedBytes). `probes`: PlanUnknownLengthFreeSpaceProbe(knownTotal, writtenBefore, chunkBytes, reservedUntil) must return `probe`, `reserve` and `after` (the new reservedUntil). `reservedNames`: IsReservedEntryName. `was` records how the platforms disagreed before the rules were shared; the strictest reading won.", + "entries": [ + { + "name": "a small package", + "archiveBytes": 1000, + "entries": [ + { + "name": "index.bundlejs", + "size": 3000, + "compressed": 900 + }, + { + "name": "assets/", + "size": 0, + "compressed": 0 + }, + { + "name": "assets/logo.png", + "size": 3, + "compressed": 3 + } + ], + "expect": "ok", + "count": 3, + "total": 3003 + }, + { + "name": "an entry at the per-entry cap", + "archiveBytes": 536870912, + "entries": [ + { + "name": "big.bin", + "size": 536870912, + "compressed": 536870912 + } + ], + "expect": "ok", + "total": 536870912 + }, + { + "name": "an entry over the per-entry cap", + "archiveBytes": 629145600, + "entries": [ + { + "name": "big.bin", + "size": 536870913, + "compressed": 536870913 + } + ], + "expect": "archive entry too large: big.bin" + }, + { + "name": "the running total over the cap", + "archiveBytes": 2726297600, + "entries": [ + { + "name": "part.bin", + "size": 524288000, + "compressed": 524288000, + "repeat": 5 + } + ], + "expect": "archive expands beyond 2147483648 bytes" + }, + { + "name": "an entry at exactly 100:1 above the threshold", + "archiveBytes": 3000000, + "entries": [ + { + "name": "text.json", + "size": 2000000, + "compressed": 20000 + } + ], + "expect": "ok" + }, + { + "name": "an entry just past 100:1", + "archiveBytes": 3000000, + "entries": [ + { + "name": "text.json", + "size": 2000001, + "compressed": 20000 + } + ], + "expect": "archive entry compression ratio too high: text.json", + "was": "Android and iOS divided (2000001 / 20000 = 100) and accepted it; Harmony's whole-archive check already compared size > compressed * 100" + }, + { + "name": "no ratio check up to 1 MiB", + "archiveBytes": 2097152, + "entries": [ + { + "name": "zeros.bin", + "size": 1048576, + "compressed": 1 + } + ], + "expect": "ok" + }, + { + "name": "a compression bomb just past 1 MiB", + "archiveBytes": 2097152, + "entries": [ + { + "name": "bomb.bin", + "size": 1048577, + "compressed": 1048 + } + ], + "expect": "archive entry compression ratio too high: bomb.bin" + }, + { + "name": "a zero compressed size bounds nothing", + "archiveBytes": 4194304, + "entries": [ + { + "name": "bomb.bin", + "size": 2097152, + "compressed": 0 + } + ], + "expect": "archive entry compression ratio too high: bomb.bin", + "was": "Android and iOS skipped the ratio check when the compressed size was 0" + }, + { + "name": "small entries adding up past the whole-archive ratio", + "archiveBytes": 10240, + "entries": [ + { + "name": "small.bin", + "size": 524288, + "compressed": 1024, + "repeat": 3 + } + ], + "expect": "archive compression ratio exceeds 100:1 (1572864/10240)", + "was": "Android and iOS checked each entry only; Harmony checked the whole archive before unpacking" + }, + { + "name": "an entry of unknown size", + "archiveBytes": 1000, + "entries": [ + { + "name": "a.bin", + "size": -1, + "compressed": 10 + } + ], + "expect": "archive entry size unknown: a.bin", + "was": "Android skipped an entry whose size was -1" + }, + { + "name": "a symlink", + "archiveBytes": 1000, + "entries": [ + { + "name": "index.bundlejs", + "size": 10, + "compressed": 10 + }, + { + "name": "assets", + "size": 16, + "compressed": 16, + "symlink": true + } + ], + "expect": "archive entry is a symlink: assets", + "was": "only iOS refused symlinks; java.util.zip writes the link as a regular file and Harmony's walk followed it" + }, + { + "name": "a symlink whose name is not known yet", + "archiveBytes": 1000, + "entries": [ + { + "name": "", + "size": 10, + "compressed": 10 + }, + { + "name": "", + "size": 16, + "compressed": 16, + "symlink": true + } + ], + "expect": "archive entry is a symlink: #1" + }, + { + "name": "a reserved name", + "archiveBytes": 1000, + "entries": [ + { + "name": "index.bundlejs", + "size": 10, + "compressed": 10 + }, + { + "name": ".pushy-complete", + "size": 2, + "compressed": 2 + } + ], + "expect": "archive contains reserved entry .pushy-complete" + }, + { + "name": "a reserved directory component", + "archiveBytes": 1000, + "entries": [ + { + "name": "assets/.pushy-x/a.png", + "size": 2, + "compressed": 2 + } + ], + "expect": "archive contains reserved entry assets/.pushy-x/a.png", + "was": "Android checked the base name only and iOS the last component of each unpacked path; Harmony refused it at any depth" + }, + { + "name": "extracted entries within the whole-archive ratio", + "archiveBytes": 1048576, + "entries": [ + { + "name": "assets", + "size": 0 + }, + { + "name": "assets/a.bin", + "size": 2097152 + } + ], + "expect": "ok", + "count": 2, + "total": 2097152 + }, + { + "name": "extracted entries past the whole-archive ratio", + "archiveBytes": 20000, + "entries": [ + { + "name": "a.bin", + "size": 2097152 + } + ], + "expect": "archive compression ratio exceeds 100:1" + }, + { + "name": "extracted entry over the per-entry cap", + "archiveBytes": 536870912, + "entries": [ + { + "name": "big.bin", + "size": 536870913 + } + ], + "expect": "archive entry too large: big.bin" + }, + { + "name": "20000 entries", + "archiveBytes": 1000, + "entries": [ + { + "name": "e", + "size": 0, + "compressed": 0, + "repeat": 20000 + } + ], + "expect": "ok", + "count": 20000, + "total": 0 + }, + { + "name": "20001 entries", + "archiveBytes": 1000, + "entries": [ + { + "name": "e", + "size": 0, + "compressed": 0, + "repeat": 20001 + } + ], + "expect": "archive has too many entries (> 20000)" + } + ], + "uncompressed": [ + { + "name": "within both limits", + "archiveBytes": 1024, + "uncompressedBytes": 4096, + "expect": "ok" + }, + { + "name": "no ratio check up to 1 MiB", + "archiveBytes": 1, + "uncompressedBytes": 1048576, + "expect": "ok" + }, + { + "name": "at the total cap", + "archiveBytes": 2147483648, + "uncompressedBytes": 2147483648, + "expect": "ok" + }, + { + "name": "past the total cap", + "archiveBytes": 2147483648, + "uncompressedBytes": 2147483649, + "expect": "archive expands beyond 2147483648 bytes (2147483649)" + }, + { + "name": "exactly 100:1", + "archiveBytes": 1048576, + "uncompressedBytes": 104857600, + "expect": "ok" + }, + { + "name": "just past 100:1", + "archiveBytes": 1048576, + "uncompressedBytes": 104857601, + "expect": "archive compression ratio exceeds 100:1" + }, + { + "name": "an empty archive expanding past 1 MiB", + "archiveBytes": 0, + "uncompressedBytes": 2097152, + "expect": "archive compression ratio exceeds 100:1", + "was": "Harmony skipped the ratio when the archive size was 0" + }, + { + "name": "an empty archive expanding to little", + "archiveBytes": 0, + "uncompressedBytes": 1048576, + "expect": "ok" + } + ], + "probes": [ + { + "name": "never probes when the length is known", + "knownTotal": 1000, + "writtenBefore": 0, + "chunkBytes": 4096, + "reservedUntil": 0, + "probe": false, + "reserve": 8388608, + "after": 0 + }, + { + "name": "probes the first chunk, reserving PROBE ahead", + "knownTotal": 0, + "writtenBefore": 0, + "chunkBytes": 4096, + "reservedUntil": 0, + "probe": true, + "reserve": 8388608, + "after": 8388608 + }, + { + "name": "a chunk inside the reservation", + "knownTotal": 0, + "writtenBefore": 4096, + "chunkBytes": 4096, + "reservedUntil": 8388608, + "probe": false, + "reserve": 8388608, + "after": 8388608 + }, + { + "name": "a chunk ending on the reservation", + "knownTotal": 0, + "writtenBefore": 8388598, + "chunkBytes": 10, + "reservedUntil": 8388608, + "probe": false, + "reserve": 8388608, + "after": 8388608 + }, + { + "name": "a chunk crossing the reservation", + "knownTotal": 0, + "writtenBefore": 8388598, + "chunkBytes": 11, + "reservedUntil": 8388608, + "probe": true, + "reserve": 8388608, + "after": 16777206 + }, + { + "name": "a chunk larger than PROBE reserves its own size", + "knownTotal": 0, + "writtenBefore": 100, + "chunkBytes": 16777217, + "reservedUntil": 0, + "probe": true, + "reserve": 16777217, + "after": 16777317 + } + ], + "reservedNames": { + ".pushy-complete": true, + "sub/dir/.pushy-complete": true, + "sub\\dir\\.pushy-anything": true, + ".pushy-complete/": true, + "a/.pushy-x/b.png": true, + ".pushy-": true, + "pushy-complete": false, + "sub/.pushy/file": false, + "index.bundlejs": false, + "": false, + "a/b.pushy-c": false, + ".pushy": false, + "assets/x.pushy-": false + } +} diff --git a/cpp/patch_core/tests/fuzz_patch_manifest.cpp b/cpp/patch_core/tests/fuzz_patch_manifest.cpp new file mode 100644 index 00000000..b1d7cd5e --- /dev/null +++ b/cpp/patch_core/tests/fuzz_patch_manifest.cpp @@ -0,0 +1,58 @@ +// libFuzzer harness for the __diff.json parser (patch_manifest.h): the whole +// manifest is untrusted archive content. Build and run with +// FUZZ=1 ./scripts/test-patch-core.sh +// (clang -fsanitize=fuzzer,address,undefined). Any crash, sanitizer report or +// abort is a finding. The first input byte picks the options so both the +// plain and the HarmonyOS (rawfile prefix) forms are covered. +#include +#include +#include +#include +#include +#include + +#include "../archive_patch_core.h" +#include "../hbc_transform_wire.h" +#include "../patch_manifest.h" + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + if (size == 0) { + return 0; + } + pushy::delta::PatchManifestOptions options; + options.bundle_patch_entry_name = + (data[0] & 1) ? "bundle.harmony.js.patch" : "index.bundlejs.patch"; + if (data[0] & 2) { + options.copy_source_prefix = "resources/rawfile/"; + } + const std::string json(reinterpret_cast(data + 1), size - 1); + + pushy::delta::PatchManifest manifest; + if (!pushy::delta::ParsePatchManifest(json, options, &manifest).ok) { + return 0; + } + // Whatever parses is safe to apply and self-consistent. + if (!pushy::delta::ValidateManifest(manifest).ok) { + std::abort(); + } + std::unordered_map crc_by_source; + for (const pushy::delta::CopyOperation& copy : manifest.copies) { + if (!copy.has_expected_crc) { + continue; + } + const auto inserted = crc_by_source.emplace(copy.from, copy.expected_crc); + if (!inserted.second && inserted.first->second != copy.expected_crc) { + std::abort(); + } + } + std::vector groups; + if (!pushy::archive_patch::BuildCopyGroups(manifest, &groups).ok) { + std::abort(); + } + // The re-serialized metadata goes on to the strict wire parser. + if (!manifest.bundle_hbc_transform_meta.empty()) { + pushy::hbc::HbcTransformMeta meta; + pushy::hbc::ParseHbcTransformMeta(manifest.bundle_hbc_transform_meta, &meta); + } + return 0; +} diff --git a/cpp/patch_core/tests/install-record-vectors.json b/cpp/patch_core/tests/install-record-vectors.json new file mode 100644 index 00000000..3870ebee --- /dev/null +++ b/cpp/patch_core/tests/install-record-vectors.json @@ -0,0 +1,222 @@ +{ + "description": "Completion records (cpp/patch_core/install_record.h), shared by Android, iOS and HarmonyOS. Each vector writes `record` as the raw text of /.pushy-complete (null: no record file) next to a bundle whose content is `var a = 1;` (unless `bundle` is false), then checks IsComplete(dir, `hash` or \"abc\") against `complete`, CheckActivation(dir, hash, grandfathered = `grandfathered`) against `activation` (\"ok\" or a substring of the rejection) and RecordedBundleSha256 against `recorded` (default \"\"). In the record text, @SHA@ is the bundle's SHA-256 (lowercase hex), @SHA_UPPER@ the same in uppercase and @OTHER_SHA@ the digest of other content. `was` records how the platforms disagreed before the logic was shared.", + "vectors": [ + { + "name": "legacy empty marker: installed, activates unverified", + "record": "", + "complete": true, + "activation": "ok" + }, + { + "name": "schema-1 record with a matching digest", + "record": "{\"schema\":1,\"versionHash\":\"abc\",\"bundleSha256\":\"@SHA@\",\"artifactSha256\":\"ff\"}", + "complete": true, + "activation": "ok", + "recorded": "@SHA@" + }, + { + "name": "digest compare is case-insensitive", + "record": "{\"schema\":1,\"versionHash\":\"abc\",\"bundleSha256\":\"@SHA_UPPER@\"}", + "complete": true, + "activation": "ok", + "recorded": "@SHA_UPPER@" + }, + { + "name": "unknown members are ignored", + "record": "{\"schema\":1,\"versionHash\":\"abc\",\"bundleSha256\":\"@SHA@\",\"future\":{\"x\":[1,2]}}", + "complete": true, + "activation": "ok", + "recorded": "@SHA@" + }, + { + "name": "schema written as 1.0 is the number 1", + "record": "{\"schema\":1.0,\"versionHash\":\"abc\",\"bundleSha256\":\"@SHA@\"}", + "complete": true, + "activation": "ok", + "recorded": "@SHA@" + }, + { + "name": "tampered bundle", + "record": "{\"schema\":1,\"versionHash\":\"abc\",\"bundleSha256\":\"@OTHER_SHA@\"}", + "complete": true, + "activation": "bundle digest mismatch", + "recorded": "@OTHER_SHA@" + }, + { + "name": "no bundleSha256: installed for dedup, never activated", + "record": "{\"schema\":1,\"versionHash\":\"abc\"}", + "complete": true, + "activation": "completion record mismatch" + }, + { + "name": "empty bundleSha256", + "record": "{\"schema\":1,\"versionHash\":\"abc\",\"bundleSha256\":\"\"}", + "complete": true, + "activation": "completion record mismatch" + }, + { + "name": "null bundleSha256", + "record": "{\"schema\":1,\"versionHash\":\"abc\",\"bundleSha256\":null}", + "complete": true, + "activation": "completion record mismatch" + }, + { + "name": "numeric bundleSha256 is not a digest", + "record": "{\"schema\":1,\"versionHash\":\"abc\",\"bundleSha256\":123}", + "complete": true, + "activation": "completion record mismatch", + "was": "Android's crash-attribution read coerced it to \"123\"" + }, + { + "name": "only an artifact digest", + "record": "{\"schema\":1,\"versionHash\":\"abc\",\"artifactSha256\":\"@SHA@\"}", + "complete": true, + "activation": "completion record mismatch" + }, + { + "name": "record of another version", + "record": "{\"schema\":1,\"versionHash\":\"def\",\"bundleSha256\":\"@SHA@\"}", + "complete": false, + "activation": "completion record mismatch", + "recorded": "@SHA@", + "was": "iOS/Harmony switchVersion said \"is incomplete.\"" + }, + { + "name": "future schema", + "record": "{\"schema\":2,\"versionHash\":\"abc\",\"bundleSha256\":\"@SHA@\"}", + "complete": false, + "activation": "completion record mismatch", + "recorded": "@SHA@" + }, + { + "name": "schema as a string", + "record": "{\"schema\":\"1\",\"versionHash\":\"abc\",\"bundleSha256\":\"@SHA@\"}", + "complete": false, + "activation": "completion record mismatch", + "recorded": "@SHA@", + "was": "Android (optInt) accepted it" + }, + { + "name": "schema as a boolean", + "record": "{\"schema\":true,\"versionHash\":\"abc\",\"bundleSha256\":\"@SHA@\"}", + "complete": false, + "activation": "completion record mismatch", + "recorded": "@SHA@", + "was": "iOS (NSNumber intValue) accepted it" + }, + { + "name": "fractional schema", + "record": "{\"schema\":1.5,\"versionHash\":\"abc\",\"bundleSha256\":\"@SHA@\"}", + "complete": false, + "activation": "completion record mismatch", + "recorded": "@SHA@", + "was": "Android (optInt) and iOS (intValue) truncated it to 1" + }, + { + "name": "numeric versionHash", + "hash": "123", + "record": "{\"schema\":1,\"versionHash\":123,\"bundleSha256\":\"@SHA@\"}", + "complete": false, + "activation": "completion record mismatch", + "recorded": "@SHA@", + "was": "Android (optString) coerced it to \"123\"" + }, + { + "name": "an empty JSON object is not the legacy marker", + "record": "{}", + "complete": false, + "activation": "completion record mismatch", + "was": "every platform trusted it like the zero-byte marker" + }, + { + "name": "malformed JSON", + "record": "not json", + "complete": false, + "activation": "has no valid completion record" + }, + { + "name": "truncated record", + "record": "{\"schema\":1,\"versionHash\":\"abc\"", + "complete": false, + "activation": "has no valid completion record" + }, + { + "name": "an array", + "record": "[1,2]", + "complete": false, + "activation": "has no valid completion record" + }, + { + "name": "null", + "record": "null", + "complete": false, + "activation": "has no valid completion record" + }, + { + "name": "whitespace only", + "record": " \n", + "complete": false, + "activation": "has no valid completion record" + }, + { + "name": "no record file", + "record": null, + "complete": false, + "activation": "is incomplete", + "was": "Android said \"has no valid completion record.\"" + }, + { + "name": "no record file, current/last version: grandfathered", + "record": null, + "grandfathered": true, + "complete": false, + "activation": "ok" + }, + { + "name": "a recorded current/last version is verified", + "record": "{\"schema\":1,\"versionHash\":\"abc\"}", + "grandfathered": true, + "complete": true, + "activation": "completion record mismatch" + }, + { + "name": "a recorded current/last version with a bad digest is refused", + "record": "{\"schema\":1,\"versionHash\":\"abc\",\"bundleSha256\":\"@OTHER_SHA@\"}", + "grandfathered": true, + "complete": true, + "activation": "bundle digest mismatch", + "recorded": "@OTHER_SHA@" + }, + { + "name": "legacy marker of a current/last version", + "record": "", + "grandfathered": true, + "complete": true, + "activation": "ok" + }, + { + "name": "no bundle", + "record": "{\"schema\":1,\"versionHash\":\"abc\",\"bundleSha256\":\"@SHA@\"}", + "bundle": false, + "complete": false, + "activation": "not found", + "recorded": "@SHA@" + }, + { + "name": "no bundle, legacy marker", + "record": "", + "bundle": false, + "complete": false, + "activation": "not found" + }, + { + "name": "no bundle, grandfathered: still not found", + "record": null, + "bundle": false, + "grandfathered": true, + "complete": false, + "activation": "not found", + "was": "iOS let a grandfathered version without its bundle through" + } + ] +} diff --git a/cpp/patch_core/tests/patch-manifest-vectors.json b/cpp/patch_core/tests/patch-manifest-vectors.json new file mode 100644 index 00000000..6694cfea --- /dev/null +++ b/cpp/patch_core/tests/patch-manifest-vectors.json @@ -0,0 +1,327 @@ +{ + "description": "__diff.json parsing (cpp/patch_core/patch_manifest.h ParsePatchManifest), shared by Android, iOS and HarmonyOS. Each vector feeds `manifest` (serialized) or the raw `text`, with `options` (bundlePatchEntryName defaults to index.bundlejs.patch, copySourcePrefix to none). A vector expects either `error` (a substring of the message) or the parsed `copies` ([from, to, crc-or-null] in document order), `deletes` and `hbcTransformMeta` (all three default to empty). `was` records how the platforms disagreed before the parser was shared.", + "vectors": [ + { + "name": "server ppk diff: moved copies and deletes object", + "manifest": { + "copies": { "assets/b.png": "assets/a.png", "assets/c/d.png": "assets/a.png" }, + "deletes": { "assets/old.png": 1, "assets/gone/": 1 } + }, + "copies": [ + ["assets/a.png", "assets/b.png", null], + ["assets/a.png", "assets/c/d.png", null] + ], + "deletes": ["assets/old.png", "assets/gone/"] + }, + { + "name": "server package diff: same-path and moved copies with per-target CRCs", + "manifest": { + "copies": { "assets/a.png": "", "assets/b.png": "assets/old/b.png", "assets/c.png": "" }, + "copiesCrc": { "assets/a.png": 3421780262, "assets/b.png": 0, "assets/c.png": 4294967295 } + }, + "copies": [ + ["assets/a.png", "assets/a.png", 3421780262], + ["assets/old/b.png", "assets/b.png", 0], + ["assets/c.png", "assets/c.png", 4294967295] + ] + }, + { + "name": "empty object", + "manifest": {} + }, + { + "name": "null members count as absent", + "manifest": { "copies": null, "copiesCrc": null, "deletes": null, "hbcTransform": null } + }, + { + "name": "unknown members are ignored", + "manifest": { "copies": { "a.png": "b.png" }, "version": 3, "extra": [1, { "x": null }] }, + "copies": [["b.png", "a.png", null]] + }, + { + "name": "deletes as an array of paths", + "was": "Android ignored an array (deleted nothing); iOS and HarmonyOS accepted it", + "manifest": { "deletes": ["assets/old.png", "assets/removed.txt"] }, + "deletes": ["assets/old.png", "assets/removed.txt"] + }, + { + "name": "deletes array with a non-string entry", + "was": "HarmonyOS coerced it with String(); iOS rejected it", + "manifest": { "deletes": ["assets/old.png", 7] }, + "error": "deletes entry is not a string" + }, + { + "name": "deletes of another type", + "was": "Android and HarmonyOS ignored it; iOS rejected it", + "manifest": { "deletes": "assets/old.png" }, + "error": "deletes is not an object or array" + }, + { + "name": "copies as an array", + "was": "HarmonyOS read the indices as targets; Android ignored it; iOS rejected it", + "manifest": { "copies": ["assets/a.png"] }, + "error": "copies is not an object" + }, + { + "name": "numeric copy source", + "was": "HarmonyOS coerced it with String(); Android threw JSONException; iOS rejected it", + "manifest": { "copies": { "assets/a.png": 5 } }, + "error": "copies entry is not a string: assets/a.png" + }, + { + "name": "null copy source means the same path", + "was": "HarmonyOS kept an empty source (then failed path validation); Android read the string \"null\"; iOS rejected it", + "manifest": { "copies": { "assets/a.png": null } }, + "copies": [["assets/a.png", "assets/a.png", null]] + }, + { + "name": "empty copy source means the same path without a prefix option too", + "was": "HarmonyOS ppk patches kept the empty source", + "manifest": { "copies": { "assets/a.png": "" } }, + "copies": [["assets/a.png", "assets/a.png", null]] + }, + { + "name": "copiesCrc is keyed by target: two targets of one source share its CRC", + "was": "Android keyed copiesCrc by source, last target winning", + "manifest": { + "copies": { "assets/x.png": "assets/a.png", "assets/y.png": "assets/a.png" }, + "copiesCrc": { "assets/x.png": 123, "assets/y.png": 123 } + }, + "copies": [ + ["assets/a.png", "assets/x.png", 123], + ["assets/a.png", "assets/y.png", 123] + ] + }, + { + "name": "copiesCrc on only some targets of one source", + "manifest": { + "copies": { "assets/x.png": "assets/a.png", "assets/y.png": "assets/a.png" }, + "copiesCrc": { "assets/y.png": 9 } + }, + "copies": [ + ["assets/a.png", "assets/x.png", null], + ["assets/a.png", "assets/y.png", 9] + ] + }, + { + "name": "copiesCrc conflicting for one source", + "was": "Android kept the last CRC per source; iOS and HarmonyOS checked each target", + "manifest": { + "copies": { "assets/x.png": "assets/a.png", "assets/y.png": "assets/a.png" }, + "copiesCrc": { "assets/x.png": 1, "assets/y.png": 2 } + }, + "error": "conflicting copiesCrc for source assets/a.png" + }, + { + "name": "copiesCrc null skips the check, unknown targets are ignored", + "manifest": { + "copies": { "a": "x", "b": "y" }, + "copiesCrc": { "a": null, "zzz": 77 } + }, + "copies": [["x", "a", null], ["y", "b", null]] + }, + { + "name": "copiesCrc given as a string", + "was": "Android parsed numeric strings; HarmonyOS and iOS skipped the check", + "manifest": { "copies": { "a": "x" }, "copiesCrc": { "a": "123" } }, + "error": "copiesCrc entry is not a CRC32: a" + }, + { + "name": "copiesCrc negative", + "manifest": { "copies": { "a": "x" }, "copiesCrc": { "a": -1 } }, + "error": "copiesCrc entry is not a CRC32: a" + }, + { + "name": "copiesCrc fractional", + "was": "iOS truncated it", + "manifest": { "copies": { "a": "x" }, "copiesCrc": { "a": 1.5 } }, + "error": "copiesCrc entry is not a CRC32: a" + }, + { + "name": "copiesCrc beyond 32 bits", + "was": "iOS truncated it to 32 bits", + "manifest": { "copies": { "a": "x" }, "copiesCrc": { "a": 4294967296 } }, + "error": "copiesCrc entry is not a CRC32: a" + }, + { + "name": "copiesCrc boolean", + "was": "iOS read true as CRC 1", + "manifest": { "copies": { "a": "x" }, "copiesCrc": { "a": true } }, + "error": "copiesCrc entry is not a CRC32: a" + }, + { + "name": "copiesCrc of another type", + "was": "every platform ignored it", + "manifest": { "copies": { "a": "x" }, "copiesCrc": [1] }, + "error": "copiesCrc is not an object" + }, + { + "name": "HarmonyOS rawfile prefix is stripped from copy sources", + "manifest": { + "copies": { + "assets/icon.png": "resources/rawfile/assets/icon.png", + "assets/media.png": "resources/base/media/media.png" + }, + "copiesCrc": { "assets/icon.png": 5 } + }, + "options": { "copySourcePrefix": "resources/rawfile/", "bundlePatchEntryName": "bundle.harmony.js.patch" }, + "copies": [ + ["assets/icon.png", "assets/icon.png", 5], + ["resources/base/media/media.png", "assets/media.png", null] + ] + }, + { + "name": "without the option the prefix stays", + "manifest": { "copies": { "assets/icon.png": "resources/rawfile/assets/icon.png" } }, + "copies": [["resources/rawfile/assets/icon.png", "assets/icon.png", null]] + }, + { + "name": "the prefix is stripped only at the start", + "was": "HarmonyOS replaced its first occurrence anywhere in the path", + "manifest": { "copies": { "a.png": "assets/resources/rawfile/a.png" } }, + "options": { "copySourcePrefix": "resources/rawfile/" }, + "copies": [["assets/resources/rawfile/a.png", "a.png", null]] + }, + { + "name": "a source that is only the prefix means the same path", + "manifest": { "copies": { "assets/icon.png": "resources/rawfile/" } }, + "options": { "copySourcePrefix": "resources/rawfile/" }, + "copies": [["assets/icon.png", "assets/icon.png", null]] + }, + { + "name": "hbcTransform metadata of the bundle patch entry, re-serialized", + "text": "{\"hbcTransform\": {\"other.patch\": {\"v\": 9}, \"index.bundlejs.patch\": {\"v\": 1, \"hbcVersion\": 96, \"layout\": {\"counts\": 19, \"sections\": [[2, 16, [[0, 0, 25]]]]}}}}", + "hbcTransformMeta": "{\"v\":1,\"hbcVersion\":96,\"layout\":{\"counts\":19,\"sections\":[[2,16,[[0,0,25]]]]}}" + }, + { + "name": "hbcTransform under a platform-specific bundle patch name", + "manifest": { "hbcTransform": { "index.bundlejs.patch": { "v": 2 }, "bundle.harmony.js.patch": { "v": 1 } } }, + "options": { "bundlePatchEntryName": "bundle.harmony.js.patch" }, + "hbcTransformMeta": "{\"v\":1}" + }, + { + "name": "hbcTransform without the bundle patch entry", + "manifest": { "hbcTransform": { "other.patch": {} } } + }, + { + "name": "hbcTransform entry null", + "manifest": { "hbcTransform": { "index.bundlejs.patch": null } } + }, + { + "name": "hbcTransform entry that is not an object", + "was": "every platform ignored it and applied the patch untransformed", + "manifest": { "hbcTransform": { "index.bundlejs.patch": "{\"v\":1}" } }, + "error": "hbcTransform entry is not an object: index.bundlejs.patch" + }, + { + "name": "hbcTransform that is not an object", + "was": "every platform ignored it", + "manifest": { "hbcTransform": [{ "v": 1 }] }, + "error": "hbcTransform is not an object" + }, + { + "name": "duplicate copy targets: the last source wins", + "text": "{\"copies\":{\"a.png\":\"x.png\",\"b.png\":\"y.png\",\"a.png\":\"z.png\"}}", + "copies": [["z.png", "a.png", null], ["y.png", "b.png", null]] + }, + { + "name": "non-ASCII paths, escaped or raw", + "text": "{\"copies\":{\"assets/\\u56fe.png\":\"assets/图片.png\",\"assets/\\ud83d\\ude00.png\":\"\"}}", + "copies": [ + ["assets/图片.png", "assets/图.png", null], + ["assets/😀.png", "assets/😀.png", null] + ] + }, + { + "name": "root array", + "text": "[]", + "error": "not a JSON object" + }, + { + "name": "root string", + "text": "\"copies\"", + "error": "not a JSON object" + }, + { + "name": "empty text", + "text": "", + "error": "invalid JSON" + }, + { + "name": "malformed JSON", + "text": "{\"copies\":{\"a\":\"b\",}}", + "error": "invalid JSON" + }, + { + "name": "trailing garbage", + "text": "{} {}", + "error": "invalid JSON" + }, + { + "name": "malicious: copy target escapes the version directory", + "manifest": { "copies": { "../escape.txt": "assets/a.png" } }, + "error": "Unsafe copy target path: ../escape.txt" + }, + { + "name": "malicious: nested parent segment in a copy target", + "manifest": { "copies": { "assets/../../escape.txt": "assets/a.png" } }, + "error": "Unsafe copy target path" + }, + { + "name": "malicious: absolute copy source", + "manifest": { "copies": { "assets/a.png": "/etc/passwd" } }, + "error": "Unsafe copy source path: /etc/passwd" + }, + { + "name": "malicious: absolute copy target", + "manifest": { "copies": { "/data/local/tmp/x": "" } }, + "error": "Unsafe copy" + }, + { + "name": "malicious: backslash traversal", + "manifest": { "copies": { "assets\\..\\..\\x": "assets/a.png" } }, + "error": "Unsafe copy target path" + }, + { + "name": "malicious: empty and dot segments", + "manifest": { "copies": { "assets//a.png": "assets/a.png" } }, + "error": "Unsafe copy target path" + }, + { + "name": "malicious: current-directory segment", + "manifest": { "copies": { "./a.png": "assets/a.png" } }, + "error": "Unsafe copy target path" + }, + { + "name": "malicious: NUL truncating to a parent segment", + "manifest": { "copies": { "..\u0000x": "assets/a.png" } }, + "error": "Unsafe copy target path" + }, + { + "name": "malicious: control byte in a source", + "manifest": { "copies": { "a.png": "assets/a\u0001.png" } }, + "error": "Unsafe copy source path" + }, + { + "name": "malicious: prefix stripping cannot smuggle a parent segment", + "manifest": { "copies": { "a.png": "resources/rawfile/../../x" } }, + "options": { "copySourcePrefix": "resources/rawfile/" }, + "error": "Unsafe copy source path: ../../x" + }, + { + "name": "malicious: delete escapes the version directory", + "manifest": { "deletes": { "../../Documents": 1 } }, + "error": "Unsafe deleted path: ../../Documents" + }, + { + "name": "malicious: absolute delete in an array", + "manifest": { "deletes": ["/"] }, + "error": "Unsafe deleted path: /" + }, + { + "name": "malicious: empty delete", + "manifest": { "deletes": { "": 1 } }, + "error": "Unsafe deleted path" + } + ] +} diff --git a/cpp/patch_core/tests/patch_core_test.cpp b/cpp/patch_core/tests/patch_core_test.cpp index 3fafc8b9..b5b83300 100644 --- a/cpp/patch_core/tests/patch_core_test.cpp +++ b/cpp/patch_core/tests/patch_core_test.cpp @@ -1,11 +1,16 @@ +#include "../archive_limits.h" #include "../archive_patch_core.h" #include "../digest.h" +#include "../install_record.h" #include "../patch_core.h" +#include "../patch_manifest.h" #include "../state_core.h" +#include "../../update_flow_core/flow_json.h" #include #include #include +#include #include #include #include @@ -163,7 +168,7 @@ void TestApplyPatchWithHbcTransform() { options.bundle_patch_path = patch; options.bundle_output_path = JoinPath(temp.path, "out/index.bundlejs"); options.enable_merge = false; - options.bundle_hbc_transform_meta = meta; + options.manifest.bundle_hbc_transform_meta = meta; Status status = ApplyPatchFromFileSource(options); Expect(status.ok, "hbc transform patch should succeed: " + status.message); @@ -195,7 +200,7 @@ void TestApplyPatchWithHbcTransformRejectsBadMeta() { options.enable_merge = false; // 不可解析的元数据 → 失败(绝不能忽略元数据直接 hpatch) - options.bundle_hbc_transform_meta = "not json"; + options.manifest.bundle_hbc_transform_meta = "not json"; Expect( !ApplyPatchFromFileSource(options).ok, "malformed hbcTransform metadata must fail"); @@ -207,7 +212,7 @@ void TestApplyPatchWithHbcTransformRejectsBadMeta() { const size_t pos = meta.find(needle); Expect(pos != std::string::npos, "meta fixture must contain v:1"); meta.replace(pos, needle.size(), "\"v\":9"); - options.bundle_hbc_transform_meta = meta; + options.manifest.bundle_hbc_transform_meta = meta; Status status = ApplyPatchFromFileSource(options); Expect(!status.ok, "unsupported hbcTransform version must fail"); Expect( @@ -246,7 +251,7 @@ void TestApplyStreamFormatWithHbcTransform() { options.bundle_patch_path = JoinPath(g_fixtures_dir, "v96.tstreampatch.bin"); options.bundle_output_path = JoinPath(temp.path, "out/index.bundlejs"); options.enable_merge = false; - options.bundle_hbc_transform_meta = + options.manifest.bundle_hbc_transform_meta = ReadFile(JoinPath(g_fixtures_dir, "v96.meta.json")); Status status = ApplyPatchFromFileSource(options); @@ -570,7 +575,7 @@ void TestHpatchRejectsOversizedLzmaDictionary() { options.bundle_patch_path = tampered; options.bundle_output_path = JoinPath(temp.path, "out/index.bundlejs"); options.enable_merge = false; - options.bundle_hbc_transform_meta = ReadFile(JoinPath(g_fixtures_dir, "v96.meta.json")); + options.manifest.bundle_hbc_transform_meta = ReadFile(JoinPath(g_fixtures_dir, "v96.meta.json")); Status status = ApplyPatchFromFileSource(options); Expect(!status.ok, "a patch declaring a 4 GB LZMA dictionary must be refused"); @@ -775,6 +780,16 @@ void TestArchivePatchCoreBuildPlanAndCopyGroups() { Expect(groups.size() == 1, "copy groups should merge identical sources"); ExpectEq(groups[0].from, "assets/a.png", "copy group source mismatch"); Expect(groups[0].to_paths.size() == 2, "copy group target count mismatch"); + Expect(!groups[0].has_expected_crc, "no CRC declared, none grouped"); + + // A CRC declared on a later copy of the source still lands on its group. + manifest.copies[1].has_expected_crc = true; + manifest.copies[1].expected_crc = 7; + status = pushy::archive_patch::BuildCopyGroups(manifest, &groups); + Expect(status.ok, status.message); + Expect(groups.size() == 1 && groups[0].has_expected_crc && + groups[0].expected_crc == 7, + "group CRC comes from whichever copy declared it"); FileSourcePatchOptions options; status = pushy::archive_patch::BuildFileSourcePatchOptions( @@ -1116,8 +1131,763 @@ void TestApplyPatchCopiesVerifyExpectedCrc() { "drifted content must not be copied"); } +// ---- __diff.json (patch_manifest.h) ---- + +std::string DescribeManifest(const PatchManifest& manifest) { + std::string out = "copies="; + for (const CopyOperation& copy : manifest.copies) { + out += "[" + copy.from + "," + copy.to + "," + + (copy.has_expected_crc ? std::to_string(copy.expected_crc) + : std::string("null")) + + "]"; + } + out += " deletes="; + for (const std::string& path : manifest.deletes) { + out += "[" + path + "]"; + } + out += " meta=" + manifest.bundle_hbc_transform_meta; + return out; +} + +// The shared vectors (tests/patch-manifest-vectors.json): server shapes, the +// cases the platforms used to disagree on, and hostile paths. +void TestPatchManifestVectors() { + const std::string path = + JoinPath(g_fixtures_dir, "../patch-manifest-vectors.json"); + bool ok = false; + const flowjson::Value doc = flowjson::Parse(ReadFile(path), &ok); + Expect(ok && doc.Get("vectors").IsArray(), "vectors file must parse: " + path); + Expect(doc.Get("vectors").Size() >= 40, "vectors file lost its cases"); + for (const flowjson::Value& vector : doc.Get("vectors").elements()) { + const std::string name = vector.Get("name").AsString(); + const std::string text = vector.Get("text").IsString() + ? vector.Get("text").AsString() + : flowjson::Stringify(vector.Get("manifest")); + pushy::delta::PatchManifestOptions options; + options.bundle_patch_entry_name = "index.bundlejs.patch"; + const flowjson::Value& given = vector.Get("options"); + if (given.Get("bundlePatchEntryName").IsString()) { + options.bundle_patch_entry_name = + given.Get("bundlePatchEntryName").AsString(); + } + if (given.Get("copySourcePrefix").IsString()) { + options.copy_source_prefix = given.Get("copySourcePrefix").AsString(); + } + + PatchManifest manifest; + manifest.deletes.push_back("sentinel"); + const Status status = + pushy::delta::ParsePatchManifest(text, options, &manifest); + if (vector.Get("error").IsString()) { + const std::string expected = vector.Get("error").AsString(); + Expect(!status.ok, name + ": expected an error, got " + + DescribeManifest(manifest)); + Expect(status.message.find(expected) != std::string::npos, + name + ": error [" + status.message + "] lacks [" + expected + "]"); + Expect(manifest.deletes.size() == 1 && manifest.copies.empty(), + name + ": a failed parse must leave the output untouched"); + continue; + } + Expect(status.ok, name + ": " + status.message); + + PatchManifest expected; + for (const flowjson::Value& copy : vector.Get("copies").elements()) { + CopyOperation operation; + operation.from = copy.At(0).AsString(); + operation.to = copy.At(1).AsString(); + if (copy.At(2).IsNumber()) { + operation.has_expected_crc = true; + operation.expected_crc = static_cast(copy.At(2).AsNumber()); + } + expected.copies.push_back(operation); + } + for (const flowjson::Value& deleted : vector.Get("deletes").elements()) { + expected.deletes.push_back(deleted.AsString()); + } + if (vector.Get("hbcTransformMeta").IsString()) { + expected.bundle_hbc_transform_meta = + vector.Get("hbcTransformMeta").AsString(); + } + ExpectEq(DescribeManifest(manifest), DescribeManifest(expected), name); + } +} + +void TestPatchManifestSizeCaps() { + pushy::delta::PatchManifestOptions options; + options.bundle_patch_entry_name = "index.bundlejs.patch"; + PatchManifest manifest; + + // Over kMaxManifestBytes: rejected before parsing. + std::string big = "{\"pad\":\""; + big.append(static_cast(pushy::archive_limits::kMaxManifestBytes), 'x'); + big += "\"}"; + Status status = pushy::delta::ParsePatchManifest(big, options, &manifest); + Expect(!status.ok, "a manifest over kMaxManifestBytes must be rejected"); + + // A real-sized manifest well over flowjson's 1 MB response cap still parses. + std::string large = "{\"copies\":{"; + std::string large_crc = "\"copiesCrc\":{"; + const int copies = 20000; + for (int i = 0; i < copies; ++i) { + const std::string to = "assets/node_modules/some-package/images/icon_" + + std::to_string(i) + "@3x.png"; + large += (i ? ",\"" : "\"") + to + "\":\"\""; + large_crc += (i ? ",\"" : "\"") + to + "\":" + std::to_string(i); + } + large += "}," + large_crc + "}}"; + Expect(large.size() > 2 * 1024 * 1024, "the large manifest must exceed 2 MB"); + status = pushy::delta::ParsePatchManifest(large, options, &manifest); + Expect(status.ok, "a 20000-copy manifest must parse: " + status.message); + Expect(manifest.copies.size() == static_cast(copies) && + manifest.copies.back().has_expected_crc && + manifest.copies.back().expected_crc == copies - 1, + "every copy keeps its CRC"); + + // Over kMaxManifestNodes values: rejected. + std::string nodes = "{\"deletes\":["; + for (size_t i = 0; i < pushy::delta::kMaxManifestNodes; ++i) { + nodes += i ? ",\"a\"" : "\"a\""; + } + nodes += "]}"; + status = pushy::delta::ParsePatchManifest(nodes, options, &manifest); + Expect(!status.ok, "a manifest over kMaxManifestNodes values must be rejected"); + + Expect(!pushy::delta::ParsePatchManifest("{}", options, nullptr).ok, + "a null output must be rejected"); +} + +pushy::archive_patch::ArchivePatchInput PpkInput(const std::string& manifest_json) { + pushy::archive_patch::ArchivePatchInput input; + input.type = pushy::archive_patch::ArchivePatchType::kPatchFromPpk; + input.entry_names = {"__diff.json", "index.bundlejs.patch"}; + input.manifest_json = manifest_json; + return input; +} + +void TestPlanArchivePatch() { + pushy::archive_patch::ArchivePatchPlan plan; + + pushy::archive_patch::ArchivePatchInput input = PpkInput("not json"); + input.entry_names = {"index.bundlejs.patch"}; + Status status = pushy::archive_patch::PlanArchivePatch(input, &plan); + ExpectEq(status.message, "diff.json not found", + "a missing manifest entry is reported before the text is parsed"); + input.entry_names = {"__diff.json"}; + status = pushy::archive_patch::PlanArchivePatch(input, &plan); + ExpectEq(status.message, "bundle delta not found", "missing bundle patch"); + + input = PpkInput("{\"copies\":{\"../x\":\"\"}}"); + status = pushy::archive_patch::PlanArchivePatch(input, &plan); + Expect(!status.ok && status.message.find("Unsafe copy") != std::string::npos, + "the plan validates the parsed paths: " + status.message); + + input = PpkInput( + "{\"copies\":{\"assets/b.png\":\"assets/a.png\"}," + "\"deletes\":{\"assets/old.png\":1}," + "\"hbcTransform\":{\"index.bundlejs.patch\":{\"v\":1}}}"); + status = pushy::archive_patch::PlanArchivePatch(input, &plan); + Expect(status.ok, status.message); + Expect(plan.enable_merge && plan.merge_source_subdir.empty(), "ppk merge plan"); + Expect(plan.manifest.copies.size() == 1 && + plan.manifest.copies[0].from == "assets/a.png" && + plan.manifest.deletes.size() == 1, + "the plan carries the parsed manifest"); + ExpectEq(plan.manifest.bundle_hbc_transform_meta, "{\"v\":1}", + "the plan carries the hbcTransform metadata"); + + // HarmonyOS: its own bundle patch name keys both the entry check and the + // metadata; the rawfile prefix is stripped. + input.type = pushy::archive_patch::ArchivePatchType::kPatchFromPackage; + input.entry_names = {"__diff.json", "bundle.harmony.js.patch"}; + input.bundle_patch_entry_name = "bundle.harmony.js.patch"; + input.copy_source_prefix = "resources/rawfile/"; + input.manifest_json = + "{\"copies\":{\"assets/a.png\":\"resources/rawfile/assets/a.png\"}," + "\"hbcTransform\":{\"bundle.harmony.js.patch\":{\"v\":1}}}"; + status = pushy::archive_patch::PlanArchivePatch(input, &plan); + Expect(status.ok, status.message); + ExpectEq(plan.merge_source_subdir, "assets", "package merge subdir"); + ExpectEq(plan.manifest.copies[0].from, "assets/a.png", "prefix stripped"); + ExpectEq(plan.manifest.bundle_hbc_transform_meta, "{\"v\":1}", + "metadata under the platform's bundle patch name"); + + // A full package plans nothing and never looks at the text. + input = PpkInput("not json"); + input.type = pushy::archive_patch::ArchivePatchType::kFull; + input.entry_names.clear(); + status = pushy::archive_patch::PlanArchivePatch(input, &plan); + Expect(status.ok && !plan.enable_merge && plan.manifest.copies.empty(), + "full package plan: " + status.message); + Expect(!pushy::archive_patch::PlanArchivePatch(input, nullptr).ok, + "a null plan output must be rejected"); +} + +void TestApplyArchivePatchFromPpk() { + TempDir temp; + const std::string source = JoinPath(temp.path, "origin"); + const std::string target = JoinPath(temp.path, "target"); + + WriteFile(JoinPath(source, "index.bundlejs"), "old bundle"); + WriteFile(JoinPath(source, "assets/keep.txt"), "keep"); + WriteFile(JoinPath(source, "assets/delete.txt"), "delete"); + WriteFile(JoinPath(source, "assets/from.txt"), "123456789"); + WriteFile(JoinPath(target, "index.bundlejs.patch"), "unused patch"); + + pushy::archive_patch::ArchivePatchRequest request; + request.archive = PpkInput( + "{\"copies\":{\"assets/renamed.txt\":\"assets/from.txt\"}," + "\"copiesCrc\":{\"assets/renamed.txt\":3421780262}," + "\"deletes\":{\"assets/delete.txt\":1}}"); + request.source_root = source; + request.target_root = target; + request.origin_bundle_path = JoinPath(source, "index.bundlejs"); + request.bundle_patch_path = JoinPath(target, "index.bundlejs.patch"); + request.bundle_output_path = JoinPath(target, "index.bundlejs"); + + FakeBundlePatcher patcher("patched bundle"); + std::vector host_copies(1); + Status status = + pushy::archive_patch::ApplyArchivePatch(request, &host_copies, patcher); + Expect(status.ok, status.message); + Expect(host_copies.empty(), "nothing is left to the host"); + ExpectEq(ReadFile(JoinPath(target, "index.bundlejs")), "patched bundle", "bundle"); + ExpectEq(ReadFile(JoinPath(target, "assets/renamed.txt")), "123456789", "copy"); + ExpectEq(ReadFile(JoinPath(target, "assets/keep.txt")), "keep", "merge"); + Expect(!Exists(JoinPath(target, "assets/delete.txt")), "delete honoured"); + + // The declared CRC is checked against the source: drift fails the patch. + WriteFile(JoinPath(source, "assets/from.txt"), "drifted"); + const std::string target2 = JoinPath(temp.path, "target2"); + request.target_root = target2; + request.bundle_output_path = JoinPath(target2, "index.bundlejs"); + status = pushy::archive_patch::ApplyArchivePatch(request, nullptr, patcher); + Expect(!status.ok && status.message.find("crc32") != std::string::npos, + "a drifted copy source must fail: " + status.message); + + // A manifest error stops before the bundle is touched. + FakeBundlePatcher untouched("x"); + request.archive.manifest_json = "{\"deletes\":[\"../../x\"]}"; + status = pushy::archive_patch::ApplyArchivePatch(request, nullptr, untouched); + Expect(!status.ok && untouched.calls == 0, + "an unsafe manifest must fail before patching"); + + request.archive.type = pushy::archive_patch::ArchivePatchType::kFull; + Expect(!pushy::archive_patch::ApplyArchivePatch(request, nullptr, untouched).ok, + "a full package has no patch to apply"); +} + +// Android APK / HarmonyOS rawfile packages: only the bundle is rebuilt; the +// copies come back grouped by source, with the CRC each group must match. +void TestApplyArchivePatchHostCopies() { + TempDir temp; + const std::string work = JoinPath(temp.path, "work"); + WriteFile(JoinPath(work, ".origin.bundle"), "old bundle"); + WriteFile(JoinPath(work, "index.bundlejs.patch"), "unused patch"); + WriteFile(JoinPath(work, "assets/new.png"), "new"); + + pushy::archive_patch::ArchivePatchRequest request; + request.archive = PpkInput( + "{\"copies\":{\"res/a.png\":\"\",\"res/b.png\":\"res/a.png\"," + "\"res/c.png\":\"res/c_old.png\"}," + "\"copiesCrc\":{\"res/b.png\":42}}"); + request.archive.type = pushy::archive_patch::ArchivePatchType::kPatchFromPackage; + request.source_root = work; + request.target_root = work; + request.origin_bundle_path = JoinPath(work, ".origin.bundle"); + request.bundle_patch_path = JoinPath(work, "index.bundlejs.patch"); + request.bundle_output_path = JoinPath(work, "index.bundlejs"); + request.host_copies_resources = true; + + FakeBundlePatcher patcher("patched bundle"); + Expect(!pushy::archive_patch::ApplyArchivePatch(request, nullptr, patcher).ok, + "host copies need an output"); + Expect(patcher.calls == 0, "nothing runs without the output"); + + std::vector groups; + Status status = pushy::archive_patch::ApplyArchivePatch(request, &groups, patcher); + Expect(status.ok, status.message); + ExpectEq(ReadFile(JoinPath(work, "index.bundlejs")), "patched bundle", "bundle"); + Expect(!Exists(JoinPath(work, "res")), "the core copies nothing itself"); + ExpectEq(ReadFile(JoinPath(work, "assets/new.png")), "new", "no merge into work"); + Expect(groups.size() == 2, "two sources"); + ExpectEq(groups[0].from, "res/a.png", "first group source"); + Expect(groups[0].to_paths.size() == 2 && groups[0].to_paths[1] == "res/b.png", + "targets of one source are grouped in order"); + Expect(groups[0].has_expected_crc && groups[0].expected_crc == 42, + "a CRC declared for any target covers the group"); + Expect(!groups[1].has_expected_crc, "no CRC declared for the second source"); +} + +void TestApplyArchivePatchWithHbcTransform() { + TempDir temp; + const std::string meta = ReadFile(JoinPath(g_fixtures_dir, "v96.meta.json")); + pushy::archive_patch::ArchivePatchRequest request; + request.archive = PpkInput( + "{\"copies\":{},\"hbcTransform\":{\"index.bundlejs.patch\":" + meta + "}}"); + request.source_root = JoinPath(temp.path, "src"); + request.target_root = JoinPath(temp.path, "dst"); + request.origin_bundle_path = JoinPath(g_fixtures_dir, "v96.hbc"); + request.bundle_patch_path = JoinPath(g_fixtures_dir, "v96.tpatch.bin"); + request.bundle_output_path = JoinPath(temp.path, "dst/index.bundlejs"); + Status status = pushy::archive_patch::ApplyArchivePatch(request, nullptr); + Expect(status.ok, "transform-domain patch through the manifest: " + status.message); + Expect(ReadFile(request.bundle_output_path) == + ReadFile(JoinPath(g_fixtures_dir, "v96b.hbc")), + "restored bundle must equal the new bundle byte-for-byte"); + + // Without the metadata the same patch must not silently produce a bundle + // that matches: the transform is part of the manifest contract. + request.archive.manifest_json = "{}"; + request.bundle_output_path = JoinPath(temp.path, "dst2/index.bundlejs"); + status = pushy::archive_patch::ApplyArchivePatch(request, nullptr); + Expect(!status.ok || ReadFile(request.bundle_output_path) != + ReadFile(JoinPath(g_fixtures_dir, "v96b.hbc")), + "the metadata is what makes the transform-domain patch apply"); +} + } // namespace +// ---- Completion records (install_record.h) ---- + +namespace ir = pushy::install_record; + +const char kRecordBundle[] = "index.bundlejs"; + +std::string ReplaceAll(std::string text, const std::string& from, const std::string& to) { + for (size_t at = text.find(from); at != std::string::npos; + at = text.find(from, at + to.size())) { + text.replace(at, from.size(), to); + } + return text; +} + +std::string Upper(std::string text) { + for (char& c : text) { + if (c >= 'a' && c <= 'z') { + c = static_cast(c - 'a' + 'A'); + } + } + return text; +} + +std::vector ListDirectory(const std::string& path) { + std::vector names; + DIR* dir = opendir(path.c_str()); + if (dir == nullptr) { + return names; + } + while (struct dirent* entry = readdir(dir)) { + const std::string name = entry->d_name; + if (name != "." && name != "..") { + names.push_back(name); + } + } + closedir(dir); + return names; +} + +// The shared vectors (tests/install-record-vectors.json): what each record +// shape means for launch/dedup, activation and crash attribution, including +// the cases the platforms used to disagree on. +void TestInstallRecordVectors() { + const std::string path = + JoinPath(g_fixtures_dir, "../install-record-vectors.json"); + bool ok = false; + const flowjson::Value doc = flowjson::Parse(ReadFile(path), &ok); + Expect(ok && doc.Get("vectors").IsArray(), "vectors file must parse: " + path); + Expect(doc.Get("vectors").Size() >= 30, "vectors file lost its cases"); + const std::string bundle = "var a = 1;"; + pushy::digest::Sha256 hasher; + hasher.Update(reinterpret_cast(bundle.data()), bundle.size()); + const std::string bundle_sha = hasher.HexDigest(); + pushy::digest::Sha256 other; + other.Update(reinterpret_cast("var a = 2;"), 10); + const std::string other_sha = other.HexDigest(); + auto expand = [&](const std::string& text) { + return ReplaceAll(ReplaceAll(ReplaceAll(text, "@SHA_UPPER@", Upper(bundle_sha)), + "@OTHER_SHA@", other_sha), + "@SHA@", bundle_sha); + }; + + for (const flowjson::Value& vector : doc.Get("vectors").elements()) { + const std::string name = vector.Get("name").AsString(); + const std::string hash = + vector.Get("hash").IsString() ? vector.Get("hash").AsString() : "abc"; + TempDir temp; + const std::string dir = temp.path + "/" + hash; + EnsureDirectory(dir); + if (!vector.Get("bundle").IsBool() || vector.Get("bundle").AsBool()) { + WriteFile(dir + "/" + kRecordBundle, bundle); + } + const bool has_record = vector.Get("record").IsString(); + if (has_record) { + WriteFile(dir + "/" + ir::kFileName, expand(vector.Get("record").AsString())); + } + Expect(ir::HasRecordFile(dir) == has_record, name + ": HasRecordFile"); + Expect(ir::IsComplete(dir, hash, kRecordBundle) == + vector.Get("complete").AsBool(), + name + ": IsComplete"); + const std::string recorded = vector.Get("recorded").IsString() + ? expand(vector.Get("recorded").AsString()) + : ""; + ExpectEq(ir::RecordedBundleSha256(dir), recorded, name + ": RecordedBundleSha256"); + + const bool grandfathered = + vector.Get("grandfathered").IsBool() && vector.Get("grandfathered").AsBool(); + ir::Activation activation; + activation.record_text = "sentinel"; + const Status status = + ir::CheckActivation(dir, hash, kRecordBundle, grandfathered, &activation); + const std::string expected = vector.Get("activation").AsString(); + if (expected == "ok") { + Expect(status.ok, name + ": " + status.message); + Expect(ir::Unchanged(dir, kRecordBundle, grandfathered, activation), + name + ": an untouched directory is unchanged"); + Expect(!has_record || activation.basis != ir::Activation::Basis::kGrandfathered, + name + ": a recorded directory is never grandfathered"); + } else { + Expect(!status.ok, name + ": expected [" + expected + "], activated"); + Expect(status.message.find(expected) != std::string::npos, + name + ": rejection [" + status.message + "] lacks [" + expected + "]"); + Expect(status.message.find("Bundle version " + hash + " ") == 0, + name + ": rejection names the version: " + status.message); + ExpectEq(activation.record_text, "sentinel", + name + ": a rejection leaves the output untouched"); + } + } +} + +void TestInstallRecordParse() { + Expect(ir::Parse("").state == ir::Record::State::kLegacy, "empty text is legacy"); + const ir::Record record = ir::Parse( + "{\"schema\":1,\"versionHash\":\"abc\",\"bundleSha256\":\"b\",\"artifactSha256\":\"a\"}"); + Expect(record.state == ir::Record::State::kPresent && record.current_schema, + "schema-1 record parses"); + ExpectEq(record.version_hash, "abc", "versionHash"); + ExpectEq(record.bundle_sha256, "b", "bundleSha256"); + ExpectEq(record.artifact_sha256, "a", "artifactSha256"); + Expect(ir::Names(record, "abc") && !ir::Names(record, "def") && !ir::Names(record, ""), + "Names matches the version only"); + Expect(!ir::Names(ir::Parse(""), "abc"), "the legacy marker names no version"); + + // The size cap: a padded record of exactly kMaxRecordBytes still parses + // (on disk as well), one byte more does not. + const std::string body = "{\"schema\":1,\"versionHash\":\"abc\",\"bundleSha256\":\"b\"}"; + const std::string at_cap = + body + std::string(static_cast(ir::kMaxRecordBytes) - body.size(), ' '); + Expect(ir::Parse(at_cap).state == ir::Record::State::kPresent, "record at the cap"); + Expect(ir::Parse(at_cap + " ").state == ir::Record::State::kInvalid, + "record over the cap"); + TempDir temp; + WriteFile(temp.path + "/" + kRecordBundle, "x"); + WriteFile(temp.path + "/" + ir::kFileName, at_cap); + Expect(ir::IsComplete(temp.path, "abc", kRecordBundle), "on-disk record at the cap"); + WriteFile(temp.path + "/" + ir::kFileName, at_cap + " "); + Expect(ir::Read(temp.path).state == ir::Record::State::kInvalid, + "on-disk record over the cap"); + Expect(!ir::IsComplete(temp.path, "abc", kRecordBundle), "oversized record is not complete"); + + // A directory where the record belongs is a record file, but not a record. + unlink((temp.path + "/" + ir::kFileName).c_str()); + EnsureDirectory(temp.path + "/" + ir::kFileName); + Expect(ir::HasRecordFile(temp.path), "a directory counts as a record file"); + Expect(ir::Read(temp.path).state == ir::Record::State::kInvalid, + "a directory is not a record"); + ir::Activation activation; + const Status status = + ir::CheckActivation(temp.path, "abc", kRecordBundle, true, &activation); + Expect(!status.ok && status.message.find("no valid completion record") != std::string::npos, + "a directory record is never grandfathered: " + status.message); + + // A directory where the bundle belongs is no bundle. + TempDir other; + EnsureDirectory(other.path + "/" + kRecordBundle); + WriteFile(other.path + "/" + ir::kFileName, ""); + Expect(!ir::IsComplete(other.path, "abc", kRecordBundle), "bundle must be a file"); +} + +void TestInstallRecordBuild() { + std::string text; + Expect(ir::Build("abc", "bb", "aa", &text).ok, "build a full record"); + ExpectEq(text, + "{\"schema\":1,\"versionHash\":\"abc\",\"bundleSha256\":\"bb\",\"artifactSha256\":\"aa\"}", + "record text"); + Expect(ir::Build("abc", "bb", "", &text).ok, "artifactSha256 is optional"); + ExpectEq(text, "{\"schema\":1,\"versionHash\":\"abc\",\"bundleSha256\":\"bb\"}", + "record without artifact digest"); + text = "untouched"; + const Status refused = ir::Build("abc", "", "aa", &text); + Expect(!refused.ok && refused.message.find("no bundle digest") != std::string::npos, + "a record without bundleSha256 is never built: " + refused.message); + ExpectEq(text, "untouched", "a refused build leaves the output untouched"); +} + +void TestInstallRecordWriteIsAtomic() { + TempDir temp; + std::string text; + Expect(ir::Build("abc", "bb", "", &text).ok, "build"); + Expect(ir::Write(temp.path, text).ok, "write a record"); + ExpectEq(ReadFile(temp.path + "/" + ir::kFileName), text, "record content"); + Expect(ListDirectory(temp.path).size() == 1, "no temporary file is left behind"); + + // Rewriting replaces the record whole (rename over it). + Expect(ir::Write(temp.path, "{\"x\":1}").ok, "rewrite"); + ExpectEq(ReadFile(temp.path + "/" + ir::kFileName), "{\"x\":1}", "rewritten content"); + Expect(ListDirectory(temp.path).size() == 1, "still no temporary file"); + + // A stale temporary file (crash mid-write) is overwritten, not appended to. + WriteFile(temp.path + "/" + ir::kFileName + ".tmp", std::string(100, 'z')); + Expect(ir::Write(temp.path, text).ok, "write over a stale temporary file"); + ExpectEq(ReadFile(temp.path + "/" + ir::kFileName), text, "content after a stale temp"); + Expect(ListDirectory(temp.path).size() == 1, "the stale temporary file is consumed"); + + const Status missing = ir::Write(temp.path + "/missing", text); + Expect(!missing.ok && missing.message.find("cannot create completion record") != std::string::npos, + "a missing directory fails the write: " + missing.message); +} + +void TestInstallRecordSealAndPromote() { + TempDir temp; + const std::string staging = temp.path + "/abc" + ir::kStagingSuffix; + const std::string version = temp.path + "/abc"; + EnsureDirectory(staging); + + ir::SealResult sealed = ir::Seal(staging, "abc", kRecordBundle, "ff", ""); + Expect(!sealed.status.ok && !sealed.file_failure && + sealed.status.message.find("bundle missing after install") != std::string::npos, + "no bundle: a bundle failure: " + sealed.status.message); + Expect(!ir::HasRecordFile(staging), "no record without a bundle"); + + WriteFile(staging + "/" + kRecordBundle, "var a = 1;"); + sealed = ir::Seal(staging, "abc", kRecordBundle, "ff", ""); + Expect(sealed.status.ok, "seal hashes the bundle: " + sealed.status.message); + const ir::Record record = ir::Read(staging); + ExpectEq(record.bundle_sha256, pushy::digest::Sha256File(staging + "/" + kRecordBundle), + "sealed digest is the bundle's"); + ExpectEq(record.artifact_sha256, "ff", "sealed artifact digest"); + Expect(ir::IsComplete(staging, "abc", kRecordBundle), "sealed staging is complete"); + + // A digest computed while extracting is taken as-is. + sealed = ir::Seal(staging, "abc", kRecordBundle, "", "0123"); + Expect(sealed.status.ok, "seal with a known digest"); + ExpectEq(ir::Read(staging).bundle_sha256, "0123", "known digest recorded"); + ExpectEq(ir::Read(staging).artifact_sha256, "", "no artifact digest"); + + // The version directory must be gone before the rename. + EnsureDirectory(version + "/old"); + const Status blocked = ir::Promote(staging, version); + Expect(!blocked.ok && blocked.message.find("cannot move") != std::string::npos, + "promote never merges into an existing version directory: " + blocked.message); + std::system(("rm -rf \"" + version + "\"").c_str()); + Expect(ir::Promote(staging, version).ok, "promote"); + Expect(!Exists(staging) && ir::IsComplete(version, "abc", kRecordBundle), + "promoted: installed"); + ir::Activation activation; + const Status wrong = ir::CheckActivation(version, "abc", kRecordBundle, false, &activation); + Expect(!wrong.ok && wrong.message.find("bundle digest mismatch") != std::string::npos, + "a wrong known digest is caught at activation: " + wrong.message); + + // A record that cannot be written is a file failure. A directory where the + // temporary file goes makes the write fail on every platform and user. + TempDir blocked_dir; + WriteFile(blocked_dir.path + "/" + kRecordBundle, "x"); + EnsureDirectory(blocked_dir.path + "/" + ir::kFileName + ".tmp"); + sealed = ir::Seal(blocked_dir.path, "abc", kRecordBundle, "", ""); + Expect(!sealed.status.ok && sealed.file_failure, + "an unwritable record is a file failure: " + sealed.status.message); +} + +void TestInstallRecordUnchanged() { + TempDir temp; + const std::string dir = temp.path + "/abc"; + const std::string bundle = dir + "/" + kRecordBundle; + WriteFile(bundle, "var a = 1;"); + Expect(ir::Seal(dir, "abc", kRecordBundle, "", "").status.ok, "seal"); + + ir::Activation passed; + Expect(ir::CheckActivation(dir, "abc", kRecordBundle, false, &passed).ok, "activate"); + Expect(passed.basis == ir::Activation::Basis::kDigest, "verified by digest"); + Expect(ir::Unchanged(dir, kRecordBundle, false, passed), "untouched"); + + // Same bytes, new file: a different inode / ctime. + unlink(bundle.c_str()); + WriteFile(bundle, "var a = 1;"); + Expect(!ir::Unchanged(dir, kRecordBundle, false, passed), "a replaced bundle is a change"); + Expect(ir::CheckActivation(dir, "abc", kRecordBundle, false, &passed).ok, "re-verify"); + Expect(ir::Unchanged(dir, kRecordBundle, false, passed), "re-verified"); + + std::string text; + Expect(ir::Build("abc", "00", "", &text).ok, "build"); + Expect(ir::Write(dir, text).ok, "rewrite record"); + Expect(!ir::Unchanged(dir, kRecordBundle, false, passed), "a rewritten record is a change"); + + // Grandfathered: only while there is still no record and the version is + // still current/last. + unlink((dir + "/" + ir::kFileName).c_str()); + Expect(ir::CheckActivation(dir, "abc", kRecordBundle, true, &passed).ok, "grandfathered"); + Expect(passed.basis == ir::Activation::Basis::kGrandfathered, "basis"); + Expect(ir::Unchanged(dir, kRecordBundle, true, passed), "still grandfathered"); + Expect(!ir::Unchanged(dir, kRecordBundle, false, passed), "no longer current/last"); + WriteFile(dir + "/" + ir::kFileName, ""); + Expect(!ir::Unchanged(dir, kRecordBundle, true, passed), "a record appeared"); + + // Legacy marker: the marker and the bundle must stay put. + Expect(ir::CheckActivation(dir, "abc", kRecordBundle, false, &passed).ok, "legacy"); + Expect(passed.basis == ir::Activation::Basis::kLegacyMarker, "legacy basis"); + Expect(ir::Unchanged(dir, kRecordBundle, false, passed), "legacy untouched"); + unlink(bundle.c_str()); + Expect(!ir::Unchanged(dir, kRecordBundle, false, passed), "bundle gone"); +} + +// ---- Archive extraction limits (archive_limits.h) ---- + +namespace al = pushy::archive_limits; + +long long AsLongLong(const flowjson::Value& value) { + return static_cast(value.AsNumber()); +} + +void ExpectVerdict(const Status& status, const std::string& expected, const std::string& name) { + if (expected == "ok") { + Expect(status.ok, name + ": " + status.message); + return; + } + Expect(!status.ok, name + ": expected [" + expected + "], accepted"); + Expect(status.message.find(expected) != std::string::npos, + name + ": rejection [" + status.message + "] lacks [" + expected + "]"); +} + +flowjson::Value ArchiveLimitsVectors() { + const std::string path = JoinPath(g_fixtures_dir, "../archive-limits-vectors.json"); + bool ok = false; + flowjson::Value doc = flowjson::Parse(ReadFile(path), &ok); + Expect(ok && doc.Get("entries").IsArray(), "vectors file must parse: " + path); + return doc; +} + +// The shared vectors (tests/archive-limits-vectors.json), including the +// cases the platforms used to disagree on. +void TestArchiveLimitsVectors() { + const flowjson::Value doc = ArchiveLimitsVectors(); + Expect(doc.Get("entries").Size() >= 20, "entry vectors lost their cases"); + for (const flowjson::Value& vector : doc.Get("entries").elements()) { + const std::string name = vector.Get("name").AsString(); + al::EntryAccumulator accumulator(AsLongLong(vector.Get("archiveBytes"))); + Status status = Status::Ok(); + for (const flowjson::Value& entry : vector.Get("entries").elements()) { + const long long repeat = + entry.Get("repeat").IsNumber() ? AsLongLong(entry.Get("repeat")) : 1; + const bool symlink = entry.Get("symlink").IsBool() && entry.Get("symlink").AsBool(); + for (long long i = 0; i < repeat && status.ok; i++) { + status = entry.Get("compressed").IsNumber() + ? accumulator.Accept(AsLongLong(entry.Get("size")), + AsLongLong(entry.Get("compressed")), symlink, + entry.Get("name").AsString()) + : accumulator.AcceptExtracted(AsLongLong(entry.Get("size")), symlink, + entry.Get("name").AsString()); + } + if (!status.ok) { + break; + } + } + ExpectVerdict(status, vector.Get("expect").AsString(), name); + if (vector.Get("count").IsNumber()) { + Expect(accumulator.entries() == AsLongLong(vector.Get("count")), name + ": count"); + } + if (vector.Get("total").IsNumber()) { + Expect(accumulator.total_uncompressed() == AsLongLong(vector.Get("total")), + name + ": total"); + } + } + + for (const flowjson::Value& vector : doc.Get("uncompressed").elements()) { + ExpectVerdict(al::CheckUncompressedSize(AsLongLong(vector.Get("archiveBytes")), + AsLongLong(vector.Get("uncompressedBytes"))), + vector.Get("expect").AsString(), vector.Get("name").AsString()); + } + + for (const flowjson::Value& vector : doc.Get("probes").elements()) { + const std::string name = vector.Get("name").AsString(); + const al::FreeSpaceProbe plan = al::PlanUnknownLengthFreeSpaceProbe( + AsLongLong(vector.Get("knownTotal")), AsLongLong(vector.Get("writtenBefore")), + AsLongLong(vector.Get("chunkBytes")), AsLongLong(vector.Get("reservedUntil"))); + Expect(plan.probe == vector.Get("probe").AsBool(), name + ": probe"); + Expect(plan.reserve == AsLongLong(vector.Get("reserve")), name + ": reserve"); + Expect(plan.reserved_until == AsLongLong(vector.Get("after")), name + ": after"); + } + + const flowjson::Value& names = doc.Get("reservedNames"); + Expect(names.members().size() >= 10, "reserved name vectors lost their cases"); + for (const auto& member : names.members()) { + Expect(al::IsReservedEntryName(member.first) == member.second.AsBool(), + "IsReservedEntryName(" + member.first + ")"); + } +} + +// Walking a stream of unknown length: no write ever passes a probed +// reservation, and most chunks need no probe. +void TestArchiveLimitsProbeWalk() { + long long reserved_until = 0; + long long written = 0; + int probes = 0; + const long long chunk = 300 * 1024; + for (int i = 0; i < 100; i++) { + const al::FreeSpaceProbe plan = + al::PlanUnknownLengthFreeSpaceProbe(0, written, chunk, reserved_until); + probes += plan.probe ? 1 : 0; + reserved_until = plan.reserved_until; + written += chunk; + Expect(written <= reserved_until, "a write passed the reservation"); + } + Expect(probes > 1 && probes < 100, "probes once per reservation"); +} + +void TestArchiveLimitsExtractedDirectory() { + { + TempDir temp; + WriteFile(temp.path + "/index.bundlejs", std::string(3000, 'a')); + WriteFile(temp.path + "/assets/img/logo.png", "png"); + Status status = al::CheckExtractedDirectory(temp.path, 1000); + Expect(status.ok, "a plain tree passes: " + status.message); + // 1 MiB + 1 of expansion from a 10 KB archive: past the whole-archive ratio. + WriteFile(temp.path + "/assets/zeros.bin", std::string(1024 * 1024, '\0')); + ExpectVerdict(al::CheckExtractedDirectory(temp.path, 10240), + "archive compression ratio exceeds 100:1", "whole-archive ratio"); + } + { + TempDir temp; + WriteFile(temp.path + "/assets/.pushy-x/a.png", "png"); + ExpectVerdict(al::CheckExtractedDirectory(temp.path, 1000), + "archive contains reserved entry assets/.pushy-x", "reserved directory"); + } + { + TempDir temp; + WriteFile(temp.path + "/index.bundlejs", "x"); + Expect(symlink("/tmp", (temp.path + "/assets").c_str()) == 0, "create symlink"); + ExpectVerdict(al::CheckExtractedDirectory(temp.path, 1000), + "archive entry is a symlink: assets", "symlink is not followed"); + } + { + TempDir temp; + // 20001 entries: 200 directories of 100 files (directories themselves + // are not counted, as iOS/Android count only declared zip entries), plus + // one. + for (int d = 0; d < 200; d++) { + const std::string dir = temp.path + "/d" + std::to_string(d); + EnsureDirectory(dir); + for (int f = 0; f < 100; f++) { + WriteFile(dir + "/f" + std::to_string(f), ""); + } + } + Expect(al::CheckExtractedDirectory(temp.path, 1000).ok, "20000 entries pass"); + WriteFile(temp.path + "/extra", ""); + ExpectVerdict(al::CheckExtractedDirectory(temp.path, 1000), + "archive has too many entries (> 20000)", "20001 entries"); + } + ExpectVerdict(al::CheckExtractedDirectory("/nonexistent-pushy-dir", 1000), + "cannot list extracted directory", "missing directory"); +} + int main(int argc, char** argv) { if (argc > 1) { g_fixtures_dir = argv[1]; @@ -1158,6 +1928,21 @@ int main(int argc, char** argv) { {"Crc32KnownVectors", TestCrc32KnownVectors}, {"Crc32File", TestCrc32File}, {"ApplyPatchCopiesVerifyExpectedCrc", TestApplyPatchCopiesVerifyExpectedCrc}, + {"PatchManifestVectors", TestPatchManifestVectors}, + {"PatchManifestSizeCaps", TestPatchManifestSizeCaps}, + {"PlanArchivePatch", TestPlanArchivePatch}, + {"ApplyArchivePatchFromPpk", TestApplyArchivePatchFromPpk}, + {"ApplyArchivePatchHostCopies", TestApplyArchivePatchHostCopies}, + {"ApplyArchivePatchWithHbcTransform", TestApplyArchivePatchWithHbcTransform}, + {"InstallRecordVectors", TestInstallRecordVectors}, + {"InstallRecordParse", TestInstallRecordParse}, + {"InstallRecordBuild", TestInstallRecordBuild}, + {"InstallRecordWriteIsAtomic", TestInstallRecordWriteIsAtomic}, + {"InstallRecordSealAndPromote", TestInstallRecordSealAndPromote}, + {"InstallRecordUnchanged", TestInstallRecordUnchanged}, + {"ArchiveLimitsVectors", TestArchiveLimitsVectors}, + {"ArchiveLimitsProbeWalk", TestArchiveLimitsProbeWalk}, + {"ArchiveLimitsExtractedDirectory", TestArchiveLimitsExtractedDirectory}, }; int failures = 0; diff --git a/cpp/update_flow_core/flow_json.cpp b/cpp/update_flow_core/flow_json.cpp index eb5e4ea5..7cf40568 100644 --- a/cpp/update_flow_core/flow_json.cpp +++ b/cpp/update_flow_core/flow_json.cpp @@ -3,6 +3,7 @@ #include #include #include +#include namespace flowjson { @@ -86,6 +87,40 @@ bool Value::StrictEquals(const Value& a, const Value& b) { return false; } +bool Value::DeepEquals(const Value& a, const Value& b) { + if (a.kind_ == Kind::Array && b.kind_ == Kind::Array) { + if (a.elements_.size() != b.elements_.size()) { + return false; + } + for (size_t i = 0; i < a.elements_.size(); i++) { + if (!DeepEquals(a.elements_[i], b.elements_[i])) { + return false; + } + } + return true; + } + if (a.kind_ == Kind::Object && b.kind_ == Kind::Object) { + size_t defined = 0; + for (const auto& member : a.members_) { + if (member.second.IsUndefined()) { + continue; + } + defined++; + const Value& other = b.Get(member.first); + if (other.IsUndefined() || !DeepEquals(member.second, other)) { + return false; + } + } + for (const auto& member : b.members_) { + if (!member.second.IsUndefined()) { + defined--; + } + } + return defined == 0; + } + return StrictEquals(a, b); +} + namespace { void AppendEscaped(const std::string& s, std::string* out) { @@ -232,6 +267,8 @@ namespace { // stack limit (size and node count are capped in flow_json.h). Real // responses nest ~4 levels. constexpr int kMaxDepth = 64; +// Objects with more members than this get a hash index while parsing. +constexpr size_t kIndexedObjectMembers = 16; // Locale-independent decimal -> double. strtod() honours LC_NUMERIC, so a // host process that called setlocale() into a comma-decimal locale would stop @@ -327,7 +364,8 @@ double DecimalToDouble(const std::string& token) { class Parser { public: - Parser(const std::string& text) : text_(text) {} + Parser(const std::string& text, size_t max_nodes) + : text_(text), max_nodes_(max_nodes) {} Value Run(bool* ok) { Value v = ParseValue(); @@ -376,7 +414,7 @@ class Parser { ok_ = false; return Value::Undefined(); } - if (++nodes_ > kMaxNodes) { + if (++nodes_ > max_nodes_) { ok_ = false; return Value::Undefined(); } @@ -409,20 +447,51 @@ class Parser { } } + // Duplicate keys keep the first position and the last value (Value::Set + // semantics). Large objects (a patch manifest's copies map has thousands + // of members) look keys up through a hash index instead of Set's linear + // scan, which would make parsing quadratic in the member count. Value ParseObject() { - Value obj = Value::Object(); Consume('{'); SkipWs(); if (pos_ < text_.size() && text_[pos_] == '}') { pos_++; - return obj; + return Value::Object(); } + Members members; + std::unordered_map index; while (ok_) { SkipWs(); std::string key = ParseString(); SkipWs(); Consume(':'); - obj.Set(key, ParseValue()); + Value value = ParseValue(); + if (members.size() == kIndexedObjectMembers && index.empty()) { + for (size_t i = 0; i < members.size(); i++) { + index.emplace(members[i].first, i); + } + } + size_t existing = members.size(); + if (index.empty()) { + for (size_t i = 0; i < members.size(); i++) { + if (members[i].first == key) { + existing = i; + break; + } + } + } else { + auto found = index.find(key); + if (found != index.end()) { + existing = found->second; + } else { + index.emplace(key, members.size()); + } + } + if (existing < members.size()) { + members[existing].second = std::move(value); + } else { + members.emplace_back(std::move(key), std::move(value)); + } SkipWs(); if (pos_ < text_.size() && text_[pos_] == ',') { pos_++; @@ -431,7 +500,7 @@ class Parser { Consume('}'); break; } - return obj; + return Value::Object(std::move(members)); } Value ParseArray() { @@ -635,6 +704,7 @@ class Parser { } const std::string& text_; + const size_t max_nodes_; size_t pos_ = 0; int depth_ = 0; size_t nodes_ = 0; @@ -644,11 +714,15 @@ class Parser { } // namespace Value Parse(const std::string& text, bool* ok) { - if (text.size() > kMaxInputBytes) { + return Parse(text, ParseLimits(), ok); +} + +Value Parse(const std::string& text, const ParseLimits& limits, bool* ok) { + if (text.size() > limits.max_input_bytes) { *ok = false; return Value::Undefined(); } - Parser parser(text); + Parser parser(text, limits.max_nodes); return parser.Run(ok); } diff --git a/cpp/update_flow_core/flow_json.h b/cpp/update_flow_core/flow_json.h index 7bed9366..44d41263 100644 --- a/cpp/update_flow_core/flow_json.h +++ b/cpp/update_flow_core/flow_json.h @@ -51,6 +51,12 @@ class Value { } static Value Array() { return Value(Kind::Array); } static Value Object() { return Value(Kind::Object); } + // The members must have distinct keys (Set's invariant). + static Value Object(Members members) { + Value v(Kind::Object); + v.members_ = std::move(members); + return v; + } Kind kind() const { return kind_; } bool IsUndefined() const { return kind_ == Kind::Undefined; } @@ -81,6 +87,12 @@ class Value { static bool StrictEquals(const Value& a, const Value& b); + // Structural equality of JSON values (src/updateFlowCore.ts + // jsonDeepEqual): object key order is ignored and undefined members count + // as absent; arrays compare element-wise in order; primitives use === + // (so 1 equals 1.0, false is not 0, null equals only null). + static bool DeepEquals(const Value& a, const Value& b); + private: explicit Value(Kind kind) : kind_(kind) {} @@ -116,6 +128,15 @@ constexpr size_t kMaxNodes = 65536; // Lone surrogate escapes decode to U+FFFD so the output is always valid UTF-8. Value Parse(const std::string& text, bool* ok); +// Same parser under caller-chosen caps, for documents whose size is bounded +// by something other than a checkUpdate response (the patch core's +// __diff.json manifest, cpp/patch_core/patch_manifest.h). +struct ParseLimits { + size_t max_input_bytes = kMaxInputBytes; + size_t max_nodes = kMaxNodes; +}; +Value Parse(const std::string& text, const ParseLimits& limits, bool* ok); + } // namespace flowjson #pragma GCC visibility pop diff --git a/cpp/update_flow_core/native_config.cpp b/cpp/update_flow_core/native_config.cpp new file mode 100644 index 00000000..a94525e0 --- /dev/null +++ b/cpp/update_flow_core/native_config.cpp @@ -0,0 +1,456 @@ +#include "native_config.h" + +#include +#include +#include + +#include "../patch_core/obscured_text.h" + +namespace flowcore { + +using flowjson::Value; + +namespace { + +// Length of the ECMAScript whitespace/line terminator encoded at text[i], or +// 0 when there is none. +size_t WhitespaceAt(const std::string& text, size_t i) { + const size_t left = text.size() - i; + const auto byte = [&](size_t k) { + return static_cast(text[i + k]); + }; + const unsigned char b0 = byte(0); + if (b0 == ' ' || (b0 >= 0x09 && b0 <= 0x0d)) { + return 1; + } + if (b0 == 0xc2 && left >= 2 && byte(1) == 0xa0) { + return 2; // U+00A0 + } + if (left < 3) { + return 0; + } + const unsigned char b1 = byte(1); + const unsigned char b2 = byte(2); + if (b0 == 0xe1 && b1 == 0x9a && b2 == 0x80) { + return 3; // U+1680 + } + if (b0 == 0xe2 && b1 == 0x80 && + (b2 <= 0x8a || b2 == 0xa8 || b2 == 0xa9 || b2 == 0xaf) && b2 >= 0x80) { + return 3; // U+2000-U+200A, U+2028, U+2029, U+202F + } + if (b0 == 0xe2 && b1 == 0x81 && b2 == 0x9f) { + return 3; // U+205F + } + if (b0 == 0xe3 && b1 == 0x80 && b2 == 0x80) { + return 3; // U+3000 + } + if (b0 == 0xef && b1 == 0xbb && b2 == 0xbf) { + return 3; // U+FEFF + } + return 0; +} + +bool ContainsJsWhitespace(const std::string& text) { + for (size_t i = 0; i < text.size(); i++) { + if (WhitespaceAt(text, i) != 0) { + return true; + } + } + return false; +} + +std::string StripTrailingSlashes(std::string text) { + while (!text.empty() && text.back() == '/') { + text.pop_back(); + } + return text; +} + +// Strings lose their trailing '/' first; then every entry (string or not) +// already present by value is dropped. +Value CanonicalUrlList(const Value& list) { + if (!list.IsArray()) { + return list; + } + Value result = Value::Array(); + std::unordered_set strings; + std::vector others; + for (const Value& item : list.elements()) { + if (item.IsString()) { + std::string url = StripTrailingSlashes(item.AsString()); + if (strings.insert(url).second) { + result.Push(Value::String(std::move(url))); + } + continue; + } + bool seen = false; + for (const Value* other : others) { + if (Value::DeepEquals(*other, item)) { + seen = true; + break; + } + } + if (!seen) { + others.push_back(&item); + result.Push(item); + } + } + return result; +} + +Value ParseObject(const std::string& json) { + bool ok = false; + Value value = flowjson::Parse(json, &ok); + return ok && value.IsObject() ? value : Value::Undefined(); +} + +const std::string& PolicyNextLaunch() { + static const std::string value = + pushy::text::Reveal("2912e0ffab8e6c70323b1dedd3"); + return value; +} + +// Pushy's own service addresses, encoded by scripts/encode-native-text.ts. +std::vector DefaultEndpoints() { + return { + pushy::text::Reveal( + "3203e0c1bdd1270a372f18f8c2b6de7f4f2607f5b3d5b9817b592947e5cdefbc8a7e"), + pushy::text::Reveal( + "3203e0c1bdd1270a372f18f8c2b6de7f4f2607f5f0daac9c644a620ae88ca1ad93"), + }; +} + +// One literal per address: src/__tests__/nativeTextEncoding.test.ts finds +// them by pattern. +std::vector DefaultQueryUrls() { + return { + pushy::text::Reveal("3203e0c1bdd1270a253608fcd3fd9362476817f4f0d5a1996342631be3c2a3a9d779552507fdcde8928a6f512f5ce2ccbdc869404d2f1de79daa826d562c0913eec4fa9b7d4426"), + pushy::text::Reveal("3203e0c1bdd1270a213b12b7dca09468462e12f3b0d5bd813d482446f4c6a1be8e79552507fdcda68cd06e5c3710e480a4867048483e55e0c2ab8d7d43030d1ce9c3b183214e2601f2f0d5b782601e2719e8ca"), + }; +} + +Value StringArray(const std::vector& values) { + Value array = Value::Array(); + for (const std::string& value : values) { + array.Push(Value::String(value)); + } + return array; +} + +bool IsHexDigit(char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || + (c >= 'A' && c <= 'F'); +} + +bool IsDigit(char c) { return c >= '0' && c <= '9'; } + +bool IsHostChar(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || IsDigit(c) || + c == '.' || c == '-'; +} + +// ^https?://(\[[0-9a-fA-F:]+\]|[a-zA-Z0-9.-]+)(:[0-9]+)?([/?#]|$) — the +// scheme and authority check; anything else in the authority (userinfo, +// spaces, a missing host) fails it. Every quantifier is greedy and its +// continuation cannot start with a character it consumes, so a single +// left-to-right scan matches exactly what the backtracking regex does. +bool HasHttpAuthority(const std::string& url) { + size_t i = 0; + const std::string http = "http"; + if (url.compare(0, http.size(), http) != 0) { + return false; + } + i = http.size(); + if (i < url.size() && url[i] == 's') { + i++; + } + if (url.compare(i, 3, "://") != 0) { + return false; + } + i += 3; + const size_t host = i; + if (i < url.size() && url[i] == '[') { + i++; + while (i < url.size() && (IsHexDigit(url[i]) || url[i] == ':')) { + i++; + } + if (i == host + 1 || i >= url.size() || url[i] != ']') { + return false; + } + i++; + } else { + while (i < url.size() && IsHostChar(url[i])) { + i++; + } + if (i == host) { + return false; + } + } + if (i < url.size() && url[i] == ':') { + const size_t port = ++i; + while (i < url.size() && IsDigit(url[i])) { + i++; + } + if (i == port) { + return false; + } + } + return i == url.size() || url[i] == '/' || url[i] == '?' || url[i] == '#'; +} + +// The normalizer's checks report the first problem in *error (without the +// prefix) and return false; no exceptions, the cores build without unwind +// tables on Android. +bool ConfigString(const Value& value, const std::string& name, bool allowEmpty, + std::string* out, std::string* error) { + if (!value.IsString() || + (!allowEmpty && TrimJsWhitespace(value.AsString()).empty())) { + *error = name + " must be a string" + + (allowEmpty ? "" : " and must not be blank"); + return false; + } + *out = value.AsString(); + return true; +} + +// `base` lists (endpoints) are request bases: non-empty, no query or +// fragment, trailing '/' dropped. Discovery lists keep their URLs whole. +bool ConfigUrls(const Value& values, const std::string& name, bool base, + Value* out, std::string* error) { + if (!values.IsArray() || (base && values.Size() == 0)) { + *error = name + " must be " + (base ? "a non-empty" : "an") + " array"; + return false; + } + Value result = Value::Array(); + std::unordered_set seen; + for (const Value& item : values.elements()) { + std::string url; + if (!ConfigString(item, name, false, &url, error)) { + return false; + } + url = TrimJsWhitespace(url); + if (!HasHttpAuthority(url) || ContainsJsWhitespace(url) || + url.find('\\') != std::string::npos || + (base && url.find_first_of("?#") != std::string::npos)) { + *error = name + " requires absolute HTTP(S) URLs without credentials" + + (base ? ", queries or fragments" : ""); + return false; + } + if (base) { + url = StripTrailingSlashes(std::move(url)); + } + if (seen.insert(url).second) { + result.Push(Value::String(std::move(url))); + } + } + *out = std::move(result); + return true; +} + +bool Normalize(const std::string& optionsJson, Value* out, + std::string* error) { + const Value options = ParseObject(optionsJson); + if (!options.IsObject()) { + *error = "expected an object"; + return false; + } + static const char* const kKeys[] = { + "appKey", "endpoints", "queryUrls", "afterDownload", + "disabled", "packageVersion", "rnu", "rn"}; + for (const auto& member : options.members()) { + bool known = false; + for (const char* key : kKeys) { + known = known || member.first == key; + } + if (!known) { + *error = "unknown option " + member.first; + return false; + } + } + const auto has = [&](const char* key) { + return !options.Get(key).IsUndefined(); + }; + // A string option: absent takes `fallback`, present must pass ConfigString. + const auto string = [&](const char* key, bool allowEmpty, + const std::string& fallback, Value* result) { + std::string value = fallback; + if (has(key) && + !ConfigString(options.Get(key), key, allowEmpty, &value, error)) { + return false; + } + *result = Value::String(std::move(value)); + return true; + }; + + Value appKey, endpoints, queryUrls, rnu, rn, packageVersion; + if (!has("appKey")) { + *error = "appKey must be a string and must not be blank"; + return false; + } + if (!string("appKey", false, "", &appKey)) { + return false; + } + const bool customEndpoints = has("endpoints"); + if (!ConfigUrls(customEndpoints ? options.Get("endpoints") + : StringArray(DefaultEndpoints()), + "endpoints", true, &endpoints, error) || + !ConfigUrls(has("queryUrls") ? options.Get("queryUrls") + : customEndpoints ? Value::Array() + : StringArray(DefaultQueryUrls()), + "queryUrls", false, &queryUrls, error)) { + return false; + } + Value afterDownload = Value::String("none"); + if (has("afterDownload")) { + afterDownload = options.Get("afterDownload"); + if (!afterDownload.IsString() || (afterDownload.AsString() != "none" && + afterDownload.AsString() != + PolicyNextLaunch())) { + *error = "afterDownload must be none or " + PolicyNextLaunch(); + return false; + } + } + const Value& disabled = options.Get("disabled"); + if (!disabled.IsUndefined() && !disabled.IsBool()) { + *error = "disabled must be a boolean"; + return false; + } + if (!string("rnu", true, "", &rnu) || !string("rn", true, "", &rn) || + (has("packageVersion") && + !string("packageVersion", false, "", &packageVersion))) { + return false; + } + Value result = Value::Object(); + result.Set("appKey", std::move(appKey)); + result.Set("endpoints", std::move(endpoints)); + result.Set("queryUrls", std::move(queryUrls)); + result.Set("afterDownload", std::move(afterDownload)); + result.Set("disabled", Value::Bool(disabled.IsBool() && disabled.AsBool())); + result.Set("rnu", std::move(rnu)); + result.Set("rn", std::move(rn)); + if (!packageVersion.IsUndefined()) { + result.Set("packageVersion", std::move(packageVersion)); + } + *out = std::move(result); + return true; +} + +} // namespace + +std::string TrimJsWhitespace(const std::string& text) { + size_t begin = 0; + while (begin < text.size()) { + const size_t length = WhitespaceAt(text, begin); + if (length == 0) { + break; + } + begin += length; + } + size_t end = text.size(); + while (end > begin) { + // A whitespace sequence is 1-3 bytes; try each length ending at `end`. + size_t length = 0; + for (size_t candidate = 1; candidate <= 3 && candidate <= end - begin; + candidate++) { + if (WhitespaceAt(text, end - candidate) == candidate) { + length = candidate; + break; + } + } + if (length == 0) { + break; + } + end -= length; + } + return text.substr(begin, end - begin); +} + +Value CanonicalNativeConfig(const std::string& json) { + Value config = ParseObject(json); + if (!config.IsObject()) { + return config; + } + const std::pair defaults[] = { + {"disabled", Value::Bool(false)}, {"queryUrls", Value::Array()}, + {"rnu", Value::String("")}, {"rn", Value::String("")}, + {"afterDownload", Value::String("none")}, + }; + for (const auto& entry : defaults) { + if (config.Get(entry.first).IsUndefined()) { + config.Set(entry.first, entry.second); + } + } + for (const char* key : {"endpoints", "queryUrls"}) { + const Value& list = config.Get(key); + if (list.IsArray()) { + config.Set(key, CanonicalUrlList(list)); + } + } + return config; +} + +bool ConfigsEquivalent(const std::string& a, const std::string& b) { + const Value left = CanonicalNativeConfig(a); + if (!left.IsObject()) { + return false; + } + const Value right = CanonicalNativeConfig(b); + return right.IsObject() && Value::DeepEquals(left, right); +} + +RunnableConfig ParseRunnableConfig(const std::string& json) { + RunnableConfig result; + if (json.empty()) { + result.status = "skipped"; + result.reason = "not_configured"; + return result; + } + Value config = ParseObject(json); + if (!config.IsObject()) { + result.status = "failed"; + result.reason = "invalid_config"; + return result; + } + if (config.Get("disabled").Truthy()) { + result.status = "skipped"; + result.reason = "disabled"; + return result; + } + const Value& appKey = config.Get("appKey"); + std::string trimmed = + appKey.IsString() ? TrimJsWhitespace(appKey.AsString()) : std::string(); + if (trimmed.empty()) { + result.status = "failed"; + result.reason = "invalid_config"; + return result; + } + result.appKey = std::move(trimmed); + result.config = std::move(config); + return result; +} + +std::string RunnableConfigJson(const std::string& json) { + const RunnableConfig parsed = ParseRunnableConfig(json); + Value result = Value::Object(); + if (parsed.runnable()) { + result.Set("appKey", Value::String(parsed.appKey)); + } else { + result.Set("status", Value::String(parsed.status)); + result.Set("reason", Value::String(parsed.reason)); + } + return flowjson::Stringify(result); +} + +bool NormalizeConfiguration(const std::string& optionsJson, std::string* out, + std::string* error) { + Value result; + std::string why; + if (!Normalize(optionsJson, &result, &why)) { + *error = "Invalid native configuration: " + why; + return false; + } + *out = flowjson::Stringify(result); + return true; +} + +} // namespace flowcore diff --git a/cpp/update_flow_core/native_config.h b/cpp/update_flow_core/native_config.h new file mode 100644 index 00000000..19d2954b --- /dev/null +++ b/cpp/update_flow_core/native_config.h @@ -0,0 +1,84 @@ +#pragma once + +#include + +#include "flow_json.h" + +// The persisted native check config: its validation when a host configures +// it, the gate that decides whether it can run a round, and the equivalence +// that decides whether a new one is a change. One implementation for the +// Android, iOS and Harmony orchestrators (each used to carry its own copy); +// JSON strings in and out so the bindings stay thin. +// +// The JS client keeps its own TS copy of the equivalence +// (src/updateFlowCore.ts nativeConfigsEquivalent — it must also run on web and +// on natives that predate this file); both replay +// tests/config-equivalence-vectors.json. The gate and the normalizer have no +// JS counterpart; tests/native-config-vectors.json pins them. +// Keep the core internal to whichever binary links it (see podspec Core). +#pragma GCC visibility push(hidden) + +namespace flowcore { + +// ECMAScript String.prototype.trim: strips WhiteSpace and LineTerminator +// code points (TAB, LF, VT, FF, CR, SPACE, NBSP, U+1680, U+2000-U+200A, +// U+2028, U+2029, U+202F, U+205F, U+3000, U+FEFF) from both ends of a UTF-8 +// string. Other control characters are kept. +std::string TrimJsWhitespace(const std::string& text); + +// Contract C1. Canonical form of a persisted config: the parsed object with +// absent keys defaulted ({disabled:false, queryUrls:[], rnu:"", rn:"", +// afterDownload:"none"}) and each `endpoints`/`queryUrls` array's strings +// stripped of every trailing '/', then deduplicated keeping the first +// occurrence. Present values are never coerced (0 is not false, null is not +// a default). Undefined when the text is not a JSON object. +flowjson::Value CanonicalNativeConfig(const std::string& json); + +// Two configs are equivalent iff both are JSON objects whose canonical forms +// are DeepEquals (key order ignored). Anything else is never equivalent, not +// even to itself — callers pass "" for an absent config. +bool ConfigsEquivalent(const std::string& a, const std::string& b); + +// The gate every entry point (round start, crash-hold install, the host's +// prepareBundle, the round itself) applies to the persisted config, so they +// agree on what is runnable: +// "" -> skipped / not_configured +// not a JSON object -> failed / invalid_config +// `disabled` truthy (JS truthiness) -> skipped / disabled +// `appKey` not a string, or blank -> failed / invalid_config +// after TrimJsWhitespace +// otherwise runnable, with the trimmed appKey and the parsed config. +struct RunnableConfig { + std::string status; // empty when runnable, else "skipped" / "failed" + std::string reason; + std::string appKey; + flowjson::Value config; + + bool runnable() const { return status.empty(); } +}; +RunnableConfig ParseRunnableConfig(const std::string& json); + +// Binding form of ParseRunnableConfig: {"appKey": ""} when runnable, +// otherwise {"status": "...", "reason": "..."}. The binding re-parses the +// config itself when it needs more fields. +std::string RunnableConfigJson(const std::string& json); + +// Validates the options a host passes to configure() (a JSON object) and +// returns the config to persist: +// {appKey, endpoints, queryUrls, afterDownload, disabled, rnu, rn, +// packageVersion?} +// Only those keys are accepted. appKey/packageVersion are non-blank strings +// (kept as given), rnu/rn strings. endpoints default to Pushy's own and must +// be a non-empty array of absolute http(s) URLs without credentials, queries +// or fragments; each is trimmed, stripped of trailing '/' and deduplicated. +// queryUrls default to Pushy's discovery URLs, or to [] once endpoints are +// custom; each is trimmed and deduplicated (queries allowed). afterDownload +// is "none" (default) or the next-launch policy; disabled a boolean. A +// present null is invalid, never a default. On failure returns false and +// sets *error to "Invalid native configuration: ". +bool NormalizeConfiguration(const std::string& optionsJson, std::string* out, + std::string* error); + +} // namespace flowcore + +#pragma GCC visibility pop diff --git a/cpp/update_flow_core/native_round.cpp b/cpp/update_flow_core/native_round.cpp new file mode 100644 index 00000000..b8597f31 --- /dev/null +++ b/cpp/update_flow_core/native_round.cpp @@ -0,0 +1,326 @@ +#include "native_round.h" + +#include +#include + +#include "../patch_core/hbc_transform_wire.h" +#include "../patch_core/obscured_text.h" +#include "native_config.h" +#include "update_flow_core.h" + +namespace flowcore { + +using flowjson::Value; + +namespace { + +// Stored encoded (see obscured_text.h). +const std::string& QueryPath() { + static const std::string value = pushy::text::Reveal("7514fcd4ad805d55263e08fc99"); + return value; +} +const std::string& InfoForceBoot() { + static const std::string value = + pushy::text::Reveal("3c18e6d2aba9674a360d19ead5a695"); + return value; +} + +// The lists NextQueryStep walks, in order; the state's "list" names the +// current one ("" once the query is over). +const char kEndpoints[] = "endpoints"; +const char kQueryUrls[] = "queryUrls"; +const char kRemote[] = "remote"; + +bool HasPrefixIgnoringAsciiCase(const std::string& text, const char* prefix) { + size_t i = 0; + for (; prefix[i] != '\0'; i++) { + if (i >= text.size()) { + return false; + } + char c = text[i]; + if (c >= 'A' && c <= 'Z') { + c = static_cast(c - 'A' + 'a'); + } + if (c != prefix[i]) { + return false; + } + } + return true; +} + +bool IsHttpsUrl(const std::string& url) { + return HasPrefixIgnoringAsciiCase(url, "https://"); +} + +bool IsCleartextUrl(const std::string& url) { + return HasPrefixIgnoringAsciiCase(url, "http://"); +} + +std::string StripTrailingSlashes(std::string url) { + while (!url.empty() && url.back() == '/') { + url.pop_back(); + } + return url; +} + +Value StringOr(const Value& value, const char* fallback) { + return value.IsString() ? value : Value::String(fallback); +} + +// A non-negative integral state counter; anything else reads as 0. +size_t Counter(const Value& value) { + const double number = value.AsNumber(); + if (!value.IsNumber() || !std::isfinite(number) || number < 0) { + return 0; + } + return number >= 1e15 ? static_cast(1e15) + : static_cast(number); +} + +// { packageVersion, currentVersion, uuid, rolledBackVersion } — what +// HandleResponse keys its decision on. +Value Identity(const Value& config, const Value& env) { + const Value& configured = config.Get("packageVersion"); + Value identity = Value::Object(); + identity.Set("packageVersion", + configured.IsString() && !configured.AsString().empty() + ? configured + : env.Get("packageVersion")); + identity.Set("currentVersion", env.Get("currentVersion")); + identity.Set("uuid", StringOr(env.Get("uuid"), "")); + identity.Set("rolledBackVersion", env.Get("rolledBackVersion")); + return identity; +} + +std::string RequestBody(const Value& config, const Value& env) { + const Value identity = Identity(config, env); + Value cInfo = Value::Object(); + cInfo.Set("rnu", StringOr(config.Get("rnu"), "")); + cInfo.Set("rn", StringOr(config.Get("rn"), "")); + cInfo.Set("os", env.Get("os")); + cInfo.Set("uuid", identity.Get("uuid")); + Value input = Value::Object(); + input.Set("packageVersion", identity.Get("packageVersion")); + input.Set("currentVersion", env.Get("currentVersion")); + input.Set("buildTime", env.Get("buildTime")); + input.Set("cInfo", std::move(cInfo)); + input.Set("supportedDiffVersion", + Value::Number(pushy::hbc::kSupportedDiffVersion)); + input.Set("bundleHash", env.Get("bundleHash")); + return flowjson::Stringify(BuildRequestBody(input)); +} + +bool Contains(const Value& list, const std::string& text) { + for (const Value& entry : list.elements()) { + if (entry.AsString() == text) { + return true; + } + } + return false; +} + +Value PlanAttempts(const Value& attempts, bool hasOrigin, bool httpsOnly) { + Value planned = Value::Array(); + for (const Value& attempt : attempts.elements()) { + // diff patches the running version; with none running it cannot apply. + if (attempt.Get("type").AsString() == "diff" && !hasOrigin) { + continue; + } + Value urls = Value::Array(); + for (const Value& url : attempt.Get("urls").elements()) { + if (url.IsString() && !url.AsString().empty() && + !(httpsOnly && IsCleartextUrl(url.AsString()))) { + urls.Push(url); + } + } + if (urls.Size() > 0) { + Value kept = Value::Object(); + kept.Set("type", attempt.Get("type")); + kept.Set("urls", std::move(urls)); + planned.Push(std::move(kept)); + } + } + return planned; +} + +Value VersionInfo(const Value& info) { + Value hashInfo = Value::Object(); + for (const char* key : {"name", "description", "metaInfo"}) { + if (info.Get(key).IsString()) { + hashInfo.Set(key, info.Get(key)); + } + } + // A forceBoot activation is the brick-rescue path: JS reports it once + // this version survives to markSuccess. Only the server's directive counts + // — a silent-strategy activation is ordinary delivery. + if (info.Get("config").Get("forceBoot").Truthy()) { + hashInfo.Set(InfoForceBoot(), Value::Bool(true)); + } + return hashInfo; +} + +} // namespace + +bool IsHttpsOnly(const Value& endpoints) { + if (endpoints.elements().empty()) { + return false; + } + for (const Value& endpoint : endpoints.elements()) { + if (!endpoint.IsString() || !IsHttpsUrl(endpoint.AsString())) { + return false; + } + } + return true; +} + +Value PrepareRound(const std::string& configJson, const Value& env, + double randomSample) { + RunnableConfig gate = ParseRunnableConfig(configJson); + if (!gate.runnable() || !env.IsObject()) { + return Value::Undefined(); + } + const Value& config = gate.config; + const Value& queryUrls = config.Get(kQueryUrls); + Value query = Value::Object(); + query.Set("appKey", Value::String(gate.appKey)); + query.Set("httpsOnly", Value::Bool(IsHttpsOnly(config.Get(kEndpoints)))); + query.Set(kEndpoints, + OrderEndpointCandidates(config.Get(kEndpoints), randomSample)); + query.Set(kQueryUrls, queryUrls.IsArray() ? queryUrls : Value::Array()); + query.Set("list", Value::String(kEndpoints)); + query.Set("next", Value::Number(0)); + query.Set("tried", Value::Array()); + query.Set("spent", Value::Number(0)); + + Value round = Value::Object(); + round.Set("body", Value::String(RequestBody(config, env))); + round.Set("query", std::move(query)); + return round; +} + +Value NextQueryStep(const Value& previous, const Value& responseText) { + if (!previous.IsObject()) { + return Value::Undefined(); + } + Value state = previous; + if (state.Get("url").IsString()) { + const bool post = state.Get("post").Truthy(); + state.Remove("url"); + state.Remove("post"); + if (responseText.IsString()) { + if (post && IsValidResponse(responseText.AsString())) { + state.Set("found", Value::Bool(true)); + } else if (!post) { + bool ok = false; + Value remote = flowjson::Parse(responseText.AsString(), &ok); + if (ok && remote.IsArray()) { + state.Set("list", Value::String(kRemote)); + state.Set(kRemote, std::move(remote)); + state.Set("next", Value::Number(0)); + } + } + } + } + if (state.Get("found").Truthy()) { + return state; + } + const bool httpsOnly = state.Get("httpsOnly").Truthy(); + for (;;) { + const std::string list = state.Get("list").AsString(); + if (list != kEndpoints && list != kQueryUrls && list != kRemote) { + state.Set("list", Value::String("")); + return state; + } + const bool isListOfLists = list == kQueryUrls; + const Value& entries = state.Get(list); + size_t next = Counter(state.Get("next")); + std::string target; + while (target.empty() && next < entries.Size()) { + const Value& entry = entries.At(next++); + if (!entry.IsString()) { + continue; + } + if (isListOfLists) { + target = entry.AsString(); + continue; + } + std::string base = StripTrailingSlashes(entry.AsString()); + if (base.empty() || Contains(state.Get("tried"), base)) { + continue; + } + // A remotely injected endpoint must not downgrade an https + // configuration to plaintext. + if (list == kRemote && httpsOnly && !IsHttpsUrl(base)) { + continue; + } + target = std::move(base); + } + state.Set("next", Value::Number(static_cast(next))); + if (target.empty()) { + // The configured endpoints are followed by the list discovery; a + // fetched remote list (or running out of lists) ends the query. + state.Set("list", Value::String(list == kEndpoints ? kQueryUrls : "")); + state.Set("next", Value::Number(0)); + continue; + } + const size_t spent = Counter(state.Get("spent")); + if (spent >= static_cast(kMaxQueryRequests)) { + state.Set("list", Value::String("")); + return state; + } + state.Set("spent", Value::Number(static_cast(spent + 1))); + if (isListOfLists) { + state.Set("url", Value::String(target)); + state.Set("post", Value::Bool(false)); + } else { + Value tried = state.Get("tried").IsArray() ? state.Get("tried") + : Value::Array(); + tried.Push(Value::String(target)); + state.Set("tried", std::move(tried)); + state.Set("url", Value::String(target + QueryPath() + + state.Get("appKey").AsString())); + state.Set("post", Value::Bool(true)); + } + return state; + } +} + +Value HandleRoundResponse(const std::string& responseText, + const std::string& configJson, const Value& env, + const std::string& requestBody, + double responseAtSeconds) { + bool ok = false; + const Value config = flowjson::Parse(configJson, &ok); + if (!ok || !config.IsObject() || !env.IsObject()) { + return Value::Undefined(); + } + const Value& afterDownload = config.Get("afterDownload"); + const Value decision = HandleResponse( + responseText, Identity(config, env), false, + afterDownload.IsString() ? afterDownload.AsString() : std::string()); + + Value round = Value::Object(); + round.Set("action", decision.Get("action")); + if (decision.Get("action").AsString() == "download") { + round.Set("hash", decision.Get("hash")); + round.Set("attempts", + PlanAttempts(decision.Get("attempts"), + env.Get("currentVersion").Truthy(), + IsHttpsOnly(config.Get(kEndpoints)))); + round.Set("activate", decision.Get("activate")); + round.Set("hashInfo", VersionInfo(decision.Get("info"))); + } else { + round.Set("reason", decision.Get("reason")); + } + // Scoped to the exact request and config that produced the response (§10.3); + // freshness is anchored to the response's arrival. + Value cache = Value::Object(); + cache.Set("ts", Value::Number(responseAtSeconds)); + cache.Set("body", Value::String(responseText)); + cache.Set("request", Value::String(requestBody)); + cache.Set("config", Value::String(configJson)); + round.Set("cache", Value::String(flowjson::Stringify(cache))); + return round; +} + +} // namespace flowcore diff --git a/cpp/update_flow_core/native_round.h b/cpp/update_flow_core/native_round.h new file mode 100644 index 00000000..a5827d89 --- /dev/null +++ b/cpp/update_flow_core/native_round.h @@ -0,0 +1,90 @@ +#pragma once + +#include + +#include "flow_json.h" + +// The pure steps of the native cold-start round (NATIVE_CHECKUPDATE_DESIGN +// §10), one implementation for the Android, iOS and Harmony orchestrators: +// what to send, which endpoint to ask next, and everything a commit needs +// from the answer. HTTP, timeouts, locks, downloads and persistence stay +// native. JS has no counterpart (its client builds its own request through +// src/updateFlowCore.ts buildCheckRequestBody, which BuildRequestBody +// mirrors); tests/native-round-vectors.json pins this layer. +// +// `config` is the persisted config text (already through +// ParseRunnableConfig). `env` is what the platform knows about this launch, +// as a JSON object: +// packageVersion the binary's package version +// currentVersion the running version exactly as the platform's JS +// constant reports it (Android null, iOS absent, +// Harmony "" when none), so the request matches the one +// JS builds and JS can reuse the response cache +// uuid missing -> "" +// rolledBackVersion the launch-path rollback snapshot +// buildTime passed through +// os " ", as JS cInfo.os +// bundleHash omitted from the request when empty +// Keep the core internal to whichever binary links it (see podspec Core). +#pragma GCC visibility push(hidden) + +namespace flowcore { + +// Total HTTP requests one query may issue: endpoint queries and remote list +// fetches alike. +constexpr int kMaxQueryRequests = 8; + +// {"body": "", "query": }. The body is BuildRequestBody over the config and env: a non-empty +// string config.packageVersion overrides env.packageVersion, cInfo.rnu/rn are +// the config's strings ("" otherwise), diffV is this core's +// kSupportedDiffVersion. randomSample ∈ [0, 1) picks the first endpoint +// (OrderEndpointCandidates). Undefined when the config is not runnable or +// env is not an object. +flowjson::Value PrepareRound(const std::string& configJson, + const flowjson::Value& env, double randomSample); + +// One step of the sequential endpoint fallback (§5.1). Pass the previous +// state and the text the request it named returned (Undefined when it failed +// or none was made). The result is the next state, with +// "url" (and "post"): issue that request — a POST of the round's body for +// an endpoint query, a GET for a remote endpoint list — then call again; +// "found": true: the text just passed in is the valid check response; +// neither: no endpoint answered; the query is over. +// Policy: the configured endpoints in sampled order, then the first +// queryUrls list that fetches and parses as a JSON array, whose entries are +// queried in turn (one list is enough). Bases are stripped of trailing '/' +// and asked once each; entries that are not strings are skipped; a remote +// entry must be https when every configured endpoint is (IsHttpsOnly), so the +// network cannot downgrade the round. A response counts only when +// IsValidResponse accepts it. At most kMaxQueryRequests requests. Undefined +// for a state that is not an object. +flowjson::Value NextQueryStep(const flowjson::Value& state, + const flowjson::Value& responseText); + +// Every configured endpoint is an https URL (ASCII case-insensitive); false +// for an empty or missing list. +bool IsHttpsOnly(const flowjson::Value& endpoints); + +// HandleResponse for the round, plus what each commit needs: +// {action: "none", reason, cache} +// {action: "download", hash, attempts, activate, hashInfo, cache} +// attempts are planned for this device: no diff attempt without a running +// version (env.currentVersion falsy), no cleartext http:// URL when the +// config IsHttpsOnly; strategies left without a URL are dropped. hashInfo is +// the version info to persist next to the download (the JS side's +// setLocalHashInfo): the info's string name/description/metaInfo, plus +// forceBootRescue when the server's config.forceBoot is truthy (the same +// truthiness that activates it). cache is the response-cache text JS reuses +// ({ts, body, request, config}). afterDownload is the config's string +// (otherwise ""). Undefined when the config is not a JSON object or env is +// not an object. +flowjson::Value HandleRoundResponse(const std::string& responseText, + const std::string& configJson, + const flowjson::Value& env, + const std::string& requestBody, + double responseAtSeconds); + +} // namespace flowcore + +#pragma GCC visibility pop diff --git a/cpp/update_flow_core/tests/config-equivalence-vectors.json b/cpp/update_flow_core/tests/config-equivalence-vectors.json index e93b72b8..bdbc0fb9 100644 --- a/cpp/update_flow_core/tests/config-equivalence-vectors.json +++ b/cpp/update_flow_core/tests/config-equivalence-vectors.json @@ -1,5 +1,5 @@ { - "description": "Contract C1: native config JSON equivalence, replayed by the JS (src/updateFlowCore.ts), Harmony (UpdateContext.ts), Android (ConfigEquivalence.java) and iOS (PushyConfigsEquivalent) implementations. Two strings are equivalent iff both parse to JSON objects whose canonical forms deep-equal (key order ignored). Canonical: absent keys take {disabled:false, queryUrls:[], rnu:\"\", rn:\"\", afterDownload:\"none\"}; each string in endpoints/queryUrls (when an array) loses ALL trailing \"/\", then entries are deduplicated keeping the first occurrence. Present values are never coerced (0 is not false, null is not a default). Anything that is not a JSON object is never equivalent, not even to itself. Every case must hold in both argument orders. Hand-maintained: edit this file, then keep every platform green.", + "description": "Contract C1: native config JSON equivalence, replayed by the JS (src/updateFlowCore.ts) and the shared native (cpp/update_flow_core/native_config.h ConfigsEquivalent, used by Android, iOS and Harmony) implementations. Two strings are equivalent iff both parse to JSON objects whose canonical forms deep-equal (key order ignored). Canonical: absent keys take {disabled:false, queryUrls:[], rnu:\"\", rn:\"\", afterDownload:\"none\"}; each string in endpoints/queryUrls (when an array) loses ALL trailing \"/\", then entries are deduplicated keeping the first occurrence. Present values are never coerced (0 is not false, null is not a default). Anything that is not a JSON object is never equivalent, not even to itself. Every case must hold in both argument orders. Hand-maintained: edit this file, then keep every platform green.", "cases": [ { "name": "identical", @@ -348,6 +348,30 @@ "a": "{\"appKey\":\"k\"}{\"appKey\":\"k\"}", "b": "{\"appKey\":\"k\"}{\"appKey\":\"k\"}", "equivalent": false + }, + { + "name": "exponent notation equals the integer", + "a": "{\"appKey\":\"k\",\"n\":1e2}", + "b": "{\"appKey\":\"k\",\"n\":100}", + "equivalent": true + }, + { + "name": "negative zero equals zero", + "a": "{\"appKey\":\"k\",\"n\":-0}", + "b": "{\"appKey\":\"k\",\"n\":0}", + "equivalent": true + }, + { + "name": "appKey whitespace is significant (only the run gate trims)", + "a": "{\"appKey\":\" k\"}", + "b": "{\"appKey\":\"k\"}", + "equivalent": false + }, + { + "name": "disabled 1 is not true", + "a": "{\"appKey\":\"k\",\"disabled\":1}", + "b": "{\"appKey\":\"k\",\"disabled\":true}", + "equivalent": false } ] } diff --git a/cpp/update_flow_core/tests/fuzz_flow_json.cpp b/cpp/update_flow_core/tests/fuzz_flow_json.cpp index 36c420ff..f6229928 100644 --- a/cpp/update_flow_core/tests/fuzz_flow_json.cpp +++ b/cpp/update_flow_core/tests/fuzz_flow_json.cpp @@ -9,6 +9,8 @@ #include #include "../flow_json.h" +#include "../native_config.h" +#include "../native_round.h" #include "../update_flow_core.h" extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { @@ -41,5 +43,48 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { flowjson::Stringify( flowcore::HandleResponse(text, identity, false, "none")); flowcore::IsValidResponse(text); + + // The round: the text as the persisted config, as a response and as a + // remote endpoint list. Every query walk must end within its request cap. + flowjson::Value env = flowjson::Value::Object(); + env.Set("packageVersion", flowjson::Value::String("2.3.4")); + env.Set("currentVersion", flowjson::Value::String("cur")); + env.Set("os", flowjson::Value::String("fuzz 1")); + const flowjson::Value round = flowcore::PrepareRound(text, env, 0.5); + flowjson::Value state = round.Get("query"); + for (int step = 0; state.IsObject(); step++) { + if (step > 2 * flowcore::kMaxQueryRequests + 2) { + std::abort(); + } + state = flowcore::NextQueryStep(state, flowjson::Value::String(text)); + if (state.Get("found").Truthy() || !state.Get("url").IsString()) { + break; + } + } + flowjson::Stringify(flowcore::HandleRoundResponse( + text, "{\"appKey\":\"k\",\"endpoints\":[\"https://a\"]}", env, "{}", 1)); + flowjson::Stringify( + flowcore::HandleRoundResponse("{\"upToDate\":true}", text, env, text, 1)); + + // The persisted-config layer reads host/JS-written text: equivalence must + // be reflexive for every object, and a normalized config must be runnable + // (or disabled) and stable under its own normalization. + flowcore::RunnableConfigJson(text); + if (flowcore::CanonicalNativeConfig(text).IsObject() && + !flowcore::ConfigsEquivalent(text, text)) { + std::abort(); + } + std::string normalized; + std::string error; + if (flowcore::NormalizeConfiguration(text, &normalized, &error)) { + const flowcore::RunnableConfig gate = + flowcore::ParseRunnableConfig(normalized); + std::string again; + if (!(gate.runnable() || gate.reason == "disabled") || + !flowcore::NormalizeConfiguration(normalized, &again, &error) || + again != normalized) { + std::abort(); + } + } return 0; } diff --git a/cpp/update_flow_core/tests/native-config-vectors.json b/cpp/update_flow_core/tests/native-config-vectors.json new file mode 100644 index 00000000..ffea12b9 --- /dev/null +++ b/cpp/update_flow_core/tests/native-config-vectors.json @@ -0,0 +1,451 @@ +{ + "description": "Native config gate and normalizer (cpp/update_flow_core/native_config.h), shared by the Android, iOS and Harmony orchestrators and replayed by scripts/test-update-flow-core.sh. `runnable`: the persisted config text -> RunnableConfigJson ({appKey} when a round may run, else {status, reason}); disabled follows JS truthiness and appKey is trimmed like String.prototype.trim. `normalize`: the options JSON a host passes to configure() -> the persisted config (`expected`, compared as text) or the error message (`error`). Hand-maintained.", + "runnable": [ + { + "name": "absent config is not configured", + "config": "", + "expected": { + "status": "skipped", + "reason": "not_configured" + } + }, + { + "name": "whitespace-only text is invalid JSON", + "config": " ", + "expected": { + "status": "failed", + "reason": "invalid_config" + } + }, + { + "name": "unterminated object", + "config": "{", + "expected": { + "status": "failed", + "reason": "invalid_config" + } + }, + { + "name": "trailing content after the object is invalid", + "config": "{\"appKey\":\"k\"} x", + "expected": { + "status": "failed", + "reason": "invalid_config" + } + }, + { + "name": "JSON array is not a config", + "config": "[]", + "expected": { + "status": "failed", + "reason": "invalid_config" + } + }, + { + "name": "JSON null is not a config", + "config": "null", + "expected": { + "status": "failed", + "reason": "invalid_config" + } + }, + { + "name": "JSON string is not a config", + "config": "\"{}\"", + "expected": { + "status": "failed", + "reason": "invalid_config" + } + }, + { + "name": "appKey alone is runnable", + "config": "{\"appKey\":\"k\"}", + "expected": { + "appKey": "k" + } + }, + { + "name": "full persisted config is runnable", + "config": "{\"appKey\":\"k\",\"endpoints\":[\"https://update.react-native.cn/api\",\"https://update.reactnative.cn/api\"],\"queryUrls\":[\"https://gitee.com/sunnylqm/react-native-pushy/raw/master/endpoints.json\",\"https://cdn.jsdelivr.net/gh/reactnativecn/react-native-update@master/endpoints.json\"],\"afterDownload\":\"none\",\"disabled\":false,\"rnu\":\"\",\"rn\":\"\"}", + "expected": { + "appKey": "k" + } + }, + { + "name": "appKey is trimmed", + "config": "{\"appKey\":\" \\t k \\n\"}", + "expected": { + "appKey": "k" + } + }, + { + "name": "appKey trim follows String.prototype.trim (NBSP, ideographic space, BOM)", + "config": "{\"appKey\":\"\u00a0\ufeffk\u3000\u2028\"}", + "expected": { + "appKey": "k" + } + }, + { + "name": "control characters other than TAB/LF/VT/FF/CR are not trimmed", + "config": "{\"appKey\":\"\\u0001k\"}", + "expected": { + "appKey": "\u0001k" + } + }, + { + "name": "inner whitespace is kept", + "config": "{\"appKey\":\" a b \"}", + "expected": { + "appKey": "a b" + } + }, + { + "name": "blank appKey is invalid", + "config": "{\"appKey\":\" \\n\u3000\"}", + "expected": { + "status": "failed", + "reason": "invalid_config" + } + }, + { + "name": "empty appKey is invalid", + "config": "{\"appKey\":\"\"}", + "expected": { + "status": "failed", + "reason": "invalid_config" + } + }, + { + "name": "missing appKey is invalid", + "config": "{}", + "expected": { + "status": "failed", + "reason": "invalid_config" + } + }, + { + "name": "null appKey is invalid", + "config": "{\"appKey\":null}", + "expected": { + "status": "failed", + "reason": "invalid_config" + } + }, + { + "name": "numeric appKey is invalid", + "config": "{\"appKey\":1}", + "expected": { + "status": "failed", + "reason": "invalid_config" + } + }, + { + "name": "duplicate keys: the last one wins", + "config": "{\"appKey\":\"\",\"appKey\":\"k\"}", + "expected": { + "appKey": "k" + } + }, + { + "name": "disabled true", + "config": "{\"appKey\":\"k\",\"disabled\":true}", + "expected": { + "status": "skipped", + "reason": "disabled" + } + }, + { + "name": "disabled wins over a missing appKey", + "config": "{\"disabled\":true}", + "expected": { + "status": "skipped", + "reason": "disabled" + } + }, + { + "name": "disabled 1 is truthy", + "config": "{\"appKey\":\"k\",\"disabled\":1}", + "expected": { + "status": "skipped", + "reason": "disabled" + } + }, + { + "name": "disabled \"true\" is truthy", + "config": "{\"appKey\":\"k\",\"disabled\":\"true\"}", + "expected": { + "status": "skipped", + "reason": "disabled" + } + }, + { + "name": "disabled \"false\" is a non-empty string, so truthy", + "config": "{\"appKey\":\"k\",\"disabled\":\"false\"}", + "expected": { + "status": "skipped", + "reason": "disabled" + } + }, + { + "name": "disabled [] is truthy", + "config": "{\"appKey\":\"k\",\"disabled\":[]}", + "expected": { + "status": "skipped", + "reason": "disabled" + } + }, + { + "name": "disabled {} is truthy", + "config": "{\"appKey\":\"k\",\"disabled\":{}}", + "expected": { + "status": "skipped", + "reason": "disabled" + } + }, + { + "name": "disabled false runs", + "config": "{\"appKey\":\"k\",\"disabled\":false}", + "expected": { + "appKey": "k" + } + }, + { + "name": "disabled 0 runs", + "config": "{\"appKey\":\"k\",\"disabled\":0}", + "expected": { + "appKey": "k" + } + }, + { + "name": "disabled \"\" runs", + "config": "{\"appKey\":\"k\",\"disabled\":\"\"}", + "expected": { + "appKey": "k" + } + }, + { + "name": "disabled null runs", + "config": "{\"appKey\":\"k\",\"disabled\":null}", + "expected": { + "appKey": "k" + } + } + ], + "normalize": [ + { + "name": "appKey alone takes Pushy's endpoints and discovery", + "options": "{\"appKey\":\"test-app\"}", + "expected": "{\"appKey\":\"test-app\",\"endpoints\":[\"https://update.react-native.cn/api\",\"https://update.reactnative.cn/api\"],\"queryUrls\":[\"https://gitee.com/sunnylqm/react-native-pushy/raw/master/endpoints.json\",\"https://cdn.jsdelivr.net/gh/reactnativecn/react-native-update@master/endpoints.json\"],\"afterDownload\":\"none\",\"disabled\":false,\"rnu\":\"\",\"rn\":\"\"}" + }, + { + "name": "appKey is persisted as given", + "options": "{\"appKey\":\" k \"}", + "expected": "{\"appKey\":\" k \",\"endpoints\":[\"https://update.react-native.cn/api\",\"https://update.reactnative.cn/api\"],\"queryUrls\":[\"https://gitee.com/sunnylqm/react-native-pushy/raw/master/endpoints.json\",\"https://cdn.jsdelivr.net/gh/reactnativecn/react-native-update@master/endpoints.json\"],\"afterDownload\":\"none\",\"disabled\":false,\"rnu\":\"\",\"rn\":\"\"}" + }, + { + "name": "custom endpoints get no default discovery and are deduplicated", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"https://updates.example/api/\",\" https://updates.example/api \",\"https://updates.example/api//\"],\"afterDownload\":\"setNeedUpdate\"}", + "expected": "{\"appKey\":\"a\",\"endpoints\":[\"https://updates.example/api\"],\"queryUrls\":[],\"afterDownload\":\"setNeedUpdate\",\"disabled\":false,\"rnu\":\"\",\"rn\":\"\"}" + }, + { + "name": "explicit discovery, identity overrides and disabled", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"http://localhost:8080/api\"],\"queryUrls\":[\"https://updates.example/endpoints.json?v=1\",\"https://updates.example/endpoints.json?v=1\"],\"packageVersion\":\"2.0\",\"rn\":\"0.77.3\",\"rnu\":\"test-sdk\",\"disabled\":true}", + "expected": "{\"appKey\":\"a\",\"endpoints\":[\"http://localhost:8080/api\"],\"queryUrls\":[\"https://updates.example/endpoints.json?v=1\"],\"afterDownload\":\"none\",\"disabled\":true,\"rnu\":\"test-sdk\",\"rn\":\"0.77.3\",\"packageVersion\":\"2.0\"}" + }, + { + "name": "discovery URLs keep their trailing slash", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"https://e.example\"],\"queryUrls\":[\"https://q.example/list/\"]}", + "expected": "{\"appKey\":\"a\",\"endpoints\":[\"https://e.example\"],\"queryUrls\":[\"https://q.example/list/\"],\"afterDownload\":\"none\",\"disabled\":false,\"rnu\":\"\",\"rn\":\"\"}" + }, + { + "name": "IPv6 host with port", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"http://[::1]:8080\"]}", + "expected": "{\"appKey\":\"a\",\"endpoints\":[\"http://[::1]:8080\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"disabled\":false,\"rnu\":\"\",\"rn\":\"\"}" + }, + { + "name": "empty rnu and rn are allowed", + "options": "{\"appKey\":\"a\",\"rnu\":\"\",\"rn\":\"\"}", + "expected": "{\"appKey\":\"a\",\"endpoints\":[\"https://update.react-native.cn/api\",\"https://update.reactnative.cn/api\"],\"queryUrls\":[\"https://gitee.com/sunnylqm/react-native-pushy/raw/master/endpoints.json\",\"https://cdn.jsdelivr.net/gh/reactnativecn/react-native-update@master/endpoints.json\"],\"afterDownload\":\"none\",\"disabled\":false,\"rnu\":\"\",\"rn\":\"\"}" + }, + { + "name": "explicit defaults equal the omitted ones", + "options": "{\"appKey\":\"a\",\"afterDownload\":\"none\",\"disabled\":false}", + "expected": "{\"appKey\":\"a\",\"endpoints\":[\"https://update.react-native.cn/api\",\"https://update.reactnative.cn/api\"],\"queryUrls\":[\"https://gitee.com/sunnylqm/react-native-pushy/raw/master/endpoints.json\",\"https://cdn.jsdelivr.net/gh/reactnativecn/react-native-update@master/endpoints.json\"],\"afterDownload\":\"none\",\"disabled\":false,\"rnu\":\"\",\"rn\":\"\"}" + }, + { + "name": "not JSON", + "options": "{", + "error": "Invalid native configuration: expected an object" + }, + { + "name": "JSON array", + "options": "[]", + "error": "Invalid native configuration: expected an object" + }, + { + "name": "JSON null", + "options": "null", + "error": "Invalid native configuration: expected an object" + }, + { + "name": "unknown option", + "options": "{\"appKey\":\"a\",\"endponts\":[\"https://example.com\"]}", + "error": "Invalid native configuration: unknown option endponts" + }, + { + "name": "missing appKey", + "options": "{}", + "error": "Invalid native configuration: appKey must be a string and must not be blank" + }, + { + "name": "blank appKey", + "options": "{\"appKey\":\" \u3000\"}", + "error": "Invalid native configuration: appKey must be a string and must not be blank" + }, + { + "name": "numeric appKey", + "options": "{\"appKey\":12}", + "error": "Invalid native configuration: appKey must be a string and must not be blank" + }, + { + "name": "null appKey", + "options": "{\"appKey\":null}", + "error": "Invalid native configuration: appKey must be a string and must not be blank" + }, + { + "name": "empty endpoints", + "options": "{\"appKey\":\"a\",\"endpoints\":[]}", + "error": "Invalid native configuration: endpoints must be a non-empty array" + }, + { + "name": "null endpoints", + "options": "{\"appKey\":\"a\",\"endpoints\":null}", + "error": "Invalid native configuration: endpoints must be a non-empty array" + }, + { + "name": "string endpoints", + "options": "{\"appKey\":\"a\",\"endpoints\":\"https://example.com\"}", + "error": "Invalid native configuration: endpoints must be a non-empty array" + }, + { + "name": "unsafe scheme", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"file:///tmp/update\"]}", + "error": "Invalid native configuration: endpoints requires absolute HTTP(S) URLs without credentials, queries or fragments" + }, + { + "name": "upper-case scheme", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"HTTPS://example.com\"]}", + "error": "Invalid native configuration: endpoints requires absolute HTTP(S) URLs without credentials, queries or fragments" + }, + { + "name": "credentials", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"https://user:pass@example.com\"]}", + "error": "Invalid native configuration: endpoints requires absolute HTTP(S) URLs without credentials, queries or fragments" + }, + { + "name": "relative URL", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"/api\"]}", + "error": "Invalid native configuration: endpoints requires absolute HTTP(S) URLs without credentials, queries or fragments" + }, + { + "name": "missing host", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"https:///api\"]}", + "error": "Invalid native configuration: endpoints requires absolute HTTP(S) URLs without credentials, queries or fragments" + }, + { + "name": "empty port", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"https://example.com:/api\"]}", + "error": "Invalid native configuration: endpoints requires absolute HTTP(S) URLs without credentials, queries or fragments" + }, + { + "name": "inner whitespace", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"https://example.com/a b\"]}", + "error": "Invalid native configuration: endpoints requires absolute HTTP(S) URLs without credentials, queries or fragments" + }, + { + "name": "inner ideographic space", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"https://example.com/a\u3000b\"]}", + "error": "Invalid native configuration: endpoints requires absolute HTTP(S) URLs without credentials, queries or fragments" + }, + { + "name": "backslash", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"https://example.com\\\\@evil.example\"]}", + "error": "Invalid native configuration: endpoints requires absolute HTTP(S) URLs without credentials, queries or fragments" + }, + { + "name": "base query", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"https://example.com/api?x=1\"]}", + "error": "Invalid native configuration: endpoints requires absolute HTTP(S) URLs without credentials, queries or fragments" + }, + { + "name": "base fragment", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"https://example.com#x\"]}", + "error": "Invalid native configuration: endpoints requires absolute HTTP(S) URLs without credentials, queries or fragments" + }, + { + "name": "blank endpoint", + "options": "{\"appKey\":\"a\",\"endpoints\":[\" \"]}", + "error": "Invalid native configuration: endpoints must be a string and must not be blank" + }, + { + "name": "wrong discovery entry type", + "options": "{\"appKey\":\"a\",\"queryUrls\":[42]}", + "error": "Invalid native configuration: queryUrls must be a string and must not be blank" + }, + { + "name": "null discovery", + "options": "{\"appKey\":\"a\",\"queryUrls\":null}", + "error": "Invalid native configuration: queryUrls must be an array" + }, + { + "name": "discovery with credentials", + "options": "{\"appKey\":\"a\",\"queryUrls\":[\"https://u@example.com/x.json\"]}", + "error": "Invalid native configuration: queryUrls requires absolute HTTP(S) URLs without credentials" + }, + { + "name": "wrong activation", + "options": "{\"appKey\":\"a\",\"afterDownload\":\"immediate\"}", + "error": "Invalid native configuration: afterDownload must be none or setNeedUpdate" + }, + { + "name": "null activation", + "options": "{\"appKey\":\"a\",\"afterDownload\":null}", + "error": "Invalid native configuration: afterDownload must be none or setNeedUpdate" + }, + { + "name": "string disabled", + "options": "{\"appKey\":\"a\",\"disabled\":\"false\"}", + "error": "Invalid native configuration: disabled must be a boolean" + }, + { + "name": "numeric disabled", + "options": "{\"appKey\":\"a\",\"disabled\":1}", + "error": "Invalid native configuration: disabled must be a boolean" + }, + { + "name": "null disabled", + "options": "{\"appKey\":\"a\",\"disabled\":null}", + "error": "Invalid native configuration: disabled must be a boolean" + }, + { + "name": "numeric rn", + "options": "{\"appKey\":\"a\",\"rn\":77}", + "error": "Invalid native configuration: rn must be a string" + }, + { + "name": "null rnu", + "options": "{\"appKey\":\"a\",\"rnu\":null}", + "error": "Invalid native configuration: rnu must be a string" + }, + { + "name": "blank package version", + "options": "{\"appKey\":\"a\",\"packageVersion\":\"\"}", + "error": "Invalid native configuration: packageVersion must be a string and must not be blank" + }, + { + "name": "null package version", + "options": "{\"appKey\":\"a\",\"packageVersion\":null}", + "error": "Invalid native configuration: packageVersion must be a string and must not be blank" + } + ] +} diff --git a/cpp/update_flow_core/tests/native-round-vectors.json b/cpp/update_flow_core/tests/native-round-vectors.json new file mode 100644 index 00000000..b7d8c6af --- /dev/null +++ b/cpp/update_flow_core/tests/native-round-vectors.json @@ -0,0 +1,1765 @@ +{ + "description": "The native cold-start round (cpp/update_flow_core/native_round.h), shared by the Android, iOS and Harmony orchestrators and replayed by scripts/test-update-flow-core.sh. `prepare`: config text + env + sample -> PrepareRound ({body (compared as an object), query}; no `expected` = Undefined). `query`: the requests a NextQueryStep walk issues against the scripted `responses` (url -> text; a missing url is a failed request) and the response it settles on. `respond`: HandleRoundResponse. `before` records what each platform's own orchestrator did where they disagreed before this core replaced them; the expected value is the JS-equivalent or strictest of them. Hand-maintained.", + "prepare": [ + { + "name": "typical round", + "config": "{\"appKey\":\" key \",\"endpoints\":[\"https://a.example/\",\"HTTPS://b.example\"],\"queryUrls\":[\"https://l.example/list.json\"],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\",\"packageVersion\":\"2.0.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "expected": { + "body": { + "packageVersion": "2.0.0", + "hash": "cur", + "buildTime": "1700000000", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": true, + "endpoints": [ + "https://a.example/", + "HTTPS://b.example" + ], + "queryUrls": [ + "https://l.example/list.json" + ], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "the sample picks the first endpoint", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\",\"https://b.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0.6, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": "cur", + "buildTime": "1700000000", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": true, + "endpoints": [ + "https://b.example", + "https://a.example" + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "no packageVersion override uses the binary's", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": "cur", + "buildTime": "1700000000", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": true, + "endpoints": [ + "https://a.example" + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "an empty packageVersion uses the binary's", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\",\"packageVersion\":\"\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": "cur", + "buildTime": "1700000000", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": true, + "endpoints": [ + "https://a.example" + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "a non-string packageVersion uses the binary's", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\",\"packageVersion\":2}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "before": { + "android": "\"2\" (optString coerces)", + "ios": "\"1.0.0\" (the binary's)", + "harmony": "2 (the number, as sent)" + }, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": "cur", + "buildTime": "1700000000", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": true, + "endpoints": [ + "https://a.example" + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "no running version, Android form (null) is sent as null", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": null, + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": null, + "buildTime": "1700000000", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": true, + "endpoints": [ + "https://a.example" + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "no running version, iOS form (absent) is left out", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "expected": { + "body": { + "packageVersion": "1.0.0", + "buildTime": "1700000000", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": true, + "endpoints": [ + "https://a.example" + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "no running version, Harmony form (\"\") is sent as \"\"", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": "", + "buildTime": "1700000000", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": true, + "endpoints": [ + "https://a.example" + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "absent rnu/rn are sent as \"\"", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "before": { + "android": "\"\" and \"\"", + "ios": "both left out of cInfo", + "harmony": "\"\" and \"\"" + }, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": "cur", + "buildTime": "1700000000", + "cInfo": { + "rnu": "", + "rn": "", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": true, + "endpoints": [ + "https://a.example" + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "non-string rnu/rn are sent as \"\"", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":null,\"rn\":1}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "before": { + "android": "\"null\" and \"1\" (optString coerces)", + "ios": "null and 1", + "harmony": "\"\" and 1" + }, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": "cur", + "buildTime": "1700000000", + "cInfo": { + "rnu": "", + "rn": "", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": true, + "endpoints": [ + "https://a.example" + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "no uuid is sent as \"\"", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": "cur", + "buildTime": "1700000000", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": true, + "endpoints": [ + "https://a.example" + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "no bundleHash is left out", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34" + }, + "sample": 0, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": "cur", + "buildTime": "1700000000", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2 + }, + "query": { + "appKey": "key", + "httpsOnly": true, + "endpoints": [ + "https://a.example" + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "an empty bundleHash is left out", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "" + }, + "sample": 0, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": "cur", + "buildTime": "1700000000", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2 + }, + "query": { + "appKey": "key", + "httpsOnly": true, + "endpoints": [ + "https://a.example" + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "no buildTime is left out", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": "cur", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": true, + "endpoints": [ + "https://a.example" + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "one http endpoint turns https-only off", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\",\"http://b.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": "cur", + "buildTime": "1700000000", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": false, + "endpoints": [ + "https://a.example", + "http://b.example" + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "a non-string endpoint turns https-only off and is kept for the walk", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\",42],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": "cur", + "buildTime": "1700000000", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": false, + "endpoints": [ + "https://a.example", + 42 + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "non-array endpoints and queryUrls read as empty", + "config": "{\"appKey\":\"key\",\"endpoints\":\"https://a.example\",\"queryUrls\":\"https://l.example\",\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "before": { + "android": "no endpoints, no list discovery", + "ios": "no endpoints, no list discovery", + "harmony": "no request at all (the ordering binding rejected a non-array); a string queryUrls was walked per character" + }, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": "cur", + "buildTime": "1700000000", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": false, + "endpoints": [], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "a disabled config prepares nothing", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\",\"disabled\":true}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0 + }, + { + "name": "an invalid config prepares nothing", + "config": "{", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0 + }, + { + "name": "an env that is not an object prepares nothing", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": [], + "sample": 0 + } + ], + "query": [ + { + "name": "falls back through configured endpoints, deduped by normalized base", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a/\",\"https://a\",\"\",\"https://b\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "responses": { + "https://b/checkUpdate/key": "{\"upToDate\":true}" + }, + "expected": { + "requests": [ + "POST https://a/checkUpdate/key", + "POST https://b/checkUpdate/key" + ], + "result": "{\"upToDate\":true}" + } + }, + { + "name": "an invalid response does not stop the fallback", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a\",\"https://b\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "responses": { + "https://a/checkUpdate/key": "{\"error\":\"x\"}", + "https://b/checkUpdate/key": "{\"upToDate\":true}" + }, + "expected": { + "requests": [ + "POST https://a/checkUpdate/key", + "POST https://b/checkUpdate/key" + ], + "result": "{\"upToDate\":true}" + } + }, + { + "name": "the appKey is trimmed in the URL", + "config": "{\"appKey\":\" key\\t\",\"endpoints\":[\"https://a\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "responses": { + "https://a/checkUpdate/key": "{\"upToDate\":true}" + }, + "expected": { + "requests": [ + "POST https://a/checkUpdate/key" + ], + "result": "{\"upToDate\":true}" + } + }, + { + "name": "the sample decides which endpoint goes first", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a\",\"https://b\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0.9, + "responses": { + "https://a/checkUpdate/key": "{\"upToDate\":true}" + }, + "expected": { + "requests": [ + "POST https://b/checkUpdate/key", + "POST https://a/checkUpdate/key" + ], + "result": "{\"upToDate\":true}" + } + }, + { + "name": "discovers remote endpoints, skipping tried, non-string and cleartext ones", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a\"],\"queryUrls\":[\"\",\"https://list\"],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "responses": { + "https://list": "[\"https://a/\",42,\"http://plain\",\"https://c\"]", + "https://c/checkUpdate/key": "{\"upToDate\":true}" + }, + "expected": { + "requests": [ + "POST https://a/checkUpdate/key", + "GET https://list", + "POST https://c/checkUpdate/key" + ], + "result": "{\"upToDate\":true}" + }, + "before": { + "android": "42 was coerced to \"42\" and dropped as non-https", + "ios": "same requests", + "harmony": "same requests" + } + }, + { + "name": "cleartext remote endpoints are allowed without an https-only config", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"http://a\"],\"queryUrls\":[\"https://list\"],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "responses": { + "https://list": "[\"http://plain\"]", + "http://plain/checkUpdate/key": "{\"upToDate\":true}" + }, + "expected": { + "requests": [ + "POST http://a/checkUpdate/key", + "GET https://list", + "POST http://plain/checkUpdate/key" + ], + "result": "{\"upToDate\":true}" + } + }, + { + "name": "only the first parsable remote list is used", + "config": "{\"appKey\":\"key\",\"endpoints\":[],\"queryUrls\":[\"https://bad\",\"https://l1\",\"https://l2\"],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "responses": { + "https://bad": "{\"not\":\"array\"}", + "https://l1": "[\"https://x\"]", + "https://l2": "[\"https://y\"]" + }, + "expected": { + "requests": [ + "GET https://bad", + "GET https://l1", + "POST https://x/checkUpdate/key" + ], + "result": null + } + }, + { + "name": "a failed list fetch moves on to the next list", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a\"],\"queryUrls\":[\"https://down\",\"https://l1\"],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "responses": { + "https://l1": "[\"https://x\"]", + "https://x/checkUpdate/key": "{\"upToDate\":true}" + }, + "expected": { + "requests": [ + "POST https://a/checkUpdate/key", + "GET https://down", + "GET https://l1", + "POST https://x/checkUpdate/key" + ], + "result": "{\"upToDate\":true}" + } + }, + { + "name": "a remote list must be strict JSON", + "config": "{\"appKey\":\"key\",\"endpoints\":[],\"queryUrls\":[\"https://lenient\",\"https://strict\"],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "responses": { + "https://lenient": "['https://x']", + "https://strict": "[\"https://y\"]", + "https://y/checkUpdate/key": "{\"upToDate\":true}" + }, + "expected": { + "requests": [ + "GET https://lenient", + "GET https://strict", + "POST https://y/checkUpdate/key" + ], + "result": "{\"upToDate\":true}" + }, + "before": { + "android": "org.json accepted the single-quoted list and queried https://x", + "ios": "same requests", + "harmony": "same requests" + } + }, + { + "name": "stops after 8 HTTP requests, list fetches included", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://e0\",\"https://e1\",\"https://e2\",\"https://e3\",\"https://e4\",\"https://e5\"],\"queryUrls\":[\"https://list\"],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "responses": { + "https://list": "[\"https://r0\",\"https://r1\",\"https://r2\"]", + "https://r2/checkUpdate/key": "{\"upToDate\":true}" + }, + "expected": { + "requests": [ + "POST https://e0/checkUpdate/key", + "POST https://e1/checkUpdate/key", + "POST https://e2/checkUpdate/key", + "POST https://e3/checkUpdate/key", + "POST https://e4/checkUpdate/key", + "POST https://e5/checkUpdate/key", + "GET https://list", + "POST https://r0/checkUpdate/key" + ], + "result": null + } + }, + { + "name": "non-string configured endpoints are skipped without spending a request", + "config": "{\"appKey\":\"key\",\"endpoints\":[42,\"https://a\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "responses": { + "https://a/checkUpdate/key": "{\"upToDate\":true}" + }, + "expected": { + "requests": [ + "POST https://a/checkUpdate/key" + ], + "result": "{\"upToDate\":true}" + }, + "before": { + "android": "requested \"42/checkUpdate/key\" first (optString coerces)", + "ios": "same requests", + "harmony": "the round threw (normalizeEndpointBase on a number) and failed internal_error" + } + }, + { + "name": "non-string and empty queryUrls entries are skipped without spending a request", + "config": "{\"appKey\":\"key\",\"endpoints\":[],\"queryUrls\":[7,\"\",\"https://l\"],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "responses": { + "https://l": "[\"https://x\"]", + "https://x/checkUpdate/key": "{\"upToDate\":true}" + }, + "expected": { + "requests": [ + "GET https://l", + "POST https://x/checkUpdate/key" + ], + "result": "{\"upToDate\":true}" + }, + "before": { + "android": "fetched \"7\" first (optString coerces), spending a request", + "ios": "same requests", + "harmony": "spent a request on 7 (the fetch failed), then the same" + } + }, + { + "name": "non-array endpoints still try the list discovery", + "config": "{\"appKey\":\"key\",\"endpoints\":\"https://a\",\"queryUrls\":[\"https://l\"],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "responses": { + "https://l": "[\"https://x\"]", + "https://x/checkUpdate/key": "{\"upToDate\":true}" + }, + "expected": { + "requests": [ + "GET https://l", + "POST https://x/checkUpdate/key" + ], + "result": "{\"upToDate\":true}" + }, + "before": { + "android": "same requests", + "ios": "same requests", + "harmony": "no request at all" + } + }, + { + "name": "nothing reachable", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a\",\"https://b\"],\"queryUrls\":[\"https://l\"],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "responses": {}, + "expected": { + "requests": [ + "POST https://a/checkUpdate/key", + "POST https://b/checkUpdate/key", + "GET https://l" + ], + "result": null + } + }, + { + "name": "no endpoints and no lists issue no request", + "config": "{\"appKey\":\"key\",\"endpoints\":[],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "responses": {}, + "expected": { + "requests": [], + "result": null + } + } + ], + "respond": [ + { + "name": "up to date", + "response": "{\"upToDate\":true}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "expected": { + "action": "none", + "reason": "noUpdate", + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"upToDate\\\":true}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "a download drops cleartext artifact URLs under an https-only config", + "response": "{\"update\":true,\"hash\":\"h2\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"{\\\"k\\\":1}\",\"diff\":\"d.hdiff\",\"pdiff\":\"p.hdiff\",\"full\":\"f.ppk\",\"paths\":[\"cdn.x.com\",\"http://plain.x.com\"]}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "expected": { + "action": "download", + "hash": "h2", + "attempts": [ + { + "type": "diff", + "urls": [ + "https://cdn.x.com/d.hdiff" + ] + }, + { + "type": "pdiff", + "urls": [ + "https://cdn.x.com/p.hdiff" + ] + }, + { + "type": "full", + "urls": [ + "https://cdn.x.com/f.ppk" + ] + } + ], + "activate": false, + "hashInfo": { + "name": "v2", + "description": "notes", + "metaInfo": "{\"k\":1}" + }, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"{\\\\\\\"k\\\\\\\":1}\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\",\\\"http://plain.x.com\\\"]}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "cleartext artifact URLs are kept without an https-only config", + "response": "{\"update\":true,\"hash\":\"h2\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"{\\\"k\\\":1}\",\"diff\":\"d.hdiff\",\"pdiff\":\"p.hdiff\",\"full\":\"f.ppk\",\"paths\":[\"cdn.x.com\",\"http://plain.x.com\"]}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"http://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "expected": { + "action": "download", + "hash": "h2", + "attempts": [ + { + "type": "diff", + "urls": [ + "https://cdn.x.com/d.hdiff", + "http://plain.x.com/d.hdiff" + ] + }, + { + "type": "pdiff", + "urls": [ + "https://cdn.x.com/p.hdiff", + "http://plain.x.com/p.hdiff" + ] + }, + { + "type": "full", + "urls": [ + "https://cdn.x.com/f.ppk", + "http://plain.x.com/f.ppk" + ] + } + ], + "activate": false, + "hashInfo": { + "name": "v2", + "description": "notes", + "metaInfo": "{\"k\":1}" + }, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"{\\\\\\\"k\\\\\\\":1}\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\",\\\"http://plain.x.com\\\"]}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"http://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "no running version (null) drops the diff attempt", + "response": "{\"update\":true,\"hash\":\"h2\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"{\\\"k\\\":1}\",\"diff\":\"d.hdiff\",\"pdiff\":\"p.hdiff\",\"full\":\"f.ppk\",\"paths\":[\"cdn.x.com\",\"http://plain.x.com\"]}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": null, + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "expected": { + "action": "download", + "hash": "h2", + "attempts": [ + { + "type": "pdiff", + "urls": [ + "https://cdn.x.com/p.hdiff" + ] + }, + { + "type": "full", + "urls": [ + "https://cdn.x.com/f.ppk" + ] + } + ], + "activate": false, + "hashInfo": { + "name": "v2", + "description": "notes", + "metaInfo": "{\"k\":1}" + }, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"{\\\\\\\"k\\\\\\\":1}\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\",\\\"http://plain.x.com\\\"]}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "no running version (absent) drops the diff attempt", + "response": "{\"update\":true,\"hash\":\"h2\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"{\\\"k\\\":1}\",\"diff\":\"d.hdiff\",\"pdiff\":\"p.hdiff\",\"full\":\"f.ppk\",\"paths\":[\"cdn.x.com\",\"http://plain.x.com\"]}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "expected": { + "action": "download", + "hash": "h2", + "attempts": [ + { + "type": "pdiff", + "urls": [ + "https://cdn.x.com/p.hdiff" + ] + }, + { + "type": "full", + "urls": [ + "https://cdn.x.com/f.ppk" + ] + } + ], + "activate": false, + "hashInfo": { + "name": "v2", + "description": "notes", + "metaInfo": "{\"k\":1}" + }, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"{\\\\\\\"k\\\\\\\":1}\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\",\\\"http://plain.x.com\\\"]}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "no running version (\"\") drops the diff attempt", + "response": "{\"update\":true,\"hash\":\"h2\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"{\\\"k\\\":1}\",\"diff\":\"d.hdiff\",\"pdiff\":\"p.hdiff\",\"full\":\"f.ppk\",\"paths\":[\"cdn.x.com\",\"http://plain.x.com\"]}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "expected": { + "action": "download", + "hash": "h2", + "attempts": [ + { + "type": "pdiff", + "urls": [ + "https://cdn.x.com/p.hdiff" + ] + }, + { + "type": "full", + "urls": [ + "https://cdn.x.com/f.ppk" + ] + } + ], + "activate": false, + "hashInfo": { + "name": "v2", + "description": "notes", + "metaInfo": "{\"k\":1}" + }, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"{\\\\\\\"k\\\\\\\":1}\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\",\\\"http://plain.x.com\\\"]}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "only cleartext mirrors under https-only leave no attempt", + "response": "{\"update\":true,\"hash\":\"h2\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"{\\\"k\\\":1}\",\"diff\":\"d.hdiff\",\"pdiff\":\"p.hdiff\",\"full\":\"f.ppk\",\"paths\":[\"http://plain.x.com\"]}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "expected": { + "action": "download", + "hash": "h2", + "attempts": [], + "activate": false, + "hashInfo": { + "name": "v2", + "description": "notes", + "metaInfo": "{\"k\":1}" + }, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"{\\\\\\\"k\\\\\\\":1}\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"http://plain.x.com\\\"]}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "forceBoot activates and marks the rescue", + "response": "{\"update\":true,\"hash\":\"h2\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"{\\\"k\\\":1}\",\"diff\":\"d.hdiff\",\"pdiff\":\"p.hdiff\",\"full\":\"f.ppk\",\"paths\":[\"cdn.x.com\",\"http://plain.x.com\"],\"config\":{\"forceBoot\":true}}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "expected": { + "action": "download", + "hash": "h2", + "attempts": [ + { + "type": "diff", + "urls": [ + "https://cdn.x.com/d.hdiff" + ] + }, + { + "type": "pdiff", + "urls": [ + "https://cdn.x.com/p.hdiff" + ] + }, + { + "type": "full", + "urls": [ + "https://cdn.x.com/f.ppk" + ] + } + ], + "activate": true, + "hashInfo": { + "name": "v2", + "description": "notes", + "metaInfo": "{\"k\":1}", + "forceBootRescue": true + }, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"{\\\\\\\"k\\\\\\\":1}\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\",\\\"http://plain.x.com\\\"],\\\"config\\\":{\\\"forceBoot\\\":true}}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "a truthy forceBoot 1 marks the rescue it activates", + "response": "{\"update\":true,\"hash\":\"h2\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"{\\\"k\\\":1}\",\"full\":\"f.ppk\",\"paths\":[\"cdn.x.com\",\"http://plain.x.com\"],\"config\":{\"forceBoot\":1}}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "before": { + "android": "activated without the marker (optBoolean(1) is false)", + "ios": "marked", + "harmony": "marked" + }, + "expected": { + "action": "download", + "hash": "h2", + "attempts": [ + { + "type": "full", + "urls": [ + "https://cdn.x.com/f.ppk" + ] + } + ], + "activate": true, + "hashInfo": { + "name": "v2", + "description": "notes", + "metaInfo": "{\"k\":1}", + "forceBootRescue": true + }, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"{\\\\\\\"k\\\\\\\":1}\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\",\\\"http://plain.x.com\\\"],\\\"config\\\":{\\\"forceBoot\\\":1}}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "a truthy forceBoot \"false\" marks the rescue it activates", + "response": "{\"update\":true,\"hash\":\"h2\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"{\\\"k\\\":1}\",\"full\":\"f.ppk\",\"paths\":[\"cdn.x.com\",\"http://plain.x.com\"],\"config\":{\"forceBoot\":\"false\"}}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "before": { + "android": "activated without the marker (optBoolean(\"false\") is false)", + "ios": "marked", + "harmony": "marked" + }, + "expected": { + "action": "download", + "hash": "h2", + "attempts": [ + { + "type": "full", + "urls": [ + "https://cdn.x.com/f.ppk" + ] + } + ], + "activate": true, + "hashInfo": { + "name": "v2", + "description": "notes", + "metaInfo": "{\"k\":1}", + "forceBootRescue": true + }, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"{\\\\\\\"k\\\\\\\":1}\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\",\\\"http://plain.x.com\\\"],\\\"config\\\":{\\\"forceBoot\\\":\\\"false\\\"}}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "a falsy forceBoot neither activates nor marks", + "response": "{\"update\":true,\"hash\":\"h2\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"{\\\"k\\\":1}\",\"full\":\"f.ppk\",\"paths\":[\"cdn.x.com\",\"http://plain.x.com\"],\"config\":{\"forceBoot\":0}}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "expected": { + "action": "download", + "hash": "h2", + "attempts": [ + { + "type": "full", + "urls": [ + "https://cdn.x.com/f.ppk" + ] + } + ], + "activate": false, + "hashInfo": { + "name": "v2", + "description": "notes", + "metaInfo": "{\"k\":1}" + }, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"{\\\\\\\"k\\\\\\\":1}\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\",\\\"http://plain.x.com\\\"],\\\"config\\\":{\\\"forceBoot\\\":0}}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "the silent strategy activates without marking a rescue", + "response": "{\"update\":true,\"hash\":\"h2\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"{\\\"k\\\":1}\",\"full\":\"f.ppk\",\"paths\":[\"cdn.x.com\",\"http://plain.x.com\"]}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"setNeedUpdate\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "expected": { + "action": "download", + "hash": "h2", + "attempts": [ + { + "type": "full", + "urls": [ + "https://cdn.x.com/f.ppk" + ] + } + ], + "activate": true, + "hashInfo": { + "name": "v2", + "description": "notes", + "metaInfo": "{\"k\":1}" + }, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"{\\\\\\\"k\\\\\\\":1}\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\",\\\"http://plain.x.com\\\"]}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"setNeedUpdate\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "a non-string afterDownload never activates", + "response": "{\"update\":true,\"hash\":\"h2\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"{\\\"k\\\":1}\",\"full\":\"f.ppk\",\"paths\":[\"cdn.x.com\",\"http://plain.x.com\"]}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":1,\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "before": { + "android": "same decision", + "ios": "same decision", + "harmony": "the binding rejected the number: failed invalid_response, no cache" + }, + "expected": { + "action": "download", + "hash": "h2", + "attempts": [ + { + "type": "full", + "urls": [ + "https://cdn.x.com/f.ppk" + ] + } + ], + "activate": false, + "hashInfo": { + "name": "v2", + "description": "notes", + "metaInfo": "{\"k\":1}" + }, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"{\\\\\\\"k\\\\\\\":1}\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\",\\\"http://plain.x.com\\\"]}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":1,\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "only string name/description/metaInfo are kept", + "response": "{\"update\":true,\"hash\":\"h2\",\"name\":2,\"description\":null,\"metaInfo\":\"m\",\"full\":\"f.ppk\",\"paths\":[\"cdn.x.com\",\"http://plain.x.com\"]}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "expected": { + "action": "download", + "hash": "h2", + "attempts": [ + { + "type": "full", + "urls": [ + "https://cdn.x.com/f.ppk" + ] + } + ], + "activate": false, + "hashInfo": { + "metaInfo": "m" + }, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":2,\\\"description\\\":null,\\\"metaInfo\\\":\\\"m\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\",\\\"http://plain.x.com\\\"]}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "the launch's rolled-back version is never downloaded again", + "response": "{\"update\":true,\"hash\":\"bad\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"{\\\"k\\\":1}\",\"diff\":\"d.hdiff\",\"pdiff\":\"p.hdiff\",\"full\":\"f.ppk\",\"paths\":[\"cdn.x.com\",\"http://plain.x.com\"]}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh", + "rolledBackVersion": "bad" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "expected": { + "action": "none", + "reason": "rolledBack", + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"bad\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"{\\\\\\\"k\\\\\\\":1}\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\",\\\"http://plain.x.com\\\"]}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "the running version is already current", + "response": "{\"update\":true,\"hash\":\"cur\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"{\\\"k\\\":1}\",\"diff\":\"d.hdiff\",\"pdiff\":\"p.hdiff\",\"full\":\"f.ppk\",\"paths\":[\"cdn.x.com\",\"http://plain.x.com\"]}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "expected": { + "action": "none", + "reason": "noUpdate", + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"cur\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"{\\\\\\\"k\\\\\\\":1}\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\",\\\"http://plain.x.com\\\"]}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "the packageVersion override keys the gray release", + "response": "{\"update\":true,\"hash\":\"root\",\"full\":\"root.ppk\",\"paths\":[\"cdn.x.com\"],\"expVersion\":{\"name\":\"g\",\"hash\":\"gray\",\"full\":\"gray.ppk\",\"config\":{\"rollout\":{\"2.3.4\":63}}}}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\",\"packageVersion\":\"2.3.4\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "test1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "expected": { + "action": "download", + "hash": "gray", + "attempts": [ + { + "type": "full", + "urls": [ + "https://cdn.x.com/gray.ppk" + ] + } + ], + "activate": false, + "hashInfo": { + "name": "g" + }, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"root\\\",\\\"full\\\":\\\"root.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\"],\\\"expVersion\\\":{\\\"name\\\":\\\"g\\\",\\\"hash\\\":\\\"gray\\\",\\\"full\\\":\\\"gray.ppk\\\",\\\"config\\\":{\\\"rollout\\\":{\\\"2.3.4\\\":63}}}}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\",\\\"packageVersion\\\":\\\"2.3.4\\\"}\"}" + } + }, + { + "name": "the cache keeps every text verbatim", + "response": "{\"upToDate\":true,\"s\":\"a/b \\u00e9 \\\"q\\\"\"}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example/\"]}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"hash\":\"x/y\"}", + "ts": 1700000000, + "expected": { + "action": "none", + "reason": "noUpdate", + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"upToDate\\\":true,\\\"s\\\":\\\"a/b \\\\u00e9 \\\\\\\"q\\\\\\\"\\\"}\",\"request\":\"{\\\"hash\\\":\\\"x/y\\\"}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example/\\\"]}\"}" + } + }, + { + "name": "an invalid config handles nothing", + "response": "{\"upToDate\":true}", + "config": "{", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000 + }, + { + "name": "an env that is not an object handles nothing", + "response": "{\"upToDate\":true}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": "env", + "request": "{\"r\":1}", + "ts": 1700000000 + } + ] +} diff --git a/cpp/update_flow_core/tests/update_flow_core_test.cpp b/cpp/update_flow_core/tests/update_flow_core_test.cpp index 4644a1ce..f1481cae 100644 --- a/cpp/update_flow_core/tests/update_flow_core_test.cpp +++ b/cpp/update_flow_core/tests/update_flow_core_test.cpp @@ -12,6 +12,8 @@ #include #include "../flow_json.h" +#include "../native_config.h" +#include "../native_round.h" #include "../update_flow_core.h" using flowjson::Parse; @@ -173,6 +175,29 @@ int RunParserRobustness() { } } + // Same duplicate-key rule once an object is large enough to be parsed + // through the hash index: k0..k19, then k0 and k17 again. + { + std::string input = "{"; + std::string expected = "{"; + for (int i = 0; i < 20; i++) { + const std::string key = "\"k" + std::to_string(i) + "\":"; + input += key + std::to_string(i) + ","; + expected += key + + (i == 0 ? std::string("100") : i == 17 ? std::string("117") + : std::to_string(i)) + + (i == 19 ? "}" : ","); + } + input += "\"k0\":100,\"k17\":117}"; + bool ok = false; + std::string actual = Stringify(Parse(input, &ok)); + if (!ok || actual != expected) { + std::fprintf(stderr, "robustness: large duplicate-key object -> %s\n", + actual.c_str()); + failures++; + } + } + return failures; } @@ -298,6 +323,39 @@ int RunInputCaps() { } } } + { + // Caller-chosen caps (ParseLimits) replace both defaults, in either + // direction. + flowjson::ParseLimits limits; + limits.max_input_bytes = 8; + limits.max_nodes = 3; + bool ok = false; + Parse("[1,2]", limits, &ok); + if (!ok) { + std::fprintf(stderr, "caps: 3 nodes within an 8-byte cap must parse\n"); + failures++; + } + ok = true; + Parse("[1,2,3]", limits, &ok); + if (ok) { + std::fprintf(stderr, "caps: a 4th node over max_nodes must be rejected\n"); + failures++; + } + ok = true; + Parse("\"abcdefg\"", limits, &ok); + if (ok) { + std::fprintf(stderr, "caps: 9 bytes over max_input_bytes must be rejected\n"); + failures++; + } + limits.max_input_bytes = flowjson::kMaxInputBytes + 16; + limits.max_nodes = 1; + ok = false; + Parse("\"" + std::string(flowjson::kMaxInputBytes, 'a') + "\"", limits, &ok); + if (!ok) { + std::fprintf(stderr, "caps: a raised max_input_bytes must admit the document\n"); + failures++; + } + } { // Through the pipeline an oversized body is an invalid response. std::string body = "{\"update\":true,\"hash\":\"h\",\"pad\":\"" + @@ -418,25 +476,268 @@ int RunHandleCheckResponse() { return failures; } -} // namespace +// Contract C1: every case must hold in both argument orders (shared with +// the JS implementation, src/__tests__/configEquivalenceVectors.test.ts). +int RunConfigEquivalence(const Value& doc) { + int failures = 0; + const Value& cases = doc.Get("cases"); + if (!cases.IsArray() || cases.Size() < 30) { + std::fprintf(stderr, "config equivalence vectors: too few cases\n"); + return 1; + } + for (const Value& c : cases.elements()) { + const std::string& a = c.Get("a").AsString(); + const std::string& b = c.Get("b").AsString(); + const bool expected = c.Get("equivalent").AsBool(); + if (flowcore::ConfigsEquivalent(a, b) != expected || + flowcore::ConfigsEquivalent(b, a) != expected) { + std::fprintf(stderr, "config equivalence \"%s\" MISMATCH (expected %s)\n", + c.Get("name").AsString().c_str(), + expected ? "equivalent" : "different"); + failures++; + } + } + return failures; +} -int main(int argc, char* argv[]) { - if (argc < 2) { - std::fprintf(stderr, "usage: %s \n", argv[0]); - return 2; +int RunNativeConfig(const Value& doc) { + int failures = 0; + const Value& runnable = doc.Get("runnable"); + const Value& normalize = doc.Get("normalize"); + if (!runnable.IsArray() || runnable.Size() == 0 || !normalize.IsArray() || + normalize.Size() == 0) { + std::fprintf(stderr, "native config vectors: missing cases\n"); + return 1; + } + for (const Value& c : runnable.elements()) { + const std::string actual = + flowcore::RunnableConfigJson(c.Get("config").AsString()); + const std::string expected = Stringify(c.Get("expected")); + if (actual != expected) { + std::fprintf(stderr, + "runnable \"%s\" MISMATCH\n expected: %s\n actual: %s\n", + c.Get("name").AsString().c_str(), expected.c_str(), + actual.c_str()); + failures++; + } + } + for (const Value& c : normalize.elements()) { + std::string out; + std::string error; + const bool ok = flowcore::NormalizeConfiguration( + c.Get("options").AsString(), &out, &error); + const std::string actual = ok ? out : "error: " + error; + const Value& expectedValue = c.Get("expected"); + const std::string expected = expectedValue.IsString() + ? expectedValue.AsString() + : "error: " + c.Get("error").AsString(); + if (actual != expected) { + std::fprintf(stderr, + "normalize \"%s\" MISMATCH\n expected: %s\n actual: %s\n", + c.Get("name").AsString().c_str(), expected.c_str(), + actual.c_str()); + failures++; + continue; + } + // A normalized config is what the gate later reads: it must be + // runnable (or disabled) and equivalent to itself. + if (ok) { + const flowcore::RunnableConfig gate = flowcore::ParseRunnableConfig(out); + if (!(gate.runnable() || gate.reason == "disabled") || + !flowcore::ConfigsEquivalent(out, out)) { + std::fprintf(stderr, "normalize \"%s\": output not runnable/stable\n", + c.Get("name").AsString().c_str()); + failures++; + } + } + } + return failures; +} + +int RunTrimAndDeepEquals() { + int failures = 0; + const struct { + const char* input; + const char* expected; + } trims[] = { + {"", ""}, + {" ", ""}, + {"\t\n\v\f\r k \r\n", "k"}, + {"\xc2\xa0k\xe3\x80\x80", "k"}, // NBSP, U+3000 + {"\xef\xbb\xbfk\xe2\x80\xa8", "k"}, // BOM, U+2028 + {"\xe2\x80\x8ak\xe2\x80\xaf\xe2\x81\x9f", "k"}, // U+200A U+202F U+205F + {"\xe1\x9a\x80k", "k"}, // U+1680 + {"\xe2\x80\x8bk", "\xe2\x80\x8bk"}, // U+200B is not whitespace + {"\x01k\x1f", "\x01k\x1f"}, // C0 controls are kept + {"\xc2\x85k", "\xc2\x85k"}, // U+0085 (NEL) is kept + {"a b", "a b"}, + {"\xc2", "\xc2"}, // truncated sequence + }; + for (const auto& t : trims) { + if (flowcore::TrimJsWhitespace(t.input) != t.expected) { + std::fprintf(stderr, "TrimJsWhitespace(\"%s\") MISMATCH\n", t.input); + failures++; + } + } + const auto parse = [](const char* text) { + bool ok = false; + return Parse(text, &ok); + }; + const struct { + const char* a; + const char* b; + bool equal; + } pairs[] = { + {"{\"a\":1,\"b\":[1,{\"c\":null}]}", "{\"b\":[1,{\"c\":null}],\"a\":1}", true}, + {"{\"a\":1}", "{\"a\":1,\"b\":2}", false}, + {"{\"a\":1,\"b\":2}", "{\"a\":1}", false}, + {"[1,2]", "[2,1]", false}, + {"1", "1.0", true}, + {"0", "false", false}, + {"null", "null", true}, + {"\"1\"", "1", false}, + {"[]", "{}", false}, + }; + for (const auto& p : pairs) { + if (Value::DeepEquals(parse(p.a), parse(p.b)) != p.equal) { + std::fprintf(stderr, "DeepEquals(%s, %s) MISMATCH\n", p.a, p.b); + failures++; + } + } + // Undefined members count as absent. + Value withUndefined = Value::Object(); + withUndefined.Set("a", Value::Number(1)); + withUndefined.Set("b", Value::Undefined()); + if (!Value::DeepEquals(withUndefined, parse("{\"a\":1}")) || + !Value::DeepEquals(parse("{\"a\":1}"), withUndefined)) { + std::fprintf(stderr, "DeepEquals: undefined member is not absent\n"); + failures++; + } + return failures; +} + +// The native round (native_round.h): request preparation, the endpoint +// query walk (replayed against a scripted network) and the response +// handling, from tests/native-round-vectors.json. Cases whose `before` +// records per-platform outputs pin a deliberate alignment. +int RunNativeRound(const Value& doc) { + int failures = 0; + const Value& prepare = doc.Get("prepare"); + const Value& query = doc.Get("query"); + const Value& respond = doc.Get("respond"); + if (!prepare.IsArray() || prepare.Size() == 0 || !query.IsArray() || + query.Size() == 0 || !respond.IsArray() || respond.Size() == 0) { + std::fprintf(stderr, "native round vectors: missing cases\n"); + return 1; + } + const auto report = [&failures](const char* section, const Value& c, + const std::string& expected, + const std::string& actual) { + if (actual != expected) { + std::fprintf(stderr, + "%s \"%s\" MISMATCH\n expected: %s\n actual: %s\n", + section, c.Get("name").AsString().c_str(), expected.c_str(), + actual.c_str()); + failures++; + } + }; + for (const Value& c : prepare.elements()) { + const Value round = flowcore::PrepareRound( + c.Get("config").AsString(), c.Get("env"), c.Get("sample").AsNumber()); + Value actual = Value::Undefined(); + if (round.IsObject()) { + // The body is compared as an object so the vectors stay readable; it + // must also be exactly the canonical text. + bool ok = false; + Value body = Parse(round.Get("body").AsString(), &ok); + if (!ok || Stringify(body) != round.Get("body").AsString()) { + std::fprintf(stderr, "prepare \"%s\": body is not canonical JSON\n", + c.Get("name").AsString().c_str()); + failures++; + } + actual = Value::Object(); + actual.Set("body", std::move(body)); + actual.Set("query", round.Get("query")); + } + report("prepare", c, Stringify(c.Get("expected")), Stringify(actual)); + } + for (const Value& c : query.elements()) { + const Value round = flowcore::PrepareRound( + c.Get("config").AsString(), c.Get("env"), c.Get("sample").AsNumber()); + Value state = round.Get("query"); + Value requests = Value::Array(); + Value text = Value::Undefined(); + Value result = Value::Null(); + for (int step = 0; step < 64; step++) { + state = flowcore::NextQueryStep(state, text); + if (state.Get("found").Truthy()) { + result = text; + break; + } + const Value& url = state.Get("url"); + if (!url.IsString()) { + break; + } + requests.Push(Value::String( + (state.Get("post").Truthy() ? "POST " : "GET ") + url.AsString())); + const Value& scripted = c.Get("responses").Get(url.AsString()); + text = scripted.IsString() ? scripted : Value::Undefined(); + } + Value actual = Value::Object(); + actual.Set("requests", std::move(requests)); + actual.Set("result", std::move(result)); + report("query", c, Stringify(c.Get("expected")), Stringify(actual)); + } + for (const Value& c : respond.elements()) { + const Value actual = flowcore::HandleRoundResponse( + c.Get("response").AsString(), c.Get("config").AsString(), + c.Get("env"), c.Get("request").AsString(), c.Get("ts").AsNumber()); + report("respond", c, Stringify(c.Get("expected")), Stringify(actual)); + } + // Walking past the end, or a state that is not an object, stays inert. + if (!flowcore::NextQueryStep(Value::Array(), Value::Undefined()) + .IsUndefined()) { + std::fprintf(stderr, "NextQueryStep: a non-object state must be rejected\n"); + failures++; } - std::ifstream file(argv[1]); + return failures; +} + +bool LoadJson(const char* path, Value* doc) { + std::ifstream file(path); if (!file) { - std::fprintf(stderr, "cannot open %s\n", argv[1]); - return 2; + std::fprintf(stderr, "cannot open %s\n", path); + return false; } std::stringstream buffer; buffer << file.rdbuf(); - bool ok = false; - Value doc = Parse(buffer.str(), &ok); + *doc = Parse(buffer.str(), &ok); if (!ok) { - std::fprintf(stderr, "failed to parse %s\n", argv[1]); + std::fprintf(stderr, "failed to parse %s\n", path); + } + return ok; +} + +} // namespace + +int main(int argc, char* argv[]) { + if (argc < 5) { + std::fprintf(stderr, + "usage: %s " + " " + " " + "\n", + argv[0]); + return 2; + } + Value doc; + Value equivalenceDoc; + Value nativeConfigDoc; + Value nativeRoundDoc; + if (!LoadJson(argv[1], &doc) || !LoadJson(argv[2], &equivalenceDoc) || + !LoadJson(argv[3], &nativeConfigDoc) || + !LoadJson(argv[4], &nativeRoundDoc)) { return 2; } @@ -446,7 +747,10 @@ int main(int argc, char* argv[]) { return 2; } int failures = RunParserRobustness() + RunNumberConversion() + - RunInputCaps() + RunHandleCheckResponse(); + RunInputCaps() + RunHandleCheckResponse() + + RunConfigEquivalence(equivalenceDoc) + + RunNativeConfig(nativeConfigDoc) + + RunNativeRound(nativeRoundDoc) + RunTrimAndDeepEquals(); for (size_t i = 0; i < cases.Size(); i++) { const Value& testCase = cases.At(i); const std::string& fn = testCase.Get("fn").AsString(); @@ -470,11 +774,20 @@ int main(int argc, char* argv[]) { } if (failures) { - std::fprintf(stderr, "%d failures (%zu vectors + parser robustness)\n", + std::fprintf(stderr, + "%d failures (%zu flow vectors, config vectors + parser " + "robustness)\n", failures, cases.Size()); return 1; } - std::printf("all %zu flow vectors + parser robustness passed\n", - cases.Size()); + std::printf( + "all %zu flow vectors, %zu config equivalence vectors, %zu+%zu native " + "config vectors, %zu+%zu+%zu native round vectors + parser robustness " + "passed\n", + cases.Size(), equivalenceDoc.Get("cases").Size(), + nativeConfigDoc.Get("runnable").Size(), + nativeConfigDoc.Get("normalize").Size(), + nativeRoundDoc.Get("prepare").Size(), nativeRoundDoc.Get("query").Size(), + nativeRoundDoc.Get("respond").Size()); return 0; } diff --git a/cpp/update_flow_core/update_flow_core.h b/cpp/update_flow_core/update_flow_core.h index 55b5ac16..3f1a76dd 100644 --- a/cpp/update_flow_core/update_flow_core.h +++ b/cpp/update_flow_core/update_flow_core.h @@ -81,8 +81,8 @@ bool ShouldActivateAfterDownload(const flowjson::Value& info, const std::string& afterDownload); // Composes Parse → ResolveResult → DecideDownload: one call from the -// raw checkUpdate response text to a download decision, so the platform -// orchestrators contain no decision logic at all. identity is the union of +// raw checkUpdate response text to a download decision (the native round +// reaches it through HandleRoundResponse, native_round.h). identity is the union of // both composed functions' needs: { packageVersion, currentVersion?, uuid, // rolledBackVersion? }. afterDownload is the client's persisted activation // policy; a download decision carries `activate` (ShouldActivateAfterDownload diff --git a/cpp/update_flow_core/update_flow_jni.cpp b/cpp/update_flow_core/update_flow_jni.cpp deleted file mode 100644 index c7ab42dd..00000000 --- a/cpp/update_flow_core/update_flow_jni.cpp +++ /dev/null @@ -1,150 +0,0 @@ -// JNI surface of the update-flow decision layer for the Android orchestrator -// (SyncCoordinator.java). Pure string-in/string-out: every payload is -// JSON, matching the decision layer's own boundary. A null return means -// "input did not parse" and the caller skips the check round; a C++ exception -// (bad_alloc/length_error from an oversized body) maps onto that same path — -// it must never unwind through the JNI boundary. -#include - -#include "jni_natives.h" - -#include -#include -#include -#include -#include - -#include "../patch_core/jni_util.h" -#include "flow_json.h" -#include "update_flow_core.h" - -namespace { - -jstring ToJString(JNIEnv* env, const std::string& value) { - // flow_json emits standard UTF-8, while NewStringUTF expects JNI's modified - // UTF-8 and corrupts supplementary code points. Decode explicitly and pass - // UTF-16 code units to NewString. Accept encoded surrogate code points too: - // strings originating in Java may have entered through modified UTF-8. - std::vector utf16; - utf16.reserve(value.size()); - size_t i = 0; - while (i < value.size()) { - const unsigned char first = static_cast(value[i]); - uint32_t codePoint = 0xfffd; - size_t width = 1; - if (first < 0x80) { - codePoint = first; - } else if (first >= 0xc2 && first <= 0xdf && i + 1 < value.size()) { - const unsigned char b1 = static_cast(value[i + 1]); - if ((b1 & 0xc0) == 0x80) { - codePoint = ((first & 0x1f) << 6) | (b1 & 0x3f); - width = 2; - } - } else if (first == 0xc0 && i + 1 < value.size() && - static_cast(value[i + 1]) == 0x80) { - codePoint = 0; // modified UTF-8 encoding of U+0000 - width = 2; - } else if (first >= 0xe0 && first <= 0xef && i + 2 < value.size()) { - const unsigned char b1 = static_cast(value[i + 1]); - const unsigned char b2 = static_cast(value[i + 2]); - if ((b1 & 0xc0) == 0x80 && (b2 & 0xc0) == 0x80 && - !(first == 0xe0 && b1 < 0xa0)) { - codePoint = ((first & 0x0f) << 12) | ((b1 & 0x3f) << 6) | - (b2 & 0x3f); - width = 3; - } - } else if (first >= 0xf0 && first <= 0xf4 && i + 3 < value.size()) { - const unsigned char b1 = static_cast(value[i + 1]); - const unsigned char b2 = static_cast(value[i + 2]); - const unsigned char b3 = static_cast(value[i + 3]); - if ((b1 & 0xc0) == 0x80 && (b2 & 0xc0) == 0x80 && - (b3 & 0xc0) == 0x80 && !(first == 0xf0 && b1 < 0x90) && - !(first == 0xf4 && b1 > 0x8f)) { - codePoint = ((first & 0x07) << 18) | ((b1 & 0x3f) << 12) | - ((b2 & 0x3f) << 6) | (b3 & 0x3f); - width = 4; - } - } - i += width; - if (codePoint <= 0xffff) { - utf16.push_back(static_cast(codePoint)); - } else { - codePoint -= 0x10000; - utf16.push_back(static_cast(0xd800 + (codePoint >> 10))); - utf16.push_back(static_cast(0xdc00 + (codePoint & 0x3ff))); - } - } - if (utf16.size() > static_cast(std::numeric_limits::max())) { - return nullptr; - } - return env->NewString(utf16.empty() ? nullptr : utf16.data(), - static_cast(utf16.size())); -} - -} // namespace - -jstring pushy::jni_natives::FlowBuildRequestBody( - JNIEnv* env, jclass, jstring inputJson) { - try { - bool ok = false; - flowjson::Value input = flowjson::Parse( - pushy::jni_util::JStringToString(env, inputJson), &ok); - if (!ok || !input.IsObject()) { - return nullptr; - } - return ToJString( - env, flowjson::Stringify(flowcore::BuildRequestBody(input))); - } catch (...) { - return nullptr; - } -} - -jstring pushy::jni_natives::FlowOrderEndpointCandidates( - JNIEnv* env, jclass, jstring endpointsJson, jdouble randomSample) { - try { - bool ok = false; - flowjson::Value endpoints = flowjson::Parse( - pushy::jni_util::JStringToString(env, endpointsJson), &ok); - if (!ok || !endpoints.IsArray()) { - return nullptr; - } - return ToJString(env, flowjson::Stringify(flowcore::OrderEndpointCandidates( - endpoints, randomSample))); - } catch (...) { - return nullptr; - } -} - -jboolean pushy::jni_natives::FlowIsValidResponse( - JNIEnv* env, jclass, jstring responseText) { - if (responseText == nullptr) { - return JNI_FALSE; - } - try { - return flowcore::IsValidResponse( - pushy::jni_util::JStringToString(env, responseText)) - ? JNI_TRUE - : JNI_FALSE; - } catch (...) { - return JNI_FALSE; - } -} - -jstring pushy::jni_natives::FlowHandleResponse( - JNIEnv* env, jclass, jstring responseText, jstring identityJson, - jstring afterDownload) { - try { - bool ok = false; - flowjson::Value identity = flowjson::Parse( - pushy::jni_util::JStringToString(env, identityJson), &ok); - if (!ok || !identity.IsObject()) { - return nullptr; - } - return ToJString(env, flowjson::Stringify(flowcore::HandleResponse( - pushy::jni_util::JStringToString(env, responseText), - identity, false, - pushy::jni_util::JStringToString(env, afterDownload)))); - } catch (...) { - return nullptr; - } -} diff --git a/docs/BUNDLEHASH_MIGRATION.md b/docs/BUNDLEHASH_MIGRATION.md index 4250f377..ab7ad871 100644 --- a/docs/BUNDLEHASH_MIGRATION.md +++ b/docs/BUNDLEHASH_MIGRATION.md @@ -1,14 +1,17 @@ # bundleHash 迁移执行方案 -> 状态:**Phase 0/1/2 已实现、已提交;两台服务端已完成生产迁移与部署(2026-07-29)** -> - DB:pushy 北京 RDS 与 cresc Cloud SQL 均已加 `packages.bundleHash` 列(幂等 runner 在 `scripts/migrate-add-package-bundle-hash.ts`;cresc 经一次性 Cloud Run Job 进 VPC 执行) -> - 服务:pushy `p`/`s1` @ `2026.7.29-a2ecac26`,cresc api/worker/dailyjob @ `2026.7.29-3aabff91`,线上带 `bundleHash` 冒烟均通过 -> - **`bundleHashJudge.enabled` 仍为关**——待 CLI/客户端发版、覆盖率起来后再开 -> - **客户端与 CLI 已发版(2026-07-30)**:`react-native-update@10.49.0`、`react-native-update-cli@2.20.3`。全链路(客户端上报 → CLI 注册 → 服务端判定基建)就位,仅待覆盖率与开开关 -> - **`bundleHashJudge.enabled` 已于 2026-08-03 双端开启**(pushy + cresc,redis `config_dynamic`,回退=写回 false)。同日:CLI 2.21.2 补全 copiesCrc(§4.2.1 第 2 条)并随两服务端部署;客户端三端 CRC 校验 + fallback 遥测(§4.2.1 第 3/4 条)已实现、待随下个客户端版本发布 -> - Phase 2 遗留见 §4.5,Phase 3 待观察期后 -> 依据:`BUNDLEHASH_DESIGN.md`(设计定稿)。本文只讲**怎么落地**,不重复论证为什么。 -> 下游:`NATIVE_CHECKUPDATE_DESIGN.md` 的协议下沉必须等本迁移完成,否则协议下沉后再改协议要返工。 +> 状态:**Phase 0/1/2 已全部上线,Phase 3 未开始(§5)** +> - 客户端 `react-native-update@10.49.0`(bundleHash 上报,da48a685)、 +> `10.50.0`(三端 copiesCrc 校验 + `downloadFallback` 遥测,29da9b7a); +> CLI `react-native-update-cli@2.20.3`(注册 bundleHash)、`2.21.2`(全量 copiesCrc) +> - 服务端:pushy / cresc 两端 `packages.bundleHash` 列已迁移并部署(2026-07-29); +> `bundleHashJudge.enabled` 已于 2026-08-03 双端开启(redis `config_dynamic`,回退=写回 false) +> - 仍开放:§5.1 `SyncBinaryVersion` 迁移、§5.2 `digests` 下发与校验 +> +> 依据:原设计稿 `BUNDLEHASH_DESIGN.md`(未入库)。本文只讲**怎么落地**,并记录落地时 +> 推翻原设计的几处(§2.2 缓存键、§4.1 服务端缓存键、§4.2.1 `touchesPackageAssets`)。 +> Phase 0–2 的逐步实施记录(服务端改动明细、cresc 两态矩阵移植、e2e 踩坑)已收敛,见 +> `git show 4ca8972e:docs/BUNDLEHASH_MIGRATION.md`。 --- @@ -22,239 +25,108 @@ | | 客户端哈希的对象 | pdiff 的源读取(哈希必须与之逐字对齐) | |---|---|---| -| iOS | `[RCTPushy binaryBundleURL]` 返回的文件(`mainBundle` 的 `main.jsbundle`) | 就是 pdiff 的 `fromBundle`(`RCTPushy.mm:727`) | -| Android | **硬编码 `index.android.bundle`**,`context.getAssets().open()` 读 | `DownloadTask.copyBundledAssetToFile("index.android.bundle", …)`(`DownloadTask.java:322`)——pdiff 源本来就是硬编码的,**不走** `getBundleAssetName()` 反射;自定义 bundle 名的用户 pdiff 今天就打不了、CLI 也传不上(`getApkInfo` 的正则同样硬编码),三处一致地退化到 buildTime 启发式 | -| Harmony | rawfile `bundle.harmony.js` | pdiff 的 `getRawFileContent('bundle.harmony.js')`(`DownloadTask.ts:559`) | -| CLI | 解包后的同名条目 | `getIpaInfo` / `getApkInfo` / `getAppInfo` **已经把 bundleFile 拿在手里了** | - -最后一行是这次调研最大的好消息,见 §3。 +| iOS | `[RCTPushy binaryBundleURL]` 返回的文件(`mainBundle` 的 `main.jsbundle`) | 就是 pdiff 的 `fromBundle` | +| Android | **硬编码 `index.android.bundle`**,`context.getAssets().open()` 读 | `DownloadTask.copyBundledAssetToFile("index.android.bundle", …)`——pdiff 源本来就是硬编码的,**不走** `getBundleAssetName()` 反射;自定义 bundle 名的用户 pdiff 打不了、CLI 也传不上(`getApkInfo` 的正则同样硬编码),三处一致地退化到 buildTime 启发式 | +| Harmony | rawfile `bundle.harmony.js` | pdiff 的 `getRawFileContent('bundle.harmony.js')` | +| CLI | 解包后的同名条目 | `getIpaInfo` / `getApkInfo` / `getAppInfo` 已经把 bundleFile 拿在手里 | --- -## 1. 实施前定稿(原设计 §11 的四个开放问题) +## 1. 实施前定稿(原设计的四个开放问题) | # | 问题 | 定稿 | |---|---|---| -| 1 | 字段名 | `bundleHash` / `bundleStatus` / `digests`,维持原议 | -| 2 | `digests` 用 sha256 还是复用 OSS md5 | **sha256**。OSS 的 etag 在分片上传下不是内容 md5,不能直接复用;且 §0 的 sha256 工具本来就要进 C++,复用同一套心智 | -| 3 | 控制台聚合口径 | 按 **(app, packageVersion)**。因为对应的开发者动作就是"把这个包传上来",按 app 聚合无法定位到具体动作 | -| 4 | `assetsHash` | **不做**。metro 把受管资源的内容 md5 嵌进 bundle,资源变则 bundle 字节必变,需求趋近于零。等真实反馈 | +| 1 | 字段名 | `bundleHash` / `bundleStatus` / `digests` | +| 2 | `digests` 用 sha256 还是复用 OSS md5 | **sha256**(OSS etag 在分片上传下不是内容 md5) | +| 3 | 控制台聚合口径 | 按 **(app, packageVersion)** | +| 4 | `assetsHash` | **不做**(metro 把受管资源内容 md5 嵌进 bundle,资源变则 bundle 字节必变) | --- -## 2. Phase 0 — 客户端(react-native-update) - -### 2.1 sha256 工具进 `cpp/patch_core`——但 Android 用 `MessageDigest` - -`cpp/patch_core/digest.{h,cpp}`,流式接口 + `Sha256File`,同时为 §8 的下载校验铺路: - -```cpp -namespace pushy::digest { - class Sha256 { void Update(const uint8_t*, size_t); std::string HexDigest(); }; - std::string Sha256File(const std::string& path); // iOS 用 -} -``` - -各端消费方式(审查后修订,原稿要求三端全走 C++): - -| 平台 | 实现 | 理由 | -|---|---|---| -| iOS | `pushy::digest`(podspec 已从源码编译 patch_core,加一个文件即可) | 免费共享 | -| Harmony | NAPI 包装 `pushy::digest`(CMakeLists 同样从源码编译) | 免费共享 | -| **Android** | **`java.security.MessageDigest("SHA-256")`** | `librnupdate.so` 是预编译产物,为这一个函数重编 4 ABI 不值;SHA-256 是标准算法,C++ 侧的 NIST 测试向量同时锚定两个实现 | - -**收益:Phase 0 完全不需要重编 `.so`**,原方案的 R4(ABI/对齐回归)风险在本阶段消失。digest.cpp 照常加进 `.so` 的构建脚本,下次因其他原因重编时自动带上,Phase 3 的 C++ 侧校验直接可用。 - -### 2.2 缓存键:设计文档这里是错的 - -原设计写"缓存键 `(packageVersion, bundle 文件 size, mtime)`"。**Android 和 Harmony 的 bundle 不是磁盘文件**——一个在 APK 的 assets 里,一个在 rawfile 里,都没有独立的 size/mtime 可读。 - -改为按平台取各自的"安装身份": - -| 平台 | 缓存键 | 读取方式 | -|---|---|---| -| iOS | `(packageVersion, 文件 size, mtime)` | `main.jsbundle` 是真实文件,原设计可用 | -| Android | `(packageVersion, PackageInfo.lastUpdateTime)` | 每次安装/覆盖安装都会变,含同版本重装 | -| Harmony | `(packageVersion, BundleInfo.updateTime)` | 已核对 `BundleInfo.d.ts:390`,字段存在 | - -键存 SharedPreferences / NSUserDefaults / preferences,与现有状态同一存储。 - -### 2.3 读取与计算 - -- **Android**:`context.getAssets().open("index.android.bundle")` 流式喂给 `MessageDigest`——**与 pdiff 源读取(`copyBundledAssetToFile`)完全同一 API、同一硬编码名**(§0)。资源不存在(debug / 自定义名 / 无嵌入 bundle)→ 返回空串。 -- **iOS**:`Sha256File([[RCTPushy binaryBundleURL] path])`;文件不存在 → 空串。 -- **Harmony**:`resourceManager.getRawFileContent('bundle.harmony.js')` 拿到 buffer 后经 NAPI 喂给 sha256(与 pdiff 读法完全一致)。 - -线程:Android `StateSerialRunner`,iOS `_fileQueue`,Harmony taskpool。几 MB 约 10-50ms。 - -### 2.4 暴露给 JS - -**异步 getter,不进 `getConstants`**(AN-6/IO-4 的教训): - -```ts -// NativePushy.ts -getBundleHash(): Promise; // 计算失败 / debug 构建返回空串 -``` - -### 2.5 JS 接入:预取 + 同步读,checkUpdate 零 await - -初稿在 checkUpdate 里做 200ms 超时 race,评审否决——没必要的复杂度。定稿: - -```ts -// core.ts 模块加载时 fire-and-forget 预取,settle 后存模块变量 -if (typeof PushyModule.getBundleHash === 'function') { - Promise.resolve(PushyModule.getBundleHash()).then((h) => { bundleHash = String(h || ''); }); -} -export const getBundleHash = (): string => bundleHash; - -// client.ts checkUpdate 内:同步读,没有就省略 -const bundleHash = __DEV__ ? '' : getBundleHash(); -``` - -拿不到就省略字段,服务端自动退回 buildTime 启发式(判定矩阵第 4 行),下一次检查自然带上。代价是每个进程的**第一次** checkUpdate 可能缺字段(原生缓存命中时预取通常几 ms 内 settle,实际很少缺)——一次退化完全可接受。 - -**顺带消掉一个曾踩过的坑**:初稿的 race `await` 插进了"5s 去重检查 → `lastRespJson` 赋值"的同步窗口,导致并发 checkUpdate 双发请求(JS2-1 测试抓住)。同步读方案下 checkUpdate 完全没有新增 await,这类问题在结构上不可能出现。 - -### 2.6 `bundleStatus` 消费(字段先行) - -服务端上线前不会有这个字段,先把消费侧写好: -- `unknownBundle` → `warn()`(新 i18n key `warn_unknown_bundle`)+ `report({ type: 'bundleMismatch' })` 进 logger -- `afterCheckUpdate` 的 `UpdateCheckState` 透传 `bundleStatus`(result 自带字段,无需额外代码) - -**修订原设计 §7 触达第 1 条**:\"`__DEV__` 下 console.warn 联调期拦截\"实际不可达——dev 构建不上报 bundleHash(metro bundle 无嵌入产物),服务端按判定矩阵第 4 行走 buildTime 启发式,永远不会返回 `unknownBundle`。warn 实际只出现在 release 日志里;开发者触达的主渠道是控制台聚合(触达第 3 条)与 CLI 前置拦截(第 4 条),不受影响。 +## 2. Phase 0 — 客户端(已发布,10.49.0) + +- **sha256**:`cpp/patch_core/digest.{h,cpp}`(流式 + `Sha256File`,NIST 向量测试锚定); + iOS 直接用、Harmony 经 NAPI;**Android 用 `MessageDigest("SHA-256")`**,免得为一个 + 函数重编 `.so`。 +- **缓存键(推翻原设计)**:Android/Harmony 的 bundle 不是磁盘文件,没有 size/mtime, + 改为各端"安装身份":iOS `(packageVersion, size, mtime)`、Android + `(packageVersion, PackageInfo.lastUpdateTime)`、Harmony `(packageVersion, BundleInfo.updateTime)`。 +- **读取**:与 pdiff 源读取同一 API、同一硬编码名(§0);找不到 → 空串。线程:Android + `StateSerialRunner`、iOS `_fileQueue`、Harmony taskpool。 +- **JS 接入**:异步 `getBundleHash()`(不进 `getConstants`),模块加载时预取、 + checkUpdate 同步读,没有就省略字段(服务端退回 buildTime 启发式)。checkUpdate 不新增 + await——初稿的 200ms race 曾在"5s 去重 → `lastRespJson` 赋值"窗口里造成并发双发。 +- **`bundleStatus`**:`unknownBundle` → `warn()` + `report({ type: 'bundleMismatch' })`。 + dev 构建不上报 bundleHash,所以不会在 `__DEV__` 下出现。 ### 2.7 验收 -- ✅ C++ sha256 NIST 向量测试(`bun test:patch-core`,26/26) -- ✅ `bun test`(122,含 5 条新 bundleHash/bundleStatus 用例)/ `bun lint`(biome + tsc + harmony tsc) -- ✅ `javac` 对 RN 0.85.2 AAR(main + oldarch)/ `clang -fsyntax-only` 对 e2etest Pods -- ✅ **iOS + Android 模拟器 e2e**(`Example/e2etest/e2e/bundle-hash.test.ts`,已入 detox 套件、随 e2e_ios / e2e_android CI 跑):release 构建、真机路径全链路,原生算出的值与宿主侧对包内 bundle 算的 sha256 **逐字节一致**—— - - iOS:JS 预取 → `_fileQueue` 懒计算 → C++ digest → NSUserDefaults;宿主 `simctl get_app_container` + `plutil` 直读容器 plist 比对 `sha256(main.jsbundle)`;含缓存命中路径 - - Android:`AssetManager.open` 解压字节 == APK zip 条目字节(bundle 压缩存储,这是 iOS 覆盖不到的独立风险点)、`MessageDigest` == 标准 sha256;宿主 `adb root` 读 `shared_prefs/update.xml` 比对 `unzip -p` 提取值;gradle 构建顺带完成 newarch codegen 对 `getBundleHash` 的真实编译验证 -- ⬜ Harmony 真机比对未做:NAPI 直连已过 NIST 向量的 C++ digest,读法与 pdiff 完全同源(`getRawFileContent`),风险最低 -- **e2e 踩坑记录**:`simctl spawn defaults read` 看不到 app 容器 defaults(必须 `get_app_container` + `plutil -extract`);detox jest 环境把全局 `expect` 换成元素断言版(普通值断言用显式比较);staged 目录为空壳时 gradle autolinking 缓存会记住"无 android 平台",重 stage 后须清 `android/build/generated/autolinking`;Android release 下 detox `launchApp` 的会话握手在本机 arm64 模拟器挂起——本用例不需要 UI 交互,Android 分支用 `adb shell monkey` 拉起绕开 +- C++ sha256 NIST 向量(`bun test:patch-core`)、JS 单测、lint 全过。 +- **iOS + Android 模拟器 e2e**:`Example/e2etest/e2e/bundle-hash.test.ts`(随 e2e_ios / + e2e_android CI 跑),release 构建下原生算出的值与宿主侧对包内 bundle 算的 sha256 + **逐字节一致**(iOS 直读容器 plist;Android 比对 `unzip -p` 提取值,顺带覆盖 bundle + 压缩存储这一独立风险点)。 +- ⬜ Harmony 真机比对未做:NAPI 直连已过 NIST 向量的 C++ digest,读法与 pdiff 同源,风险最低。 --- -## 3. Phase 1 — CLI(react-native-update-cli) - -**成本远低于设计文档的预期。** `getIpaInfo` / `getApkInfo` / `getAppInfo`(`src/utils/index.ts:145/187/230`)为了校验"包里有没有 bundle",**已经把 bundle 的 Buffer 读出来了**,而且缺失时已经报错。 - -所以每个函数加一行: - -```ts -return { versionName, buildTime, bundleHash: sha256(bundleFile), ...appCredential }; -``` - -再在 `uploadNativePackage`(`src/package.ts:170`)的 `package/create` 请求体里带上 `bundleHash`。 +## 3. Phase 1 — CLI(已发布,2.20.3) -另外两件事: -- AAB **不需要单独处理**:`uploadAab` 是先 `AabParser.extractApk` 转 APK 再委托 `uploadApk`(`package.ts:258`),自动经过 `getApkInfo` -- 上传时若同 `versionName` 已存在**不同** `bundleHash` 的包记录 → 提示"重打包且 JS 有变"(设计 §7 第 4 条)。这需要服务端先提供查询,**可以延后到 Phase 2 之后**,不阻塞 - -老服务端会忽略未知字段——已验证:`pushy-server` 的 `dto.ts:280` 注释明确"未知字段由 Elysia 默认 normalize 剥离,保留前向兼容"。Phase 1 可以独立发布。 +`getIpaInfo` / `getApkInfo` / `getAppInfo` 已读出 bundle Buffer,各加一行 +`bundleHash: sha256(bundleFile)`,随 `package/create` 上报;AAB 先转 APK 自动覆盖。 +老服务端剥离未知字段,可独立发布。 --- -## 4. Phase 2 — 服务端(pushy-server) - -### 4.1 最容易漏、漏了最严重的一条 - -**`buildCheckUpdateCacheKey` 必须加 `bundleHash`。**(`code/src/utils/checkUpdateCapabilities.ts:51`) - -现在的键是 `[appId, checkUpdateRevision, packageVersion, fromHash, buildTime, cacheBucket, toHash]`。一旦响应内容开始随 `bundleHash` 变化(给不给 pdiff、`bundleStatus` 是什么),不进键就是**跨设备串响应**:一个装了未注册二进制的设备可能拿到另一个设备的"给 pdiff"缓存,直接踩中 §0 那个最坏结果。 - -基数不会明显上升——`bundleHash` 与 `buildTime` 基本一一对应,Phase 3 删掉 buildTime 后反而回落。 - -### 4.2 数据与判定 - -- `packages` 表加 `bundleHash VARCHAR(64)`(`prisma/schema.prisma:60`),`getCachedCheckUpdatePackage` 的 select 加该字段 -- 判定矩阵(设计 §6)落在 `handleCheckUpdate.ts` 的 `checkAndGenerateTaskAndCdnUrl` 之前:决定"这次允不允许下发 pdiff" -- `digests` 下发 + `__diff.json` 的 copies 附摘要 - -### 4.2.1 不实现 `touchesPackageAssets`(推翻原设计 §6) - -原设计要求 pdiff 生成时预计算 `touchesPackageAssets`(manifest 是否含"从包内拷非 bundle 文件"),用于门控"bundleHash 命中但 buildTime 不符"这一行。**该标记不应实现**: - -查 CLI 的 pdiff 生成(`react-native-update-cli/src/diff.ts:695-710`): - -```js -// If same file. -if (originEntries[entry.fileName] === entry.crc32) { - copies[entry.fileName] = ''; // 同路径拷贝 - return; -} -const movedFrom = originMap[entry.crc32]; -if (movedFrom) { - copies[entry.fileName] = movedFrom; // 按内容定位拷贝 - return; -} -await addFileFromZipEntry(...); // 只有新文件才打进 patch 包 -``` - -**ppk 里每一个与二进制包 CRC32 相同的非 bundle 文件都会进 `copies`**。任何带图片/字体的真实应用,该标记恒为 true → "重打包但 JS 未变"这一行永远拿不到 pdiff。 - -而这一行正是设计 §1 点名的最常见场景、整个方案的头号收益来源。**这个机制会把它自己要解决的主场景关掉。** - -它防的场景设计文档自己也判定为 ≈ 不存在:metro 把受管资源的内容 hash 嵌进 bundle,资源变则 bundle 字节必变、bundleHash 不会命中,根本落不到这一行。 - -**代之以运行时保证**(设计 §8 的 apply 校验,已建好一半): - -1. 该行直接下发 pdiff -2. `copiesCrc`(`diff.ts:645`,目前只记 "moved" 条目,为 APK→AAB 路径变化而设)扩展到**全部** copies 条目 -3. 客户端拷贝时校验,不符 → fallback full。代价退化为一次 pdiff 下载,**永不装出坏包** -4. 给该 fallback 加遥测 - -**已全部落地(2026-08-03)**: -- 第 2 条:CLI 2.21.2(同路径分支补记 CRC;两服务端已 bump 部署,worker 新生成的 pdiff 均带全量 copiesCrc) -- 第 3 条(三端):Android `BundledResourceCopier` 路径命中也比 CRC,不符 → 内容定位 → 仍无 → 抛错;iOS/共享 C++ `CopyOperation.expected_crc` 在 `ApplyPatchFromFileSource` 拷贝前校验(`digest::Crc32File`,NIST/check-value 测试锚定);Harmony pdiff 拷贝声明 CRC 的条目改走字节路径经 NAPI `crc32` 校验。三端不符均使整次 pdiff 失败,JS 策略链自动落 full -- 第 4 条:新增本地事件 `downloadFallback`(增量策略失败但后续策略救回),失败码为 PATCH_FAILED 时映射服务端 `patch_fail`(网络噪音不上报)。为此补齐了 Android(`PatchFailedException`:下载完成后的失败 reject 为 PATCH_FAILED,此前一律 DOWNLOAD_FAILED)与 Harmony(patch 阶段 `error.code='PATCH_FAILED'`)的稳定码 -- 注意:存量已生成的 pdiff 只有 moved 条目的 CRC,客户端只校验有声明的条目——旧 pdiff 行为不变,随版本滚动自然收敛 +## 4. Phase 2 — 服务端(已上线,判定开关已开) -若遥测显示这一行的 pdiff 失败率显著,再引入**精确**判据(如"copies 含非 metro 资源"),而不是用恒为 true 的粗标记一刀切。 +### 4.1 缓存键必须含 bundleHash -### 4.3 开关与灰度 +响应内容随 `bundleHash` 变化(给不给 pdiff、`bundleStatus`),不进 +`buildCheckUpdateCacheKey` 就是**跨设备串响应**,直接踩中 §0 的最坏结果。已进键 +(`cu:v3→v4`,`cu:manifest:v1→v2`)。 -判定逻辑挂 `dynamicConfig` 开关,默认关。开启后异常可一键退回 buildTime 启发式——**这是 Phase 2 唯一的回退手段,必须先有开关再有逻辑**。 +### 4.2 判定 -### 4.4 实现状态与两处实施决策 +`packages.bundleHash` + 判定矩阵(`handleCheckUpdate`,`dynamicConfig.bundleHashJudge.enabled` +门控)。`unknownBundle` 不再 block:扣 pdiff、照发 full。cresc 无 buildTime 启发式, +矩阵塌缩为 `matched` / `unknownBundle` 两态,对 cresc 是第一道真正的 pdiff 门控。 -已落地(`pushy-server` 工作区,未提交,`bun test` 841 全绿): +### 4.2.1 不实现 `touchesPackageAssets`(推翻原设计) -- `packages.bundleHash VARCHAR(64) NULL`(schema + `migrations/add_package_bundle_hash.sql`);`package/create` 接收并存储(DTO 带 `^[0-9a-f]{64}$` 校验) -- `checkUpdateBodyJson` 接收 `bundleHash`;`buildCheckUpdateCacheKey` 进键并 bump `cu:v3→v4`;`cu:manifest` 也 bump `v1→v2`(select 加了字段,避免旧缓存形状被误判为"老 CLI 上传") -- 判定矩阵在 `handleCheckUpdate`,`dynamicConfig.bundleHashJudge.enabled` 门控(默认关);`unknownBundle` 的 pdiff 扣发复用 artifact 状态机的 `'empty'` 语义(天然做到"不下发、不入队") -- 测试:矩阵五行 + 开关关死回退 + 缓存键分裂,7 条新用例 +原设计要求 pdiff 生成时预计算 `touchesPackageAssets`(manifest 是否含"从包内拷非 bundle +文件"),用于门控"bundleHash 命中但 buildTime 不符"这一行。**不实现**:CLI 的 pdiff 生成把 +每一个与二进制包 CRC32 相同的非 bundle 文件都放进 `copies`,任何带图片/字体的应用该标记 +恒为 true,"重打包但 JS 未变"——方案头号收益场景——永远拿不到 pdiff。它防的场景(资源变 +而 bundle 不变)因 metro 把资源 hash 嵌进 bundle 而≈不存在。 -实施决策两处(文档此前未明确): +**代之以运行时保证(已全部落地)**: -1. **`unknownBundle` 不再 block**。今天 buildTime 不符是 6 小时 block(`ApiErrorWithOp`);矩阵命中后同样场景变为"扣 pdiff、照发 full",终端用户无感知。这是行为放宽,也是设计本意(§7"所有分支自动降级到正确路径")。 -2. **`ignoreBuildTime` 的 app 在开关开启后变严**:此前它无条件发 pdiff,现在内容不符会扣发。正确——内容不符的 pdiff 下载后 hpatch 源校验必败,本来就是白费流量。 +1. 该行直接下发 pdiff; +2. `copiesCrc` 扩展到**全部** copies 条目(CLI 2.21.2); +3. 客户端拷贝前校验,不符 → 整次 pdiff 失败、JS 策略链落 full,**永不装出坏包**: + Android `BundledResourceCopier`、iOS/共享 C++ `CopyOperation.expected_crc` + (`ApplyPatchFromFileSource`,`digest::Crc32File`)、Harmony 经 NAPI `crc32`; +4. 遥测:本地事件 `downloadFallback`(增量失败但后续策略救回),失败码为 PATCH_FAILED 时 + 映射服务端 `patch_fail`(为此 Android `PatchFailedException`、Harmony patch 阶段补齐了 + 稳定码)。 -### 4.4.1 cresc-server 同步(两态矩阵版) +存量 pdiff 只有 moved 条目的 CRC,客户端只校验有声明的条目,随版本滚动自然收敛。若遥测 +显示这一行的 pdiff 失败率显著,再引入**精确**判据,而不是恒为 true 的粗标记。 -cresc-server 是独立仓库(结构同构、历史独立,`handleCheckUpdate` 已漂移约 700 行),Phase 2 已手工移植(`bun test` 760 全绿)。关键差异: +### 4.3 开关与回退 -- **cresc 没有 buildTime 启发式**(连 `ignoreBuildTime` 都已删除,见其 `drop_apps_ignore_build_time.sql`),今天 pdiff 是**无条件信任**的——正是设计 §1 "过松"失效模式的现役实例:不匹配的二进制会白下载 pdiff、hpatch 源校验失败再回落 full。bundleHash 判定对 cresc 是**第一道真正的门控** -- 矩阵塌缩为**两态**:`matched` / `unknownBundle`(rebuiltSameJs 的定义依赖 buildTime 分歧,无 buildTime 则与 matched 不可区分,也无需区分——客户端对二者都静默) -- 回退语义不同:开关关闭 = 回到"无条件信任 pdiff"(pushy 端是回到 buildTime 启发式) -- 其余同构:`packages.bundleHash` 列 + migration、DTO、`cu:v3→v4`、`cu:manifest:v1→v2`、`'empty'` 语义扣发、`dynamicConfig.bundleHashJudge.enabled` 默认关 +判定逻辑挂 `dynamicConfig` 开关——Phase 2 唯一的回退手段。关闭 = pushy 回到 buildTime +启发式、cresc 回到无条件信任 pdiff。 -注:cresc-server 干净树上有两个**存量** TS 错误(`src/routers/user.ts:916/957`,email 变更通知的 audit extra 字段),与本次改动无关,未处理。 +### 4.5 Phase 2 遗留 -### 4.5 Phase 2 遗留(不阻塞开启开关) +- ~~控制台 unknownBundle 聚合~~ 已实现:请求侧合并进原生包面板(报了 bundleHash 记 + `packageVersion_bundleHash`,否则 `packageVersion_buildTime`)。 +- ~~CLI 上传冲突提示~~ 已实现(服务端 409 分支用 bundleHash 区分"内容一致/JS 有变")。 +- `digests` 下发 + `__diff.json` copies 附摘要——与客户端 §5.2 同波次做。 -- ~~控制台 unknownBundle 聚合面板~~ **已实现(2026-08-01,四端部署)**:最终形态不是独立的 bundleStatus"内容判定"维度(首版上线后即废弃——它依赖开关开启+双侧 hash+恰好有更新流动,观测窗口太窄、零数据),而是**请求侧合并进原生包面板**:客户端报了 bundleHash 就记 `packageVersion_bundleHash`(admin 图例把 64 位 hex 截 8 位展示),否则回退 `packageVersion_buildTime`,同一 attribute。全量流量可见、与开关/判定结果解耦;未注册人群在面板上直接带 packageVersion 上下文。代价是丢失 matched/rebuiltSameJs 区分——那只有教育用途,unknownBundle 占比(§4.6 的推进指标)不受影响。曾考虑挂在 `classifyCheckUpdateResult` 的 hitType 上,否决:hitType 是"下发了什么产物"维度,混入二进制身份维度不干净 -- `digests` 下发 + `__diff.json` copies 附摘要——与客户端 Phase 3 的消费侧同波次做 -- ~~CLI 上传冲突提示~~ **已实现(改判为无需动 CLI)**:`(appId, name)` 唯一约束下重复上传本来就 409,在两台服务器的冲突分支里用 bundleHash 增强语义——内容一致 →"无需重复上传"、内容不同 →"重打包且 JS 有变,请提升原生版本号"、任一侧无 hash → 通用文案不变。查询仅发生在冲突路径。cresc 3 条路由级用例覆盖;pushy 无路由级测试脚手架,靠同构代码 + cresc 用例 + tsc 兜底 - -另:cresc-server 顺手修了一个与本迁移无关的存量 bug——`/email/confirm` 的 `select` 漏了 `extra`/`name`(`user.ts:888`),导致改邮箱时 OAuth 凭据吊销实际不生效(安全相关)+ 通知邮件用户名恒为兜底文案。 - -### 4.6 推进门槛 - -Phase 2 的收益与 Phase 0/1 的覆盖率成正比,但**不需要等覆盖率**:判定矩阵第 4/5 行本来就规定了"没上报 bundleHash → 原样走 buildTime 启发式"。所以 Phase 2 可以在覆盖率很低时就上线,收益随客户端升级自然增长。 - -真正要看的指标:`unknownBundle` 占比。它高说明教育有效(发现了真问题),持续不降说明 §7 的触达链路没起作用。 +推进指标:`unknownBundle` 占比。持续不降说明触达链路没起作用。 --- @@ -271,6 +143,9 @@ Phase 2 的收益与 Phase 0/1 的覆盖率成正比,但**不需要等覆盖 > pdiff,但客户端 buildTime 一变仍清状态回内置包再白下载一遍;hash 优先后 > 同 JS 重打包做到真正无感。§5.2 的 digests/copies 摘要照旧待做;§5.3 的 > 降级序列与"删构建注入"**取消**。 +> +> 现状(2026-10):未开始——`cpp/patch_core/state_core.cpp` 的 `SyncBinaryVersion` +> 仍按 `(packageVersion, buildTime)` 比较。 ### 5.1 `SyncBinaryVersion` 迁移有个状态危险,必须处理 @@ -294,62 +169,29 @@ Phase 2 的收益与 Phase 0/1 的覆盖率成正比,但**不需要等覆盖 把当前 bundleHash 写入,stateSchemaVersion = 2 保持 currentVersion / firstTime 等原样 否则: - 正常比较 (packageVersion, bundleHash) + 正常比较 (packageVersion, bundleHash || buildTime) ``` -即"首次遇到旧格式状态时,认领它而不是否决它"。这是纯函数逻辑,落在 `cpp/patch_core/state_core.cpp`,可以直接单测覆盖。 +即"首次遇到旧格式状态时,认领它而不是否决它"。这是纯函数逻辑,落在 `cpp/patch_core/state_core.cpp`,可以直接单测覆盖。schema 版本号不可回退,需谨慎发布。 ### 5.2 其余收尾 - 下载产物 `digests` 校验(IO-6 本体) - apply 时 copies 摘要校验 → 不符即 fallback full,**永不装出坏包** -- 删除 `pushy_build_time` 构建注入:podspec 的 script_phase 写 node_modules(pnpm 下的老问题)、gradle 的 resValue、Harmony 的 meta.json 一并消失 - -### 5.3 buildTime 的降级顺序 - -不要一步到位。`bundleHash` 为空时(计算失败、老二进制)仍需要一个身份标识,所以: - -1. 先让 `SyncBinaryVersion` 变成 `(packageVersion, bundleHash || buildTime)` -2. 观察一个版本周期,确认 `bundleHash` 为空的比例足够低 -3. 再删构建期注入 - ---- +- ~~删除 `pushy_build_time` 构建注入~~ 取消(见上方定位修订) -## 6. 分阶段发布与回退 +### 5.3 ~~buildTime 的降级顺序~~ -| Phase | 仓库 | 可独立发布 | 回退方式 | -|---|---|---|---| -| 0 | react-native-update | ✅ | 老服务端忽略未知字段,天然无害 | -| 1 | react-native-update-cli | ✅ | 同上 | -| 2 | pushy-server | ✅ | `dynamicConfig` 开关(§4.3) | -| 3 | react-native-update | ❌ 依赖 Phase 2 稳定 | 各项独立回退;§5.1 的 schema 版本号不可回退,需谨慎发布 | - -任意新老组合的行为 = 两侧能力交集,最差退化为现状。 +取消(见上方定位修订)。 --- -## 7. 风险 +## 6. 风险(仍相关的) | # | 风险 | 处置 | |---|---|---| -| R1 | 客户端与 CLI 哈希了不同的文件 | §0:两侧都复用已有的 pdiff 源解析逻辑,不新写;验收里有手工比对 | -| R2 | 服务端缓存串响应 | §4.1,必须先改缓存键再上判定逻辑 | +| R1 | 客户端与 CLI 哈希了不同的文件 | §0:两侧都复用已有的 pdiff 源解析逻辑;e2e 逐字节比对(§2.7) | +| R2 | 服务端缓存串响应 | §4.1,缓存键已含 bundleHash | | R3 | Phase 3 的状态迁移抹掉全网热更 | §5.1 的 schema 版本号;纯函数,单测覆盖 | -| R4 | ~~`librnupdate.so` 重编引入对齐/ABI 回归~~ | **Phase 0 已消除**:Android 改用 MessageDigest,不动 `.so`(§2.1)。Phase 3 重编时 CI 的 `verify-android-so.js` 兜 16KB 对齐 | -| R5 | 自定义 bundle 名 / Expo / use_frameworks 下客户端定位不到文件 | 定位不到就返回空串 → 退回 buildTime 启发式,不比现状差。自定义名三处(客户端哈希 / pdiff 源 / CLI 提取)一致硬编码,退化行为对齐(§0) | -| R6 | 首次安装后第一次 checkUpdate 拿不到哈希 | §2.5 的 200ms 超时 + 省略字段,下次自然带上 | -| R7 | "重打包但 JS 未变"行下发的 pdiff 因包内资源漂移而 apply 失败 | §4.2.1:apply 时校验 + fallback full + 遥测。这是本方案里唯一被下调保护强度的地方,用运行时保证换回主场景的收益 | - ---- - -## 8. 建议的执行顺序 - -Phase 0 与 Phase 1 无依赖,**可并行**,且 Phase 1 只有几行、应当先合(先让平台开始积累 `bundleHash` 记录,Phase 2 上线时就有数据可判)。 - -``` -Phase 1 (CLI, 数行) ─┐ - ├─> Phase 2 (服务端, 带开关) ─> 观察 ─> Phase 3 (收尾) -Phase 0 (客户端) ─┘ -``` - -**Phase 3 之前不要动 `NATIVE_CHECKUPDATE_DESIGN.md` 的协议下沉**——协议此时还在变。 +| R5 | 自定义 bundle 名 / Expo / use_frameworks 下客户端定位不到文件 | 返回空串 → 退回 buildTime 启发式,不比现状差;三处一致硬编码(§0) | +| R7 | "重打包但 JS 未变"行下发的 pdiff 因包内资源漂移而 apply 失败 | §4.2.1:apply 时校验 + fallback full + 遥测 | diff --git a/docs/NATIVE_CHECKUPDATE_DESIGN.md b/docs/NATIVE_CHECKUPDATE_DESIGN.md index a8fc36df..be0a1e98 100644 --- a/docs/NATIVE_CHECKUPDATE_DESIGN.md +++ b/docs/NATIVE_CHECKUPDATE_DESIGN.md @@ -1,13 +1,16 @@ # 原生检测更新设计:让砖机也能被修好 -> 状态:**实现完成(2026-08-07,e34f390..24f3adf)**——三端编排全部落地: -> 标定层 syncNativeConfig、iOS RCTPushyOrchestrator、Android -> NativeCheckOrchestrator(librnupdate.so 已重编 4 ABI 并过符号/对齐校验)、 -> Harmony NativeCheckOrchestrator.ts + NAPI,§10.3 的 JS 响应缓存复用 -> (getNativeCheckCache,2 分钟 TTL)也已闭环。剩余:e2e 用例(沿用 -> Example/e2etest 基建,验证"坏 bundle 下原生仍拉到修复版"端到端场景)、 -> README/CHANGELOG、发版。 -> 取代:`REMOTE_RESET_DESIGN.md`(本地启动熔断方案,已放弃,理由见 §1.2) +> 状态:**已发布**。§10 原生冷启动检测随 10.51.0 发布(#620 / 62290556, +> 含标定层 syncNativeConfig、§10.3 JS 响应缓存复用 +> getNativeCheckCache),§11 崩溃时刻救援 + 断点续传随 10.52.0 发布(2d7f2b38),鸿蒙 +> 方法表漏注册在 10.52.1 修复。现行三端编排:iOS `RCTPushyOrchestrator.mm`、 +> Android `SyncCoordinator.java`、Harmony `SyncCoordinator.ts`(b8bfe1fd 前叫 +> NativeCheckOrchestrator;下文的 `librnupdate.so` 即现在的 +> `librnpushy.so`)。README 已写明能力与覆盖边界;e2e +> `Example/e2etest/e2e/native/native-check.test.ts` 覆盖 Android/iOS 的 +> forceBoot 激活。仍开放的项(鸿蒙 native-check e2e、救砖自动化 e2e 等)见 +> `NATIVE_CHECK_FOLLOWUPS.md`。 +> 取代:本地启动熔断方案(原 `REMOTE_RESET_DESIGN.md`,未入库,已放弃,理由见 §1.2) > 前置:bundleHash 迁移 Phase 0/1/2 已上线且 buildTime 永久保留作 fallback 已定稿 > (2026-08-03)——checkUpdate 的 wire protocol 已稳定,协议前置解除 > 关联:[[native-update-reset-design]] 的 Phase 3 @@ -294,7 +297,7 @@ app 侧的 `client.ts` / `UpdateProvider` 仍然负责**交互**:更新提示 ## 9. 已写代码的处置 -`REMOTE_RESET_DESIGN.md` 那一版的本地熔断已经三端实现完毕(未提交)。按 §1.2 应当整体撤回。可留用的只有: +本地熔断那一版(原 `REMOTE_RESET_DESIGN.md`,未入库)曾三端实现完毕(未提交)。按 §1.2 应当整体撤回。可留用的只有: - `blockedVersions` —— 与本方案无关,可独立保留或一并撤回 - `autoReset` 遥测事件 —— 无 reset 动作后失去意义,撤回 diff --git a/docs/NATIVE_CHECK_FOLLOWUPS.md b/docs/NATIVE_CHECK_FOLLOWUPS.md index 5c313803..f418a23d 100644 --- a/docs/NATIVE_CHECK_FOLLOWUPS.md +++ b/docs/NATIVE_CHECK_FOLLOWUPS.md @@ -1,529 +1,37 @@ # 原生冷启动检测:遗留改进项 -> 来源:`agent/harden-native-check-update` 分支两轮评审 -> (f679e11 初评 10 项 → 181952a 修 4 缓解 1;181952a 复评又出 10 项, -> 其中 6 项为修复自身引入,已并入下文)。 -> 本文最初只记录开放项。2026-08-10 已完成代码项复核与修复;下文保留 -> 原始问题描述用于追溯,处理结论以紧随其后的状态表为准。发版动作仍保留在 -> 文末清单中,不因代码合入而自动视为完成。 - -## 2026-08-10 处理结论 - -| 项目 | 结论 | 落地方式 | -|---|---|---| -| P1 Android/Harmony 重复下载 | 已修复 | 任务真正开始时复查 `.pushy-complete` + bundle;失败清理检测到完整安装时不删目录。iOS 注册表同时升级为按 hash + artifact type 合流/排队 | -| P2 缓存 `ts` 锚点 | 已修复 | 三端在 check 响应到达时捕获时间,下载、补丁和激活结束后仍写该时间 | -| P2 下载轮次 deadline | 已修复 | 三端统一为 diff/pdiff 共享 600s、full 独享 600s;发起前检查绝对 deadline,下载任务在真正开跑时按剩余预算设置 whole-call timeout | -| P2 JS 配置回退竞态 | 已修复 | 在途写入期间仍记录与 `synced` 相同的最新期望值;B 完成后会继续把 A 写回;删除同步 throw 路径的无效递归 | -| P2 iOS 合流语义 | 已修复 | 同类型下载共享结果并向所有 join 者广播进度;不同类型按 hash 排队,避免把 diff 的失败错误归给 full | -| P3 `.pushy-complete` 迁移 | 接受一次性重下 | 不把仅有 bundle 的旧目录推断为完整安装,避免把半解压目录误标为成功;10.51.0 发布说明仍需明确这一流量成本 | -| P3 状态解析异常不调度 | 已修复 | 三端启动解析改为 finally 调度;正常路径保留最终 rollback 快照,异常路径使用空快照 | -| P3 无 hash 死弹窗 | 已修复 | Provider 将无 hash 的 update 降级为开发者日志/遥测与 `upToDate`,不再展示不可执行的确认按钮 | -| P3 `noArtifact` 静默 | 已修复 | 保持终端用户无感,同时恢复 `errorUpdate` 报告并携带目标 hash | -| P4 bundleHash 窗口 | 接受并记录 | `readNativeCheckCache` 已注明首启预取未完成时有意 cache miss,不为 hash 阻塞检查 | -| P4 缓存冗余解析 | 已修复 | 直接传入现成 `fetchBody`/native config 对象,只解析缓存中的字符串一侧 | -| P4 endpoint 斜杠重复 | 已修复 | 三端首轮请求也先查规范化后的 `tried` 集合,重复项不计入 8 次上限 | -| P4 Harmony 整请求超时 | 已修复 | check HTTP 增加 15s whole-call cap;更新下载增加绝对 deadline 并在超时后销毁请求 | - -代码验证基线:JS 完整回归 173 项、Biome/TypeScript/Harmony strict 类型检查、 -77 项 flow core ASan/UBSan、29 项 patch core、Harmony debug HAR、Android -Release Java 编译、iOS Release simulator 静态库构建均通过。 - ---- - -## 2026-08-10 第三轮评审(283dfd4)开放项 - -上表 13 项的修复经复评确认全部属实;以下为修复自身引入/暴露的新开放项 -(详情与逐条修法见评审面板)。 - -复核后处理结论: - -| 项 | 结论 | 落地方式 | -|---|---|---| -| 1 iOS joiner 预算 | 已修复 | 注册表记录 owner 的单调时钟 deadline;预算更长的 waiter 观察当前进度但 deferred,owner 成功时由完成标记立即命中,失败时以自己的完整预算重启 | -| 2 Harmony 外层时限 | 已修复 | `performAttempts` 用绝对单调 deadline 包住排队、HTTP、解压和 hpatch 的完整 Promise;底层串行任务即使晚结束也不再阻止编排器落响应缓存 | -| 3 壁钟 deadline | 已修复并修正文档结论 | iOS 改用 `systemUptime`,Harmony 改用 `systemDateTime.getUptime`;full 预算进入 full 阶段才创建,因此原文“增量阶段校时会同时耗尽尚未创建的 full 预算”不成立 | -| 4 Android full 判定 | 已修复 | 与分发逻辑及 iOS/Harmony 一致,非 diff/pdiff 统一视为 full;上游当前只生成三种合法类型,此项属于防御性收口 | -| 5 坏发布遥测膨胀 | 已修复 | 缺 hash 与 noArtifact 共用按 appKey/reason/hash 的进程内去重,保留一次服务端可见的坏发布信号 | -| 6 有 hash 无产物弹窗 | 已修复 | Provider 在展示/静默下载前复用 `decideDownload`,noArtifact 降级为 `upToDate` 与一次开发者遥测 | -| 7 deferred UX/deadline | 已修复 | deferred waiter 订阅当前同 hash 进度;旧 deadline 在重新注册前校验,过期的编排器请求不会成为 owner 或结算后来的 JS 请求 | -| 8 owner-only 进度事件 | 不采纳 | 支持路径在 JS 已按 hash 维持单一原生监听;常见 join 是无监听器的冷启动 engine + JS bridge。限制为 owner 发事件会在 engine 先成为 owner 时丢失 JS 进度 | -| 9 iOS 完成判定重复 | 已修复 | 抽取 `PushyHasCompletedVersionAtPath`,预检与冷启动编排器共用同一 bundle+marker 判定 | - -**P2(三端时限模型的二阶问题,建议一并收口)** -1. **iOS 合流者继承 owner 剩余预算**:JS 同 hash 同类型合流到编排器下载时, - 共享会话带的是编排器所剩阶段预算(可能只剩几十秒),其超时会结算全部 - 合流者——修改前 JS 独立下载固定 600s 的不变量对合流路径失效。 -2. **Harmony await 无外层时限**:统一预算只管住 HTTP 流;解压/hpatch 卡死 - 或串行链被长任务占用时 `await context.downloadX` 永不返回,救援轮次挂满 - 进程生命周期,响应缓存也不落盘。修法:performAttempts 层对每个 await 包 - Promise.race 绝对 deadline。 -3. **壁钟 vs 单调钟**:iOS/Harmony 的 deadline 锚壁钟(epoch/Date.now), - Android 锚 nanoTime——首次联网触发 NTP 前跳(恰是救援检测时刻)会让 - iOS/Harmony 全部预算(含 full 保底)瞬时过期。修法:iOS 用 - systemUptime/mach 时基,Harmony 用相对定时器。 - -**P3** -4. **Android 按字面 "full" 判保底预算**,分发 else 分支却把任意非 diff/pdiff - 类型当 full 下载;iOS/Harmony 用排除法——三端对"谁享有 full 保底"不再 - 一致,Android 改排除法即齐。 -5. **noArtifact 遥测重复膨胀**:映射到 download_fail 聚合且每次 - downloadUpdate 调用都上报(静默策略下每个检查周期一条),需按 hash - 会话内去重或换不入聚合的事件类型。 -6. **有 hash 无产物的死弹窗变体**:provider 只降级了无 hash 的 update; - 有 hash 但无任何产物 URL 的坏发布仍弹确认框、确认后静默。 -7. **iOS deferred 的 UX 与陈旧 deadline**:异类型 deferred 请求无进度、 - 延迟开始(UI 冻结);重启时携带编排器的旧 deadlineAt,排队期间过期则 - 瞬时失败并连带结算 JS 合流者。JS 发起的请求不应携带编排器 deadline。 - -**P4** -8. **iOS 合流者进度块重复发事件**(N 合流者 = N 倍 RN 事件,事件发送应只由 - owner 承担)。 -9. **完成判定(bundle+marker)在 iOS 两处手写**,抽 PushyHasCompletedVersion - helper 防漂移。 - - ---- - -## P1 — Android/Harmony 重复下载竞态,失败路径可删除已安装版本 - -**现状**:iOS 在 f679e11 引入了进程级在途下载注册表 -(`PushyRegisterDownload`/`PushyFinishDownload`),同一 hash 的并发下载合流。 -Android 与 Harmony 只有任务串行化,没有按 hash 去重,也不保护已完成目录。 - -**风险**:JS 自动下载版本 H 进行中(单线程 executor 排队),原生检测 5 秒后 -`hasCompletedVersion(H)` 为 false,再排入第二个 H 的下载任务。JS 任务完成、 -写入 `.pushy-complete` 并 `setNeedUpdate(H)` 后,排队的重复任务重新下载 H -且中途失败(断网)时,`cleanUpAfterFailure` 删除 `rootDir/H`(含 -`index.bundlejs` 与标记)。下次冷启动解析到 H 但 bundle 缺失 → 回滚—— -**已成功安装的更新被静默丢失**。 - -**建议修法**(三选一,或组合): -1. 移植 iOS 的按 hash 在途注册表(Android 用 - `ConcurrentHashMap>`,Harmony 用模块级 Map); -2. 更小的止血:`cleanUpAfterFailure` 里若目标目录已有 - `.pushy-complete` 标记则不删(失败的是重复下载,不是这份安装); -3. DownloadTask 开跑前再查一次 `hasCompletedVersion(hash)`,已完成即直接 - 走成功回调。 -方案 2+3 组合改动最小且互补;方案 1 语义最完整(还能省一次重复下载)。 - ---- - -## P2 — 响应缓存的 `ts` 锚在持久化时刻,而非响应到达时刻 - -**现状**:`persistResponseCache` 在原生下载/激活全部结束后才落盘,`ts` 取 -当时时间。181952a 给 Android 加了整轮 600s deadline,把最坏偏移压到 -~12 分钟,但锚点本身未改;iOS 仍是逐 URL 600s、Harmony 依赖下载任务自身 -超时,无整轮上限。 - -**风险**:版本发布几分钟后被运营撤下/回滚时,一个"响应早已过时但 ts 很新" -的缓存会让 JS 在 120s 窗口内把已撤回的响应当新鲜结果复用,继续下载/激活 -已撤回版本。 - -**建议修法**:响应到达时捕获 `responseAtSeconds`,作为参数传入 -`persistResponseCache` 写进 `ts`(三端各一行改动,JS 读侧无需变)。 - -**复评补充(181952a 引入/暴露)——下载轮次时限应作为一个整体设计统一三端**: -1. 整轮 600s deadline 只落了 Android;iOS 仍逐 URL 600s(上界 - attempts×urls×600s,可达 90 分钟),Harmony 完全没有(慢滴 CDN 在每个 - 60 秒窗口内返回至少一个字节即可绕过下载任务的不活动看门狗,轮次可跑数小时); -2. Android 的 deadline 在下一个下载**已发起后**才检查——超时轮次仍会 - 多启动一个孤儿下载,浪费带宽且可能与 JS 重试并发写版本文件 - (修法:发起前先查); -3. 整轮预算会被 diff/pdiff 的失败耗尽,挤压救砖最后手段 full 的时间 - (原先 full 独享 600s)——建议 full 单独保底额度。 - ---- - -## P2 — JS 配置同步的回退竞态(181952a 引入) - -**现状**:`syncNativeConfig` 的去重把当前配置与**最后一次已完成**的写入 -(`syncedNativeConfigJson`)比较。 - -**风险**:已同步 A → setOptions 改为 B(写入在途)→ B 完成前又回退为 A: -回退因等于 `synced`(仍是 A)被直接丢弃,随后 B 完成、`synced=B`、pending -为空——原生 KV 永久持有错误的策略/endpoints(如 app 已切回 alert 却按 -silent 激活),直到未来某次不同值的 setOptions 才被纠正。 - -**建议修法**(改动极小):去重时把在途/待写值一并纳入比较,或写入完成回调里 -与"最新期望值"复核不符则重新入队。顺手删掉 `flushNativeConfig` 同步抛 -catch 里的死代码递归"重试"(pending 已清空,必然早退,误导维护者)。 - ---- - -## P2 — iOS 合流下载的两个语义代价 - -**现状**:JS 的下载调用合流到冷启动引擎的在途下载后: -1. **收不到进度事件**——事件只由持有下载的实例发出,引擎实例 - `hasListeners=NO`,用户看到进度条冻在 0; -2. **继承异类 attempt 的失败**——引擎的 diff 尝试 404 时,JS 本可成功的 - full 下载调用被判失败(JS 策略链会继续下一策略+重试回退,最终多能成功, - 但单次调用的失败归因是错的)。 - -**建议修法**:注册表回调列表旁存进度回调,引擎下载的 NSURLSession 进度 -透传给所有 join 者(解决 1);join 时携带请求类型,类型不同不合流、改为 -排队串行(解决 2,代价是偶发的一次串行等待)。若认为 JS 策略链的自愈已 -足够,可显式拍板只修 1。 - ---- - -## P3 — `.pushy-complete` 无迁移,存量已下载版本升级后重下一次 - -**现状**:旧版本库下载的版本目录没有标记文件;升级到新库后 -`hasCompletedVersion` 判 false,原生检测整包重下一遍(下载完成后写标记, -自愈,每台受影响设备一次)。 - -**风险**:一次性流量成本,仅影响"已下载未激活"(alert 类策略)的存量设备。 - -**建议**:二选一显式拍板——(a) 接受成本,在 CHANGELOG 注明;(b) 加迁移: -首次运行时对"有 `index.bundlejs` 且 mtime 早于本次库安装"的目录补写标记 -(无法区分半解压目录,有误判风险,故 (a) 可能更稳)。 - ---- - -## P3 — 启动状态解析抛异常时,救援检测永不调度 - -**现状**:f679e11 把 `schedule` 从 `getBundleUrl`/`+bundleURL` 顶部移到 -各出口(为携带回滚快照),但若 `runStateCore`/回滚循环自身抛异常,所有 -出口都到不了。 - -**风险**:持久化状态损坏(恰是救援机制存在的场景)时,救援检测在后续所有 -启动中都不运行。 - -**建议修法**:状态解析段包 try/finally,finally 里以"空快照"调度 -(rolledBackVersion 传 null 即可,宁可少守卫不可不调度——与"判定失败不 -计数"同一取舍方向);三端同改。 - ---- - -## P3 — 无 hash 的灰度条目从"静默忽略"变成"死弹窗"(181952a 语义变更的副作用) - -**现状**:`resolveCheckResult` 的非空 hash 守卫移除了旧的 -`undefined === undefined` 等价:服务端误配出无 hash 的灰度条目时,内置包 -设备(currentVersion 为空)从静默 upToDate 变为返回 `update:true` 且无 -hash——已被再生成的金标向量固化。 - -**风险**:alert 策略下每次检查都弹"发现新版本",点确认后 `decideDownload` -因 `!hash` 判 noUpdate——死弹窗,按钮无效,每次检查重现。 - -**建议修法**:若新语义有意(暴露服务端错配),在 provider 层把无 hash 的 -update 降级为日志 + 遥测而非弹窗;若无意,恢复"无 hash 条目不视为可更新"。 -与下一条(noArtifact)同属"坏发布该以遥测暴露而非 UI 弹窗/静默"。 - ---- - -## P3 — `noArtifact` 分支静默返回,丢失 errorUpdate 遥测(181952a 引入) - -**现状**:decideDownload 新增的 noArtifact 拒绝让 `downloadUpdate` 无日志、 -无错误事件、无遥测地返回;旧的空 attempts 路径会上报 -`{type:'errorUpdate'}`。 - -**风险**:服务端发出 update:true 有 hash 但无任何产物 URL 的坏发布,从 -"控制台可见"退化为"客户端静默无事发生",坏发布隐形。 - -**建议修法**:noArtifact 分支恢复 report({type:'errorUpdate', ...}),保持 -用户无感但平台可见。 - ---- - -## P4 — bundleHash 未就绪窗口的请求指纹失配(记录即可) - -**现状**:JS 预取 bundleHash 未 settle 时构造的请求体缺 `bundleHash` 键, -与原生缓存的请求(原生同步计算,总带该键)键集不同 → 指纹不命中,走网络。 -窗口极窄(通常仅首启,且该时刻缓存多半尚不存在);`overridePackageVersion` -的失配已在 181952a 通过配置携带 `packageVersion` 修复。 - -**建议**:在 `readNativeCheckCache` 注释里写明这是已知且有意的失配面, -不做代码改动。 - ---- - -## P4 — `readNativeCheckCache` 冗余解析(cleanup) - -**现状**:调用方刚 stringify 的请求体被立即 parse 回来做结构比较,外加对 -当前 config JSON 的一次重复解析——每次 release 路径 checkUpdate 的固定 -开销。 - -**建议**:把现成的 `fetchBody` 对象传入,只 parse `entry.request` 一侧。 - ---- - -## P4 — 若干小项(复评新增) - -- **斜杠变体 endpoint 重复请求**:尾斜杠归一化发生在 C++ 去重之后,且首轮 - 循环只 add 不查 `tried`——`https://u.example.com` 与 `…com/` 会对同一 - URL POST 两次,还白占 8 次 HTTP 上限中的两次。修法:归一化提前进纯层 - (`orderEndpointCandidates` 前),或首轮也查 `tried`。 -- **Harmony 检查请求缺整请求封顶**:只有 connect/read 超时,没有 Android - `callTimeout(15s)` / iOS 超时取消的对应物,慢滴响应可拖长每次尝试。 - ---- - -## 2026-08-11 第四轮评审(60f5fd2..5f5d0cb)开放项 - -上一轮 9 项闭环全部属实(第 8 项"owner-only 进度事件"维护者明确不采纳,理由 -成立)。以下为仍开放项;**前三条建议合入前处理**,其余可随小版本。 - -### 合并阻断 —— 已于 2026-08-11 修复(见下方"修复说明") - -#### 原始问题描述 - -1. **原生检测无视 `checkStrategy`,并能撞销 `resetToPackagedBundle`** - (CI 已红)。`getNativeConfig` 只用 `updateStrategy` 折算 `afterDownload`, - 从不读 `checkStrategy`;三端也没有 reset↔检测 的任何联动(无 generation、 - 无取消,reset 也不清 `nativeCheckResp`)。e2e app 明写 `checkStrategy: null` - 却仍被原生自动下载+激活;`resetToPackagedBundle` 可被在飞的检测撤销。 - **证据**:e2e-ios 在 `0f4651e` 与 `5f5d0cb` 两次运行均挂在 `beforeEach` - 的 `bundleLabel: BINARY_BASE`,且两次挂的是不同用例(竞态签名);失败态 - `currentHash: e2e-full-v1` 恰是"reset 后从零检查会拿到的第一个版本"; - master 上 e2e-ios 为绿 → 本分支引入的回归。本分支新增的 - `hasCompletedVersion` 快路径(版本已落盘则跳过下载直接 switchVersion) - 把竞态窗口从"下载完"压到"5 秒后瞬间",是这轮才炸的原因。 - **修法**:`afterDownload` 计算纳入 `checkStrategy`(为 null 时降为 - `'none'`——只下载不激活,`forceBoot` 仍可救砖);`resetToPackagedBundle` - bump 进程级 generation,编排器在 `switchVersion`/落缓存前比对,变了就 - 放弃,并清掉响应缓存。 - -2. **iOS 同类型 join 分支实际不可达**。`deadlineUptime > ownerDeadline` 是 - 严格大于,而 JS 发起的请求总晚于 owner 的计算时刻,因此永远走 deferred: - P1"一个 hash 共享一次下载"的意图失效,退化为串行重下;CDN 黑洞时用户 - 弹窗可转近 20 分钟(改前是合流后一次失败并回退下一候选 URL)。修法:比较 - 加容忍阈值,或 JS 发起的请求不参与 deadline 比较。 - -3. **Harmony 用 `TimeType.STARTUP`**(计深度睡眠),而 iOS `systemUptime` / - Android `nanoTime` 睡眠时停走——"三端统一单调钟"不成立。锁屏休眠数分钟 - 即让预算过期、救援中止,同网络的另两端能续传完成。一行改 - `TimeType.ACTIVE`。 - -#### 修复说明(2026-08-11) - -1. **checkStrategy + reset 竞态**:`getNativeConfig` 的 `afterDownload` 现在 - 要求 `checkStrategy != null` —— 关掉自动检查的应用不会再被塞一次它没要 - 过的版本切换;检测本身照跑(救砖能力不变),`forceBoot` 仍可激活。另加 - **reset 代数守卫**:`resetToPackagedBundle` 递增进程级计数并清掉响应缓存, - 编排器在开跑前采样、在激活与落缓存前比对,不一致即整轮丢弃。三端同构 - (iOS `std::atomic`、Android `AtomicLong`、Harmony 静态计数)。 -2. **iOS join 分支不可达**:比较从"绝对 deadline 严格大于"改为"剩余预算", - 仅当 owner 剩余不足新来者的一半时才 defer —— 正常情况(JS 晚几秒发起) - 恢复合流,只有真正濒临耗尽的 owner 才让位。 -3. **Harmony 单调钟**:`TimeType.STARTUP` → `TimeType.ACTIVE`,与 iOS - `systemUptime` / Android `nanoTime` 的"睡眠时停走"语义对齐。 - -**补强(2026-08-11,CodeRabbit 复核后)**:初版守卫是 compare-and-act,且 -`hash_` 元信息写在守卫之前——reset 若落在"比对通过"与"写入"之间仍 -可被覆盖。现改为**一次原子提交**:版本元信息 + 激活 + 响应缓存合并为 -`commitNativeCheckResult(expectedGeneration, ...)`,内部先复核代数再落全部 -写入;`resetToPackagedBundle` 与之互斥并**先失效代数再清状态**。互斥手段按 -端选取:iOS 复用既有 `PushyWithStateLock`(为此把 switchVersion 拆出无锁核 -`PushySwitchVersionLocked`,避免不可重入死锁)、Android 用共享 -`commitLock`、Harmony 是 ArkTS 单线程且提交内无 await,天然原子(已注释说 -明)。三处落点(元信息/激活/缓存)与三个调用点(nothing-to-do、未下载、成功) -全部走同一入口。 - -验证:JS 178 项(新增 2 项 checkStrategy 折算用例)、Biome/tsc/DevEco -strict、77 金标向量 + ASan/UBSan、`.so` 符号与 16KB 对齐、iOS clang -(DEBUG=0/1)、Android javac(main+oldarch)、OHOS 工具链语法。本轮不触 -`cpp/`,无需重生成向量或重编 `.so`。 - -### 后续小版本 - -4. Harmony 超时只放弃编排器的 `await`,卡死的 DownloadTask 仍占 `taskChain`, - 后续 attempt 只是排队并空烧自己的新鲜预算(有效 full 产物 + 完整预算都在 - 却仍救不回)。修法:超时真正取消底层任务,或救援轮次用独立链。 -5. iOS/Harmony 的 `switchVersion` 仍可激活半完成安装(Android 本轮加了无标记 - 拒绝守卫,三端 parity 分歧)。 -6. iOS deferred 异类型 waiter 订阅了 owner 的进度流,收到的是另一种产物的 - 字节数(进度条先到 100% 再回 0%)。修法:只向同类型合流者广播。 -7. 缺 hash 守卫新加的 `!info.expired` 让 `{expired:true, update:true}` 且无 - hash 的畸形响应既不降级也不上报,却仍把 `update:true` 发给业务侧。 -8. `getNativeConfigJson() ?? '{"disabled":true}'` 会把瞬时为空的 - `appKey`/`server.main` 变成持久 disabled 写入,覆盖上一份可用配置并长期 - 关掉救砖能力。修法:仅在确实不可用(web/旧原生)时写 disabled。 -9. `noArtifact` 上报位置从 `client.downloadUpdate`(实际尝试下载)移到 - `provider.checkUpdate`(只要检查就发),映射到服务端 `download_fail` 聚合 - 后会把一次坏发布放大成全量设备级的健康下降。 -10. 两处 cleanup:Android `switchVersion` 重写了同文件已有的 - `hasCompletedVersion` 谓词;provider 为一个是非问题重跑完整 - `decideDownload`(分配 URL 计划后丢弃)。 - -### 累计 - -四轮共 40 条发现:27 修复、2 显式接受、1 不采纳、10 开放(其中 3 条建议合入 -前处理)。 - ---- - -## 发版清单(非代码缺陷,勿遗漏) - -- [ ] e2e:坏 bundle → 原生拉修复版 → 下次启动复活的端到端用例 - (沿用 Example/e2etest 基建;harmony 走 hdc/uitest 链路) -- [ ] README / README-CN / CHANGELOG:原生冷启动检测、forceBoot、 - 绑定依赖硬校验(RN 版本一致、rnu 不降级) -- [ ] 客户端 10.51.0 发版(admin 的 forceBoot 门槛与之对齐) -- [ ] 服务端已推送未激活:pushy 主机 `install-npm-release.sh` + - `service-cli.sh restart all`(验 serverVersion);cresc 跑 - `update-cloudrun.sh`;两个 admin 走各自 CI - -## 2026-08-12 救砖实测:能救什么、救不了什么(Android 真机 + 生产服务端) - -两轮实测,同一套 forceBoot 通道,结论截然相反,差别只在崩溃时刻。 - -**能救(已验证)**:markSuccess 之后 10s 才崩的砖。 -时间线:proc_start → 9.4s 原生检测 commit `set for next launch` → 10.0s JS 崩溃 → -下次启动进入修复版并 markSuccess。救援仅领先崩溃点 567ms。 - -**救不了(已验证)**:markSuccess 后写 storage 标记、下次启动读到标记即崩的砖 -(用户提出的理想场景,崩溃点约 proc_start+230ms)。连续 4 次冷启动,原生检测 -一次都没能走到 commit,设备停留在 "keeps stopping"。 - -**根因(不是延迟参数问题)**:5s 固定延迟只是其一,检查+下载本身实测还需 ~4.4s。 -即使把延迟归零也远超 230ms。更本质的是原生检测线程与 JS 同进程,JS 崩溃时 -进程整体被杀,该轮下载进度全部丢弃,下次启动从零开始——无论重启多少次都无法收敛。 - -**可行方向(未实施,需三端评估)**: -1. 下载断点续传 + 跨启动累积:每次启动补几百毫秒,多次启动后凑齐并激活(治本,改动最大) -2. 原生检测移入独立进程:JS 崩溃不再连坐(Android 可行;iOS 无此模型) -3. 上次启动异常检测 → 本次启动同步阻塞式检查(牺牲启动速度换救援,需谨慎) - -在此之前,产品口径应明确:forceBoot 救的是"能活过启动阶段、之后才出问题"的砖, -不覆盖"启动即刻崩"的场景。文档与卖点文案不应过度承诺。 - -## 2026-08-12 缺口闭环(10.52.0):crash-handler hold + 断点续传 - -上面三个方向重新评审后全部改判,最终方案见设计文档 §11: - -- **主机制 = 崩溃时刻扣留进程**(维护者提出):uncaught handler 里 hold 住 - 垂死进程数秒完成检查+下载。崩溃即信号,零误判、零检测态、首崩即救, - 且修复在垂死会话末尾 commit,下次启动零阻塞直接进修复版。方向 2(独立 - 进程)与方向 3(启动阻塞)被它整体取代;方向 1(续传)保留为地基。 -- **断点续传三端落地**:sidecar(url/验证器/总长)+ Range/If-Range, - 206/200/416 全处理;归档与 sidecar 同生共死;下载阶段失败保留 partial, - patch 阶段失败判定归档有毒连 sidecar 一起删。 -- **round-incomplete 标记**:轮次开始落盘、结束清除;残留 = 上个进程死于 - 轮中,下次启动跳过 5s 延迟立即续传。 -- **救援轮强制激活** + hashInfo `crashRescue` 标记(与 forceBootRescue 对 - 称);本机 rolledBack 黑名单与 first_time 保护语义不变。 -- **边界**:只逮 exception 形态崩溃(native signal/ANR/OOM 逃逸);鸿蒙本 - 版无 crash hold(errorManager 同步回调驱动不了 ArkTS 异步 IO),只有续 - 传+零延迟。 - -- [x] 10.52.0 发版后按本节上方同一实验复测 crash-on-launch 砖(见下) - -### 2026-08-12 实测:crash-on-launch 砖被救回(Android 真机 emu + 生产服务端) - -10.52.0 发布到 npm,testHotUpdate 升级并出 release APK(rnu 10.52.0)。 -复刻 crash-on-launch 砖:砖包活过一次启动并 markSuccess(first_time 保护 -解除),之后每次启动读到标记即在 JS 线程抛错。fix 版本绑定到同一原生版本 -1.87.1,砖包磁盘上的 fix 预下载被删除,逼救援在崩溃窗口内完整走 -检查+下载+commit。 - -冷启动时序(同一进程 PID 11498): - -``` -22:59:52.365 JS 线程抛 BRICK(mqt_v_native),进程存活 122ms -22:59:52.367 crash rescue: holding process for up to 10000ms (uptime 122ms) -22:59:56.645 native check: downloaded -IxhPnHy(fix) and set for next launch -22:59:56.651 前任 handler 接管,进程被 SIG9 终止 -``` - -崩溃到 commit 全程 **4.28s**,落在 10s(后台线程)预算内。下次冷启动直接 -进 fix 版本(hot-fix-1052),不再崩溃;fix 立即把标记 disarm。 - -遥测闭环:fix 的 currentVersionInfo 带 `crashRescue: true`,markSuccess 后 -JS 紧跟着上报一条 `crashRescue` 回执(logcat 实测 23:00:16.180 markSuccess -→ 23:00:16.181 crashRescue)。 - -对比 2026-08-12 上午同一台设备的旧结论:230ms 砖连续 4 次冷启动不收敛。 -10.52.0 下**一次崩溃即救回**——崩溃本身触发 hold,进度不再随进程死亡丢弃。 -鸿蒙真机复测待补(只有续传+零延迟无 hold)。 - -### 2026-08-12 iOS 模拟器实测:同一场景救回(Release-iphonesimulator + 本地服务端) - -e2e 基建复用:本地 rnu 副本刷到 10.52.0 源码 → pod install → Release 模拟器 -构建(e2e-ios 的常态形态,`#if !DEBUG` 门控激活);Detox 逮不住 JS 起不来的 -app,绕开它直接 simctl + 文件系统/崩溃报告观察。实验脚手架已入库: -`e2e/entry.brick.ts` / `e2e/entry.fix.ts` / `scripts/ios-rescue-server.ts` -(端口 31337,链:base→brick(带 forceBoot 投放)→fix(**刻意不带 forceBoot**))。 - -砖状态与 Android 同构:brick 经 forceBoot 装上、首启健康 markSuccess -(isFirstTime 消费、isFirstLoadOK=true)、8s 后 arm 标记;磁盘上预下载的 -fix 版本目录删除,逼救援窗口真下载。 - -armed 冷启动结果(procLaunch 23:24:44.233,崩溃报告 .ips 为证): - -- JS fatal 重抛在 **com.meta.react.turbomodulemanager.queue**(后台线程 → - 10s 预算档;此前对"JS 崩落在哪个线程"的分析在 bridgeless 下得到证实) -- 救援窗口内完成整轮:服务端记录到 checkUpdate(hash=brick)→ 下发 fix → - **fix.ppk 真实下载** → commit;本地服务端下全程 <0.9s(captureTime - 23:24:45.156,远低于预算——4.4s 是生产网络的数字) -- **强制激活的独立证据**:fix 响应无 forceBoot、app 配置 - checkStrategy:null/afterDownload:none——常规路径绝不会激活,而 - currentVersion 已切到 fix 且 hashInfo 带 `crashRescue:true`,只能来自 - crashRescueActive -- 链式礼仪:hold 结束后前任 handler 照常执行,SIGABRT 崩溃报告正常生成 - (崩溃上报不丢) -- 下次启动进 fix、disarm 标记、markSuccess - -**主线程 3.5s 预算档不做人工实测,理由记录**:iOS 上落主线程的 RCTFatal 只有 -bundle 加载失败一族(RCTInstance 先 RCTExecuteOnMainQueue 再 RCTFatal),而 -加载失败的包永远跑不到 markSuccess,一律被既有 first_time 回滚接住,不构成 -真实砖形态;JS 运行时错误(砖的实际形态)实证落在后台线程。另:主线程档 -3.5s 是按 Android ANR 定的,iOS watchdog 上限 ~20s,未来可放宽。 - -**iOS 覆盖边界补记**:若宿主 app 调用了 `RCTSetFatalHandler`,RCTFatal 走 -handler 分支不再抛异常,crash-rescue 不会触发(Android 无对应抢占点)。 -文档口径需含此项。 - -## 2026-08-13 鸿蒙模拟器实测:两个实证 + 一个 10.51 以来的功能性断裂 - -DevEco api20 模拟器(HarmonyOS 6.0 / arm64)+ harmony_use_pushy(RN 0.72 + -RNOH 0.72.96)+ 本地 e2e 服务端。探针脚手架入库: -`harmony_use_pushy/e2e/entry.crashprobe.ts`(+1s 未捕获 JS 错误)、 -`entry.diag.ts`(TM 方法表诊断)。 - -**实证一(缺口 0 的分叉答案):RNOH 未捕获 JS 错误不杀进程。** +1s 抛错后 -进程存活 40s+ 无恙,错误只落 #RNOH_JS/#RNOH_ARK 日志。Hermes 在 RNOH 自管 -线程上,异常不重抛 ArkTS 主线程,errorManager 根本不参与。因此**鸿蒙不需要 -crash hold**:ets 编排器在 JS 死后照常跑完整轮。此前 §11.5 的 -errorManager/appRecovery 论述前提不成立,已改写。 - -**实证二(端到端救砖):** crashprobe 作基座(每次启动 JS +1s 崩),服务端 -forceBoot 下发 v1:+5s 轮次照常检查→下载 v1.ppk→激活(currentVersion 写 -入),重启直接运行 e2e-full-v1,砖消失。JS 崩溃对轮次零影响,无预算压力。 - -**功能性断裂(已修,severity 高):`PushyTurboModule.cpp` 的 C++ 方法表漏 -注册 10.50+ 的全部四个新方法**(syncNativeConfig / getNativeCheckCache / -getBundleHash / resetToPackagedBundle)。RNOH 的 ArkTS TM 只有经 C++ -methodMap 注册的方法才对 JS 可见,缺失 = JS 侧 undefined → feature-detect -判"老原生"静默跳过 → **原生冷启动检测在鸿蒙自 10.51.0 从未生效** -(nativeConfig 从未落盘,ets 编排器+本次的续传/零延迟全是死代码); -resetToPackagedBundle(10.50 Phase 1)在鸿蒙 JS 侧也一直不可用。诊断链: -健康启动 preferences 无 nativeConfig → entry.diag 打 typeof=undefined + -keys 只有 14 个老方法 → cpp 方法表对不上 ets 类。 - -**为什么 CI 没逮住**:harmony e2e 套件 testMatch 只含 `e2e/harmony/**` -(smoke + local-update),native-check.test.ts 从未在鸿蒙执行;而 JS 的 -feature-detect 把缺失当"老原生"优雅跳过——这是[[no-graceful-degradation]] -教训的又一实例:兼容旧原生的降级路径把新原生的接线断裂藏了三个版本。 - -- [x] cpp 方法表补注册(含"新增 spec 方法必须同步注册"的醒目注释) -- [ ] harmony e2e 补 native-check 场景(driver 是 hdc+uitest 非 detox, - 需移植;顺带把 `hdc rport` 写进用例基建——模拟器 127.0.0.1 不通宿主) -- [ ] 发版 10.52.1(鸿蒙 bridging 修复;Android/iOS 无变化) -- [ ] Logger.ts isDebug 硬编码 false,轮次全程无日志可观测,排障全靠副作用 - (preferences/服务端流量);考虑 error 级关键节点或可配置开关 - -## 2026-09-02 处理结论(全面审计后续,见 CODE_AUDIT.md §8) - -| 项 | 结论 | -|---|---| -| 第四轮"后续小版本"第 4 项(Harmony 超时不取消底层任务) | 已修:下载超时按剩余 deadline 设置 read/connect timeout,编排器与任务链共享绝对 deadline | -| 第 5 项(iOS/Harmony `switchVersion` 可激活半完成安装) | 已由 6e9ae76 的安装记录 digest 复验关闭;鸿蒙本轮在提交前先校验再写入 | -| 第 6 项(iOS deferred 异类型 waiter 进度串流) | 已修(同类型合流广播,异类型 deferred) | -| 第 7 项(`expired+update` 无 hash 畸形响应) | 已修:按 expired-only 处理 | -| 第 8 项(瞬时空 appKey/server 写入 `disabled`) | 已修:只有 `disableNativeCheck` 才持久化 disabled,瞬时无效配置沿用上一份 | -| 第 9 项(`noArtifact` 上报位置) | 已修:每个坏发布只报一次,静默策略由 client 报、弹窗策略由 provider 报 | -| 第 10 项(两处 cleanup) | 已修 | -| 鸿蒙 e2e 补 native-check 场景 | 未做(需 hdc/uitest 设备链路) | -| `Logger.ts isDebug` 硬编码 | 已修:由宿主 `isDebugModeEnabled` 在模块构造时打开(HAR 内无法引用 hvigor 生成的 `BuildProfile`,首次 CI 编译已证实),轮次里程碑提升到 info/warn | -| "P1 Android/Harmony 重复下载" 遗留的 launchVersion 保护 | 已修:三端清理都保留本进程启动版本;Android 在 keep 集合不含它时跳过本次清理 | +> 设计见 `NATIVE_CHECKUPDATE_DESIGN.md`(§10 冷启动检测、§11 崩溃救援)。 +> 本文只保留**仍开放**的项。四轮评审(2026-08-10/11)、2026-09-02 全面审计 +> 后续与 2026-08-12/13 三端救砖实测的完整记录(40 条发现的逐条问题描述、 +> 修法与实测时序)已收敛,见 git 历史: +> `git show 4ca8972e:docs/NATIVE_CHECK_FOLLOWUPS.md`。 + +## 已收敛结论(备查) + +- 四轮评审 40 条发现:除下文开放项外全部修复或显式拍板(P3 `.pushy-complete` + 无迁移 → 接受一次性重下;P4 bundleHash 未就绪窗口指纹失配 → 接受并在 + `readNativeCheckCache` 注明;iOS owner-only 进度事件 → 不采纳)。 +- 崩溃时刻救援(10.52.0,§11):Android 真机与 iOS Release 模拟器均实测 + "启动即崩"的砖一次崩溃即救回(Android 崩溃→commit 4.28s);鸿蒙 RNOH 未捕获 + JS 错误不杀进程,无需 crash hold,模拟器实测 forceBoot 端到端救回。 +- 2026-08-13 鸿蒙功能性断裂:`PushyTurboModule.cpp` 方法表漏注册 10.50+ 的 + 新方法,原生冷启动检测在鸿蒙自 10.51.0 起从未生效;已修(10.52.1),并由 + `scripts/check-native-spec-parity.js` 与 harmony-build CI 防回归。 +- 已知覆盖边界(README 已写明):crash hold 只逮 JS exception 形态的崩溃, + 不覆盖原生崩溃 / ANR / OOM 击杀,以及 iOS 上安装了自定义 + `RCTSetFatalHandler` 的应用。 + +## 开放项 + +- [ ] **鸿蒙 e2e 缺 native-check 场景**:`e2e/harmony.jest.config.js` 的 + testMatch 只含 `e2e/harmony/**`(smoke + local-update), + `e2e/native/native-check.test.ts` 只在 Android/iOS 跑。需把用例移植到 + hdc + uitest driver(模拟器 127.0.0.1 不通宿主,用 `hdc rport`)。 +- [ ] **救砖(crash-on-launch)无自动化 e2e**:§11 的三端实测都是手工的 + (Detox 逮不住 JS 起不来的 app)。现有 native-check 用例只覆盖 + "服务端 forceBoot → 原生激活",不覆盖"坏 bundle 崩溃 → 救援 → 下次 + 启动进修复版"。 +- [ ] **鸿蒙真机救砖复测**:目前只有 DevEco 模拟器实证。 +- [ ] (可选)iOS 主线程 crash hold 预算 3.5s 按 Android ANR 定,iOS watchdog + 上限约 20s,可放宽;主线程 RCTFatal 实际只有 bundle 加载失败一族 + (由 first_time 回滚接住),优先级低。 diff --git a/harmony/hvigor-plugin.ts b/harmony/hvigor-plugin.ts index 433343d8..d0ffc2c9 100644 --- a/harmony/hvigor-plugin.ts +++ b/harmony/hvigor-plugin.ts @@ -156,26 +156,18 @@ export function reactNativeUpdatePlugin() { console.log(`Build time written to ${metaFilePath} (${buildMode})`); }; - if (typeof safeNode.registerTask === 'function') { - try { - safeNode.registerTask({ - name: TASK_NAME, - run: writeMeta, - dependencies: ['default@PreBuild'], - postDependencies: ['default@ProcessResource'], - }); - return; - } catch (e) { - console.warn( - `reactNativeUpdatePlugin: task registration failed (${ - e instanceof Error ? e.message : String(e) - }), writing meta.json at apply time`, - ); - } + if (typeof safeNode.registerTask !== 'function') { + throw new Error( + 'reactNativeUpdatePlugin: this hvigor cannot register build tasks; ' + + 'apply the plugin from a module hvigorfile with hvigor 5 or later.', + ); } - // Very old hvigor without task registration: keep the previous - // behaviour (write at apply time), still only for assemble builds. - writeMeta(); + safeNode.registerTask({ + name: TASK_NAME, + run: writeMeta, + dependencies: ['default@PreBuild'], + postDependencies: ['default@ProcessResource'], + }); }, }; } diff --git a/harmony/pushy/BuildProfile.ets b/harmony/pushy/BuildProfile.ets deleted file mode 100644 index 58c9fdc4..00000000 --- a/harmony/pushy/BuildProfile.ets +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Use these variables when you tailor your ArkTS code. They must be of the const type. - */ -import packageJson from '../../package.json' - -export const HAR_VERSION = packageJson.version; -export const BUILD_MODE_NAME = 'debug'; -export const DEBUG = true; -export const TARGET_NAME = 'default'; - -/** - * BuildProfile Class is used only for compatibility purposes. - */ -export default class BuildProfile { - static readonly HAR_VERSION = HAR_VERSION; - static readonly BUILD_MODE_NAME = BUILD_MODE_NAME; - static readonly DEBUG = DEBUG; - static readonly TARGET_NAME = TARGET_NAME; -} \ No newline at end of file diff --git a/harmony/pushy/OAT.xml b/harmony/pushy/OAT.xml deleted file mode 100644 index 55e3f13b..00000000 --- a/harmony/pushy/OAT.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - LICENSE - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/harmony/pushy/oh-package.json5 b/harmony/pushy/oh-package.json5 index bc3f7710..d4fcfe70 100644 --- a/harmony/pushy/oh-package.json5 +++ b/harmony/pushy/oh-package.json5 @@ -1,9 +1,6 @@ { license: 'MIT', types: '', - devDependencies: { - '@ohos/hypium': '1.0.19', - }, name: 'pushy', description: '', main: 'index.ets', diff --git a/harmony/pushy/src/main/cpp/CMakeLists.txt b/harmony/pushy/src/main/cpp/CMakeLists.txt index 1e242470..9a492418 100644 --- a/harmony/pushy/src/main/cpp/CMakeLists.txt +++ b/harmony/pushy/src/main/cpp/CMakeLists.txt @@ -7,38 +7,31 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_VERBOSE_MAKEFILE on) +# The shared native sources are compiled in place from the repository root +# (or the npm package root, which ships cpp/ and the android/jni submodules). set(REPO_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/../../../../../) -set(STAGED_NATIVE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/android-generated) -set(ANDROID_JNI_DIR ${REPO_ROOT}/android/jni) -if(NOT EXISTS ${ANDROID_JNI_DIR}/hpatch.c) - set(ANDROID_JNI_DIR ${STAGED_NATIVE_DIR}/jni) -endif() set(PATCH_CORE_DIR ${REPO_ROOT}/cpp/patch_core) -if(NOT EXISTS ${PATCH_CORE_DIR}/patch_core.cpp) - set(PATCH_CORE_DIR ${STAGED_NATIVE_DIR}/patch_core) -endif() set(UPDATE_FLOW_CORE_DIR ${REPO_ROOT}/cpp/update_flow_core) -if(NOT EXISTS ${UPDATE_FLOW_CORE_DIR}/update_flow_core.cpp) - set(UPDATE_FLOW_CORE_DIR ${STAGED_NATIVE_DIR}/update_flow_core) -endif() +set(HPATCH_DIR ${REPO_ROOT}/cpp/hpatch) +set(HDIFFPATCH_DIR ${REPO_ROOT}/android/jni/HDiffPatch) +set(LZMA_DIR ${REPO_ROOT}/android/jni/lzma) # Always compile from source. A prebuilt-import branch used to live here, but # it had no symbol verification: a stale libs//librnpushy.so missing a # newly added export (e.g. getSupportedDiffVersion) would silently ship and # crash getConstants at runtime. -set(HDIFFPATCH_DIR ${ANDROID_JNI_DIR}/HDiffPatch) -set(LZMA_DIR ${ANDROID_JNI_DIR}/lzma) +# +# The platform-neutral cores are picked up by directory (non-recursive, so +# tests/ stays out; the Android JNI glue lives in cpp/android_jni), so a new +# core source needs no edit here. CONFIGURE_DEPENDS re-globs on every build. +file(GLOB CORE_SOURCES CONFIGURE_DEPENDS + ${PATCH_CORE_DIR}/*.cpp + ${UPDATE_FLOW_CORE_DIR}/*.cpp + ${HPATCH_DIR}/*.c +) set(HDP_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/pushy.cpp - ${PATCH_CORE_DIR}/archive_patch_core.cpp - ${PATCH_CORE_DIR}/digest.cpp - ${PATCH_CORE_DIR}/hbc_transform.cpp - ${PATCH_CORE_DIR}/hbc_transform_wire.cpp - ${PATCH_CORE_DIR}/patch_core.cpp - ${PATCH_CORE_DIR}/state_core.cpp - ${UPDATE_FLOW_CORE_DIR}/flow_json.cpp - ${UPDATE_FLOW_CORE_DIR}/update_flow_core.cpp - ${ANDROID_JNI_DIR}/hpatch.c + ${CORE_SOURCES} ${HDIFFPATCH_DIR}/libHDiffPatch/HPatch/patch.c ${HDIFFPATCH_DIR}/file_for_patch.c ${LZMA_DIR}/C/LzmaDec.c @@ -49,14 +42,12 @@ add_library(rnpushy SHARED ${HDP_SOURCES} ) +# pushy.cpp includes the cores by name; the cores themselves (and hpatch's +# HDiffPatch/lzma includes) resolve file-relative. target_include_directories(rnpushy PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${PATCH_CORE_DIR} ${UPDATE_FLOW_CORE_DIR} - ${ANDROID_JNI_DIR} - ${HDIFFPATCH_DIR} - ${HDIFFPATCH_DIR}/libHDiffPatch/HPatch - ${LZMA_DIR}/C ) target_link_libraries(rnpushy PUBLIC diff --git a/harmony/pushy/src/main/cpp/PushyPackage.h b/harmony/pushy/src/main/cpp/PushyPackage.h index 52d16012..b8702e74 100644 --- a/harmony/pushy/src/main/cpp/PushyPackage.h +++ b/harmony/pushy/src/main/cpp/PushyPackage.h @@ -22,8 +22,8 @@ * SOFTWARE. */ -#ifndef GEO_LOCATION_PACKAGE_H -#define GEO_LOCATION_PACKAGE_H +#ifndef PUSHY_PACKAGE_H +#define PUSHY_PACKAGE_H #include "RNOH/Package.h" #include "PushyTurboModule.h" diff --git a/harmony/pushy/src/main/cpp/PushyTurboModule.cpp b/harmony/pushy/src/main/cpp/PushyTurboModule.cpp index 444606c0..5fb03eaa 100644 --- a/harmony/pushy/src/main/cpp/PushyTurboModule.cpp +++ b/harmony/pushy/src/main/cpp/PushyTurboModule.cpp @@ -3,82 +3,61 @@ using namespace facebook; using namespace rnoh; -namespace { - -jsi::Value CallSync( - jsi::Runtime &rt, - react::TurboModule &turboModule, - const char *methodName, - const jsi::Value *args, - size_t count) { - return jsi::Value( - static_cast(turboModule).call( - rt, methodName, args, count)); -} +// The JS-visible method table: (name, argument count, SYNC | ASYNC). +// +// This table is the only bridge RNOH gives JS into the ArkTS module: a method +// missing here is `undefined` on the JS side, and the JS feature-detects then +// treat the whole capability as "old native" and silently skip it — which is +// how the native cold-start check shipped dark on Harmony. Every spec method +// (src/NativePushy.ts) MUST be listed. +// +// Only the constants and the RN event plumbing are SYNC. Every +// Promise-returning spec method MUST be ASYNC: a sync registration converts +// the ArkTS Promise into an empty jsi object and its rejection becomes an +// unhandled ArkTS rejection, so JS would report e.g. setNeedUpdate as +// successful while nothing was switched. scripts/check-native-spec-parity.js +// enforces both rules against this table. +#define PUSHY_METHODS(X) \ + X(getConstants, 0, SYNC) \ + X(addListener, 1, SYNC) \ + X(removeListeners, 1, SYNC) \ + X(setLocalHashInfo, 2, ASYNC) \ + X(getLocalHashInfo, 1, ASYNC) \ + X(setUuid, 1, ASYNC) \ + X(reloadUpdate, 1, ASYNC) \ + X(restartApp, 0, ASYNC) \ + X(setNeedUpdate, 1, ASYNC) \ + X(markSuccess, 0, ASYNC) \ + X(downloadPatchFromPpk, 1, ASYNC) \ + X(downloadPatchFromPackage, 1, ASYNC) \ + X(downloadFullUpdate, 1, ASYNC) \ + X(downloadAndInstallApk, 1, ASYNC) \ + X(syncNativeConfig, 1, ASYNC) \ + X(getNativeCheckCache, 0, ASYNC) \ + X(markJsCheckCompleted, 1, ASYNC) \ + X(getBundleHash, 0, ASYNC) \ + X(resetToPackagedBundle, 0, ASYNC) -jsi::Value CallAsync( - jsi::Runtime &rt, - react::TurboModule &turboModule, - const char *methodName, - const jsi::Value *args, - size_t count) { - return jsi::Value( - static_cast(turboModule).callAsync( - rt, methodName, args, count)); -} +// ArkTSTurboModule entry point per kind. +#define PUSHY_INVOKE_SYNC call +#define PUSHY_INVOKE_ASYNC callAsync -#define PUSHY_SYNC_METHOD(method_name) \ - static jsi::Value HostFunction_##method_name( \ - jsi::Runtime &rt, \ - react::TurboModule &turboModule, \ - const jsi::Value *args, \ - size_t count) { \ - return CallSync(rt, turboModule, #method_name, args, count); \ - } +namespace { -#define PUSHY_ASYNC_METHOD(method_name) \ - static jsi::Value HostFunction_##method_name( \ +#define PUSHY_HOST_FUNCTION(method_name, arg_count, kind) \ + jsi::Value HostFunction_##method_name( \ jsi::Runtime &rt, \ react::TurboModule &turboModule, \ const jsi::Value *args, \ size_t count) { \ - return CallAsync(rt, turboModule, #method_name, args, count); \ + return jsi::Value( \ + static_cast(turboModule) \ + .PUSHY_INVOKE_##kind(rt, #method_name, args, count)); \ } -// Only the constants and the RN event plumbing are synchronous. Every -// Promise-returning spec method (src/NativePushy.ts) MUST be registered -// with PUSHY_ASYNC_METHOD: a sync registration converts the ArkTS Promise -// into an empty jsi object and its rejection becomes an unhandled ArkTS -// rejection, so JS would report e.g. setNeedUpdate as successful while -// nothing was switched. scripts/check-native-spec-parity.js enforces this. -PUSHY_SYNC_METHOD(getConstants) -PUSHY_SYNC_METHOD(addListener) -PUSHY_SYNC_METHOD(removeListeners) +PUSHY_METHODS(PUSHY_HOST_FUNCTION) -PUSHY_ASYNC_METHOD(setLocalHashInfo) -PUSHY_ASYNC_METHOD(getLocalHashInfo) -PUSHY_ASYNC_METHOD(setUuid) -PUSHY_ASYNC_METHOD(setNeedUpdate) -PUSHY_ASYNC_METHOD(markSuccess) -PUSHY_ASYNC_METHOD(reloadUpdate) -PUSHY_ASYNC_METHOD(restartApp) -PUSHY_ASYNC_METHOD(downloadPatchFromPpk) -PUSHY_ASYNC_METHOD(downloadPatchFromPackage) -PUSHY_ASYNC_METHOD(downloadFullUpdate) -PUSHY_ASYNC_METHOD(downloadAndInstallApk) -// 10.50+ additions. This table is the only bridge RNOH gives JS into the -// ArkTS module: a method missing here is `undefined` on the JS side, and the -// JS feature-detects then treat the whole capability as "old native" and -// silently skip it — which is how the native cold-start check shipped dark -// on Harmony. Every new spec method MUST be registered here. -PUSHY_ASYNC_METHOD(syncNativeConfig) -PUSHY_ASYNC_METHOD(getNativeCheckCache) -PUSHY_ASYNC_METHOD(markJsCheckCompleted) -PUSHY_ASYNC_METHOD(getBundleHash) -PUSHY_ASYNC_METHOD(resetToPackagedBundle) - -#undef PUSHY_SYNC_METHOD -#undef PUSHY_ASYNC_METHOD +#undef PUSHY_HOST_FUNCTION } // namespace @@ -86,33 +65,15 @@ PushyTurboModule::PushyTurboModule( const ArkTSTurboModule::Context ctx, const std::string name) : ArkTSTurboModule(ctx, name) { - const auto registerMethod = - [this](const std::string &methodName, - size_t argCount, - auto hostFunction) { - methodMap_[methodName] = MethodMetadata{argCount, hostFunction}; - }; +#define PUSHY_REGISTER_METHOD(method_name, arg_count, kind) \ + methodMap_[#method_name] = \ + MethodMetadata{arg_count, HostFunction_##method_name}; + + PUSHY_METHODS(PUSHY_REGISTER_METHOD) - registerMethod("getConstants", 0, HostFunction_getConstants); - registerMethod("setLocalHashInfo", 2, HostFunction_setLocalHashInfo); - registerMethod("getLocalHashInfo", 1, HostFunction_getLocalHashInfo); - registerMethod("setUuid", 1, HostFunction_setUuid); - registerMethod("reloadUpdate", 1, HostFunction_reloadUpdate); - registerMethod("restartApp", 0, HostFunction_restartApp); - registerMethod("setNeedUpdate", 1, HostFunction_setNeedUpdate); - registerMethod("markSuccess", 0, HostFunction_markSuccess); - registerMethod("downloadPatchFromPpk", 1, HostFunction_downloadPatchFromPpk); - registerMethod( - "downloadPatchFromPackage", 1, HostFunction_downloadPatchFromPackage); - registerMethod("downloadFullUpdate", 1, HostFunction_downloadFullUpdate); - registerMethod( - "downloadAndInstallApk", 1, HostFunction_downloadAndInstallApk); - registerMethod("addListener", 1, HostFunction_addListener); - registerMethod("removeListeners", 1, HostFunction_removeListeners); - registerMethod("syncNativeConfig", 1, HostFunction_syncNativeConfig); - registerMethod("getNativeCheckCache", 0, HostFunction_getNativeCheckCache); - registerMethod("markJsCheckCompleted", 1, HostFunction_markJsCheckCompleted); - registerMethod("getBundleHash", 0, HostFunction_getBundleHash); - registerMethod( - "resetToPackagedBundle", 0, HostFunction_resetToPackagedBundle); +#undef PUSHY_REGISTER_METHOD } + +#undef PUSHY_INVOKE_SYNC +#undef PUSHY_INVOKE_ASYNC +#undef PUSHY_METHODS diff --git a/harmony/pushy/src/main/cpp/pushy.cpp b/harmony/pushy/src/main/cpp/pushy.cpp index 31f35e7b..b198618f 100644 --- a/harmony/pushy/src/main/cpp/pushy.cpp +++ b/harmony/pushy/src/main/cpp/pushy.cpp @@ -1,7 +1,8 @@ #include #include "flow_json.h" -#include "update_flow_core.h" +#include "native_config.h" +#include "native_round.h" #include #include @@ -9,18 +10,18 @@ #include #include #include +#include #include +#include "archive_limits.h" #include "archive_patch_core.h" #include "digest.h" +#include "install_record.h" #include "hbc_transform_wire.h" #include "patch_core.h" #include "state_core.h" #include "state_ops.h" -extern "C" { -} - namespace { using pushy::state_ops::StateOperation; @@ -317,7 +318,11 @@ napi_value NewBoolean(napi_env env, bool value) { napi_value NewString(napi_env env, const std::string& value) { napi_value result = nullptr; - napi_create_string_utf8(env, value.c_str(), value.size(), &result); + if (napi_create_string_utf8(env, value.c_str(), value.size(), &result) != + napi_ok) { + ThrowError(env, "Failed to create string"); + return nullptr; + } return result; } @@ -340,14 +345,19 @@ void SetBoolProperty( napi_set_named_property(env, object, name, NewBoolean(env, value)); } +// Per-operation outputs reported next to the state; all default to "nothing". +struct StateResultExtras { + bool changed = false; + std::string stale_version_to_delete; + std::string load_version; + bool did_rollback = false; + bool consumed_first_time = false; +}; + napi_value NewStateResult( napi_env env, const pushy::state::State& state, - bool changed, - const std::string& stale_version_to_delete, - const std::string& load_version, - bool did_rollback, - bool consumed_first_time) { + const StateResultExtras& extras = StateResultExtras()) { napi_value result = nullptr; napi_create_object(env, &result); @@ -358,21 +368,12 @@ napi_value NewStateResult( SetBoolProperty(env, result, "firstTime", state.first_time); SetBoolProperty(env, result, "firstTimeOk", state.first_time_ok); SetStringProperty(env, result, "rolledBackVersion", state.rolled_back_version); - SetBoolProperty(env, result, "changed", changed); - SetStringProperty(env, result, "staleVersionToDelete", stale_version_to_delete); - SetStringProperty(env, result, "loadVersion", load_version); - SetBoolProperty(env, result, "didRollback", did_rollback); - SetBoolProperty(env, result, "consumedFirstTime", consumed_first_time); - return result; -} - -napi_value NewArchivePatchPlanResult( - napi_env env, - const pushy::archive_patch::ArchivePatchPlan& plan) { - napi_value result = nullptr; - napi_create_object(env, &result); - SetStringProperty(env, result, "mergeSourceSubdir", plan.merge_source_subdir); - SetBoolProperty(env, result, "enableMerge", plan.enable_merge); + SetBoolProperty(env, result, "changed", extras.changed); + SetStringProperty( + env, result, "staleVersionToDelete", extras.stale_version_to_delete); + SetStringProperty(env, result, "loadVersion", extras.load_version); + SetBoolProperty(env, result, "didRollback", extras.did_rollback); + SetBoolProperty(env, result, "consumedFirstTime", extras.consumed_first_time); return result; } @@ -397,24 +398,18 @@ napi_value NewCopyGroupArray( NewString(env, groups[index].to_paths[target_index])); } napi_set_named_property(env, group, "toPaths", to_paths); + if (groups[index].has_expected_crc) { + // A CRC32 fits a double exactly, like the manifest's JSON number. + napi_value crc = nullptr; + napi_create_double( + env, static_cast(groups[index].expected_crc), &crc); + napi_set_named_property(env, group, "expectedCrc", crc); + } napi_set_element(env, result, index, group); } return result; } -pushy::delta::PatchManifest BuildManifest( - const std::vector& copy_froms, - const std::vector& copy_tos, - const std::vector& deletes) { - pushy::delta::PatchManifest manifest; - for (size_t index = 0; index < copy_froms.size(); ++index) { - manifest.copies.push_back( - pushy::delta::CopyOperation{copy_froms[index], copy_tos[index]}); - } - manifest.deletes = deletes; - return manifest; -} - napi_value SyncStateWithBinaryVersion(napi_env env, napi_callback_info info) { napi_value args[3] = {nullptr, nullptr, nullptr}; size_t argc = 3; @@ -439,7 +434,9 @@ napi_value SyncStateWithBinaryVersion(napi_env env, napi_callback_info info) { const pushy::state::BinaryVersionSyncResult result = pushy::state::SyncBinaryVersion(state, package_version, build_time); - return NewStateResult(env, result.state, result.changed, std::string(), std::string(), false, false); + StateResultExtras extras; + extras.changed = result.changed; + return NewStateResult(env, result.state, extras); } napi_value RunStateCore(napi_env env, napi_callback_info info) { @@ -480,59 +477,32 @@ napi_value RunStateCore(napi_env env, napi_callback_info info) { switch (static_cast(operation)) { case StateOperation::kSwitchVersion: - return NewStateResult( - env, - pushy::state::SwitchVersion(state, string_arg), - false, - std::string(), - std::string(), - false, - false); + return NewStateResult(env, pushy::state::SwitchVersion(state, string_arg)); case StateOperation::kMarkSuccess: { const pushy::state::MarkSuccessResult result = pushy::state::MarkSuccess(state); - return NewStateResult( - env, - result.state, - false, - result.stale_version_to_delete, - std::string(), - false, - false); + StateResultExtras extras; + extras.stale_version_to_delete = result.stale_version_to_delete; + return NewStateResult(env, result.state, extras); } case StateOperation::kRollback: { const pushy::state::State next = pushy::state::Rollback(state); - return NewStateResult( - env, next, false, std::string(), next.current_version, true, false); + StateResultExtras extras; + extras.load_version = next.current_version; + extras.did_rollback = true; + return NewStateResult(env, next, extras); } case StateOperation::kClearFirstTime: - return NewStateResult( - env, - pushy::state::ClearFirstTime(state), - false, - std::string(), - std::string(), - false, - false); + return NewStateResult(env, pushy::state::ClearFirstTime(state)); case StateOperation::kClearRollbackMark: - return NewStateResult( - env, - pushy::state::ClearRollbackMark(state), - false, - std::string(), - std::string(), - false, - false); + return NewStateResult(env, pushy::state::ClearRollbackMark(state)); case StateOperation::kResolveLaunch: { const pushy::state::LaunchDecision decision = pushy::state::ResolveLaunchState(state, flag_a, flag_b); - return NewStateResult( - env, - decision.state, - false, - std::string(), - decision.load_version, - decision.did_rollback, - decision.consumed_first_time); + StateResultExtras extras; + extras.load_version = decision.load_version; + extras.did_rollback = decision.did_rollback; + extras.consumed_first_time = decision.consumed_first_time; + return NewStateResult(env, decision.state, extras); } } @@ -540,105 +510,10 @@ napi_value RunStateCore(napi_env env, napi_callback_info info) { return nullptr; } -napi_value BuildArchivePatchPlan(napi_env env, napi_callback_info info) { - napi_value args[6] = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}; - size_t argc = 6; - if (!GetArgCount(env, info, &argc, args) || argc < 5) { - ThrowError(env, "Wrong number of arguments"); - return nullptr; - } - - int32_t patch_type = 0; - if (!GetInt32(env, args[0], &patch_type)) { - return nullptr; - } - - std::vector entry_names; - std::vector copy_froms; - std::vector copy_tos; - std::vector deletes; - if (!GetStringArray(env, args[1], &entry_names) || - !GetStringArray(env, args[2], ©_froms) || - !GetStringArray(env, args[3], ©_tos) || - !GetStringArray(env, args[4], &deletes)) { - return nullptr; - } - if (copy_froms.size() != copy_tos.size()) { - ThrowError(env, "copyFroms and copyTos length mismatch"); - return nullptr; - } - - std::string bundle_patch_entry_name = - pushy::archive_patch::DefaultBundleDeltaEntryName(); - if (argc >= 6) { - bool ok = false; - const std::string candidate = GetString(env, args[5], &ok); - if (!ok) { - return nullptr; - } - if (!candidate.empty()) { - bundle_patch_entry_name = candidate; - } - } - - const pushy::delta::PatchManifest manifest = - BuildManifest(copy_froms, copy_tos, deletes); - pushy::archive_patch::ArchivePatchType archive_type; - if (!pushy::archive_patch::TryParseArchivePatchType(patch_type, &archive_type)) { - ThrowError(env, "Unknown archive delta type"); - return nullptr; - } - pushy::archive_patch::ArchivePatchPlan plan; - const pushy::delta::Status status = pushy::archive_patch::BuildArchivePatchPlan( - archive_type, - manifest, - entry_names, - &plan, - bundle_patch_entry_name); - if (!status.ok) { - ThrowError(env, status.message); - return nullptr; - } - - return NewArchivePatchPlanResult(env, plan); -} - -napi_value BuildCopyGroups(napi_env env, napi_callback_info info) { - napi_value args[2] = {nullptr, nullptr}; - size_t argc = 2; - if (!GetArgCount(env, info, &argc, args) || argc < 2) { - ThrowError(env, "Wrong number of arguments"); - return nullptr; - } - - std::vector copy_froms; - std::vector copy_tos; - if (!GetStringArray(env, args[0], ©_froms) || - !GetStringArray(env, args[1], ©_tos)) { - return nullptr; - } - if (copy_froms.size() != copy_tos.size()) { - ThrowError(env, "copyFroms and copyTos length mismatch"); - return nullptr; - } - - const pushy::delta::PatchManifest manifest = - BuildManifest(copy_froms, copy_tos, std::vector()); - std::vector groups; - const pushy::delta::Status status = - pushy::archive_patch::BuildCopyGroups(manifest, &groups); - if (!status.ok) { - ThrowError(env, status.message); - return nullptr; - } - - return NewCopyGroupArray(env, groups); -} - // --------------------------------------------------------------------------- // Async work plumbing for the heavy patch operations. // -// applyDeltaFromSource and cleanupOldEntries run hdiff / recursive file IO +// applyArchiveDelta and cleanupOldEntries run hdiff / recursive file IO // that can take hundreds of ms to seconds. The Pushy TurboModule executes on // the UI thread, so running these synchronously froze the UI. These are now // wrapped in napi_create_async_work: arguments are parsed on the JS thread, the @@ -646,8 +521,7 @@ napi_value BuildCopyGroups(napi_env env, napi_callback_info info) { // back on the JS thread. // --------------------------------------------------------------------------- -// Reject an already-created deferred with an Error(message). Used when async -// work fails to be created/queued, so the Promise never hangs pending. +// Reject an already-created deferred with an Error(message). void RejectDeferredWithMessage( napi_env env, napi_deferred deferred, @@ -659,16 +533,102 @@ void RejectDeferredWithMessage( napi_reject_deferred(env, deferred, error); } -struct ApplyPatchWork { +void ResolveDeferredWithUndefined(napi_env env, napi_deferred deferred) { + napi_value undefined_value = nullptr; + napi_get_undefined(env, &undefined_value); + napi_resolve_deferred(env, deferred, undefined_value); +} + +// Resolves with undefined, or rejects with the status message. +void SettleWithStatus( + napi_env env, + napi_deferred deferred, + const pushy::delta::Status& status) { + if (status.ok) { + ResolveDeferredWithUndefined(env, deferred); + } else { + RejectDeferredWithMessage(env, deferred, status.message.c_str()); + } +} + +template +struct AsyncCall { napi_async_work work = nullptr; napi_deferred deferred = nullptr; - pushy::delta::FileSourcePatchOptions options; + W data; + void (*execute)(W&) = nullptr; + void (*settle)(napi_env, napi_deferred, const W&) = nullptr; +}; + +// Runs execute(data) on a libuv worker thread and returns a Promise that +// settle(env, deferred, data) settles back on the JS thread. Every path +// settles the promise exactly once and frees the call: a creation or queue +// failure rejects immediately, and work that was cancelled or aborted before +// it ran is rejected instead of settled from meaningless results. +template +napi_value QueueWork( + napi_env env, + const char* name, + W data, + void (*execute)(W&), + void (*settle)(napi_env, napi_deferred, const W&)) { + auto* call = new AsyncCall(); + call->data = std::move(data); + call->execute = execute; + call->settle = settle; + + napi_value promise = nullptr; + if (napi_create_promise(env, &call->deferred, &promise) != napi_ok) { + delete call; + ThrowError(env, "Unable to create promise"); + return nullptr; + } + + napi_value resource_name = nullptr; + napi_create_string_utf8(env, name, NAPI_AUTO_LENGTH, &resource_name); + if (napi_create_async_work( + env, + nullptr, + resource_name, + [](napi_env, void* raw) { + auto* c = static_cast*>(raw); + c->execute(c->data); + }, + [](napi_env cb_env, napi_status status, void* raw) { + auto* c = static_cast*>(raw); + if (status != napi_ok) { + RejectDeferredWithMessage(cb_env, c->deferred, "async work aborted"); + } else { + c->settle(cb_env, c->deferred, c->data); + } + napi_delete_async_work(cb_env, c->work); + delete c; + }, + call, + &call->work) != napi_ok) { + // Work was never created: settle the promise and free the call so it + // neither leaks nor hangs pending forever. + RejectDeferredWithMessage(env, call->deferred, "Unable to create async work"); + delete call; + return promise; + } + if (napi_queue_async_work(env, call->work) != napi_ok) { + // Queueing failed: the complete callback will never run, so clean up here. + napi_delete_async_work(env, call->work); + RejectDeferredWithMessage(env, call->deferred, "Unable to queue async work"); + delete call; + return promise; + } + return promise; +} + +struct ApplyArchiveDeltaWork { + pushy::archive_patch::ArchivePatchRequest request; + std::vector host_copies; pushy::delta::Status status{false, ""}; }; struct CleanupWork { - napi_async_work work = nullptr; - napi_deferred deferred = nullptr; std::string root_dir; // Every version that may still be in use: persisted current/last AND the // version this process booted from (two switches without a restart must not @@ -682,8 +642,6 @@ struct CleanupWork { // of MB; hashing it synchronously on the UI thread (where the TurboModule // runs) froze the app for hundreds of ms per archive/bundle/activation. struct Sha256FileWork { - napi_async_work work = nullptr; - napi_deferred deferred = nullptr; std::string path; std::string hex; }; @@ -693,8 +651,6 @@ struct Sha256FileWork { // the HAP). The fd is never closed here; JS closes it after the promise // settles. struct Sha256RangeWork { - napi_async_work work = nullptr; - napi_deferred deferred = nullptr; int fd = -1; int64_t offset = 0; int64_t length = 0; @@ -704,34 +660,34 @@ struct Sha256RangeWork { // pread loop (positioned: never moves a file offset the caller may share). // A short read before `length` bytes is an error, not EOF. -void HashFdRange(Sha256RangeWork* w) { +void HashFdRange(Sha256RangeWork& w) { pushy::digest::Sha256 hasher; std::vector buffer(256 * 1024); - int64_t position = w->offset; - int64_t remaining = w->length; + int64_t position = w.offset; + int64_t remaining = w.length; while (remaining > 0) { const size_t want = static_cast( remaining < static_cast(buffer.size()) ? remaining : static_cast(buffer.size())); - const ssize_t read = pread(w->fd, buffer.data(), want, static_cast(position)); + const ssize_t read = pread(w.fd, buffer.data(), want, static_cast(position)); if (read < 0) { if (errno == EINTR) { continue; } - w->error = "sha256HexRawFdAsync: read failed (errno " + + w.error = "sha256HexRawFdAsync: read failed (errno " + std::to_string(errno) + ")"; return; } if (read == 0) { - w->error = "sha256HexRawFdAsync: unexpected end of file"; + w.error = "sha256HexRawFdAsync: unexpected end of file"; return; } hasher.Update(buffer.data(), static_cast(read)); position += read; remaining -= read; } - w->hex = hasher.HexDigest(); + w.hex = hasher.HexDigest(); } bool GetNonNegativeInt64( @@ -751,7 +707,11 @@ bool GetNonNegativeInt64( return true; } -napi_value ApplyPatchFromFileSource(napi_env env, napi_callback_info info) { +// applyArchiveDelta(request) -> Promise. The manifest +// text is parsed (with the rest of the patch) on the worker thread; the +// promise resolves with the copies the caller still owes (empty unless +// hostCopiesResources) and rejects with the core's message. +napi_value ApplyArchiveDelta(napi_env env, napi_callback_info info) { napi_value args[1] = {nullptr}; size_t argc = 1; if (!GetArgCount(env, info, &argc, args) || argc < 1) { @@ -762,105 +722,60 @@ napi_value ApplyPatchFromFileSource(napi_env env, napi_callback_info info) { return nullptr; } - std::vector copy_froms; - std::vector copy_tos; - std::vector deletes; - std::string source_root; - std::string target_root; - std::string origin_bundle_path; - std::string bundle_patch_path; - std::string bundle_output_path; - std::string merge_source_subdir; - std::string bundle_hbc_transform_meta; - bool enable_merge = true; - - if (!GetOptionalStringArrayProperty(env, args[0], "copyFroms", ©_froms) || - !GetOptionalStringArrayProperty(env, args[0], "copyTos", ©_tos) || - !GetOptionalStringArrayProperty(env, args[0], "deletes", &deletes) || - !GetOptionalStringProperty(env, args[0], "sourceRoot", &source_root) || - !GetOptionalStringProperty(env, args[0], "targetRoot", &target_root) || - !GetOptionalStringProperty(env, args[0], "originBundlePath", &origin_bundle_path) || - !GetOptionalStringProperty(env, args[0], "bundleDeltaPath", &bundle_patch_path) || - !GetOptionalStringProperty(env, args[0], "bundleOutputPath", &bundle_output_path) || - !GetOptionalStringProperty(env, args[0], "mergeSourceSubdir", &merge_source_subdir) || + ApplyArchiveDeltaWork work; + pushy::archive_patch::ArchivePatchRequest& request = work.request; + napi_value patch_type_value = nullptr; + int32_t patch_type = 0; + std::string bundle_patch_entry_name; + if (!GetNamedProperty(env, args[0], "archiveType", &patch_type_value) || + !GetInt32(env, patch_type_value, &patch_type) || + !GetOptionalStringArrayProperty( + env, args[0], "entryNames", &request.archive.entry_names) || + !GetOptionalStringProperty( + env, args[0], "manifestJson", &request.archive.manifest_json) || + !GetOptionalStringProperty( + env, args[0], "bundleDeltaEntry", &bundle_patch_entry_name) || + !GetOptionalStringProperty( + env, args[0], "copySourcePrefix", &request.archive.copy_source_prefix) || + !GetOptionalStringProperty(env, args[0], "sourceRoot", &request.source_root) || + !GetOptionalStringProperty(env, args[0], "targetRoot", &request.target_root) || !GetOptionalStringProperty( - env, args[0], "bundleHbcTransformMeta", &bundle_hbc_transform_meta) || - !GetOptionalBoolProperty(env, args[0], "enableMerge", true, &enable_merge)) { + env, args[0], "originBundlePath", &request.origin_bundle_path) || + !GetOptionalStringProperty( + env, args[0], "bundleDeltaPath", &request.bundle_patch_path) || + !GetOptionalStringProperty( + env, args[0], "bundleOutputPath", &request.bundle_output_path) || + !GetOptionalBoolProperty( + env, args[0], "hostCopiesResources", false, + &request.host_copies_resources)) { return nullptr; } - if (copy_froms.size() != copy_tos.size()) { - ThrowError(env, "copyFroms and copyTos length mismatch"); + // The shared parser keeps Android and HarmonyOS in agreement on which + // integer values are valid; neither silently falls back to kFull. + if (!pushy::archive_patch::TryParseArchivePatchType( + patch_type, &request.archive.type)) { + ThrowError(env, "Unknown archive delta type"); return nullptr; } - - auto* work_data = new ApplyPatchWork(); - work_data->options.manifest = BuildManifest(copy_froms, copy_tos, deletes); - work_data->options.source_root = source_root; - work_data->options.target_root = target_root; - work_data->options.origin_bundle_path = origin_bundle_path; - work_data->options.bundle_patch_path = bundle_patch_path; - work_data->options.bundle_output_path = bundle_output_path; - work_data->options.merge_source_subdir = merge_source_subdir; - work_data->options.bundle_hbc_transform_meta = bundle_hbc_transform_meta; - work_data->options.enable_merge = enable_merge; - - napi_value promise = nullptr; - if (napi_create_promise(env, &work_data->deferred, &promise) != napi_ok) { - delete work_data; - ThrowError(env, "Unable to create promise"); - return nullptr; + if (!bundle_patch_entry_name.empty()) { + request.archive.bundle_patch_entry_name = bundle_patch_entry_name; } - napi_value resource_name = nullptr; - napi_create_string_utf8( - env, "applyDeltaFromSource", NAPI_AUTO_LENGTH, &resource_name); - if (napi_create_async_work( - env, - nullptr, - resource_name, - [](napi_env, void* data) { - auto* w = static_cast(data); - w->status = pushy::delta::ApplyPatchFromFileSource(w->options); - }, - [](napi_env cb_env, napi_status status, void* data) { - auto* w = static_cast(data); - if (status != napi_ok) { - // Cancelled/aborted before execute ran: w->status is - // meaningless; still settle the promise so it never hangs. - RejectDeferredWithMessage(cb_env, w->deferred, "async work aborted"); - } else if (w->status.ok) { - napi_value undefined_value = nullptr; - napi_get_undefined(cb_env, &undefined_value); - napi_resolve_deferred(cb_env, w->deferred, undefined_value); - } else { - napi_value error = nullptr; - napi_value message = nullptr; - napi_create_string_utf8( - cb_env, w->status.message.c_str(), NAPI_AUTO_LENGTH, &message); - napi_create_error(cb_env, nullptr, message, &error); - napi_reject_deferred(cb_env, w->deferred, error); - } - napi_delete_async_work(cb_env, w->work); - delete w; - }, - work_data, - &work_data->work) != napi_ok) { - // Work was never created: settle the promise and free the data so it does - // not leak / hang pending forever. - RejectDeferredWithMessage( - env, work_data->deferred, "Unable to create async work"); - delete work_data; - return promise; - } - if (napi_queue_async_work(env, work_data->work) != napi_ok) { - // Queued failed: the complete callback will never run, so clean up here. - napi_delete_async_work(env, work_data->work); - RejectDeferredWithMessage( - env, work_data->deferred, "Unable to queue async work"); - delete work_data; - return promise; - } - return promise; + return QueueWork( + env, + "applyArchiveDelta", + std::move(work), + [](ApplyArchiveDeltaWork& w) { + w.status = + pushy::archive_patch::ApplyArchivePatch(w.request, &w.host_copies); + }, + [](napi_env cb_env, napi_deferred deferred, const ApplyArchiveDeltaWork& w) { + if (!w.status.ok) { + RejectDeferredWithMessage(cb_env, deferred, w.status.message.c_str()); + return; + } + napi_resolve_deferred(cb_env, deferred, NewCopyGroupArray(cb_env, w.host_copies)); + }); } // cleanupOldEntries(rootDir, keepNames: string[], maxAgeDays) @@ -886,64 +801,22 @@ napi_value CleanupOldEntries(napi_env env, napi_callback_info info) { return nullptr; } - auto* work_data = new CleanupWork(); - work_data->root_dir = root_dir; - work_data->keep_names = std::move(keep_names); - work_data->max_age_days = max_age_days; - - napi_value promise = nullptr; - if (napi_create_promise(env, &work_data->deferred, &promise) != napi_ok) { - delete work_data; - ThrowError(env, "Unable to create promise"); - return nullptr; - } + CleanupWork work; + work.root_dir = root_dir; + work.keep_names = std::move(keep_names); + work.max_age_days = max_age_days; - napi_value resource_name = nullptr; - napi_create_string_utf8( - env, "cleanupOldEntries", NAPI_AUTO_LENGTH, &resource_name); - if (napi_create_async_work( - env, - nullptr, - resource_name, - [](napi_env, void* data) { - auto* w = static_cast(data); - w->status = pushy::delta::CleanupOldEntries( - w->root_dir, w->keep_names, w->max_age_days); - }, - [](napi_env cb_env, napi_status status, void* data) { - auto* w = static_cast(data); - if (status != napi_ok) { - RejectDeferredWithMessage(cb_env, w->deferred, "async work aborted"); - } else if (w->status.ok) { - napi_value undefined_value = nullptr; - napi_get_undefined(cb_env, &undefined_value); - napi_resolve_deferred(cb_env, w->deferred, undefined_value); - } else { - napi_value error = nullptr; - napi_value message = nullptr; - napi_create_string_utf8( - cb_env, w->status.message.c_str(), NAPI_AUTO_LENGTH, &message); - napi_create_error(cb_env, nullptr, message, &error); - napi_reject_deferred(cb_env, w->deferred, error); - } - napi_delete_async_work(cb_env, w->work); - delete w; - }, - work_data, - &work_data->work) != napi_ok) { - RejectDeferredWithMessage( - env, work_data->deferred, "Unable to create async work"); - delete work_data; - return promise; - } - if (napi_queue_async_work(env, work_data->work) != napi_ok) { - napi_delete_async_work(env, work_data->work); - RejectDeferredWithMessage( - env, work_data->deferred, "Unable to queue async work"); - delete work_data; - return promise; - } - return promise; + return QueueWork( + env, + "cleanupOldEntries", + std::move(work), + [](CleanupWork& w) { + w.status = pushy::delta::CleanupOldEntries( + w.root_dir, w.keep_names, w.max_age_days); + }, + [](napi_env cb_env, napi_deferred deferred, const CleanupWork& w) { + SettleWithStatus(cb_env, deferred, w.status); + }); } // sha256HexFileAsync(path) -> Promise. Rejects when the file cannot be @@ -965,55 +838,22 @@ napi_value Sha256HexFileAsync(napi_env env, napi_callback_info info) { return nullptr; } - auto* work_data = new Sha256FileWork(); - work_data->path = std::move(path); + Sha256FileWork work; + work.path = std::move(path); - napi_value promise = nullptr; - if (napi_create_promise(env, &work_data->deferred, &promise) != napi_ok) { - delete work_data; - ThrowError(env, "Unable to create promise"); - return nullptr; - } - - napi_value resource_name = nullptr; - napi_create_string_utf8( - env, "sha256HexFileAsync", NAPI_AUTO_LENGTH, &resource_name); - if (napi_create_async_work( - env, - nullptr, - resource_name, - [](napi_env, void* data) { - auto* w = static_cast(data); - w->hex = pushy::digest::Sha256File(w->path); - }, - [](napi_env cb_env, napi_status status, void* data) { - auto* w = static_cast(data); - if (status != napi_ok) { - RejectDeferredWithMessage(cb_env, w->deferred, "async work aborted"); - } else if (w->hex.empty()) { - RejectDeferredWithMessage( - cb_env, w->deferred, "sha256HexFileAsync: cannot read file"); - } else { - napi_resolve_deferred(cb_env, w->deferred, NewString(cb_env, w->hex)); - } - napi_delete_async_work(cb_env, w->work); - delete w; - }, - work_data, - &work_data->work) != napi_ok) { - RejectDeferredWithMessage( - env, work_data->deferred, "Unable to create async work"); - delete work_data; - return promise; - } - if (napi_queue_async_work(env, work_data->work) != napi_ok) { - napi_delete_async_work(env, work_data->work); - RejectDeferredWithMessage( - env, work_data->deferred, "Unable to queue async work"); - delete work_data; - return promise; - } - return promise; + return QueueWork( + env, + "sha256HexFileAsync", + std::move(work), + [](Sha256FileWork& w) { w.hex = pushy::digest::Sha256File(w.path); }, + [](napi_env cb_env, napi_deferred deferred, const Sha256FileWork& w) { + if (w.hex.empty()) { + RejectDeferredWithMessage( + cb_env, deferred, "sha256HexFileAsync: cannot read file"); + } else { + napi_resolve_deferred(cb_env, deferred, NewString(cb_env, w.hex)); + } + }); } // sha256HexRawFdAsync(fd, offset, length) -> Promise, hashed on a libuv @@ -1042,59 +882,266 @@ napi_value Sha256HexRawFdAsync(napi_env env, napi_callback_info info) { return nullptr; } - auto* work_data = new Sha256RangeWork(); - work_data->fd = fd; - work_data->offset = offset; - work_data->length = length; + Sha256RangeWork work; + work.fd = fd; + work.offset = offset; + work.length = length; - napi_value promise = nullptr; - if (napi_create_promise(env, &work_data->deferred, &promise) != napi_ok) { - delete work_data; - ThrowError(env, "Unable to create promise"); + return QueueWork( + env, + "sha256HexRawFdAsync", + std::move(work), + HashFdRange, + [](napi_env cb_env, napi_deferred deferred, const Sha256RangeWork& w) { + if (!w.error.empty() || w.hex.empty()) { + RejectDeferredWithMessage( + cb_env, + deferred, + w.error.empty() ? "sha256HexRawFdAsync: no digest" + : w.error.c_str()); + } else { + napi_resolve_deferred(cb_env, deferred, NewString(cb_env, w.hex)); + } + }); +} + +// ---- Completion records (cpp/patch_core/install_record.h) ---- + +// Reads the first `count` arguments as strings, of which the first +// `non_empty` must not be empty; throws and returns false otherwise. +bool GetStringArgs( + napi_env env, + napi_callback_info info, + const char* name, + size_t count, + size_t non_empty, + std::string* out) { + napi_value args[5] = {nullptr, nullptr, nullptr, nullptr, nullptr}; + size_t argc = count; + if (count > 5 || !GetArgCount(env, info, &argc, args) || argc < count) { + ThrowError(env, std::string(name) + ": wrong number of arguments"); + return false; + } + for (size_t i = 0; i < count; i++) { + bool ok = false; + out[i] = GetString(env, args[i], &ok); + if (!ok) { + return false; + } + if (i < non_empty && out[i].empty()) { + ThrowError(env, std::string(name) + ": argument " + std::to_string(i) + + " must be a non-empty string"); + return false; + } + } + return true; +} + +// isInstallComplete(versionDir, versionHash, bundleFileName) -> boolean. +// Synchronous: a stat and a read of the (at most 64 KB) record, no digest. +napi_value InstallRecordIsComplete(napi_env env, napi_callback_info info) { + std::string args[3]; + if (!GetStringArgs(env, info, "isInstallComplete", 3, 3, args)) { return nullptr; } + return NewBoolean(env, pushy::install_record::IsComplete(args[0], args[1], args[2])); +} - napi_value resource_name = nullptr; - napi_create_string_utf8( - env, "sha256HexRawFdAsync", NAPI_AUTO_LENGTH, &resource_name); - if (napi_create_async_work( - env, - nullptr, - resource_name, - [](napi_env, void* data) { - HashFdRange(static_cast(data)); - }, - [](napi_env cb_env, napi_status status, void* data) { - auto* w = static_cast(data); - if (status != napi_ok) { - RejectDeferredWithMessage(cb_env, w->deferred, "async work aborted"); - } else if (!w->error.empty() || w->hex.empty()) { - RejectDeferredWithMessage( - cb_env, - w->deferred, - w->error.empty() ? "sha256HexRawFdAsync: no digest" - : w->error.c_str()); - } else { - napi_resolve_deferred(cb_env, w->deferred, NewString(cb_env, w->hex)); - } - napi_delete_async_work(cb_env, w->work); - delete w; - }, - work_data, - &work_data->work) != napi_ok) { - RejectDeferredWithMessage( - env, work_data->deferred, "Unable to create async work"); - delete work_data; - return promise; +// recordedBundleSha256(versionDir) -> string; "" when the record has none. +// Synchronous like isInstallComplete. +napi_value InstallRecordBundleSha256(napi_env env, napi_callback_info info) { + std::string args[1]; + if (!GetStringArgs(env, info, "recordedBundleSha256", 1, 1, args)) { + return nullptr; } - if (napi_queue_async_work(env, work_data->work) != napi_ok) { - napi_delete_async_work(env, work_data->work); - RejectDeferredWithMessage( - env, work_data->deferred, "Unable to queue async work"); - delete work_data; - return promise; + return NewString(env, pushy::install_record::RecordedBundleSha256(args[0])); +} + +// One install_record call on a libuv worker thread: hashing a bundle (tens +// of MB) or fsyncing must not block the JS thread. +struct InstallRecordWork { + std::string args[4]; + bool grandfathered = false; + pushy::delta::Status status{false, ""}; +}; + +void SettleInstallRecordWork( + napi_env env, + napi_deferred deferred, + const InstallRecordWork& w) { + SettleWithStatus(env, deferred, w.status); +} + +// sealInstallRecord(stagingDir, versionHash, bundleFileName, artifactSha256) +// -> Promise: hashes the staged bundle and writes the record. +napi_value SealInstallRecord(napi_env env, napi_callback_info info) { + InstallRecordWork work; + if (!GetStringArgs(env, info, "sealInstallRecord", 4, 3, work.args)) { + return nullptr; } - return promise; + return QueueWork( + env, + "sealInstallRecord", + std::move(work), + [](InstallRecordWork& w) { + w.status = pushy::install_record::Seal( + w.args[0], w.args[1], w.args[2], w.args[3], std::string()) + .status; + }, + SettleInstallRecordWork); +} + +// promoteStaging(stagingDir, versionDir) -> Promise: renames the sealed +// staging directory to the (removed) version directory. +napi_value PromoteStaging(napi_env env, napi_callback_info info) { + InstallRecordWork work; + if (!GetStringArgs(env, info, "promoteStaging", 2, 2, work.args)) { + return nullptr; + } + return QueueWork( + env, + "promoteStaging", + std::move(work), + [](InstallRecordWork& w) { + w.status = pushy::install_record::Promote(w.args[0], w.args[1]); + }, + SettleInstallRecordWork); +} + +// checkActivation(versionDir, versionHash, bundleFileName, grandfathered) +// -> Promise, rejected with the reason the version must not be +// activated. +napi_value CheckActivation(napi_env env, napi_callback_info info) { + InstallRecordWork work; + if (!GetStringArgs(env, info, "checkActivation", 3, 3, work.args)) { + return nullptr; + } + napi_value args[4] = {nullptr, nullptr, nullptr, nullptr}; + size_t argc = 4; + if (!GetArgCount(env, info, &argc, args) || argc < 4) { + ThrowError(env, "checkActivation: wrong number of arguments"); + return nullptr; + } + if (!GetBoolean(env, args[3], &work.grandfathered)) { + return nullptr; + } + return QueueWork( + env, + "checkActivation", + std::move(work), + [](InstallRecordWork& w) { + pushy::install_record::Activation activation; + w.status = pushy::install_record::CheckActivation( + w.args[0], w.args[1], w.args[2], w.grandfathered, &activation); + }, + SettleInstallRecordWork); +} + +// ---- Archive extraction limits (cpp/patch_core/archive_limits.h) ---- + +// Reads `count` number arguments (byte counts: integers below 2^53). +bool GetInt64Args( + napi_env env, + napi_callback_info info, + const char* name, + size_t count, + int64_t* out) { + napi_value args[4] = {nullptr, nullptr, nullptr, nullptr}; + size_t argc = count; + if (count > 4 || !GetArgCount(env, info, &argc, args) || argc < count) { + ThrowError(env, std::string(name) + ": wrong number of arguments"); + return false; + } + for (size_t i = 0; i < count; i++) { + napi_valuetype type = napi_undefined; + if (!GetValueType(env, args[i], &type) || type != napi_number || + napi_get_value_int64(env, args[i], &out[i]) != napi_ok) { + ThrowError(env, std::string(name) + ": argument " + std::to_string(i) + + " must be a number"); + return false; + } + } + return true; +} + +// checkUncompressedSize(archiveBytes, uncompressedBytes) -> string | undefined: +// the reason the whole archive's expansion is refused. +napi_value CheckUncompressedSize(napi_env env, napi_callback_info info) { + int64_t args[2] = {0, 0}; + if (!GetInt64Args(env, info, "checkUncompressedSize", 2, args)) { + return nullptr; + } + const pushy::delta::Status status = + pushy::archive_limits::CheckUncompressedSize(args[0], args[1]); + return status.ok ? nullptr : NewString(env, status.message); +} + +// planFreeSpaceProbe(knownTotal, writtenBefore, chunkBytes, reservedUntil) +// -> {probe, reserve, reservedUntil}, before one chunk of a download is +// written. +napi_value PlanFreeSpaceProbe(napi_env env, napi_callback_info info) { + int64_t args[4] = {0, 0, 0, 0}; + if (!GetInt64Args(env, info, "planFreeSpaceProbe", 4, args)) { + return nullptr; + } + const pushy::archive_limits::FreeSpaceProbe plan = + pushy::archive_limits::PlanUnknownLengthFreeSpaceProbe( + args[0], args[1], args[2], args[3]); + napi_value result = nullptr; + napi_value reserve = nullptr; + napi_value reserved_until = nullptr; + if (napi_create_object(env, &result) != napi_ok || + napi_create_int64(env, plan.reserve, &reserve) != napi_ok || + napi_create_int64(env, plan.reserved_until, &reserved_until) != napi_ok) { + ThrowError(env, "planFreeSpaceProbe: cannot create the result"); + return nullptr; + } + SetBoolProperty(env, result, "probe", plan.probe); + napi_set_named_property(env, result, "reserve", reserve); + napi_set_named_property(env, result, "reservedUntil", reserved_until); + return result; +} + +struct ExtractedDirectoryWork { + std::string directory; + int64_t archive_bytes = 0; + pushy::delta::Status status{false, ""}; +}; + +// checkExtractedDirectory(directory, archiveBytes) -> Promise: walks +// the freshly unpacked tree on a libuv worker thread and rejects with the +// first violation. +napi_value CheckExtractedDirectory(napi_env env, napi_callback_info info) { + napi_value args[2] = {nullptr, nullptr}; + size_t argc = 2; + if (!GetArgCount(env, info, &argc, args) || argc < 2) { + ThrowError(env, "checkExtractedDirectory: wrong number of arguments"); + return nullptr; + } + ExtractedDirectoryWork work; + bool ok = false; + work.directory = GetString(env, args[0], &ok); + if (!ok) { + return nullptr; + } + napi_valuetype type = napi_undefined; + if (work.directory.empty() || !GetValueType(env, args[1], &type) || + type != napi_number || + napi_get_value_int64(env, args[1], &work.archive_bytes) != napi_ok) { + ThrowError(env, "checkExtractedDirectory: expected a directory and a size"); + return nullptr; + } + return QueueWork( + env, + "checkExtractedDirectory", + std::move(work), + [](ExtractedDirectoryWork& w) { + w.status = pushy::archive_limits::CheckExtractedDirectory( + w.directory, w.archive_bytes); + }, + [](napi_env cb_env, napi_deferred deferred, const ExtractedDirectoryWork& w) { + SettleWithStatus(cb_env, deferred, w.status); + }); } bool ExportFunction( @@ -1175,138 +1222,187 @@ static napi_value GetSupportedDiffVersion(napi_env env, napi_callback_info) { return result; } -// ---- update-flow decision layer (NATIVE_CHECKUPDATE_DESIGN §10) ---- -// String-in/string-out JSON, matching the decision layer's own boundary. -// Returning undefined (nullptr without a pending exception) means "input did -// not parse"; the ArkTS orchestrator skips the check round. +// ---- native round (update_flow_core/native_round.h) ---- +// JSON text in and out. Returning undefined (nullptr without a pending +// exception) means the core rejected the input; the ArkTS orchestrator fails +// the round. + +// Reads the first `count` arguments (at most 5) into `args`, and those of +// them flagged in `strings` (bit i = argument i) as strings into `out[i]` +// (null/undefined read as ""). False with an exception pending otherwise. +bool GetFlowArgs(napi_env env, napi_callback_info info, const char* name, + size_t count, unsigned strings, napi_value* args, + std::string* out) { + size_t argc = count; + if (!GetArgCount(env, info, &argc, args) || argc < count) { + ThrowError(env, std::string(name) + ": missing arguments"); + return false; + } + for (size_t i = 0; i < count; i++) { + bool ok = true; + if ((strings >> i) & 1u) { + out[i] = GetString(env, args[i], &ok); + } + if (!ok) { + return false; + } + } + return true; +} -static napi_value MakeUtf8String(napi_env env, const std::string& value) { - napi_value result = nullptr; - if (napi_create_string_utf8(env, value.c_str(), value.size(), &result) != - napi_ok) { - ThrowError(env, "Failed to create string"); - return nullptr; +bool GetFlowNumber(napi_env env, napi_value value, const char* name, + double* out) { + if (napi_get_value_double(env, value, out) != napi_ok) { + ThrowError(env, std::string(name) + ": expected number"); + return false; } - return result; + return true; } -static napi_value FlowBuildRequestBody(napi_env env, - napi_callback_info info) { - size_t argc = 1; - napi_value args[1] = {nullptr}; - if (!GetArgCount(env, info, &argc, args) || argc < 1) { - ThrowError(env, "buildRequestBody: missing input argument"); +// The parsed object, or Undefined (which the core rejects). +flowjson::Value ParseFlowObject(const std::string& json) { + bool ok = false; + flowjson::Value value = flowjson::Parse(json, &ok); + return ok && value.IsObject() ? value : flowjson::Value::Undefined(); +} + +napi_value FlowJsonOrUndefined(napi_env env, const flowjson::Value& value) { + return value.IsUndefined() ? nullptr + : NewString(env, flowjson::Stringify(value)); +} + +// prepareRound(configJson, envJson, randomSample) +static napi_value FlowPrepareRound(napi_env env, napi_callback_info info) { + napi_value args[3] = {nullptr, nullptr, nullptr}; + std::string text[3]; + double sample = 0; + if (!GetFlowArgs(env, info, "prepareRound", 3, 0b011, args, text) || + !GetFlowNumber(env, args[2], "prepareRound", &sample)) { return nullptr; } - bool ok = false; - std::string input_json = GetString(env, args[0], &ok); - if (!ok) { + return FlowJsonOrUndefined( + env, flowcore::PrepareRound(text[0], ParseFlowObject(text[1]), sample)); +} + +// nextQueryStep(stateJson, responseText?) — responseText undefined/null for +// a failed (or no) request. +static napi_value FlowNextQueryStep(napi_env env, napi_callback_info info) { + napi_value args[2] = {nullptr, nullptr}; + std::string text[2]; + if (!GetFlowArgs(env, info, "nextQueryStep", 2, 0b11, args, text)) { return nullptr; } - bool parsed = false; - flowjson::Value input = flowjson::Parse(input_json, &parsed); - if (!parsed || !input.IsObject()) { + return FlowJsonOrUndefined( + env, flowcore::NextQueryStep( + ParseFlowObject(text[0]), + IsNullOrUndefined(env, args[1]) + ? flowjson::Value::Undefined() + : flowjson::Value::String(std::move(text[1])))); +} + +// handleResponse(responseText, configJson, envJson, requestBody, +// responseAtSeconds) +static napi_value FlowHandleResponse(napi_env env, napi_callback_info info) { + napi_value args[5] = {nullptr, nullptr, nullptr, nullptr, nullptr}; + std::string text[5]; + double responseAt = 0; + if (!GetFlowArgs(env, info, "handleResponse", 5, 0b01111, args, text) || + !GetFlowNumber(env, args[4], "handleResponse", &responseAt)) { return nullptr; } - return MakeUtf8String( - env, flowjson::Stringify(flowcore::BuildRequestBody(input))); + return FlowJsonOrUndefined( + env, flowcore::HandleRoundResponse(text[0], text[1], + ParseFlowObject(text[2]), text[3], + responseAt)); } -static napi_value FlowOrderEndpointCandidates(napi_env env, - napi_callback_info info) { +// ---- persisted native config (update_flow_core/native_config.h) ---- +// null/undefined arguments read as "" (an absent config): never equivalent, +// not_configured at the gate, not an object for the normalizer. + +static napi_value FlowConfigsEquivalent(napi_env env, napi_callback_info info) { size_t argc = 2; napi_value args[2] = {nullptr, nullptr}; if (!GetArgCount(env, info, &argc, args) || argc < 2) { - ThrowError(env, "orderEndpointCandidates: missing arguments"); + ThrowError(env, "configsEquivalent: missing arguments"); return nullptr; } bool ok = false; - std::string endpoints_json = GetString(env, args[0], &ok); + std::string a = GetString(env, args[0], &ok); if (!ok) { return nullptr; } - double sample = 0; - if (napi_get_value_double(env, args[1], &sample) != napi_ok) { - ThrowError(env, "orderEndpointCandidates: expected number sample"); - return nullptr; - } - bool parsed = false; - flowjson::Value endpoints = flowjson::Parse(endpoints_json, &parsed); - if (!parsed || !endpoints.IsArray()) { + std::string b = GetString(env, args[1], &ok); + if (!ok) { return nullptr; } - return MakeUtf8String(env, - flowjson::Stringify(flowcore::OrderEndpointCandidates( - endpoints, sample))); + napi_value result = nullptr; + napi_get_boolean(env, flowcore::ConfigsEquivalent(a, b), &result); + return result; } -static napi_value FlowIsValidResponse(napi_env env, - napi_callback_info info) { +static napi_value FlowRunnableConfig(napi_env env, napi_callback_info info) { size_t argc = 1; napi_value args[1] = {nullptr}; if (!GetArgCount(env, info, &argc, args) || argc < 1) { - ThrowError(env, "isValidResponse: missing arguments"); + ThrowError(env, "runnableConfig: missing argument"); return nullptr; } bool ok = false; - std::string response_text = GetString(env, args[0], &ok); + std::string config_json = GetString(env, args[0], &ok); if (!ok) { - // Exception pending (non-string argument): propagate it as a throw - // instead of a boolean that the caller would treat as a verdict. return nullptr; } - napi_value result = nullptr; - napi_get_boolean(env, flowcore::IsValidResponse(response_text), - &result); - return result; + return NewString(env, flowcore::RunnableConfigJson(config_json)); } -static napi_value FlowHandleResponse(napi_env env, - napi_callback_info info) { - size_t argc = 3; - napi_value args[3] = {nullptr, nullptr, nullptr}; - if (!GetArgCount(env, info, &argc, args) || argc < 3) { - ThrowError(env, "handleResponse: missing arguments"); +// Throws Error("Invalid native configuration: ...") for options the host +// must fix. +static napi_value FlowNormalizeConfiguration(napi_env env, + napi_callback_info info) { + size_t argc = 1; + napi_value args[1] = {nullptr}; + if (!GetArgCount(env, info, &argc, args) || argc < 1) { + ThrowError(env, "normalizeConfiguration: missing argument"); return nullptr; } bool ok = false; - std::string response_text = GetString(env, args[0], &ok); - if (!ok) { - return nullptr; - } - std::string identity_json = GetString(env, args[1], &ok); + std::string options_json = GetString(env, args[0], &ok); if (!ok) { return nullptr; } - std::string after_download = GetString(env, args[2], &ok); - if (!ok) { - return nullptr; - } - bool parsed = false; - flowjson::Value identity = flowjson::Parse(identity_json, &parsed); - if (!parsed || !identity.IsObject()) { + std::string config; + std::string error; + if (!flowcore::NormalizeConfiguration(options_json, &config, &error)) { + ThrowError(env, error); return nullptr; } - return MakeUtf8String(env, - flowjson::Stringify(flowcore::HandleResponse( - response_text, identity, false, after_download))); + return NewString(env, config); } napi_value Init(napi_env env, napi_value exports) { if (!ExportFunction(env, exports, "syncStateWithBinaryVersion", SyncStateWithBinaryVersion) || !ExportFunction(env, exports, "runStateCore", RunStateCore) || - !ExportFunction(env, exports, "buildArchivePlan", BuildArchivePatchPlan) || - !ExportFunction(env, exports, "buildCopyGroups", BuildCopyGroups) || - !ExportFunction(env, exports, "applyDeltaFromSource", ApplyPatchFromFileSource) || + !ExportFunction(env, exports, "applyArchiveDelta", ApplyArchiveDelta) || !ExportFunction(env, exports, "cleanupOldEntries", CleanupOldEntries) || !ExportFunction(env, exports, "sha256HexFileAsync", Sha256HexFileAsync) || !ExportFunction(env, exports, "sha256HexRawFdAsync", Sha256HexRawFdAsync) || + !ExportFunction(env, exports, "isInstallComplete", InstallRecordIsComplete) || + !ExportFunction(env, exports, "recordedBundleSha256", InstallRecordBundleSha256) || + !ExportFunction(env, exports, "sealInstallRecord", SealInstallRecord) || + !ExportFunction(env, exports, "promoteStaging", PromoteStaging) || + !ExportFunction(env, exports, "checkActivation", CheckActivation) || + !ExportFunction(env, exports, "checkUncompressedSize", CheckUncompressedSize) || + !ExportFunction(env, exports, "planFreeSpaceProbe", PlanFreeSpaceProbe) || + !ExportFunction(env, exports, "checkExtractedDirectory", CheckExtractedDirectory) || !ExportFunction(env, exports, "crc32", Crc32) || !ExportFunction(env, exports, "getSupportedDiffVersion", GetSupportedDiffVersion) || - !ExportFunction(env, exports, "buildRequestBody", FlowBuildRequestBody) || - !ExportFunction(env, exports, "orderEndpointCandidates", FlowOrderEndpointCandidates) || - !ExportFunction(env, exports, "isValidResponse", FlowIsValidResponse) || - !ExportFunction(env, exports, "handleResponse", FlowHandleResponse)) { + !ExportFunction(env, exports, "prepareRound", FlowPrepareRound) || + !ExportFunction(env, exports, "nextQueryStep", FlowNextQueryStep) || + !ExportFunction(env, exports, "handleResponse", FlowHandleResponse) || + !ExportFunction(env, exports, "configsEquivalent", FlowConfigsEquivalent) || + !ExportFunction(env, exports, "runnableConfig", FlowRunnableConfig) || + !ExportFunction(env, exports, "normalizeConfiguration", FlowNormalizeConfiguration)) { return nullptr; } return exports; diff --git a/harmony/pushy/src/main/ets/ArchiveLimits.ts b/harmony/pushy/src/main/ets/ArchiveLimits.ts index 44ca0674..f3a2a259 100644 --- a/harmony/pushy/src/main/ets/ArchiveLimits.ts +++ b/harmony/pushy/src/main/ets/ArchiveLimits.ts @@ -4,61 +4,35 @@ import { ERROR_FILE_OPERATION_FAILED, ERROR_DELTA_FAILED, createPushyError, + getErrorMessage, } from './ErrorCodes'; -import { isReservedEntryName } from './InstallRecord'; +import NativePatchCore from './NativePatchCore'; -// ArkTS mirror of cpp/patch_core/archive_limits.h — keep in sync by hand -// (harmony/pushy/src/test/check-constant-parity.js asserts the values match). -// 损坏或恶意更新包的伤害上限:最多耗费有限的磁盘/内存/时间,绝不撑爆磁盘。 +// 解压资源上限的计数规则(条目数、单条/总解压量、压缩比、保留名、符号链接、 +// 未知长度下载的磁盘探测计划)只在 cpp/patch_core/archive_limits.h 里实现, +// 三端共用(这里经 NAPI)。下面只镜像本端仍需自己比较的常量, +// scripts/check-constant-parity.js 断言与头文件一致。 export const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024; -export const MAX_TOTAL_UNCOMPRESSED_BYTES = 2048 * 1024 * 1024; -export const MAX_ENTRY_BYTES = 512 * 1024 * 1024; -export const MAX_ENTRIES = 20000; -export const MAX_COMPRESSION_RATIO = 100; -export const RATIO_CHECK_MIN_BYTES = 1 * 1024 * 1024; export const MAX_MANIFEST_BYTES = 16 * 1024 * 1024; export const FREE_DISK_MARGIN_BYTES = 64 * 1024 * 1024; -export const UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES = 8 * 1024 * 1024; /** - * 解压前的总量校验(zlib.getOriginalSize 给出的归档解压后总字节数)。 - * - * 与 archive_limits.h 的偏差(有意为之,无法消除):头文件的压缩比上限是 - * **逐条**的(单条解压量 > RATIO_CHECK_MIN_BYTES 时才查),但鸿蒙的 - * @kit.BasicServicesKit zlib 只提供整包解压量(getOriginalSize)与整包 - * 解压(decompressFile),拿不到任何条目的压缩/解压尺寸,也没有逐条钩子。 - * 因此这里只能按整包近似:总解压量超过上限、或(总量大于 - * RATIO_CHECK_MIN_BYTES 时)总压缩比超过 MAX_COMPRESSION_RATIO 即拒绝—— - * 20MB 归档 100:1 的 2GB 载荷在解压前就被挡下,而不是先解出来再量。 - * 两个方向的差异: - * - 更松:单条高压缩比条目被同包大量不可压缩数据"稀释"时整包比可能不超限 - * (其总量仍受 MAX_TOTAL_UNCOMPRESSED_BYTES 与解压后的逐条 - * MAX_ENTRY_BYTES 约束,伤害有界); - * - 更严:大量各自 < RATIO_CHECK_MIN_BYTES 的高压缩比小文件,逐条不查, - * 但累加后整包比超限会被拒绝(真实更新包不会出现 100:1 的整包比)。 - * 解压后 measureExtractedDirectory 再逐条核对条目数/单条大小/总字节数。 + * 解压前的整包校验(zlib.getOriginalSize 给出的归档解压后总字节数):总解压 + * 量上限与整包压缩比(archive_limits::CheckUncompressedSize)。鸿蒙的 zlib + * 只提供整包解压量与整包解压,拿不到条目的压缩尺寸,逐条压缩比无从查起; + * 20MB 归档 100:1 的 2GB 载荷在解压前就被挡下,解压后 checkExtractedDirectory + * 再逐条核对。 */ export function checkUncompressedSize( archiveBytes: number, uncompressedBytes: number, ): void { - if (uncompressedBytes > MAX_TOTAL_UNCOMPRESSED_BYTES) { - throw createPushyError( - ERROR_DELTA_FAILED, - `archive expands beyond ${MAX_TOTAL_UNCOMPRESSED_BYTES} bytes ` + - `(${uncompressedBytes})`, - ); - } - if ( - uncompressedBytes > RATIO_CHECK_MIN_BYTES && - archiveBytes > 0 && - uncompressedBytes > archiveBytes * MAX_COMPRESSION_RATIO - ) { - throw createPushyError( - ERROR_DELTA_FAILED, - `archive compression ratio exceeds ${MAX_COMPRESSION_RATIO}:1 ` + - `(${uncompressedBytes}/${archiveBytes})`, - ); + const reason = NativePatchCore.checkUncompressedSize( + archiveBytes, + uncompressedBytes, + ); + if (reason !== undefined) { + throw createPushyError(ERROR_DELTA_FAILED, reason); } } @@ -90,35 +64,6 @@ export function checkOriginalSize( return size; } -export interface FreeSpaceProbePlan { - /** 本次写入前是否需要探测可用空间。 */ - probe: boolean; - /** 探测时要求的字节数(不含 FREE_DISK_MARGIN_BYTES)。 */ - reserve: number; - /** 计划后的"已预留到"位置(相对本次响应体起点)。 */ - reservedUntil: number; -} - -/** - * 未知长度下载(分块/编码响应体)的磁盘探测计划:响应到达时只能预留安全 - * 余量,所以任何会写过已预留位置的写入之前都要探测,每次至少预留接下来的 - * UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES(单个 chunk 更大时按 chunk 算)—— - * 任何写入都吃不到余量。已知总长时由响应到达时的一次性检查覆盖,不探测。 - * writtenBefore 是本 chunk 之前已收到的字节数(相对本次响应体)。 - */ -export function planUnknownLengthFreeSpaceProbe( - knownTotal: number, - writtenBefore: number, - chunkBytes: number, - reservedUntil: number, -): FreeSpaceProbePlan { - const reserve = Math.max(UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES, chunkBytes); - if (knownTotal > 0 || writtenBefore + chunkBytes <= reservedUntil) { - return { probe: false, reserve, reservedUntil }; - } - return { probe: true, reserve, reservedUntil: writtenBefore + reserve }; -} - /** * 目标所在文件系统必须还能再写 bytesToWrite + 安全余量。取不到可用空间 * (返回 0/异常)视为未知,放行。 @@ -153,55 +98,18 @@ export async function ensureFreeSpace( } } -export interface DirectoryMeasure { - entries: number; - bytes: number; -} - /** - * 递归统计目录里的条目数与总字节数;两者任一超过上限立即抛错(不必扫完)。 - * 同一趟遍历里拒绝任意深度的 `.pushy-` 保留条目(完成记录只能由 SDK 写)。 + * 解压后在 native 工作线程逐条核对刚解压的目录(archive_limits:: + * CheckExtractedDirectory):条目数、单条大小、总字节数与整包压缩比,拒绝 + * 任意深度的 `.pushy-` 保留条目(完成记录只能由 SDK 写)与符号链接。 */ -export async function measureExtractedDirectory( +export async function checkExtractedDirectory( directory: string, - acc: DirectoryMeasure = { entries: 0, bytes: 0 }, -): Promise { - const names = (await fileIo.listFile(directory)).filter( - name => name !== '.' && name !== '..', - ); - for (const name of names) { - if (isReservedEntryName(name)) { - throw createPushyError( - ERROR_DELTA_FAILED, - `archive contains reserved entry ${name}`, - ); - } - const path = `${directory}/${name}`; - const stat = await fileIo.stat(path); - acc.entries += 1; - if (acc.entries > MAX_ENTRIES) { - throw createPushyError( - ERROR_DELTA_FAILED, - `archive has too many entries (> ${MAX_ENTRIES})`, - ); - } - if (stat.isDirectory()) { - await measureExtractedDirectory(path, acc); - continue; - } - if (stat.size > MAX_ENTRY_BYTES) { - throw createPushyError( - ERROR_DELTA_FAILED, - `archive entry too large: ${name} (${stat.size} bytes)`, - ); - } - acc.bytes += stat.size; - if (acc.bytes > MAX_TOTAL_UNCOMPRESSED_BYTES) { - throw createPushyError( - ERROR_DELTA_FAILED, - `archive expands beyond ${MAX_TOTAL_UNCOMPRESSED_BYTES} bytes`, - ); - } + archiveBytes: number, +): Promise { + try { + await NativePatchCore.checkExtractedDirectory(directory, archiveBytes); + } catch (e) { + throw createPushyError(ERROR_DELTA_FAILED, getErrorMessage(e)); } - return acc; } diff --git a/harmony/pushy/src/main/ets/DownloadTask.ts b/harmony/pushy/src/main/ets/DownloadTask.ts index d7e960b2..6b6fc6f4 100644 --- a/harmony/pushy/src/main/ets/DownloadTask.ts +++ b/harmony/pushy/src/main/ets/DownloadTask.ts @@ -4,7 +4,12 @@ import common from '@ohos.app.ability.common'; import { zlib } from '@kit.BasicServicesKit'; import { EventHub } from './EventHub'; import { DownloadTaskParams } from './DownloadTaskParams'; -import { saveFileToSandbox, withRawFd } from './SaveFile'; +import { + saveFileToSandbox, + unlinkIfExists, + withRawFd, + writeFully, +} from './SaveFile'; import { util } from '@kit.ArkTS'; import NativePatchCore, { ARCHIVE_PATCH_TYPE_FROM_PACKAGE, @@ -16,17 +21,16 @@ import logger from './Logger'; import { MAX_ARCHIVE_BYTES, MAX_MANIFEST_BYTES, + checkExtractedDirectory, checkOriginalSize, checkUncompressedSize, ensureFreeSpace, - measureExtractedDirectory, - planUnknownLengthFreeSpaceProbe, } from './ArchiveLimits'; import { HARMONY_BUNDLE_FILE_NAME, - buildInstallRecord, + promoteStaging, + sealInstallRecord, stagingDirectoryFor, - writeInstallRecord, } from './InstallRecord'; import { ERROR_DOWNLOAD_FAILED, @@ -41,95 +45,18 @@ import { BUNDLE_DELTA_ENTRY } from './Texts'; const TAG = 'DownloadTask'; -export interface PatchManifestArrays { - copyFroms: string[]; - copyTos: string[]; - // 与 copyFroms/copyTos 逐位对齐的 copiesCrc(CLI >= 2.21.2 的 pdiff - // manifest 携带,键是 to);无声明时为 null。拷贝前用它校验包内资源内容, - // 不符则整次 patch 失败落 full——重打包二进制不能静默拷出漂移的资源。 - copyCrcs: (number | null)[]; - deletes: string[]; - // __diff.json 中对应 bundle patch 条目的 hbcTransform 元数据(原始 JSON - // 字符串);为空时 native 走现状路径 - hbcTransformMeta: string; -} - -export function parseManifestToArrays( - manifest: Record, - normalizeResourceCopies: boolean, -): PatchManifestArrays { - const copyFroms: string[] = []; - const copyTos: string[] = []; - const copyCrcs: (number | null)[] = []; - const deletesValue = manifest.deletes; - const deletes = Array.isArray(deletesValue) - ? deletesValue.map(item => String(item)) - : deletesValue && typeof deletesValue === 'object' - ? Object.keys(deletesValue) - : []; - - const copies = (manifest.copies || {}) as Record; - const copiesCrcValue = manifest.copiesCrc; - const copiesCrc = - copiesCrcValue && typeof copiesCrcValue === 'object' - ? (copiesCrcValue as Record) - : ({} as Record); - for (const to of Object.keys(copies)) { - const rawFrom = copies[to]; - let from = String(rawFrom || ''); - if (normalizeResourceCopies) { - from = from.replace('resources/rawfile/', ''); - if (!from) { - from = to; - } - } - copyFroms.push(from); - copyTos.push(to); - const crc = copiesCrc[to]; - copyCrcs.push(typeof crc === 'number' && Number.isFinite(crc) ? crc : null); - } - - const hbcTransform = manifest.hbcTransform as - | Record - | undefined; - const hbcTransformEntry = - hbcTransform && typeof hbcTransform === 'object' - ? hbcTransform[BUNDLE_DELTA_ENTRY] - : undefined; - const hbcTransformMeta = - hbcTransformEntry && typeof hbcTransformEntry === 'object' - ? JSON.stringify(hbcTransformEntry) - : ''; - - return { - copyFroms, - copyTos, - copyCrcs, - deletes, - hbcTransformMeta, - }; -} - +// staging 目录里的条目名 + __diff.json 原文:由原生内核统一校验与解析 +// (cpp/patch_core/patch_manifest.h),这里不解析 manifest。 interface DeltaInputs { entryNames: string[]; - manifestArrays: PatchManifestArrays; -} - -function toArrayBufferSlice( - payload: Uint8Array, - offset: number, - length: number, -): ArrayBuffer { - return (payload.buffer as ArrayBuffer).slice( - payload.byteOffset + offset, - payload.byteOffset + offset + length, - ); + manifestJson: string; } +// 包内 rawfile 在 HAP 中的路径前缀:pdiff 的拷贝源按 HAP 路径命名, +// resourceManager 按去掉前缀后的路径读取。原生解析时只剥开头一次。 +const RAWFILE_COPY_SOURCE_PREFIX = 'resources/rawfile/'; const DIFF_MANIFEST_ENTRY = '__diff.json'; -const TEMP_ORIGIN_BUNDLE_ENTRY = '.origin.bundle.harmony.js'; -const EMBEDDED_BUNDLE_RAWFILE = 'bundle.harmony.js'; -const FILE_COPY_BUFFER_SIZE = 64 * 1024; +const TEMP_ORIGIN_BUNDLE_ENTRY = `.origin.${HARMONY_BUNDLE_FILE_NAME}`; const DOWNLOAD_CALL_TIMEOUT_MS = 10 * 60 * 1000; // 断点续传 sidecar(NATIVE_CHECKUPDATE_DESIGN §11.4):记录 partial 属于 @@ -196,23 +123,12 @@ export class DownloadTask { private async promoteStaging(params: DownloadTaskParams): Promise { const work = this.stagingDirectory(params); - const bundlePath = `${work}/${HARMONY_BUNDLE_FILE_NAME}`; - if (!fileIo.accessSync(bundlePath)) { - throw createPushyError( - ERROR_DELTA_FAILED, - `bundle missing after install: ${bundlePath}`, - ); - } - // 整 bundle 几十 MB:摘要在 native 工作线程算,不冻结 UI 线程。 - const bundleSha256 = await NativePatchCore.sha256HexFileAsync(bundlePath); - await writeInstallRecord( - work, - buildInstallRecord(params.hash, bundleSha256, this.artifactSha256), - ); + // 整 bundle 几十 MB:摘要与记录写入在 native 工作线程完成,不冻结 UI 线程。 + await sealInstallRecord(work, params.hash, this.artifactSha256); if (fileIo.accessSync(params.unzipDirectory)) { await this.removeDirectory(params.unzipDirectory); } - await fileIo.rename(work, params.unzipDirectory); + await promoteStaging(work, params.unzipDirectory); } constructor(context: common.Context) { @@ -260,10 +176,7 @@ export class DownloadTask { private async deleteResumeSidecar(targetFile: string): Promise { try { - const sidecar = this.resumeSidecarPath(targetFile); - if (fileIo.accessSync(sidecar)) { - await fileIo.unlink(sidecar); - } + await unlinkIfExists(this.resumeSidecarPath(targetFile)); } catch (e) { logger.error(TAG, `Failed to delete resume sidecar: ${getErrorMessage(e)}`); } @@ -272,9 +185,7 @@ export class DownloadTask { // 归档连同 sidecar 一起删除(被消费或被判定有毒)。 private async deleteArchiveAndSidecar(targetFile: string): Promise { try { - if (fileIo.accessSync(targetFile)) { - await fileIo.unlink(targetFile); - } + await unlinkIfExists(targetFile); } catch (e) { logger.error(TAG, `Failed to delete archive: ${getErrorMessage(e)}`); } @@ -332,7 +243,7 @@ export class DownloadTask { * 解压前读取归档解压后的总字节数(zlib.getOriginalSize,API 12——RNOH 的 * 最低 API 已覆盖)。读不到就拒绝归档(checkOriginalSize):zlib.decompressFile * 没有逐条钩子,不知道展开量就放行等于允许一个 20MB 的归档在 - * measureExtractedDirectory 跑到之前先把磁盘写满。拒绝带专属错误细节 + * checkExtractedDirectory 跑到之前先把磁盘写满。拒绝带专属错误细节 * (ORIGINAL_SIZE_UNAVAILABLE)并单独记日志,与"超限"拒绝可区分。 */ private async readOriginalSize(archiveFile: string): Promise { @@ -358,8 +269,9 @@ export class DownloadTask { * 解压 + 资源上限(cpp/patch_core/archive_limits.h)。zlib.decompressFile * 没有逐条钩子:解压前用 getOriginalSize 把总解压量与整包压缩比挡在 * 上限内(20MB 归档 100:1 的 2GB 载荷不会先解出来再量),按已知解压量查 - * 磁盘,解压量未知直接拒绝;解压后再统计条目数/单条大小/总字节数并拒绝 - * 任意深度的 `.pushy-` 保留条目(超限即失败,staging 目录由失败清理删除)。 + * 磁盘,解压量未知直接拒绝;解压后再逐条核对条目数/单条大小/总字节数, + * 拒绝任意深度的 `.pushy-` 保留条目与符号链接(规则见 + * cpp/patch_core/archive_limits.h;超限即失败,staging 目录由失败清理删除)。 */ private async extractArchive( archiveFile: string, @@ -380,7 +292,7 @@ export class DownloadTask { } catch (e) { throw toPushyError(e, ERROR_DELTA_FAILED); } - await measureExtractedDirectory(unzipDirectory); + await checkExtractedDirectory(unzipDirectory, archiveStat.size); } private async recreateDirectory(path: string): Promise { @@ -418,33 +330,26 @@ export class DownloadTask { ): Promise { const payload = content instanceof Uint8Array ? content : new Uint8Array(content); + // writeFully writes from the start of its buffer: a view into a larger + // buffer at a non-zero offset needs its own copy. + const bytes = + payload.byteOffset === 0 + ? (payload.buffer as ArrayBuffer) + : (payload.buffer as ArrayBuffer).slice( + payload.byteOffset, + payload.byteOffset + payload.byteLength, + ); await this.ensureParentDirectory(targetFile); - if (fileIo.accessSync(targetFile)) { - await fileIo.unlink(targetFile); - } + await unlinkIfExists(targetFile); - let writer: fileIo.File | null = null; + const writer = await fileIo.open( + targetFile, + fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY, + ); try { - writer = await fileIo.open( - targetFile, - fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY, - ); - const chunkSize = FILE_COPY_BUFFER_SIZE; - let bytesWritten = 0; - - while (bytesWritten < payload.byteLength) { - const chunkLength = Math.min( - chunkSize, - payload.byteLength - bytesWritten, - ); - const chunk = toArrayBufferSlice(payload, bytesWritten, chunkLength); - await fileIo.write(writer.fd, chunk); - bytesWritten += chunkLength; - } + await writeFully(writer.fd, bytes, payload.byteLength); } finally { - if (writer) { - await fileIo.close(writer); - } + await fileIo.close(writer); } } @@ -454,19 +359,11 @@ export class DownloadTask { ) as Record; } - private async readManifestArrays( - directory: string, - normalizeResourceCopies: boolean, - ): Promise { + // __diff.json 原文;归档里没有时为空串(原生内核报 "diff.json not found")。 + private async readManifestJson(directory: string): Promise { const manifestPath = `${directory}/${DIFF_MANIFEST_ENTRY}`; if (!fileIo.accessSync(manifestPath)) { - return { - copyFroms: [], - copyTos: [], - copyCrcs: [], - deletes: [], - hbcTransformMeta: '', - }; + return ''; } const manifestStat = await fileIo.stat(manifestPath); @@ -476,42 +373,39 @@ export class DownloadTask { `delta manifest too large: ${manifestStat.size} bytes`, ); } - return parseManifestToArrays( - this.parseJsonEntry(await this.readFileContent(manifestPath)), - normalizeResourceCopies, + return new util.TextDecoder().decodeToString( + new Uint8Array(await this.readFileContent(manifestPath)), ); } - /** - * pdiff 的 bundle 补丁:hpatch 需要文件源,先把二进制内嵌的 bundle 从 - * rawfile descriptor 异步流式拷到 staging 里的临时文件(不整包读进内存、 - * 不在 UI 线程同步 IO),补丁完成后删除。 + * pdiff:hpatch 需要文件源,先把二进制内嵌的 bundle 从 rawfile descriptor + * 异步流式拷到 staging 里的临时文件(不整包读进内存、不在 UI 线程同步 + * IO),原生内核只重建 bundle,拷贝分组交回由 copyFromResource 完成。 */ - private async applyBundlePatchFromEmbeddedBundle( - workingDirectory: string, - bundleDeltaPath: string, - outputFile: string, - hbcTransformMeta = '', - ): Promise { - const originBundlePath = `${workingDirectory}/${TEMP_ORIGIN_BUNDLE_ENTRY}`; + private async applyPatchFromEmbeddedBundle( + work: string, + inputs: DeltaInputs, + ): Promise { + const originBundlePath = `${work}/${TEMP_ORIGIN_BUNDLE_ENTRY}`; try { await withRawFd( this.context.resourceManager, - EMBEDDED_BUNDLE_RAWFILE, + HARMONY_BUNDLE_FILE_NAME, descriptor => saveFileToSandbox(descriptor, originBundlePath), ); - await NativePatchCore.applyDeltaFromSource({ - copyFroms: [], - copyTos: [], - deletes: [], - sourceRoot: workingDirectory, - targetRoot: workingDirectory, + return await NativePatchCore.applyArchiveDelta({ + archiveType: ARCHIVE_PATCH_TYPE_FROM_PACKAGE, + entryNames: inputs.entryNames, + manifestJson: inputs.manifestJson, + bundleDeltaEntry: BUNDLE_DELTA_ENTRY, + copySourcePrefix: RAWFILE_COPY_SOURCE_PREFIX, + sourceRoot: work, + targetRoot: work, originBundlePath, - bundleDeltaPath, - bundleOutputPath: outputFile, - enableMerge: false, - bundleHbcTransformMeta: hbcTransformMeta, + bundleDeltaPath: `${work}/${BUNDLE_DELTA_ENTRY}`, + bundleOutputPath: `${work}/${HARMONY_BUNDLE_FILE_NAME}`, + hostCopiesResources: true, }); } catch (error) { throw createPushyError( @@ -519,9 +413,7 @@ export class DownloadTask { `Failed to process bundle delta: ${getErrorMessage(error)}`, ); } finally { - if (fileIo.accessSync(originBundlePath)) { - await fileIo.unlink(originBundlePath); - } + await unlinkIfExists(originBundlePath); } } @@ -626,6 +518,24 @@ export class DownloadTask { authorize = resolve; }); + // 本 url 的 sidecar 内容:验证器优先取本次响应头,缺失时沿用上次的; + // 总长未知(0)时不写。 + const buildResumeMeta = (total: number): ResumeMeta => { + const meta: ResumeMeta = { url: params.url }; + const etag = etagHeader || resumeMeta?.etag; + const lastModified = lastModifiedHeader || resumeMeta?.lastModified; + if (etag) { + meta.etag = etag; + } + if (lastModified) { + meta.lastModified = lastModified; + } + if (total > 0) { + meta.total = total; + } + return meta; + }; + const reportProgress = () => { const overall = baseOffset + received; const overallTotal = totalAll > 0 @@ -664,9 +574,10 @@ export class DownloadTask { `archive too large: exceeded ${MAX_ARCHIVE_BYTES}`, ); } - // 未知长度:见 planUnknownLengthFreeSpaceProbe。失败保留 partial 供 - // 下次续传。 - const plan = planUnknownLengthFreeSpaceProbe( + // 未知长度:响应到达时只能预留安全余量,任何会写过已预留位置的写入 + // 之前都要探测(archive_limits::PlanUnknownLengthFreeSpaceProbe)。 + // 失败保留 partial 供下次续传。 + const plan = NativePatchCore.planFreeSpaceProbe( totalAll, received - data.byteLength, data.byteLength, @@ -907,9 +818,7 @@ export class DownloadTask { ); } else { // 服务端忽略了 Range(或本就没发):从零开始。 - if (fileIo.accessSync(params.targetFile)) { - await fileIo.unlink(params.targetFile); - } + await unlinkIfExists(params.targetFile); baseOffset = 0; totalAll = contentLength > 0 ? contentLength : 0; writer = await fileIo.open( @@ -929,19 +838,7 @@ export class DownloadTask { totalAll > 0 ? totalAll - baseOffset : 0, ); // 先落 sidecar 再放行写入,流中崩溃才有得续。 - const meta: ResumeMeta = { url: params.url }; - const etag = etagHeader || resumeMeta?.etag; - const lastModified = lastModifiedHeader || resumeMeta?.lastModified; - if (etag) { - meta.etag = etag; - } - if (lastModified) { - meta.lastModified = lastModified; - } - if (totalAll > 0) { - meta.total = totalAll; - } - await this.writeResumeMeta(params.targetFile, meta); + await this.writeResumeMeta(params.targetFile, buildResumeMeta(totalAll)); // 鉴权通过:回放缓冲,之后的数据直写。 const buffered = pendingChunks ?? []; @@ -983,15 +880,10 @@ export class DownloadTask { } // 用最终观测值刷新 sidecar:首次写入可能因头部竞态缺 total/验证器, // 补齐后“下载完、解压前死”的窗口才能被 complete 检测覆盖。 - const finalMeta: ResumeMeta = { url: params.url, total: stats.size }; - if (etagHeader || resumeMeta?.etag) { - finalMeta.etag = etagHeader || resumeMeta?.etag; - } - if (lastModifiedHeader || resumeMeta?.lastModified) { - finalMeta.lastModified = - lastModifiedHeader || resumeMeta?.lastModified; - } - await this.writeResumeMeta(params.targetFile, finalMeta); + await this.writeResumeMeta( + params.targetFile, + buildResumeMeta(stats.size), + ); } else { // 编码传输不可续传:盘上是解码后的字节,编码域的长度与偏移都失效。 await this.deleteResumeSidecar(params.targetFile); @@ -1047,16 +939,12 @@ export class DownloadTask { return work; } - /** 解压目录里的条目名 + __diff.json 解析结果,供 patch plan 使用。 */ - private async readDeltaInputs( - work: string, - normalizeResourceCopies: boolean, - ): Promise { + private async readDeltaInputs(work: string): Promise { const results = await Promise.all([ this.listEntryNames(work), - this.readManifestArrays(work, normalizeResourceCopies), + this.readManifestJson(work), ]); - return { entryNames: results[0], manifestArrays: results[1] }; + return { entryNames: results[0], manifestJson: results[1] }; } private async doFullPatch(params: DownloadTaskParams): Promise { @@ -1066,73 +954,27 @@ export class DownloadTask { private async doPatchFromApp(params: DownloadTaskParams): Promise { const work = await this.downloadAndExtract(params); - const inputs = await this.readDeltaInputs(work, true); - const manifestArrays = inputs.manifestArrays; - - NativePatchCore.buildArchivePlan( - ARCHIVE_PATCH_TYPE_FROM_PACKAGE, - inputs.entryNames, - manifestArrays.copyFroms, - manifestArrays.copyTos, - manifestArrays.deletes, - BUNDLE_DELTA_ENTRY, - ); - - const bundleDeltaPath = `${work}/${BUNDLE_DELTA_ENTRY}`; - if (!fileIo.accessSync(bundleDeltaPath)) { - throw createPushyError(ERROR_DELTA_FAILED, 'bundle delta not found'); - } - await this.applyBundlePatchFromEmbeddedBundle( + const copyGroups = await this.applyPatchFromEmbeddedBundle( work, - bundleDeltaPath, - `${work}/${HARMONY_BUNDLE_FILE_NAME}`, - manifestArrays.hbcTransformMeta, - ); - // 组按 from 聚合;同一 from 的内容必然一致,所以每组一个 CRC 即可 - // (与 manifest 逐位对齐的 copyCrcs 里取第一个非空值)。 - const crcByFrom = new Map(); - manifestArrays.copyFroms.forEach((from, index) => { - const crc = manifestArrays.copyCrcs[index]; - if (crc !== null && !crcByFrom.has(from)) { - crcByFrom.set(from, crc); - } - }); - await this.copyFromResource( - NativePatchCore.buildCopyGroups( - manifestArrays.copyFroms, - manifestArrays.copyTos, - ), - work, - crcByFrom, + await this.readDeltaInputs(work), ); + await this.copyFromResource(copyGroups, work); await this.deleteArchiveAndSidecar(params.targetFile); } private async doPatchFromPpk(params: DownloadTaskParams): Promise { const work = await this.downloadAndExtract(params); - const inputs = await this.readDeltaInputs(work, false); - const manifestArrays = inputs.manifestArrays; - - const plan = NativePatchCore.buildArchivePlan( - ARCHIVE_PATCH_TYPE_FROM_PPK, - inputs.entryNames, - manifestArrays.copyFroms, - manifestArrays.copyTos, - manifestArrays.deletes, - BUNDLE_DELTA_ENTRY, - ); - await NativePatchCore.applyDeltaFromSource({ - copyFroms: manifestArrays.copyFroms, - copyTos: manifestArrays.copyTos, - deletes: manifestArrays.deletes, + const inputs = await this.readDeltaInputs(work); + await NativePatchCore.applyArchiveDelta({ + archiveType: ARCHIVE_PATCH_TYPE_FROM_PPK, + entryNames: inputs.entryNames, + manifestJson: inputs.manifestJson, + bundleDeltaEntry: BUNDLE_DELTA_ENTRY, sourceRoot: params.originDirectory, targetRoot: work, - originBundlePath: `${params.originDirectory}/bundle.harmony.js`, + originBundlePath: `${params.originDirectory}/${HARMONY_BUNDLE_FILE_NAME}`, bundleDeltaPath: `${work}/${BUNDLE_DELTA_ENTRY}`, bundleOutputPath: `${work}/${HARMONY_BUNDLE_FILE_NAME}`, - mergeSourceSubdir: plan.mergeSourceSubdir, - enableMerge: plan.enableMerge, - bundleHbcTransformMeta: manifestArrays.hbcTransformMeta, }); logger.info(TAG, 'Delta from PPK completed'); await this.deleteArchiveAndSidecar(params.targetFile); @@ -1141,7 +983,6 @@ export class DownloadTask { private async copyFromResource( copyGroups: CopyGroupResult[], targetRoot: string, - crcByFrom?: Map, ): Promise { let currentFrom = ''; try { @@ -1153,7 +994,7 @@ export class DownloadTask { if (targets.length === 0) { continue; } - const expectedCrc = crcByFrom?.get(currentFrom); + const expectedCrc = group.expectedCrc; if (currentFrom.startsWith('resources/base/media/')) { // Strip only the final extension: 'icon.round.png' -> 'icon.round', @@ -1237,25 +1078,7 @@ export class DownloadTask { } } - private async doCleanUp(params: DownloadTaskParams): Promise { - try { - await NativePatchCore.cleanupOldEntries( - params.unzipDirectory, - [params.hash, params.originHash].filter(name => !!name), - 3, - ); - } catch (error) { - const message = `Cleanup failed: ${getErrorMessage(error)}`; - logger.error(TAG, message); - throw createPushyError(ERROR_FILE_OPERATION_FAILED, message); - } - } - public async execute(params: DownloadTaskParams): Promise { - const isPatchTask = - params.type === DownloadTaskParams.TASK_TYPE_PATCH_FULL || - params.type === DownloadTaskParams.TASK_TYPE_PATCH_FROM_APP || - params.type === DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK; try { switch (params.type) { case DownloadTaskParams.TASK_TYPE_PATCH_FULL: @@ -1267,24 +1090,13 @@ export class DownloadTask { case DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK: await this.doPatchFromPpk(params); break; - case DownloadTaskParams.TASK_TYPE_CLEANUP: - await this.doCleanUp(params); - break; - case DownloadTaskParams.TASK_TYPE_PLAIN_DOWNLOAD: - await this.downloadFile(params); - // 文件在完成时即是最终产物——残留的 sidecar 会让下次同 URL 的 - // 下载不发请求直接返回这份旧字节。 - await this.deleteResumeSidecar(params.targetFile); - break; default: throw createPushyError( ERROR_INVALID_OPTIONS, `Unknown task type: ${params.type}`, ); } - if (isPatchTask) { - await this.promoteStaging(params); - } + await this.promoteStaging(params); } catch (rawError) { // 稳定错误码:下载阶段的失败是 DOWNLOAD_FAILED;归档收齐之后的失败 // (解压/hpatch/资源拷贝含 copiesCrc 校验/完成记录)一律 PATCH_FAILED, @@ -1295,29 +1107,22 @@ export class DownloadTask { this.downloadPhaseCompleted ? ERROR_DELTA_FAILED : ERROR_DOWNLOAD_FAILED, ); logger.error(TAG, `Task execution failed: ${error.message}`); - if (params.type !== DownloadTaskParams.TASK_TYPE_CLEANUP) { - try { - if (params.type === DownloadTaskParams.TASK_TYPE_PLAIN_DOWNLOAD) { - await fileIo.unlink(params.targetFile); - await this.deleteResumeSidecar(params.targetFile); - } else { - // Only the staging directory is ours to drop: the final version - // directory is never touched by a failed install, so a failed - // duplicate task cannot remove an earlier task's install. - await this.removeDirectory(this.stagingDirectory(params)); - if (this.downloadPhaseCompleted) { - // 收齐后才失败的是解压/patch 失败:归档有毒,续传只会撞上 - // 同一个失败。下载阶段的失败则保留 partial + sidecar——那 - // 就是续传状态。 - await this.deleteArchiveAndSidecar(params.targetFile); - } - } - } catch (cleanupError) { - logger.error( - TAG, - `Cleanup after error failed: ${getErrorMessage(cleanupError)}`, - ); + try { + // Only the staging directory is ours to drop: the final version + // directory is never touched by a failed install, so a failed + // duplicate task cannot remove an earlier task's install. + await this.removeDirectory(this.stagingDirectory(params)); + if (this.downloadPhaseCompleted) { + // 收齐后才失败的是解压/patch 失败:归档有毒,续传只会撞上 + // 同一个失败。下载阶段的失败则保留 partial + sidecar——那 + // 就是续传状态。 + await this.deleteArchiveAndSidecar(params.targetFile); } + } catch (cleanupError) { + logger.error( + TAG, + `Cleanup after error failed: ${getErrorMessage(cleanupError)}`, + ); } throw error; } diff --git a/harmony/pushy/src/main/ets/DownloadTaskParams.ts b/harmony/pushy/src/main/ets/DownloadTaskParams.ts index ff612466..6c1c94b9 100644 --- a/harmony/pushy/src/main/ets/DownloadTaskParams.ts +++ b/harmony/pushy/src/main/ets/DownloadTaskParams.ts @@ -3,13 +3,11 @@ */ export class DownloadTaskParams { // 任务类型常量 - static readonly TASK_TYPE_CLEANUP: number = 0; // 保留hash和originHash static readonly TASK_TYPE_PATCH_FULL: number = 1; // 全量补丁 static readonly TASK_TYPE_PATCH_FROM_APP: number = 2; // 从APP补丁 static readonly TASK_TYPE_PATCH_FROM_PPK: number = 3; // 从PPK补丁 - static readonly TASK_TYPE_PLAIN_DOWNLOAD: number = 4; // 普通下载 - type: number = DownloadTaskParams.TASK_TYPE_CLEANUP; // 任务类型 + type: number = DownloadTaskParams.TASK_TYPE_PATCH_FULL; // 任务类型 url: string = ''; // 下载URL hash: string = ''; // 文件哈希值 originHash: string = ''; // 原始文件哈希值 diff --git a/harmony/pushy/src/main/ets/ErrorCodes.ts b/harmony/pushy/src/main/ets/ErrorCodes.ts index c03f1f58..611ca654 100644 --- a/harmony/pushy/src/main/ets/ErrorCodes.ts +++ b/harmony/pushy/src/main/ets/ErrorCodes.ts @@ -1,5 +1,5 @@ // ArkTS mirror of cpp/patch_core/error_codes.h — keep in sync by hand -// (harmony/pushy/src/test/check-constant-parity.js asserts the values match). +// (scripts/check-constant-parity.js asserts the values match). // 与 Android/iOS 共用的稳定错误码:JS 层(src/error.ts)按 code 聚合遥测, // 消息文案各平台可不同,只有码是契约。 // @@ -62,16 +62,3 @@ export function toPushyError(error: unknown, defaultCode: string): PushyError { } return new PushyError(defaultCode, getErrorMessage(error)); } - -/** 从 `[CODE] message` 形式的消息里解析错误码;无前缀返回空串。 */ -export function parseErrorCodePrefix(message: string): string { - if (typeof message !== 'string' || !message.startsWith('[')) { - return ''; - } - const end = message.indexOf('] '); - if (end <= 1) { - return ''; - } - const code = message.substring(1, end); - return /^[A-Z][A-Z0-9_]*$/.test(code) ? code : ''; -} diff --git a/harmony/pushy/src/main/ets/EventHub.ts b/harmony/pushy/src/main/ets/EventHub.ts index 4e705709..e1144953 100644 --- a/harmony/pushy/src/main/ets/EventHub.ts +++ b/harmony/pushy/src/main/ets/EventHub.ts @@ -1,5 +1,5 @@ // The subset of RNOH's RNInstance the SDK needs: device events for download -// progress. Typed structurally so the hypium tests can pass a mock. +// progress. Typed structurally so tests can pass a mock. export interface DeviceEventEmitter { emitDeviceEvent(eventName: string, payload: Object): void; } diff --git a/harmony/pushy/src/main/ets/InstallRecord.ts b/harmony/pushy/src/main/ets/InstallRecord.ts index aeade0e6..35af5704 100644 --- a/harmony/pushy/src/main/ets/InstallRecord.ts +++ b/harmony/pushy/src/main/ets/InstallRecord.ts @@ -1,205 +1,88 @@ -import fileIo from '@ohos.file.fs'; -import { util } from '@kit.ArkTS'; import NativePatchCore from './NativePatchCore'; import { ERROR_DELTA_FAILED, ERROR_SWITCH_VERSION_FAILED, createPushyError, + getErrorMessage, } from './ErrorCodes'; -// ArkTS mirror of cpp/patch_core/install_record.h — keep in sync by hand -// (harmony/pushy/src/test/check-constant-parity.js asserts the values match). -// 两阶段安装的完成记录:作为安装最后一步写进 staging 目录,随后 staging -// 原子 rename 成版本目录;激活前重新校验 bundle 摘要。空文件 = 旧版 SDK -// 写的历史标记,仍视为完整安装(无摘要可验)。 -export const INSTALL_RECORD_SCHEMA = 1; -export const INSTALL_RECORD_FILE_NAME = '.pushy-complete'; +// 两阶段安装的完成记录(cpp/patch_core/install_record.h,三端共用的唯一 +// 实现):解压/打补丁全在 staging 目录里进行,最后写入记录,再一次 rename 成 +// 版本目录;激活前重新校验 bundle 摘要。这里只拼路径、把原生结果映射成 +// 稳定错误码。 +// STAGING_SUFFIX 镜像 kStagingSuffix(scripts/check-constant-parity.js 断言)。 export const STAGING_SUFFIX = '.staging'; -// 归档不得携带任何 `.pushy-` 前缀的条目(任意深度):这些名字保留给 SDK 自己 -// 写的记录,与 Android 的规则一致。 -export const RESERVED_ENTRY_PREFIX = '.pushy-'; export const HARMONY_BUNDLE_FILE_NAME = 'bundle.harmony.js'; -export interface InstallRecordData { - schema?: number; - versionHash?: string; - bundleSha256?: string; - artifactSha256?: string; -} - export function stagingDirectoryFor(versionDir: string): string { return `${versionDir}${STAGING_SUFFIX}`; } -export function isReservedEntryName(name: string): boolean { - return name.startsWith(RESERVED_ENTRY_PREFIX); -} - -export function buildInstallRecord( +/** + * 在 native 工作线程计算 staging 里 bundle 的摘要并原子写入完成记录 + * (schema 1 的 bundleSha256 必填:算不出摘要就安装失败)。失败 = PATCH_FAILED。 + */ +export async function sealInstallRecord( + stagingDir: string, versionHash: string, - bundleSha256: string, artifactSha256: string, -): string { - // schema 1 的 bundleSha256 是必填:算不出摘要的版本必须安装失败,绝不能 - // 写成一条无法复核的记录。 - if (!bundleSha256) { - throw createPushyError( - ERROR_DELTA_FAILED, - `Bundle version ${versionHash} has no bundle digest.`, - ); - } - const record: InstallRecordData = { - schema: INSTALL_RECORD_SCHEMA, - versionHash, - bundleSha256, - }; - if (artifactSha256) { - record.artifactSha256 = artifactSha256; - } - return JSON.stringify(record); -} - -/** 写入并 fsync:紧随其后的 rename 必须能在盘上看到它。 */ -export async function writeInstallRecord( - versionDir: string, - recordJson: string, ): Promise { - const path = `${versionDir}/${INSTALL_RECORD_FILE_NAME}`; - if (fileIo.accessSync(path)) { - await fileIo.unlink(path); - } - const file = await fileIo.open( - path, - fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY, - ); try { - const bytes = new util.TextEncoder().encodeInto(recordJson); - await fileIo.write(file.fd, bytes.buffer as ArrayBuffer); - await fileIo.fsync(file.fd); - } finally { - await fileIo.close(file); - } -} - -/** - * 解析记录文本:空串(历史标记)返回 {};畸形/非对象返回 null。纯函数, - * 供 readInstallRecord 与单测共用。 - */ -export function parseInstallRecord(text: string): InstallRecordData | null { - if (text.length === 0) { - return {}; - } - try { - const parsed = JSON.parse(text) as InstallRecordData | null; - return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) - ? parsed - : null; + await NativePatchCore.sealInstallRecord( + stagingDir, + versionHash, + HARMONY_BUNDLE_FILE_NAME, + artifactSha256, + ); } catch (e) { - return null; + throw createPushyError(ERROR_DELTA_FAILED, getErrorMessage(e)); } } -/** - * 读取记录:文件缺失/畸形返回 null;空文件(历史标记)返回 {}。 - */ -export function readInstallRecord(versionDir: string): InstallRecordData | null { - const path = `${versionDir}/${INSTALL_RECORD_FILE_NAME}`; - if (!fileIo.accessSync(path)) { - return null; - } +/** staging rename 成版本目录(调用方先删掉已有的版本目录)。失败 = PATCH_FAILED。 */ +export async function promoteStaging( + stagingDir: string, + versionDir: string, +): Promise { try { - const stat = fileIo.statSync(path); - if (stat.size === 0) { - return {}; - } - if (stat.size > 64 * 1024) { - return null; - } - return parseInstallRecord(fileIo.readTextSync(path)); + await NativePatchCore.promoteStaging(stagingDir, versionDir); } catch (e) { - return null; + throw createPushyError(ERROR_DELTA_FAILED, getErrorMessage(e)); } } -/** 记录是否宣告本版本完整安装(历史空记录视为是)。纯函数。 */ -export function isRecordForVersion( - record: InstallRecordData | null, - versionHash: string, -): boolean { - if (record === null) { - return false; - } - if (Object.keys(record).length === 0) { - return true; - } - return ( - record.schema === INSTALL_RECORD_SCHEMA && record.versionHash === versionHash +/** 存在性判定(启动/去重路径,不算摘要)。 */ +export function isInstallComplete(versionDir: string, versionHash: string): boolean { + return NativePatchCore.isInstallComplete( + versionDir, + versionHash, + HARMONY_BUNDLE_FILE_NAME, ); } -/** 存在性判定(启动/去重路径,不算摘要):记录存在且(非历史空文件时)指向本版本。 */ -export function isInstallComplete(versionDir: string, versionHash: string): boolean { - return isRecordForVersion(readInstallRecord(versionDir), versionHash); +/** 记录里的 bundleSha256(崩溃归因);没有时为空串。 */ +export function recordedBundleSha256(versionDir: string): string { + return NativePatchCore.recordedBundleSha256(versionDir); } /** - * 激活前的记录校验(不含摘要):返回需要复核的 bundle 摘要,仅历史空记录 - * 返回空串;记录缺失/不符或缺摘要(schema 1 必填)抛 SWITCH_VERSION_FAILED。 - * 纯函数。 - */ -export function expectedBundleSha256ForActivation( - record: InstallRecordData | null, - versionHash: string, -): string { - if (record === null) { - throw createPushyError( - ERROR_SWITCH_VERSION_FAILED, - `Bundle version ${versionHash} has no valid completion record.`, - ); - } - if (Object.keys(record).length === 0) { - return ''; - } - if ( - record.schema !== INSTALL_RECORD_SCHEMA || - record.versionHash !== versionHash - ) { - throw createPushyError( - ERROR_SWITCH_VERSION_FAILED, - `Bundle version ${versionHash} completion record mismatch.`, - ); - } - const expected = record.bundleSha256; - if (typeof expected !== 'string' || expected.length === 0) { - throw createPushyError( - ERROR_SWITCH_VERSION_FAILED, - `Bundle version ${versionHash} completion record mismatch.`, - ); - } - return expected; -} - -/** - * 激活前校验:非历史空记录时在 native 工作线程重新计算 bundle 摘要(整包 - * 几十 MB,不能在 UI 线程同步算);不通过则抛错说明原因。 + * 激活前校验:bundle 在盘上,且记录的摘要与盘上字节一致(整包几十 MB,在 + * native 工作线程算)。无记录文件的目录只有是 current/last(grandfathered, + * 标记机制之前就激活过)时放行。不通过抛 SWITCH_VERSION_FAILED。 */ export async function verifyInstallForActivation( versionDir: string, versionHash: string, - bundlePath: string, + grandfathered: boolean, ): Promise { - const expected = expectedBundleSha256ForActivation( - readInstallRecord(versionDir), - versionHash, - ); - if (!expected) { - return; - } - const actual = await NativePatchCore.sha256HexFileAsync(bundlePath); - if (actual.toLowerCase() !== expected.toLowerCase()) { - throw createPushyError( - ERROR_SWITCH_VERSION_FAILED, - `Bundle version ${versionHash} bundle digest mismatch.`, + try { + await NativePatchCore.checkActivation( + versionDir, + versionHash, + HARMONY_BUNDLE_FILE_NAME, + grandfathered, ); + } catch (e) { + throw createPushyError(ERROR_SWITCH_VERSION_FAILED, getErrorMessage(e)); } } diff --git a/harmony/pushy/src/main/ets/Logger.ts b/harmony/pushy/src/main/ets/Logger.ts index b85aecdc..8aa0d5de 100644 --- a/harmony/pushy/src/main/ets/Logger.ts +++ b/harmony/pushy/src/main/ets/Logger.ts @@ -1,5 +1,12 @@ import hilog from '@ohos.hilog'; +type HilogFunction = ( + domain: number, + tag: string, + format: string, + ...args: string[] +) => void; + class Logger { private domain: number; private prefix: string; @@ -24,54 +31,33 @@ class Logger { this.isDebug = enabled; } - private tagOf(args: string[]): string { - return args.length > 0 ? args[0] : ''; - } - - private messageOf(args: string[]): string { - return args.length > 1 ? args.slice(1).join(' ') : ''; + // args[0] is the tag, the rest is joined into the message. + private log(fn: HilogFunction, args: string[]): void { + fn( + this.domain, + this.prefix, + this.format, + args.length > 0 ? args[0] : '', + args.length > 1 ? args.slice(1).join(' ') : '', + ); } debug(...args: string[]): void { if (this.isDebug) { - hilog.debug( - this.domain, - this.prefix, - this.format, - this.tagOf(args), - this.messageOf(args), - ); + this.log(hilog.debug, args); } } info(...args: string[]): void { - hilog.info( - this.domain, - this.prefix, - this.format, - this.tagOf(args), - this.messageOf(args), - ); + this.log(hilog.info, args); } warn(...args: string[]): void { - hilog.warn( - this.domain, - this.prefix, - this.format, - this.tagOf(args), - this.messageOf(args), - ); + this.log(hilog.warn, args); } error(...args: string[]): void { - hilog.error( - this.domain, - this.prefix, - this.format, - this.tagOf(args), - this.messageOf(args), - ); + this.log(hilog.error, args); } } diff --git a/harmony/pushy/src/main/ets/NativePatchCore.ts b/harmony/pushy/src/main/ets/NativePatchCore.ts index 5f32d7ae..93f76153 100644 --- a/harmony/pushy/src/main/ets/NativePatchCore.ts +++ b/harmony/pushy/src/main/ets/NativePatchCore.ts @@ -3,11 +3,9 @@ import NativeCore from 'librnpushy.so'; export const STATE_OP_SWITCH_VERSION = 1; export const STATE_OP_MARK_SUCCESS = 2; export const STATE_OP_ROLLBACK = 3; -export const STATE_OP_CLEAR_FIRST_TIME = 4; export const STATE_OP_CLEAR_ROLLBACK_MARK = 5; export const STATE_OP_RESOLVE_LAUNCH = 6; -export const ARCHIVE_PATCH_TYPE_FULL = 1; export const ARCHIVE_PATCH_TYPE_FROM_PACKAGE = 2; export const ARCHIVE_PATCH_TYPE_FROM_PPK = 3; @@ -26,29 +24,43 @@ export interface StateCoreResult { consumedFirstTime?: boolean; } -export interface ArchivePatchPlanResult { - mergeSourceSubdir?: string; - enableMerge: boolean; -} - export interface CopyGroupResult { from: string; toPaths: string[]; + /** 源内容必须匹配的 CRC32(__diff.json copiesCrc);未声明时缺省 */ + expectedCrc?: number; } -export interface FileSourcePatchRequest { - copyFroms: string[]; - copyTos: string[]; - deletes: string[]; +/** + * 一次 patch 安装的原生部分(cpp/patch_core/archive_patch_core.h + * ApplyArchivePatch):校验条目、解析并校验 __diff.json、重建 bundle; + * hostCopiesResources 为 false 时同时完成拷贝/删除/合并。 + */ +export interface ArchivePatchRequest { + archiveType: number; + /** staging 目录里解压出的条目名 */ + entryNames: string[]; + /** __diff.json 原文;归档里没有时为空串 */ + manifestJson: string; + bundleDeltaEntry: string; + /** 从拷贝源开头剥掉的前缀(包内 rawfile 路径) */ + copySourcePrefix?: string; sourceRoot: string; targetRoot: string; originBundlePath: string; bundleDeltaPath: string; bundleOutputPath: string; - mergeSourceSubdir?: string; - enableMerge?: boolean; - /** __diff.json 中该 bundle patch 条目的 hbcTransform 元数据(原始 JSON) */ - bundleHbcTransformMeta?: string; + /** 资源不在 sourceRoot 下(rawfile):只重建 bundle,拷贝分组交回调用方 */ + hostCopiesResources?: boolean; +} + +export interface FreeSpaceProbePlan { + /** 本次写入前是否需要探测可用空间。 */ + probe: boolean; + /** 探测时要求的字节数(不含 FREE_DISK_MARGIN_BYTES)。 */ + reserve: number; + /** 计划后的"已预留到"位置(相对本次响应体起点)。 */ + reservedUntil: number; } interface NativePatchCoreBindings { @@ -64,16 +76,8 @@ interface NativePatchCoreBindings { flagA?: boolean, flagB?: boolean, ): StateCoreResult; - buildArchivePlan( - patchType: number, - entryNames: string[], - copyFroms: string[], - copyTos: string[], - deletes: string[], - bundlePatchEntryName?: string, - ): ArchivePatchPlanResult; - buildCopyGroups(copyFroms: string[], copyTos: string[]): CopyGroupResult[]; - applyDeltaFromSource(options: FileSourcePatchRequest): Promise; + /** 工作线程执行;resolve 为调用方仍需完成的拷贝(hostCopiesResources 之外为空) */ + applyArchiveDelta(request: ArchivePatchRequest): Promise; /** * 删除 rootDir 下超过 maxAgeDays 且名字不在 keepNames 里的条目(native * 工作线程)。keepNames 必须包含所有可能仍在使用的版本:持久化的 @@ -90,27 +94,98 @@ interface NativePatchCoreBindings { // pread 流式计算(内嵌 bundle 走 resourceManager.getRawFd)。fd 由调用方 // 持有,promise 落定前不得关闭;读失败/提前 EOF 时 reject。 sha256HexRawFdAsync(fd: number, offset: number, length: number): Promise; + // 完成记录(cpp/patch_core/install_record.h,与 Android/iOS 共用)。 + /** 存在性判定(启动/去重路径,不算摘要):bundle 在盘上且记录指向本版本 */ + isInstallComplete( + versionDir: string, + versionHash: string, + bundleFileName: string, + ): boolean; + /** 记录里的 bundleSha256(崩溃归因);没有时为空串 */ + recordedBundleSha256(versionDir: string): string; + /** 工作线程上计算 staging 里 bundle 的摘要并原子写入完成记录 */ + sealInstallRecord( + stagingDir: string, + versionHash: string, + bundleFileName: string, + artifactSha256: string, + ): Promise; + /** 把已写完记录的 staging 目录 rename 成版本目录(调用方先删掉旧目录) */ + promoteStaging(stagingDir: string, versionDir: string): Promise; + /** + * 激活前校验(工作线程重算 bundle 摘要);不可激活时以原因 reject。 + * grandfathered = 该版本是持久化的 current/last。 + */ + checkActivation( + versionDir: string, + versionHash: string, + bundleFileName: string, + grandfathered: boolean, + ): Promise; + // 解压资源上限(cpp/patch_core/archive_limits.h,与 Android/iOS 共用)。 + /** 整包解压量与整包压缩比;超限时返回原因,否则 undefined */ + checkUncompressedSize( + archiveBytes: number, + uncompressedBytes: number, + ): string | undefined; + /** 工作线程遍历刚解压的目录(不跟随符号链接),超限/保留名/符号链接时以原因 reject */ + checkExtractedDirectory(directory: string, archiveBytes: number): Promise; + /** + * 未知长度下载每个 chunk 写入前的磁盘探测计划。writtenBefore 是本次响应体 + * 在该 chunk 之前已收到的字节数;knownTotal > 0 时从不探测。 + */ + planFreeSpaceProbe( + knownTotal: number, + writtenBefore: number, + chunkBytes: number, + reservedUntil: number, + ): FreeSpaceProbePlan; /** CRC32(zip/zlib 多项式)。pdiff 拷贝前与 copiesCrc 比对用 */ crc32(data: Uint8Array | ArrayBuffer): number; /** 原生 patch 内核可消费的 diff 轨道版本(2 = hdiffv2 轨道) */ getSupportedDiffVersion(): number; - // 更新流程决策层(cpp/update_flow_core,NATIVE_CHECKUPDATE_DESIGN §10): - // JSON 字符串进出,与决策层自身的边界一致。返回 undefined = 输入未通过 - // 解析,编排器跳过本轮检测。 - buildRequestBody(inputJson: string): string | undefined; - orderEndpointCandidates( - endpointsJson: string, + // 原生冷启动轮次(cpp/update_flow_core/native_round.h,与 Android/iOS 共用): + // JSON 字符串进出。返回 undefined = 内核拒收了输入,本轮失败。 + // envJson 描述本次启动(字段见 native_round.h)。 + /** {"body": 请求体文本, "query": 首个查询状态};配置不可运行时 undefined */ + prepareRound( + configJson: string, + envJson: string, randomSample: number, ): string | undefined; + /** + * 下一步查询状态:带 "url"(及 "post")= 发这个请求后再调;"found" = 刚传入 + * 的文本就是有效响应;都没有 = 查询结束。responseText 为 undefined 表示请求 + * 失败或尚未发出。 + */ + nextQueryStep( + stateJson: string, + responseText: string | undefined, + ): string | undefined; + /** 决策(已按本机规划的 attempts)、要持久化的 hashInfo 与响应缓存文本 */ handleResponse( responseText: string, - identityJson: string, - afterDownload: string, + configJson: string, + envJson: string, + requestBody: string, + responseAtSeconds: number, ): string | undefined; - // 响应 schema 门槛(update_flow_core::IsValidResponse):200 但只带 - // `{"error":...}` 的节点算失败,不得阻止继续 fallback。 - isValidResponse(responseText: string): boolean; + + // 持久化的原生配置(cpp/update_flow_core/native_config.h,与 Android/iOS + // 共用)。undefined 视同空串:缺省配置。 + /** C1 配置等价:同一配置的不同序列化。任一缺省即不等价。 */ + configsEquivalent(a: string | undefined, b: string | undefined): boolean; + /** + * 轮次门槛:可运行时为 {"appKey": 去空白后的值},否则 + * {"status": "skipped" | "failed", "reason": ...}。 + */ + runnableConfig(configJson: string | undefined): string; + /** + * 校验宿主 configure() 的选项(JSON 对象文本),返回要持久化的配置; + * 不合法时抛出 Error("Invalid native configuration: ...")。 + */ + normalizeConfiguration(optionsJson: string | undefined): string; } export default NativeCore as unknown as NativePatchCoreBindings; diff --git a/harmony/pushy/src/main/ets/PushyConfiguration.ts b/harmony/pushy/src/main/ets/PushyConfiguration.ts index ce8b1537..e4b9271b 100644 --- a/harmony/pushy/src/main/ets/PushyConfiguration.ts +++ b/harmony/pushy/src/main/ets/PushyConfiguration.ts @@ -1,4 +1,4 @@ -import { POLICY_NEXT_LAUNCH, revealText } from './Texts'; +import NativePatchCore from './NativePatchCore'; /** Options accepted by PushyFileJSBundleProvider.configure(). */ export interface PushyConfiguration { @@ -16,89 +16,12 @@ export interface PushyConfiguration { rn?: string; } -interface PersistedPushyConfiguration { - appKey: string; - endpoints: string[]; - queryUrls: string[]; - afterDownload: string; - disabled: boolean; - packageVersion?: string; - rnu: string; - rn: string; -} - -const DEFAULT_ENDPOINTS: string[] = [ - revealText('3203e0c1bdd1270a372f18f8c2b6de7f4f2607f5b3d5b9817b592947e5cdefbc8a7e'), - revealText('3203e0c1bdd1270a372f18f8c2b6de7f4f2607f5f0daac9c644a620ae88ca1ad93'), -]; -const DEFAULT_QUERY_URLS: string[] = [ - revealText('3203e0c1bdd1270a253608fcd3fd9362476817f4f0d5a1996342631be3c2a3a9d779552507fdcde8928a6f512f5ce2ccbdc869404d2f1de79daa826d562c0913eec4fa9b7d4426'), - revealText('3203e0c1bdd1270a213b12b7dca09468462e12f3b0d5bd813d482446f4c6a1be8e79552507fdcda68cd06e5c3710e480a4867048483e55e0c2ab8d7d43030d1ce9c3b183214e2601f2f0d5b782601e2719e8ca'), -]; -const CONFIG_KEYS: string[] = [ - 'appKey', 'endpoints', 'queryUrls', 'afterDownload', 'disabled', - 'packageVersion', 'rnu', 'rn', -]; - -function configString(value: string, name: string, allowEmpty: boolean): string { - if (typeof value !== 'string' || (!allowEmpty && value.trim().length === 0)) { - throw new Error(`Invalid native configuration: ${name} must be a string${allowEmpty ? '' : ' and must not be blank'}`); - } - return value; -} - -function configUrls(values: string[], name: string, base: boolean): string[] { - if (!Array.isArray(values) || (base && values.length === 0)) { - throw new Error(`Invalid native configuration: ${name} must be ${base ? 'a non-empty' : 'an'} array`); - } - const result: string[] = []; - for (const value of values) { - const url = configString(value, name, false).trim(); - // Restrict the authority and scheme, without relying on browser URL globals - // unavailable in ArkTS. Platform networking performs the final URL parsing. - if (!/^https?:\/\/(\[[0-9a-fA-F:]+\]|[a-zA-Z0-9.-]+)(:[0-9]+)?([/?#]|$)/.test(url) - || /\s|\\/.test(url) || (base && /[?#]/.test(url))) { - throw new Error(`Invalid native configuration: ${name} requires absolute HTTP(S) URLs without credentials${base ? ', queries or fragments' : ''}`); - } - const normalized = base ? url.replace(/\/+$/, '') : url; - if (!result.includes(normalized)) { - result.push(normalized); - } - } - return result; -} - -/** Pure validation, before any native state is touched. Does not mutate options. */ +/** + * Pure validation, before any native state is touched. Does not mutate options. + * The rules and defaults are the shared core's (cpp/update_flow_core + * NormalizeConfiguration, also used by Android and iOS); it throws + * Error("Invalid native configuration: ...") for options the host must fix. + */ export function normalizePushyConfiguration(options: PushyConfiguration): string { - if (!options || typeof options !== 'object' || Array.isArray(options)) { - throw new Error('Invalid native configuration: expected an object'); - } - for (const key of Object.keys(options)) { - if (!CONFIG_KEYS.includes(key)) { - throw new Error(`Invalid native configuration: unknown option ${key}`); - } - } - const appKey = configString(options.appKey, 'appKey', false); - const customEndpoints = options.endpoints !== undefined; - const endpoints = configUrls(customEndpoints ? options.endpoints! : DEFAULT_ENDPOINTS, 'endpoints', true); - const queryUrls = configUrls( - options.queryUrls !== undefined ? options.queryUrls : (customEndpoints ? [] : DEFAULT_QUERY_URLS), - 'queryUrls', false, - ); - const afterDownload = options.afterDownload === undefined ? 'none' : options.afterDownload; - if (afterDownload !== 'none' && afterDownload !== POLICY_NEXT_LAUNCH) { - throw new Error(`Invalid native configuration: afterDownload must be none or ${POLICY_NEXT_LAUNCH}`); - } - if (options.disabled !== undefined && typeof options.disabled !== 'boolean') { - throw new Error('Invalid native configuration: disabled must be a boolean'); - } - const result: PersistedPushyConfiguration = { - appKey, endpoints, queryUrls, afterDownload, disabled: options.disabled ?? false, - rnu: options.rnu === undefined ? '' : configString(options.rnu, 'rnu', true), - rn: options.rn === undefined ? '' : configString(options.rn, 'rn', true), - }; - if (options.packageVersion !== undefined) { - result.packageVersion = configString(options.packageVersion, 'packageVersion', false); - } - return JSON.stringify(result); + return NativePatchCore.normalizeConfiguration(JSON.stringify(options)); } diff --git a/harmony/pushy/src/main/ets/PushyTurboModule.ts b/harmony/pushy/src/main/ets/PushyTurboModule.ts index ad73d032..6380440c 100644 --- a/harmony/pushy/src/main/ets/PushyTurboModule.ts +++ b/harmony/pushy/src/main/ets/PushyTurboModule.ts @@ -37,11 +37,6 @@ interface RestartWant { abilityName?: string; } -// applicationContext.restartApp 是 API 12 才有的可选能力,按结构探测。 -interface RestartableApplicationContext { - restartApp?: (want: RestartWant) => void; -} - interface RestartEventEmitter { emit(event: string, payload: Object): void; } @@ -122,13 +117,10 @@ export class PushyTurboModule extends UITurboModule { abilityName: this.mUiCtx.abilityInfo?.name, }; try { - const applicationContext = - this.mUiCtx.getApplicationContext() as unknown as RestartableApplicationContext; - if (applicationContext && typeof applicationContext.restartApp === 'function') { - logger.debug(TAG, 'restartAbility via applicationContext.restartApp'); - applicationContext.restartApp(want); - return; - } + // applicationContext.restartApp exists since API 12, the HAR's minimum. + logger.debug(TAG, 'restartAbility via applicationContext.restartApp'); + this.mUiCtx.getApplicationContext().restartApp(want); + return; } catch (e) { logger.error(TAG, `restartAbility via restartApp failed: ${getErrorMessage(e)}`); } @@ -206,23 +198,17 @@ export class PushyTurboModule extends UITurboModule { uuid, supportedDiffVersion: NativePatchCore.getSupportedDiffVersion(), }; - const logResult = { - downloadRootDir: result.downloadRootDir, - currentVersionInfo: result.currentVersionInfo, - packageVersion: result.packageVersion, - currentVersion: result.currentVersion, - buildTime: result.buildTime, - isUsingBundleUrl: result.isUsingBundleUrl, - isFirstTime: result.isFirstTime, - rolledBackVersion: result.rolledBackVersion, - uuidSet: !!result.uuid, - }; - logger.info(TAG, `,getConstants result: ${JSON.stringify(logResult)}`); + // The install uuid stays out of the logs; only whether it is set. + const { uuid: _uuid, ...logResult } = result; + logger.info( + TAG, + `,getConstants result: ${JSON.stringify({ ...logResult, uuidSet: !!uuid })}`, + ); return result; } // 以下方法在 JS spec(src/NativePushy.ts)里都返回 Promise,C++ 方法表 - // (PushyTurboModule.cpp)相应用 PUSHY_ASYNC_METHOD 注册:同步注册会把 + // (PushyTurboModule.cpp 的 PUSHY_METHODS)相应登记为 ASYNC:同步注册会把 // ArkTS 的 Promise 变成空对象、把 reject 丢成 ArkTS 侧的 unhandled rejection。 // 每个拒绝都带稳定错误码(ErrorCodes.ts):`code` 属性 + `[CODE] ` 消息前缀。 diff --git a/harmony/pushy/src/main/ets/SaveFile.ts b/harmony/pushy/src/main/ets/SaveFile.ts index 0b1ffdbd..f29db3e8 100644 --- a/harmony/pushy/src/main/ets/SaveFile.ts +++ b/harmony/pushy/src/main/ets/SaveFile.ts @@ -53,7 +53,8 @@ export async function withRawFd( } } -async function writeFully( +/** 把 buffer 的前 length 字节完整写入 fd(处理短写)。 */ +export async function writeFully( fd: number, buffer: ArrayBuffer, length: number, @@ -69,6 +70,13 @@ async function writeFully( } } +/** 文件存在则删除(删除失败照常抛出)。 */ +export async function unlinkIfExists(path: string): Promise { + if (fs.accessSync(path)) { + await fs.unlink(path); + } +} + /** * 把 rawfile descriptor 指向的区间异步拷贝到沙箱文件(覆盖)。全程异步 * fileIo:调用方在 UI 线程(TurboModule),同步 IO 会冻结界面。读到预期 diff --git a/harmony/pushy/src/main/ets/SyncCoordinator.ts b/harmony/pushy/src/main/ets/SyncCoordinator.ts index 6bbaa05d..2034bf65 100644 --- a/harmony/pushy/src/main/ets/SyncCoordinator.ts +++ b/harmony/pushy/src/main/ets/SyncCoordinator.ts @@ -6,10 +6,12 @@ import type { UpdateContext } from './UpdateContext'; import { BundlePreparationRound, bundlePreparationResult } from './BundlePreparationResult'; import type { BundlePreparationResult } from './BundlePreparationResult'; import { isSafePathComponent } from './PathUtils'; -import { QUERY_PATH, STATUS_NONE } from './Texts'; +import { STATUS_NONE } from './Texts'; import { monotonicNowMs } from './MonotonicClock'; import { + ERROR_DELTA_FAILED, ERROR_DOWNLOAD_FAILED, + PushyError, createPushyError, getErrorMessage, } from './ErrorCodes'; @@ -38,38 +40,26 @@ const DOWNLOAD_PHASE_TIMEOUT_MS = 10 * 60 * 1000; const DOWNLOAD_TYPE_DIFF = 'diff'; const DOWNLOAD_TYPE_PDIFF = 'pdiff'; -interface NativeConfig { - appKey?: string; - packageVersion?: string; - endpoints?: string[]; - queryUrls?: string[]; - afterDownload?: string; - rnu?: string; - rn?: string; - disabled?: boolean; -} - -interface FlowIdentity { +// 本次启动的事实,交给共享内核(cpp/update_flow_core/native_round.h)。 +interface RoundEnv { packageVersion: string; - currentVersion?: string; + currentVersion: string; uuid: string; - rolledBackVersion?: string; + rolledBackVersion: string; + buildTime: string; + os: string; + bundleHash: string; } -interface FlowCInfo { - rnu: string; - rn: string; - os: string; - uuid: string; +interface PreparedRound { + body: string; + query: Object; } -interface FlowInput { - packageVersion: string; - currentVersion?: string; - buildTime: string; - cInfo: FlowCInfo; - supportedDiffVersion: number; - bundleHash: string; +interface QueryStep { + url?: string; + post?: boolean; + found?: boolean; } interface DecisionAttempt { @@ -77,35 +67,16 @@ interface DecisionAttempt { urls?: string[]; } -interface DecisionInfoConfig { - forceBoot?: boolean; -} - -interface DecisionInfo { - name?: string; - description?: string; - metaInfo?: string; - // 服务端按版本下发的配置(forceBoot = 救砖指令)。 - config?: DecisionInfoConfig; - // 写进 hash 信息:这个版本是被救援通道送进来的,JS 在 markSuccess 时据此 - // 上报 forceBootRescue 回执(与 Android/iOS 一致)。 - forceBootRescue?: boolean; -} - interface Decision { action?: string; reason?: string; hash?: string; attempts?: DecisionAttempt[]; activate?: boolean; - info?: DecisionInfo; -} - -interface RespCacheEntry { - ts: number; - body: string; - request: string; - config: string; + // 要与版本一起持久化的元信息(JS 侧 setLocalHashInfo;forceBootRescue 由内核标记)。 + hashInfo?: Object; + // 供 JS 复用的响应缓存文本(§10.3)。 + cache?: string; } let scheduled = false; @@ -139,54 +110,29 @@ function startNativeRound( interface ConfigRead { json: string; - config: NativeConfig; - /** Trimmed; the same value gates the round and builds the request URL. */ - appKey: string; /** Set when the persisted configuration cannot start a round. */ rejection?: BundlePreparationResult; } // The single configuration gate shared by the preflight, the host API and // the round itself, so the three can never disagree about what is runnable. +// The verdict is the shared core's (cpp/update_flow_core ParseRunnableConfig, +// also used by Android and iOS). +interface RunnableVerdict { + status?: string; + reason?: string; +} + function readNativeConfig(context: UpdateContext): ConfigRead { const json = context.getKv(KEY_CONFIG); - const empty: NativeConfig = {}; - if (!json) { - return { - json: '', - config: empty, - appKey: '', - rejection: bundlePreparationResult('skipped', 'not_configured'), - }; - } - const invalid: ConfigRead = { - json, - config: empty, - appKey: '', - rejection: bundlePreparationResult('failed', 'invalid_config'), - }; - let config: NativeConfig; - try { - config = JSON.parse(json) as NativeConfig; - } catch (e) { - return invalid; - } - if (!config || typeof config !== 'object' || Array.isArray(config)) { - return invalid; - } - if (config.disabled) { + const verdict = JSON.parse(NativePatchCore.runnableConfig(json)) as RunnableVerdict; + if (verdict.status !== undefined) { return { json, - config, - appKey: '', - rejection: bundlePreparationResult('skipped', 'disabled'), + rejection: bundlePreparationResult(verdict.status, verdict.reason ?? ''), }; } - const appKey = typeof config.appKey === 'string' ? config.appKey.trim() : ''; - if (!appKey) { - return invalid; - } - return { json, config, appKey }; + return { json }; } // A preflight skip must not consume the process's only round: configuration @@ -278,8 +224,6 @@ async function runOnce( return; } const configJson = read.json; - const config = read.config; - const appKey = read.appKey; // 从这里起本轮开始做真实工作:留下面包屑,死于轮中时下次启动零延迟续传。 logger.info( TAG, @@ -297,8 +241,6 @@ async function runOnce( launchRolledBackVersion, resetGeneration, configJson, - config, - appKey, ); } finally { try { @@ -310,103 +252,84 @@ async function runOnce( } } -// 配置端点全为 https 时,网络下发的端点列表/制品 URL 一律拒绝明文 http: -// @ohos.net.http 内部跟随重定向且不暴露最终 URL,这是鸿蒙侧唯一能做的 -// https→http 降级防线(SR-1);其余依赖宿主 network_config.json 的 -// cleartextTrafficPermitted:false(见 harmony/pushy/src/README.md)。 -function isHttpsOnly(config: NativeConfig): boolean { - const endpoints = config.endpoints ?? []; - return ( - endpoints.length > 0 && - endpoints.every(endpoint => isHttpsUrl(endpoint)) - ); -} - -function isHttpsUrl(url: string): boolean { - return typeof url === 'string' && url.toLowerCase().startsWith('https://'); -} - -function isCleartextUrl(url: string): boolean { - return typeof url === 'string' && url.toLowerCase().startsWith('http://'); -} - async function runConfiguredRound( context: UpdateContext, launchRolledBackVersion: string, resetGeneration: number, configJson: string, - config: NativeConfig, - appKey: string, ): Promise { const currentVersion = context.getCurrentVersion(); - // getConstants consumes the persisted rollback marker during startup; use - // the launch-path snapshot captured before that happens. - const rolledBackVersion = launchRolledBackVersion; - const uuid = context.getKv('uuid') ?? ''; - const packageVersion = config.packageVersion || context.getPackageVersion(); - - const identity: FlowIdentity = { - packageVersion, + const env: RoundEnv = { + packageVersion: context.getPackageVersion(), + // 与 JS 常量同形(无版本时为 ''),JS 才能复用这次请求的响应缓存。 currentVersion, - uuid, - }; - if (rolledBackVersion) { - identity.rolledBackVersion = rolledBackVersion; - } - - const cInfo: FlowCInfo = { - rnu: config.rnu ?? '', - rn: config.rn ?? '', + uuid: context.getKv('uuid') ?? '', + // getConstants consumes the persisted rollback marker during startup; use + // the launch-path snapshot captured before that happens. + rolledBackVersion: launchRolledBackVersion, + buildTime: context.getBuildTime(), // RNOH's Platform.Version is osFullName; use the same value so JS can // reuse the response cache produced by this native request. os: `harmony ${deviceInfo.osFullName}`, - uuid, - }; - - const input: FlowInput = { - packageVersion: identity.packageVersion, - currentVersion, - buildTime: context.getBuildTime(), - cInfo, - supportedDiffVersion: NativePatchCore.getSupportedDiffVersion(), bundleHash: await context.getBundleHash(), }; - const body = NativePatchCore.buildRequestBody(JSON.stringify(input)); - if (!body) { + const envJson = JSON.stringify(env); + const preparedJson = NativePatchCore.prepareRound(configJson, envJson, Math.random()); + if (!preparedJson) { roundResult = bundlePreparationResult('failed', 'invalid_request'); return; } - - const httpsOnly = isHttpsOnly(config); - const responseText = await runQueryRequest(config, appKey, body, httpsOnly); + const prepared = JSON.parse(preparedJson) as PreparedRound; + const responseText = await runQueryRequest( + JSON.stringify(prepared.query), + prepared.body, + ); if (!responseText) { logger.warn(TAG, 'no endpoint reachable, giving up until next launch'); return; } - // Anchor cache freshness to response arrival, before download/patch work. - const responseAtSeconds = Math.floor(Date.now() / 1000); - + // 规划好的 attempts、hashInfo 与响应缓存(时间戳取响应到达时)都来自共享内核。 const decisionJson = NativePatchCore.handleResponse( responseText, - JSON.stringify(identity), - config.afterDownload ?? '', + configJson, + envJson, + prepared.body, + Math.floor(Date.now() / 1000), ); if (!decisionJson) { roundResult = bundlePreparationResult('failed', 'invalid_response'); return; } const decision = JSON.parse(decisionJson) as Decision; - if (decision.action !== 'download') { + const responseCacheJson = decision.cache ?? ''; + // Commits the round's persistent outcome (see commitSyncResult) and maps + // it to the round result; a reset during the round wins over `result`. + const commit = async ( + hash: string, + hashInfoJson: string, + activate: boolean, + result: BundlePreparationResult, + ): Promise => { const committed = await context.commitSyncResult( resetGeneration, + hash, + hashInfoJson, + activate, + responseCacheJson, + ); + roundResult = committed + ? result + : bundlePreparationResult('cancelled', 'reset'); + return committed; + }; + + if (decision.action !== 'download') { + await commit( '', '', false, - buildResponseCacheJson(configJson, body, responseText, responseAtSeconds), + bundlePreparationResult(STATUS_NONE, decision.reason ?? ''), ); - roundResult = committed - ? bundlePreparationResult(STATUS_NONE, decision.reason ?? '') - : bundlePreparationResult('cancelled', 'reset'); logger.info(TAG, `nothing to do (${decision.reason ?? ''})`); return; } @@ -427,46 +350,15 @@ async function runConfiguredRound( decision.attempts ?? [], hash, currentVersion, - httpsOnly, ); } if (!downloaded) { logger.warn(TAG, `all download attempts for ${hash} failed`); - const committed = await context.commitSyncResult( - resetGeneration, - '', - '', - false, - buildResponseCacheJson(configJson, body, responseText, responseAtSeconds), - ); - roundResult = committed - ? bundlePreparationResult('failed', 'download_failed') - : bundlePreparationResult('cancelled', 'reset'); + await commit('', '', false, bundlePreparationResult('failed', 'download_failed')); return; } - // 与 JS 侧下载成功后的 setLocalHashInfo 对齐,持久化版本元信息。 - const info = decision.info; - let hashInfoJson = ''; - if (info) { - const hashInfo: DecisionInfo = {}; - if (typeof info.name === 'string') { - hashInfo.name = info.name; - } - if (typeof info.description === 'string') { - hashInfo.description = info.description; - } - if (typeof info.metaInfo === 'string') { - hashInfo.metaInfo = info.metaInfo; - } - // forceBoot 激活就是救砖路径:记进持久化元信息,这个版本活到 markSuccess - // 时 JS 上报 forceBootRescue。只认服务端下发的指令——静默策略的激活是 - // 普通投递(Android/iOS 同一规则)。 - if (info.config && info.config.forceBoot) { - hashInfo.forceBootRescue = true; - } - hashInfoJson = JSON.stringify(hashInfo); - } + const hashInfoJson = JSON.stringify(decision.hashInfo); // 版本元信息、激活与响应缓存一次性原子提交(见 commitSyncResult); // 缓存只在原生文件/状态工作结束后公开,避免 JS 观察到响应后并发下载。 @@ -474,12 +366,11 @@ async function runConfiguredRound( const activate = decision.activate === true; let committed = false; try { - committed = await context.commitSyncResult( - resetGeneration, + committed = await commit( hash, hashInfoJson, activate, - buildResponseCacheJson(configJson, body, responseText, responseAtSeconds), + bundlePreparationResult('downloaded', '', hash, activate), ); } catch (e) { logger.error(TAG, `commit failed: ${getErrorMessage(e)}`); @@ -493,35 +384,6 @@ async function runConfiguredRound( } else { logger.info(TAG, `downloaded ${hash}, activation left to JS`); } - roundResult = committed - ? bundlePreparationResult('downloaded', '', hash, activate) - : bundlePreparationResult('cancelled', 'reset'); -} - -function buildResponseCacheJson( - configJson: string, - requestBody: string, - responseText: string, - responseAtSeconds: number, -): string { - const cacheEntry: RespCacheEntry = { - ts: responseAtSeconds, - body: responseText, - request: requestBody, - config: configJson, - }; - return JSON.stringify(cacheEntry); -} - -function isValidResponse(responseText: string | undefined): boolean { - if (responseText === undefined) { - return false; - } - try { - return NativePatchCore.isValidResponse(responseText); - } catch (e) { - return false; - } } async function httpRequest( @@ -569,92 +431,32 @@ async function httpRequest( } } -// 顺序回退(§5.1):按纯层给出的候选序逐个请求,单请求超时;整轮失败后经 -// queryUrls 发现远程候选(排除已试过的)再来一轮。刻意不做 hedged race—— -// 该路径对延迟不敏感。 +// 顺序回退(§5.1):按共享内核给出的下一步逐个请求,单请求超时 +// (NativePatchCore.nextQueryStep:候选序、queryUrls 发现、远程端点的 https +// 门槛、请求预算、响应 schema)。刻意不做 hedged race——该路径对延迟不敏感。 async function runQueryRequest( - config: NativeConfig, - appKey: string, + query: string, body: string, - httpsOnly: boolean, ): Promise { - const orderedJson = NativePatchCore.orderEndpointCandidates( - JSON.stringify(config.endpoints ?? []), - Math.random(), - ); - if (!orderedJson) { - return undefined; - } - const ordered = JSON.parse(orderedJson) as string[]; - const tried = new Set(); - let httpAttempts = 0; - for (const rawBase of ordered) { - const base = normalizeEndpointBase(rawBase); - if (!base || tried.has(base)) { - continue; + let state: string | undefined = query; + let text: string | undefined = undefined; + while (state !== undefined) { + state = NativePatchCore.nextQueryStep(state, text); + if (state === undefined) { + break; } - if (httpAttempts++ >= MAX_QUERY_HTTP_ATTEMPTS) { - return undefined; + const step = JSON.parse(state) as QueryStep; + if (step.found) { + return text; } - tried.add(base); - const response = await httpRequest(`${base}${QUERY_PATH}${appKey}`, body); - if (isValidResponse(response)) { - return response; + if (!step.url) { + break; } - } - for (const listUrl of config.queryUrls ?? []) { - if (!listUrl) { - continue; - } - if (httpAttempts++ >= MAX_QUERY_HTTP_ATTEMPTS) { - return undefined; - } - const listText = await httpRequest(listUrl); - if (listText === undefined) { - continue; - } - let remote: string[]; - try { - const parsed = JSON.parse(listText) as Object; - if (!Array.isArray(parsed)) { - continue; - } - remote = parsed as string[]; - } catch (e) { - continue; - } - for (const rawBase of remote) { - if (typeof rawBase !== 'string') { - continue; - } - const base = normalizeEndpointBase(rawBase); - if (!base || tried.has(base)) { - continue; - } - if (httpsOnly && !isHttpsUrl(base)) { - // 远程注入的端点不得把 https 配置降级成明文。 - logger.warn(TAG, `ignoring non-https remote endpoint ${base}`); - continue; - } - if (httpAttempts++ >= MAX_QUERY_HTTP_ATTEMPTS) { - return undefined; - } - tried.add(base); - const response = await httpRequest(`${base}${QUERY_PATH}${appKey}`, body); - if (isValidResponse(response)) { - return response; - } - } - // 拉到一份可解析的远程列表就够了。 - break; + text = await httpRequest(step.url, step.post ? body : undefined); } return undefined; } -function normalizeEndpointBase(base: string): string { - return base.replace(/\/+$/, ''); -} - async function runWithinDeadline( start: () => Promise, deadlineUptimeMs: number, @@ -692,16 +494,11 @@ async function performAttempts( attempts: DecisionAttempt[], hash: string, originHash: string, - httpsOnly: boolean, ): Promise { const incrementalDeadline = monotonicNowMs() + DOWNLOAD_PHASE_TIMEOUT_MS; let fullDeadline = 0; for (const attempt of attempts) { const type = attempt.type ?? ''; - if (type === DOWNLOAD_TYPE_DIFF && !originHash) { - // diff 以当前运行版本为源;没有运行中的热更版本就跳过。 - continue; - } const isFullAttempt = type !== DOWNLOAD_TYPE_DIFF && type !== DOWNLOAD_TYPE_PDIFF; if (isFullAttempt && fullDeadline === 0) { @@ -711,14 +508,6 @@ async function performAttempts( } const deadline = isFullAttempt ? fullDeadline : incrementalDeadline; for (const url of attempt.urls ?? []) { - if (!url) { - continue; - } - if (httpsOnly && isCleartextUrl(url)) { - // 决策里的明文制品 URL 不得把 https 配置降级(SR-1)。 - logger.warn(TAG, `ignoring cleartext ${type} url ${url}`); - continue; - } if (monotonicNowMs() >= deadline) { if (isFullAttempt) { return false; @@ -745,8 +534,19 @@ async function performAttempts( return true; } catch (e) { logger.warn(TAG, `${type} attempt failed: ${getErrorMessage(e)}`); + if (!isMirrorRetryable(e)) { + // The bytes arrived but could not be applied: every mirror serves + // the same artifact, so move on to the next strategy instead of + // re-downloading it (update_flow_core IsMirrorRetryableCode, JS + // client.ts isMirrorRetryableCode). + break; + } } } } return false; } + +function isMirrorRetryable(error: unknown): boolean { + return !(error instanceof PushyError && error.code === ERROR_DELTA_FAILED); +} diff --git a/harmony/pushy/src/main/ets/Texts.ts b/harmony/pushy/src/main/ets/Texts.ts index c2faa9fc..4acc0574 100644 --- a/harmony/pushy/src/main/ets/Texts.ts +++ b/harmony/pushy/src/main/ets/Texts.ts @@ -13,12 +13,8 @@ export function revealText(hex: string): string { return text; } -// Request path appended to an endpoint base. -export const QUERY_PATH: string = revealText('7514fcd4ad805d55263e08fc99'); - // Protocol values, version-info flags and storage names, stored encoded. export const STATUS_NONE: string = revealText('3418c1c1aa8a7c40'); -export const POLICY_NEXT_LAUNCH: string = revealText('2912e0ffab8e6c70323b1dedd3'); export const STORAGE_DIR_NAME: string = revealText('0502e4d5af9f6d'); export const PREFERENCES_NAME: string = revealText('2f07f0d0ba8e'); export const PPK_DELTA_SUFFIX: string = revealText('7407e4dae09b69512137'); diff --git a/harmony/pushy/src/main/ets/UpdateContext.ts b/harmony/pushy/src/main/ets/UpdateContext.ts index f8199dbc..6bd8aa0d 100644 --- a/harmony/pushy/src/main/ets/UpdateContext.ts +++ b/harmony/pushy/src/main/ets/UpdateContext.ts @@ -1,9 +1,9 @@ import preferences from '@ohos.data.preferences'; import fileIo from '@ohos.file.fs'; import { - INSTALL_RECORD_FILE_NAME, + HARMONY_BUNDLE_FILE_NAME, isInstallComplete, - readInstallRecord, + recordedBundleSha256, verifyInstallForActivation, } from './InstallRecord'; import { DownloadTask } from './DownloadTask'; @@ -19,7 +19,6 @@ import { scheduleNativeSync, } from './SyncCoordinator'; import NativePatchCore, { - STATE_OP_CLEAR_FIRST_TIME, STATE_OP_CLEAR_ROLLBACK_MARK, STATE_OP_MARK_SUCCESS, STATE_OP_RESOLVE_LAUNCH, @@ -31,7 +30,6 @@ import { assertSafePathComponent } from './PathUtils'; import { withRawFd } from './SaveFile'; import { ERROR_SWITCH_VERSION_FAILED, - createPushyError, getErrorMessage, toPushyError, } from './ErrorCodes'; @@ -43,94 +41,18 @@ const TAG = 'PushyContext'; // 常规清理保留最近 3 天内触碰过的条目(续传 partial、staging 同样按 mtime)。 const CLEANUP_MAX_AGE_DAYS = 3; -// Preferences.flushSync 是 API 14 才有、getAllSync 老 SDK 声明里没有:HAR 兼容 -// API 12,按结构化视图探测(不用交叉类型——ArkTS 不支持)。 +// Preferences.flushSync 是 API 14 才有:HAR 兼容 API 12,按结构化视图探测 +// (不用交叉类型——ArkTS 不支持)。 interface OptionalPreferencesApi { flushSync?: () => void; - getAllSync?: () => Record; } -// C1 跨平台配置等价(JS/Android/iOS 同一规则):缺省键补默认值、端点/ -// 发现地址去尾斜杠并保序去重、忽略键序。等价的配置不算"变更"——宿主重复 -// configure、或 JS 同步一份只差格式的同一配置,都不应取消在飞轮次或丢弃 -// 响应缓存。放在本文件(而非独立模块):本文件被测试原样求值,只能依赖 -// 自身源码。 -const NATIVE_CONFIG_DEFAULTS: Record = { - disabled: false, - queryUrls: [], - rnu: '', - rn: '', - afterDownload: 'none', -}; -const NATIVE_CONFIG_URL_LISTS: string[] = ['endpoints', 'queryUrls']; - -function canonicalJson(value: unknown): string { - if (Array.isArray(value)) { - return `[${value.map(item => canonicalJson(item)).join(',')}]`; - } - if (value !== null && typeof value === 'object') { - const record = value as Record; - const keys = Object.keys(record) - .filter(key => record[key] !== undefined) - .sort(); - return `{${keys - .map(key => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) - .join(',')}}`; - } - return JSON.stringify(value) ?? 'null'; -} - -/** 配置 JSON 的规范形式;不是可解析的 JSON 对象时返回 undefined。 */ -export function canonicalNativeConfig(json: string): string | undefined { - let parsed: unknown; - try { - parsed = JSON.parse(json); - } catch (e) { - return undefined; - } - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - return undefined; - } - const config: Record = {}; - const source = parsed as Record; - for (const key of Object.keys(source)) { - config[key] = source[key]; - } - for (const key of Object.keys(NATIVE_CONFIG_DEFAULTS)) { - if (config[key] === undefined) { - config[key] = NATIVE_CONFIG_DEFAULTS[key]; - } - } - for (const key of NATIVE_CONFIG_URL_LISTS) { - const list = config[key]; - if (!Array.isArray(list)) { - continue; - } - const seen = new Set(); - const normalized: unknown[] = []; - for (const item of list as unknown[]) { - const value = typeof item === 'string' ? item.replace(/\/+$/, '') : item; - const identity = canonicalJson(value); - if (!seen.has(identity)) { - seen.add(identity); - normalized.push(value); - } - } - config[key] = normalized; - } - return canonicalJson(config); -} - -/** 两份配置 JSON 是否等价(C1)。任一不是可解析的 JSON 对象即不等价。 */ -export function nativeConfigsEquivalent( - a: string | undefined, - b: string | undefined, -): boolean { - if (a === undefined || b === undefined) { - return false; - } - const left = canonicalNativeConfig(a); - return left !== undefined && left === canonicalNativeConfig(b); +// C1 配置等价(cpp/update_flow_core ConfigsEquivalent,与 Android/iOS 共用): +// 缺省键补默认值、端点/发现地址去尾斜杠并保序去重、忽略键序。等价的配置不算 +// "变更"——宿主重复 configure、或 JS 同步一份只差格式的同一配置,都不应取消 +// 在飞轮次或丢弃响应缓存。任一缺省即不等价。 +function nativeConfigsEquivalent(a: string | undefined, b: string | undefined): boolean { + return NativePatchCore.configsEquivalent(a, b); } interface PersistOptions { @@ -405,7 +327,7 @@ export class UpdateContext { } private getBundlePath(hash: string): string { - return `${this.rootDir}/${hash}/bundle.harmony.js`; + return `${this.rootDir}/${hash}/${HARMONY_BUNDLE_FILE_NAME}`; } private getStateSnapshot(): StateCoreResult { @@ -500,13 +422,9 @@ export class UpdateContext { private async executeTask(params: DownloadTaskParams): Promise { await this.enqueueSerialTask(() => { - const isPatchTask = - params.type === DownloadTaskParams.TASK_TYPE_PATCH_FULL || - params.type === DownloadTaskParams.TASK_TYPE_PATCH_FROM_APP || - params.type === DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK; // Re-check only when this queued job actually starts. A JS download may // have completed while the cold-start duplicate waited behind it. - if (isPatchTask && this.hasDownloadedVersion(params.hash)) { + if (this.hasDownloadedVersion(params.hash)) { return Promise.resolve(); } const downloadTask = new DownloadTask(this.context); @@ -594,10 +512,6 @@ export class UpdateContext { return this.flushPreferences(`remove key ${key}`); } - public isFirstTime(): boolean { - return this.getStateSnapshot().firstTime; - } - public rolledBackVersion(): string { return this.getStateSnapshot().rolledBackVersion || ''; } @@ -614,14 +528,6 @@ export class UpdateContext { await this.flushPreferences('mark success'); } - public async clearFirstTime(): Promise { - this.runStateOperation(STATE_OP_CLEAR_FIRST_TIME, '', { - cleanUp: true, - clearFirstLoadMarker: true, - }); - await this.flushPreferences('clear first time'); - } - /** getConstants(同步)里调用:落盘失败只记日志。 */ public clearRollbackMark(): void { this.runStateOperation(STATE_OP_CLEAR_ROLLBACK_MARK, '', { @@ -657,23 +563,20 @@ export class UpdateContext { let flushed: Promise = Promise.resolve(); try { // 删除已下载版本的 hash_* 元信息(不走 clear():它是异步的,且会连带清掉 - // uuid —— 见 syncStateWithBinaryVersion 的注释)。getAllSync 在旧 SDK 上 - // 可能不存在,此时残留的 hash_* 只是无害孤儿数据,不影响 reset 语义。 - const api = this.optionalPreferencesApi(); - if (typeof api.getAllSync === 'function') { - try { - const all = api.getAllSync(); - for (const key of Object.keys(all)) { - if (key.startsWith('hash_')) { - this.preferences.deleteSync(key); - } + // uuid —— 见 syncStateWithBinaryVersion 的注释)。读取失败时残留的 + // hash_* 只是无害孤儿数据,不影响 reset 语义。 + try { + const all = this.preferences.getAllSync(); + for (const key of Object.keys(all)) { + if (key.startsWith('hash_')) { + this.preferences.deleteSync(key); } - } catch (e) { - logger.error( - TAG, - `Failed to clear hash info on reset: ${getErrorMessage(e)}`, - ); } + } catch (e) { + logger.error( + TAG, + `Failed to clear hash info on reset: ${getErrorMessage(e)}`, + ); } this.writeState(resetState, { clearFirstLoadMarker: true }); // 缓存里的响应仍在宣告本次 reset 刚删掉的版本,一并丢弃,避免 JS 侧复用。 @@ -754,20 +657,15 @@ export class UpdateContext { hash: string, deadlineUptimeMs: number = 0, ): Promise { - try { - const params = this.createTaskParams( - DownloadTaskParams.TASK_TYPE_PATCH_FULL, - url, - hash, - ); - params.targetFile = `${this.rootDir}/${hash}.ppk`; - params.unzipDirectory = `${this.rootDir}/${hash}`; - params.deadlineUptimeMs = deadlineUptimeMs; - await this.executeTask(params); - } catch (e) { - logger.error(TAG, `Failed to download full package: ${getErrorMessage(e)}`); - throw e; - } + const params = this.createTaskParams( + DownloadTaskParams.TASK_TYPE_PATCH_FULL, + url, + hash, + ); + params.targetFile = `${this.rootDir}/${hash}.ppk`; + params.unzipDirectory = `${this.rootDir}/${hash}`; + params.deadlineUptimeMs = deadlineUptimeMs; + await this.executeTask(params); } public async downloadPatchFromPpk( @@ -794,23 +692,15 @@ export class UpdateContext { hash: string, deadlineUptimeMs: number = 0, ): Promise { - try { - const params = this.createTaskParams( - DownloadTaskParams.TASK_TYPE_PATCH_FROM_APP, - url, - hash, - ); - params.targetFile = `${this.rootDir}/${hash}${APP_DELTA_SUFFIX}`; - params.unzipDirectory = `${this.rootDir}/${hash}`; - params.deadlineUptimeMs = deadlineUptimeMs; - return await this.executeTask(params); - } catch (e) { - logger.error( - TAG, - `Failed to download package delta: ${getErrorMessage(e)}`, - ); - throw e; - } + const params = this.createTaskParams( + DownloadTaskParams.TASK_TYPE_PATCH_FROM_APP, + url, + hash, + ); + params.targetFile = `${this.rootDir}/${hash}${APP_DELTA_SUFFIX}`; + params.unzipDirectory = `${this.rootDir}/${hash}`; + params.deadlineUptimeMs = deadlineUptimeMs; + await this.executeTask(params); } // 运行中热更版本安装记录里的 bundleSha256(崩溃归因用);内置 bundle / @@ -820,10 +710,9 @@ export class UpdateContext { return ''; } try { - const record = readInstallRecord( + return recordedBundleSha256( `${this.rootDir}/${assertSafePathComponent(hash)}`, ); - return record?.bundleSha256 ?? ''; } catch (e) { return ''; } @@ -834,53 +723,26 @@ export class UpdateContext { public hasDownloadedVersion(hash: string): boolean { try { const safeHash = assertSafePathComponent(hash); - return fileIo.accessSync(this.getBundlePath(safeHash)) - && isInstallComplete(`${this.rootDir}/${safeHash}`, safeHash); + return isInstallComplete(`${this.rootDir}/${safeHash}`, safeHash); } catch (e) { return false; } } /** - * 激活前置校验(同步,不写任何东西):bundle 在盘上,且要么是本 SDK 记录为 - * 完整安装的版本(bundle + 记录),要么是标记机制之前就激活过、目录里没有 - * 记录文件的历史版本(current/last 里有它)。其他无记录的目录可能是崩溃 - * 留下的半成品;有记录文件的目录即便是 current/last 也照常复核。 - * 返回是否还需复核安装记录(无记录的历史版本无可核)。 - */ - private assertActivatable(safeHash: string): boolean { - if (!fileIo.accessSync(this.getBundlePath(safeHash))) { - throw createPushyError( - ERROR_SWITCH_VERSION_FAILED, - `Bundle version ${safeHash} not found.`, - ); - } - const legacyActivated = - !fileIo.accessSync(`${this.rootDir}/${safeHash}/${INSTALL_RECORD_FILE_NAME}`) && - (this.readString('currentVersion') === safeHash || - this.readString('lastVersion') === safeHash); - if (!this.hasDownloadedVersion(safeHash) && !legacyActivated) { - throw createPushyError( - ERROR_SWITCH_VERSION_FAILED, - `Bundle version ${safeHash} is incomplete.`, - ); - } - return !legacyActivated; - } - - /** - * 完整的激活校验(含 native 工作线程上的 bundle 摘要复核),不写状态。 - * 记录里的 bundle 摘要必须与盘上字节一致,才能把下次启动指向它。 + * 完整的激活校验(含 native 工作线程上的 bundle 摘要复核),不写状态: + * bundle 在盘上,且记录里的 bundle 摘要与盘上字节一致,才能把下次启动指向 + * 它。标记机制之前就激活过、目录里没有记录文件的历史版本(current/last + * 里有它)免检;有记录文件的目录即便是 current/last 也照常复核。 */ public async verifyActivation(hash: string): Promise { const safeHash = assertSafePathComponent(hash); - if (this.assertActivatable(safeHash)) { - await verifyInstallForActivation( - `${this.rootDir}/${safeHash}`, - safeHash, - this.getBundlePath(safeHash), - ); - } + await verifyInstallForActivation( + `${this.rootDir}/${safeHash}`, + safeHash, + this.readString('currentVersion') === safeHash || + this.readString('lastVersion') === safeHash, + ); } /** 内存里切换状态(不落盘);调用方负责 flush。 */ @@ -1092,7 +954,7 @@ export class UpdateContext { // fd 在 promise 落定后才关闭(withRawFd)。 hash = await withRawFd( this.context.resourceManager, - 'bundle.harmony.js', + HARMONY_BUNDLE_FILE_NAME, descriptor => NativePatchCore.sha256HexRawFdAsync( descriptor.fd, diff --git a/harmony/pushy/src/test/ArchiveLimits.test.ets b/harmony/pushy/src/test/ArchiveLimits.test.ets deleted file mode 100644 index 02c9b2e7..00000000 --- a/harmony/pushy/src/test/ArchiveLimits.test.ets +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, it, expect } from '@ohos/hypium'; -import { - ORIGINAL_SIZE_UNAVAILABLE, - UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES, - checkOriginalSize, - planUnknownLengthFreeSpaceProbe, -} from '../main/ets/ArchiveLimits'; -import { ERROR_DELTA_FAILED, PushyError } from '../main/ets/ErrorCodes'; - -const PROBE = UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES; - -function refusal(run: () => number): PushyError | null { - try { - run(); - } catch (e) { - return e as PushyError; - } - return null; -} - -export default function archiveLimitsTest() { - // 未知长度下载的磁盘探测:任何写入都不得越过已探测预留的位置。 - describe('planUnknownLengthFreeSpaceProbe', () => { - it('never probes when the total length is known', 0, () => { - const plan = planUnknownLengthFreeSpaceProbe(1000, 0, 4096, 0); - expect(plan.probe).assertFalse(); - expect(plan.reservedUntil).assertEqual(0); - }); - - it('probes the first chunk and reserves PROBE bytes ahead', 0, () => { - const plan = planUnknownLengthFreeSpaceProbe(0, 0, 4096, 0); - expect(plan.probe).assertTrue(); - expect(plan.reserve).assertEqual(PROBE); - expect(plan.reservedUntil).assertEqual(PROBE); - }); - - it('skips chunks that stay inside the reservation', 0, () => { - const plan = planUnknownLengthFreeSpaceProbe(0, 4096, 4096, PROBE); - expect(plan.probe).assertFalse(); - expect(plan.reservedUntil).assertEqual(PROBE); - // Ending exactly on the reserved boundary is still covered. - const edge = planUnknownLengthFreeSpaceProbe(0, PROBE - 10, 10, PROBE); - expect(edge.probe).assertFalse(); - }); - - it('re-probes a chunk that would cross the reservation', 0, () => { - const plan = planUnknownLengthFreeSpaceProbe(0, PROBE - 10, 11, PROBE); - expect(plan.probe).assertTrue(); - expect(plan.reservedUntil).assertEqual(PROBE - 10 + PROBE); - }); - - it('reserves a chunk larger than PROBE by its own size', 0, () => { - const big = PROBE * 2 + 1; - const plan = planUnknownLengthFreeSpaceProbe(0, 100, big, 0); - expect(plan.probe).assertTrue(); - expect(plan.reserve).assertEqual(big); - expect(plan.reservedUntil).assertEqual(100 + big); - }); - - it('walking a stream never writes past a probed reservation', 0, () => { - let reservedUntil = 0; - let written = 0; - let probes = 0; - const chunk = 300 * 1024; - for (let i = 0; i < 100; i++) { - const plan = planUnknownLengthFreeSpaceProbe(0, written, chunk, reservedUntil); - if (plan.probe) { - probes += 1; - } - reservedUntil = plan.reservedUntil; - written += chunk; - expect(written <= reservedUntil).assertTrue(); - } - expect(probes < 100).assertTrue(); - }); - }); - - // getOriginalSize 读不到:拒绝(不放行),且带专属错误细节可诊断。 - describe('checkOriginalSize', () => { - it('passes a finite non-negative size through', 0, () => { - expect(checkOriginalSize(0)).assertEqual(0); - expect(checkOriginalSize(12345)).assertEqual(12345); - }); - - it('refuses when getOriginalSize threw, with its own detail', 0, () => { - const error = refusal(() => checkOriginalSize(undefined, 'boom')); - expect(error !== null).assertTrue(); - expect(error!.code).assertEqual(ERROR_DELTA_FAILED); - expect(error!.message.includes(ORIGINAL_SIZE_UNAVAILABLE)).assertTrue(); - expect(error!.message.includes('getOriginalSize failed: boom')).assertTrue(); - }); - - it('refuses an unusable result, distinct from the thrown case', 0, () => { - const sizes: (number | undefined)[] = [undefined, NaN, -1, Infinity]; - for (const size of sizes) { - const error = refusal(() => checkOriginalSize(size)); - expect(error !== null).assertTrue(); - expect(error!.code).assertEqual(ERROR_DELTA_FAILED); - expect(error!.message.includes(ORIGINAL_SIZE_UNAVAILABLE)).assertTrue(); - expect(error!.message.includes('getOriginalSize returned')).assertTrue(); - } - }); - }); -} diff --git a/harmony/pushy/src/test/ContentRange.test.ets b/harmony/pushy/src/test/ContentRange.test.ets deleted file mode 100644 index b6d7a62f..00000000 --- a/harmony/pushy/src/test/ContentRange.test.ets +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, it, expect } from '@ohos/hypium'; -import { parseContentRangeTotal } from '../main/ets/DownloadTask'; - -// 续传响应头解析:206 的 Content-Range 决定追加的字节是否可信。 -export default function contentRangeTest() { - describe('parseContentRangeTotal', () => { - it('returns the total for a well-formed range at the expected start', 0, () => { - expect(parseContentRangeTotal('bytes 100-199/200', 100)).assertEqual(200); - expect(parseContentRangeTotal('bytes 0-9/10', 0)).assertEqual(10); - }); - - it('tolerates surrounding whitespace', 0, () => { - expect(parseContentRangeTotal('bytes 100-199 / 200 ', 100)).assertEqual(200); - }); - - it('returns 0 for an unknown total (*)', 0, () => { - expect(parseContentRangeTotal('bytes 100-199/*', 100)).assertEqual(0); - }); - - it('rejects a start that does not match the local partial', 0, () => { - expect(parseContentRangeTotal('bytes 50-199/200', 100)).assertEqual(-1); - expect(parseContentRangeTotal('bytes 0-199/200', 100)).assertEqual(-1); - }); - - it('rejects missing or foreign units', 0, () => { - expect(parseContentRangeTotal('', 0)).assertEqual(-1); - expect(parseContentRangeTotal('100-199/200', 100)).assertEqual(-1); - expect(parseContentRangeTotal('items 100-199/200', 100)).assertEqual(-1); - }); - - it('rejects malformed ranges', 0, () => { - expect(parseContentRangeTotal('bytes 100-199', 100)).assertEqual(-1); - expect(parseContentRangeTotal('bytes 100/200', 100)).assertEqual(-1); - expect(parseContentRangeTotal('bytes */200', 0)).assertEqual(-1); - expect(parseContentRangeTotal('bytes abc-199/200', 100)).assertEqual(-1); - }); - - it('rejects non-positive or non-numeric totals', 0, () => { - expect(parseContentRangeTotal('bytes 100-199/0', 100)).assertEqual(-1); - expect(parseContentRangeTotal('bytes 100-199/-5', 100)).assertEqual(-1); - expect(parseContentRangeTotal('bytes 100-199/abc', 100)).assertEqual(-1); - }); - - // 数值 total 必须大于 end(RFC 9110 §14.4);end 不能在 start 之前。 - it('rejects a range that contradicts its total', 0, () => { - expect(parseContentRangeTotal('bytes 100-199/199', 100)).assertEqual(-1); - expect(parseContentRangeTotal('bytes 100-199/150', 100)).assertEqual(-1); - expect(parseContentRangeTotal('bytes 100-99/200', 100)).assertEqual(-1); - expect(parseContentRangeTotal('bytes 100-99/*', 100)).assertEqual(-1); - expect(parseContentRangeTotal('bytes 100-x/200', 100)).assertEqual(-1); - expect(parseContentRangeTotal('bytes 100-100/200', 100)).assertEqual(200); - }); - }); -} diff --git a/harmony/pushy/src/test/DownloadTaskParams.test.ets b/harmony/pushy/src/test/DownloadTaskParams.test.ets deleted file mode 100644 index 9ac3496a..00000000 --- a/harmony/pushy/src/test/DownloadTaskParams.test.ets +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, it, expect } from '@ohos/hypium'; -import { DownloadTaskParams } from '../main/ets/DownloadTaskParams'; - -export default function downloadTaskParamsTest() { - describe('DownloadTaskParams', () => { - it('has correct task type constants', 0, () => { - expect(DownloadTaskParams.TASK_TYPE_CLEANUP).assertEqual(0); - expect(DownloadTaskParams.TASK_TYPE_PATCH_FULL).assertEqual(1); - expect(DownloadTaskParams.TASK_TYPE_PATCH_FROM_APP).assertEqual(2); - expect(DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK).assertEqual(3); - expect(DownloadTaskParams.TASK_TYPE_PLAIN_DOWNLOAD).assertEqual(4); - }); - - it('has correct default values', 0, () => { - const params = new DownloadTaskParams(); - expect(params.type).assertEqual(DownloadTaskParams.TASK_TYPE_CLEANUP); - expect(params.url).assertEqual(''); - expect(params.hash).assertEqual(''); - expect(params.originHash).assertEqual(''); - expect(params.targetFile).assertEqual(''); - expect(params.unzipDirectory).assertEqual(''); - expect(params.originDirectory).assertEqual(''); - }); - - it('allows setting all fields', 0, () => { - const params = new DownloadTaskParams(); - params.type = DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK; - params.url = 'https://example.com/patch.ppk'; - params.hash = 'abc123'; - params.originHash = 'def456'; - params.targetFile = '/data/patch.ppk'; - params.unzipDirectory = '/data/abc123'; - params.originDirectory = '/data/def456'; - - expect(params.type).assertEqual(3); - expect(params.url).assertEqual('https://example.com/patch.ppk'); - expect(params.hash).assertEqual('abc123'); - expect(params.originHash).assertEqual('def456'); - expect(params.targetFile).assertEqual('/data/patch.ppk'); - expect(params.unzipDirectory).assertEqual('/data/abc123'); - expect(params.originDirectory).assertEqual('/data/def456'); - }); - }); -} diff --git a/harmony/pushy/src/test/ErrorCodes.test.ets b/harmony/pushy/src/test/ErrorCodes.test.ets deleted file mode 100644 index b42a0a42..00000000 --- a/harmony/pushy/src/test/ErrorCodes.test.ets +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, it, expect } from '@ohos/hypium'; -import { - ERROR_DOWNLOAD_FAILED, - ERROR_DELTA_FAILED, - PushyError, - createPushyError, - getErrorMessage, - parseErrorCodePrefix, - toPushyError, -} from '../main/ets/ErrorCodes'; - -// 稳定错误码:`code` 属性 + `[CODE] ` 消息前缀(RNOH 桥丢属性时 JS 解析前缀)。 -export default function errorCodesTest() { - describe('ErrorCodes.createPushyError', () => { - it('sets the code and prefixes the message', 0, () => { - const error = createPushyError(ERROR_DELTA_FAILED, 'boom'); - expect(error instanceof Error).assertTrue(); - expect(error.code).assertEqual(ERROR_DELTA_FAILED); - expect(error.message).assertEqual('[PATCH_FAILED] boom'); - }); - }); - - describe('ErrorCodes.toPushyError', () => { - it('keeps an existing code and never double-prefixes', 0, () => { - const original = createPushyError(ERROR_DELTA_FAILED, 'boom'); - const wrapped = toPushyError(original, ERROR_DOWNLOAD_FAILED); - expect(wrapped === original).assertTrue(); - expect(wrapped.message).assertEqual('[PATCH_FAILED] boom'); - }); - - it('assigns the default code to plain errors and values', 0, () => { - const fromError = toPushyError(new Error('plain'), ERROR_DOWNLOAD_FAILED); - expect(fromError.code).assertEqual(ERROR_DOWNLOAD_FAILED); - expect(fromError.message).assertEqual('[DOWNLOAD_FAILED] plain'); - const fromString = toPushyError('raw', ERROR_DOWNLOAD_FAILED); - expect(fromString instanceof PushyError).assertTrue(); - expect(fromString.message).assertEqual('[DOWNLOAD_FAILED] raw'); - }); - }); - - describe('ErrorCodes.parseErrorCodePrefix', () => { - it('extracts a well-formed code', 0, () => { - expect(parseErrorCodePrefix('[PATCH_FAILED] boom')).assertEqual('PATCH_FAILED'); - }); - - it('returns an empty string otherwise', 0, () => { - expect(parseErrorCodePrefix('boom')).assertEqual(''); - expect(parseErrorCodePrefix('[lower] boom')).assertEqual(''); - expect(parseErrorCodePrefix('[] boom')).assertEqual(''); - expect(parseErrorCodePrefix('[NO_SPACE]boom')).assertEqual(''); - }); - }); - - describe('ErrorCodes.getErrorMessage', () => { - it('reads messages from errors, objects and primitives', 0, () => { - expect(getErrorMessage(new Error('e'))).assertEqual('e'); - expect(getErrorMessage({ message: 'm' })).assertEqual('m'); - expect(getErrorMessage('s')).assertEqual('s'); - expect(getErrorMessage(42)).assertEqual('42'); - }); - }); -} diff --git a/harmony/pushy/src/test/EventHub.test.ets b/harmony/pushy/src/test/EventHub.test.ets deleted file mode 100644 index 3527bb77..00000000 --- a/harmony/pushy/src/test/EventHub.test.ets +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, it, expect } from '@ohos/hypium'; -import { EventHub } from '../main/ets/EventHub'; - -export default function eventHubTest() { - describe('EventHub', () => { - it('is a singleton', 0, () => { - const a = EventHub.getInstance(); - const b = EventHub.getInstance(); - expect(a === b).assertEqual(true); - }); - - it('does not throw when emitting without rnInstance', 0, () => { - const hub = EventHub.getInstance(); - hub.setRNInstance(undefined); - let threw = false; - try { - hub.emit('test', { foo: 'bar' }); - } catch { - threw = true; - } - expect(threw).assertEqual(false); - }); - - it('calls emitDeviceEvent on rnInstance when set', 0, () => { - const hub = EventHub.getInstance(); - let capturedEvent = ''; - let capturedData: Record = {}; - const mockRnInstance = { - emitDeviceEvent(event: string, data: Object) { - capturedEvent = event; - capturedData = data as Record; - }, - }; - hub.setRNInstance(mockRnInstance); - hub.emit('RCTPushyDownloadProgress', { received: 100, total: 200 }); - expect(capturedEvent).assertEqual('RCTPushyDownloadProgress'); - expect(capturedData.received).assertEqual(100); - expect(capturedData.total).assertEqual(200); - hub.setRNInstance(undefined); - }); - - it('clearRNInstance only drops the instance it is given', 0, () => { - const hub = EventHub.getInstance(); - let calls = 0; - const current = { emitDeviceEvent(_event: string, _data: Object) { calls++; } }; - const stale = { emitDeviceEvent(_event: string, _data: Object) { calls++; } }; - hub.setRNInstance(current); - hub.clearRNInstance(stale); - hub.emit('RCTPushyDownloadProgress', { received: 1, total: 2 }); - expect(calls).assertEqual(1); - hub.clearRNInstance(current); - hub.emit('RCTPushyDownloadProgress', { received: 2, total: 2 }); - expect(calls).assertEqual(1); - }); - }); -} diff --git a/harmony/pushy/src/test/InstallRecord.test.ets b/harmony/pushy/src/test/InstallRecord.test.ets deleted file mode 100644 index a025942d..00000000 --- a/harmony/pushy/src/test/InstallRecord.test.ets +++ /dev/null @@ -1,172 +0,0 @@ -import { describe, it, expect } from '@ohos/hypium'; -import { - INSTALL_RECORD_FILE_NAME, - INSTALL_RECORD_SCHEMA, - InstallRecordData, - STAGING_SUFFIX, - buildInstallRecord, - expectedBundleSha256ForActivation, - isRecordForVersion, - isReservedEntryName, - parseInstallRecord, - stagingDirectoryFor, -} from '../main/ets/InstallRecord'; -import { - ERROR_DELTA_FAILED, - ERROR_SWITCH_VERSION_FAILED, - PushyError, -} from '../main/ets/ErrorCodes'; - -// 两阶段安装的完成记录(cpp/patch_core/install_record.h 的 ArkTS 镜像): -// 纯逻辑部分,不碰磁盘。 -export default function installRecordTest() { - describe('InstallRecord.buildInstallRecord', () => { - it('serializes schema, version and digests', 0, () => { - const record = parseInstallRecord( - buildInstallRecord('abc', 'BUNDLE', 'ARTIFACT'), - ); - expect(record !== null).assertTrue(); - expect(record!.schema).assertEqual(INSTALL_RECORD_SCHEMA); - expect(record!.versionHash).assertEqual('abc'); - expect(record!.bundleSha256).assertEqual('BUNDLE'); - expect(record!.artifactSha256).assertEqual('ARTIFACT'); - }); - - it('omits an empty artifact digest', 0, () => { - const record = parseInstallRecord(buildInstallRecord('abc', 'BUNDLE', '')); - expect(record !== null).assertTrue(); - expect(record!.bundleSha256).assertEqual('BUNDLE'); - expect(record!.artifactSha256 === undefined).assertTrue(); - }); - - it('refuses to build a record without a bundle digest', 0, () => { - let caught: PushyError | null = null; - try { - buildInstallRecord('abc', '', 'ARTIFACT'); - } catch (e) { - caught = e as PushyError; - } - expect(caught !== null).assertTrue(); - expect(caught!.code).assertEqual(ERROR_DELTA_FAILED); - }); - - it('derives the staging directory from the version directory', 0, () => { - expect(stagingDirectoryFor('/data/_update/abc')).assertEqual( - `/data/_update/abc${STAGING_SUFFIX}`, - ); - }); - }); - - describe('InstallRecord.parseInstallRecord', () => { - it('treats an empty file as a legacy record', 0, () => { - const record = parseInstallRecord(''); - expect(record !== null).assertTrue(); - expect(Object.keys(record!).length).assertEqual(0); - }); - - it('parses a JSON object', 0, () => { - const record = parseInstallRecord('{"schema":1,"versionHash":"abc"}'); - expect(record !== null).assertTrue(); - expect(record!.schema).assertEqual(1); - expect(record!.versionHash).assertEqual('abc'); - }); - - it('rejects malformed or non-object content', 0, () => { - expect(parseInstallRecord('{oops')).assertNull(); - expect(parseInstallRecord('[1,2]')).assertNull(); - expect(parseInstallRecord('"text"')).assertNull(); - expect(parseInstallRecord('null')).assertNull(); - }); - }); - - describe('InstallRecord.isRecordForVersion', () => { - it('is false without a record', 0, () => { - expect(isRecordForVersion(null, 'abc')).assertFalse(); - }); - - it('trusts a legacy empty record', 0, () => { - expect(isRecordForVersion({}, 'abc')).assertTrue(); - }); - - it('requires the schema and version to match', 0, () => { - const record = parseInstallRecord(buildInstallRecord('abc', 'DIGEST', '')); - expect(isRecordForVersion(record, 'abc')).assertTrue(); - expect(isRecordForVersion(record, 'def')).assertFalse(); - expect( - isRecordForVersion({ schema: INSTALL_RECORD_SCHEMA + 1, versionHash: 'abc' }, 'abc'), - ).assertFalse(); - }); - }); - - describe('InstallRecord.expectedBundleSha256ForActivation', () => { - it('throws SWITCH_VERSION_FAILED without a record', 0, () => { - let caught: PushyError | null = null; - try { - expectedBundleSha256ForActivation(null, 'abc'); - } catch (e) { - caught = e as PushyError; - } - expect(caught !== null).assertTrue(); - expect(caught!.code).assertEqual(ERROR_SWITCH_VERSION_FAILED); - expect(caught!.message.startsWith(`[${ERROR_SWITCH_VERSION_FAILED}] `)).assertTrue(); - }); - - it('has nothing to verify for a legacy empty record', 0, () => { - expect(expectedBundleSha256ForActivation({}, 'abc')).assertEqual(''); - }); - - it('rejects a schema 1 record without a bundle digest', 0, () => { - const records: InstallRecordData[] = [ - { schema: INSTALL_RECORD_SCHEMA, versionHash: 'abc' }, - { schema: INSTALL_RECORD_SCHEMA, versionHash: 'abc', bundleSha256: '' }, - { schema: INSTALL_RECORD_SCHEMA, versionHash: 'abc', artifactSha256: 'A' }, - ]; - for (const record of records) { - let caught: PushyError | null = null; - try { - expectedBundleSha256ForActivation(record, 'abc'); - } catch (e) { - caught = e as PushyError; - } - expect(caught !== null).assertTrue(); - expect(caught!.code).assertEqual(ERROR_SWITCH_VERSION_FAILED); - expect(caught!.message.includes('record mismatch')).assertTrue(); - } - }); - - it('returns the recorded digest for a matching record', 0, () => { - expect( - expectedBundleSha256ForActivation( - parseInstallRecord(buildInstallRecord('abc', 'DIGEST', '')), - 'abc', - ), - ).assertEqual('DIGEST'); - }); - - it('throws on a record for another version', 0, () => { - let threw = false; - try { - expectedBundleSha256ForActivation( - parseInstallRecord(buildInstallRecord('def', 'DIGEST', '')), - 'abc', - ); - } catch (e) { - threw = true; - } - expect(threw).assertTrue(); - }); - }); - - describe('InstallRecord.isReservedEntryName', () => { - it('reserves every .pushy- prefixed name', 0, () => { - expect(isReservedEntryName(INSTALL_RECORD_FILE_NAME)).assertTrue(); - expect(isReservedEntryName('.pushy-anything')).assertTrue(); - }); - - it('allows ordinary entries', 0, () => { - expect(isReservedEntryName('bundle.harmony.js')).assertFalse(); - expect(isReservedEntryName('.pushyx')).assertFalse(); - expect(isReservedEntryName('pushy-complete')).assertFalse(); - }); - }); -} diff --git a/harmony/pushy/src/test/List.test.ets b/harmony/pushy/src/test/List.test.ets deleted file mode 100644 index d55166f9..00000000 --- a/harmony/pushy/src/test/List.test.ets +++ /dev/null @@ -1,21 +0,0 @@ -import manifestParsingTest from './ManifestParsing.test'; -import validationTest from './Validation.test'; -import eventHubTest from './EventHub.test'; -import downloadTaskParamsTest from './DownloadTaskParams.test'; -import contentRangeTest from './ContentRange.test'; -import installRecordTest from './InstallRecord.test'; -import errorCodesTest from './ErrorCodes.test'; -import archiveLimitsTest from './ArchiveLimits.test'; -import nativeConfigTest from './NativeConfig.test'; - -export default function testsuite() { - manifestParsingTest(); - validationTest(); - eventHubTest(); - downloadTaskParamsTest(); - contentRangeTest(); - installRecordTest(); - errorCodesTest(); - archiveLimitsTest(); - nativeConfigTest(); -} diff --git a/harmony/pushy/src/test/ManifestParsing.test.ets b/harmony/pushy/src/test/ManifestParsing.test.ets deleted file mode 100644 index 17ccb893..00000000 --- a/harmony/pushy/src/test/ManifestParsing.test.ets +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, it, expect } from '@ohos/hypium'; -import { parseManifestToArrays } from '../main/ets/DownloadTask'; - -export default function manifestParsingTest() { - describe('parseManifestToArrays', () => { - it('returns empty arrays for empty manifest', 0, () => { - const result = parseManifestToArrays({}, false); - expect(result.copyFroms.length).assertEqual(0); - expect(result.copyTos.length).assertEqual(0); - expect(result.deletes.length).assertEqual(0); - }); - - it('parses copies and deletes array', 0, () => { - const manifest = { - copies: { - 'assets/icon.png': 'assets/original.png', - 'assets/logo.png': 'assets/brand.png', - }, - deletes: ['assets/old.png', 'assets/removed.txt'], - }; - const result = parseManifestToArrays(manifest, false); - expect(result.copyFroms.length).assertEqual(2); - expect(result.copyTos.length).assertEqual(2); - expect(result.copyFroms[0]).assertEqual('assets/original.png'); - expect(result.copyTos[0]).assertEqual('assets/icon.png'); - expect(result.copyFroms[1]).assertEqual('assets/brand.png'); - expect(result.copyTos[1]).assertEqual('assets/logo.png'); - expect(result.deletes.length).assertEqual(2); - expect(result.deletes[0]).assertEqual('assets/old.png'); - expect(result.deletes[1]).assertEqual('assets/removed.txt'); - }); - - it('parses deletes as object keys', 0, () => { - const manifest = { - deletes: { - 'assets/old.png': true, - 'assets/removed.txt': true, - }, - }; - const result = parseManifestToArrays(manifest, false); - expect(result.deletes.length).assertEqual(2); - expect(result.deletes[0]).assertEqual('assets/old.png'); - expect(result.deletes[1]).assertEqual('assets/removed.txt'); - }); - - it('handles missing copies gracefully', 0, () => { - const manifest = { - deletes: ['a.txt'], - }; - const result = parseManifestToArrays(manifest, false); - expect(result.copyFroms.length).assertEqual(0); - expect(result.copyTos.length).assertEqual(0); - expect(result.deletes.length).assertEqual(1); - }); - - it('handles missing deletes gracefully', 0, () => { - const manifest = { - copies: { 'a.png': 'b.png' }, - }; - const result = parseManifestToArrays(manifest, false); - expect(result.deletes.length).assertEqual(0); - expect(result.copyFroms.length).assertEqual(1); - }); - - it('normalizes resource copies when enabled', 0, () => { - const manifest = { - copies: { - 'assets/icon.png': 'resources/rawfile/assets/icon.png', - }, - }; - const result = parseManifestToArrays(manifest, true); - expect(result.copyFroms[0]).assertEqual('assets/icon.png'); - expect(result.copyTos[0]).assertEqual('assets/icon.png'); - }); - - it('falls back to key when normalized from is empty', 0, () => { - const manifest = { - copies: { - 'assets/icon.png': 'resources/rawfile/', - }, - }; - const result = parseManifestToArrays(manifest, true); - expect(result.copyFroms[0]).assertEqual('assets/icon.png'); - }); - - it('does not normalize when disabled', 0, () => { - const manifest = { - copies: { - 'assets/icon.png': 'resources/rawfile/assets/icon.png', - }, - }; - const result = parseManifestToArrays(manifest, false); - expect(result.copyFroms[0]).assertEqual('resources/rawfile/assets/icon.png'); - }); - - it('handles null-ish from values', 0, () => { - const manifest = { - copies: { - 'assets/icon.png': null, - }, - }; - const result = parseManifestToArrays(manifest as any, false); - expect(result.copyFroms[0]).assertEqual(''); - expect(result.copyTos[0]).assertEqual('assets/icon.png'); - }); - }); -} diff --git a/harmony/pushy/src/test/NativeConfig.test.ets b/harmony/pushy/src/test/NativeConfig.test.ets deleted file mode 100644 index a727d43e..00000000 --- a/harmony/pushy/src/test/NativeConfig.test.ets +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, it, expect } from '@ohos/hypium'; -import { canonicalNativeConfig, nativeConfigsEquivalent } from '../main/ets/UpdateContext'; - -// C1:跨平台配置等价规则(与 JS/Android/iOS 一致)。 -export default function nativeConfigTest() { - describe('nativeConfigsEquivalent', () => { - it('ignores key order', 0, () => { - expect(nativeConfigsEquivalent( - '{"appKey":"k","endpoints":["https://a"]}', - '{"endpoints":["https://a"],"appKey":"k"}', - )).assertTrue(); - }); - - it('fills absent keys with their defaults', 0, () => { - expect(nativeConfigsEquivalent( - '{"appKey":"k"}', - '{"appKey":"k","disabled":false,"queryUrls":[],"rnu":"","rn":"","afterDownload":"none"}', - )).assertTrue(); - expect(nativeConfigsEquivalent('{"appKey":"k"}', '{"appKey":"k","disabled":true}')) - .assertFalse(); - expect(nativeConfigsEquivalent('{"appKey":"k"}', '{"appKey":"k","rn":"0.82"}')) - .assertFalse(); - }); - - it('strips trailing slashes and dedupes URL lists, keeping first order', 0, () => { - expect(nativeConfigsEquivalent( - '{"appKey":"k","endpoints":["https://a/","https://b","https://a"],"queryUrls":["https://q/"]}', - '{"appKey":"k","endpoints":["https://a","https://b"],"queryUrls":["https://q"]}', - )).assertTrue(); - expect(nativeConfigsEquivalent( - '{"appKey":"k","endpoints":["https://a","https://b"]}', - '{"appKey":"k","endpoints":["https://b","https://a"]}', - )).assertFalse(); - // Every trailing slash goes, not just one. - expect(nativeConfigsEquivalent( - '{"appKey":"k","endpoints":["https://b.example.com//"]}', - '{"appKey":"k","endpoints":["https://b.example.com"]}', - )).assertTrue(); - }); - - it('treats unparseable or non-object JSON as not equivalent', 0, () => { - expect(nativeConfigsEquivalent('{', '{"appKey":"k"}')).assertFalse(); - expect(nativeConfigsEquivalent('[]', '{}')).assertFalse(); - // Not even against itself: only JSON objects have a canonical form. - expect(nativeConfigsEquivalent('{', '{')).assertFalse(); - expect(nativeConfigsEquivalent('"x"', '"x"')).assertFalse(); - expect(nativeConfigsEquivalent(undefined, '{"appKey":"k"}')).assertFalse(); - expect(canonicalNativeConfig('null') === undefined).assertTrue(); - }); - - it('compares every other field exactly', 0, () => { - expect(nativeConfigsEquivalent('{"appKey":"k"}', '{"appKey":"k2"}')).assertFalse(); - expect(nativeConfigsEquivalent( - '{"appKey":"k","packageVersion":"1"}', - '{"appKey":"k"}', - )).assertFalse(); - }); - }); -} diff --git a/harmony/pushy/src/test/Validation.test.ets b/harmony/pushy/src/test/Validation.test.ets deleted file mode 100644 index 1a6ace49..00000000 --- a/harmony/pushy/src/test/Validation.test.ets +++ /dev/null @@ -1,115 +0,0 @@ -import { describe, it, expect } from '@ohos/hypium'; -import { validateHashInfo, getErrorMessage } from '../main/ets/PushyTurboModule'; -import { isSafePathComponent } from '../main/ets/UpdateContext'; - -export default function validationTest() { - describe('isSafePathComponent', () => { - it('accepts a plain hash-like name', 0, () => { - expect(isSafePathComponent('a1b2c3d4')).assertEqual(true); - expect(isSafePathComponent('origin_hash-1.ppk')).assertEqual(true); - }); - - it('rejects empty and dot names', 0, () => { - expect(isSafePathComponent('')).assertEqual(false); - expect(isSafePathComponent('.')).assertEqual(false); - expect(isSafePathComponent('..')).assertEqual(false); - }); - - it('rejects names containing path separators', 0, () => { - expect(isSafePathComponent('../etc')).assertEqual(false); - expect(isSafePathComponent('a/b')).assertEqual(false); - expect(isSafePathComponent('a\\b')).assertEqual(false); - expect(isSafePathComponent('a\0b')).assertEqual(false); - }); - }); - - describe('validateHashInfo', () => { - it('accepts valid JSON object', 0, () => { - let threw = false; - try { - validateHashInfo('{"name":"v1"}'); - } catch { - threw = true; - } - expect(threw).assertEqual(false); - }); - - it('accepts empty JSON object', 0, () => { - let threw = false; - try { - validateHashInfo('{}'); - } catch { - threw = true; - } - expect(threw).assertEqual(false); - }); - - it('rejects JSON array', 0, () => { - let threw = false; - try { - validateHashInfo('[1,2,3]'); - } catch { - threw = true; - } - expect(threw).assertEqual(true); - }); - - it('rejects plain string', 0, () => { - let threw = false; - try { - validateHashInfo('"hello"'); - } catch { - threw = true; - } - expect(threw).assertEqual(true); - }); - - it('rejects invalid JSON', 0, () => { - let threw = false; - try { - validateHashInfo('{invalid}'); - } catch { - threw = true; - } - expect(threw).assertEqual(true); - }); - - it('rejects null JSON', 0, () => { - let threw = false; - try { - validateHashInfo('null'); - } catch { - threw = true; - } - expect(threw).assertEqual(true); - }); - }); - - describe('getErrorMessage', () => { - it('extracts message from Error object', 0, () => { - const err = new Error('something broke'); - expect(getErrorMessage(err)).assertEqual('something broke'); - }); - - it('extracts message from plain object with message property', 0, () => { - const err = { message: 'custom error', code: 42 }; - expect(getErrorMessage(err)).assertEqual('custom error'); - }); - - it('converts string to string', 0, () => { - expect(getErrorMessage('raw string')).assertEqual('raw string'); - }); - - it('converts number to string', 0, () => { - expect(getErrorMessage(404)).assertEqual('404'); - }); - - it('converts null to string', 0, () => { - expect(getErrorMessage(null)).assertEqual('null'); - }); - - it('converts undefined to string', 0, () => { - expect(getErrorMessage(undefined)).assertEqual('undefined'); - }); - }); -} diff --git a/harmony/pushy/src/test/check-constant-parity.js b/harmony/pushy/src/test/check-constant-parity.js deleted file mode 100644 index 0a997898..00000000 --- a/harmony/pushy/src/test/check-constant-parity.js +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env node -// Asserts that the ArkTS "hand-kept mirrors" of the shared C++ headers still -// carry the same values. Plain Node, no SDK: runs on every PR -// (.github/workflows/harmony-build.yml) so a limit or code bumped in -// cpp/patch_core cannot silently drift on Harmony. -// -// cpp/patch_core/archive_limits.h <-> harmony/.../ArchiveLimits.ts -// cpp/patch_core/install_record.h <-> harmony/.../InstallRecord.ts -// cpp/patch_core/error_codes.h <-> harmony/.../ErrorCodes.ts -'use strict'; - -const fs = require('fs'); -const path = require('path'); - -const root = path.resolve(__dirname, '..', '..', '..', '..'); -const read = (relative) => fs.readFileSync(path.join(root, relative), 'utf8'); - -// `constexpr kName = ;` — the value is either a string literal or -// an integer expression built from literals, `*`, `+`, parentheses and the -// LL suffix. -function parseCppConstants(source) { - const re = - /(?:constexpr|inline)\s+(?:const\s+)?(?:long\s+long|int|char\s*\*)\s*(?:const\s+)?k([A-Za-z0-9]+)\s*=\s*([^;]+);/g; - const values = new Map(); - let match; - while ((match = re.exec(source)) !== null) { - values.set(match[1], evaluate(match[2].trim(), match[1])); - } - return values; -} - -// `export const NAME = ;` -function parseArkTsConstants(source) { - const re = /export\s+const\s+([A-Z][A-Z0-9_]*)\s*(?::\s*[a-z]+)?\s*=\s*([^;]+);/g; - const values = new Map(); - let match; - while ((match = re.exec(source)) !== null) { - values.set(match[1], evaluate(match[2].trim(), match[1])); - } - return values; -} - -// Encoded text (obscured_text.h / Texts.ts, scripts/encode-native-text.ts): -// byte i XORed with (0x5A + 0x1D * i) & 0xFF. -function reveal(hex) { - let text = ''; - for (let i = 0; i < hex.length / 2; i++) { - const byte = parseInt(hex.slice(i * 2, i * 2 + 2), 16); - text += String.fromCharCode(byte ^ ((0x5a + 0x1d * i) & 0xff)); - } - return text; -} - -function evaluate(expression, name) { - const text = expression.replace(/\s+/g, ' ').trim(); - const encoded = text.match(/^(?:text::RevealStatic|revealText)\((["'])([0-9a-f]+)\1\)$/); - if (encoded) { - return reveal(encoded[2]); - } - const stringLiteral = text.match(/^(["'])((?:\\.|(?!\1).)*)\1$/); - if (stringLiteral) { - return stringLiteral[2]; - } - const numeric = text.replace(/(\d)LL\b/g, '$1'); - if (!/^[\d\s()*+]+$/.test(numeric)) { - throw new Error(`check-constant-parity: cannot evaluate ${name} = ${text}`); - } - // Only digits and arithmetic operators survive the regex above. - return Function(`"use strict"; return (${numeric});`)(); -} - -// kMaxArchiveBytes -> MAX_ARCHIVE_BYTES -function screamingSnake(name) { - return name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase(); -} - -const pairs = [ - { - header: 'cpp/patch_core/archive_limits.h', - mirror: 'harmony/pushy/src/main/ets/ArchiveLimits.ts', - mirrorName: (cppName) => screamingSnake(cppName), - // Must always be parsed from the header (guards the parser itself: a - // regex regression would otherwise silently drop the pair's coverage). - required: ['UnknownLengthFreeSpaceProbeBytes', 'MaxArchiveBytes'], - }, - { - header: 'cpp/patch_core/install_record.h', - mirror: 'harmony/pushy/src/main/ets/InstallRecord.ts', - mirrorName: (cppName) => - ({ - Schema: 'INSTALL_RECORD_SCHEMA', - FileName: 'INSTALL_RECORD_FILE_NAME', - StagingSuffix: 'STAGING_SUFFIX', - })[cppName] || screamingSnake(cppName), - }, - { - header: 'cpp/patch_core/error_codes.h', - mirror: 'harmony/pushy/src/main/ets/ErrorCodes.ts', - mirrorName: (cppName) => `ERROR_${screamingSnake(cppName)}`, - // Every ERROR_* export must exist in the header too (no invented codes). - mirrorPrefix: 'ERROR_', - }, -]; - -const problems = []; -let checked = 0; - -for (const pair of pairs) { - const cpp = parseCppConstants(read(pair.header)); - const arkts = parseArkTsConstants(read(pair.mirror)); - if (cpp.size === 0) { - problems.push(`${pair.header}: no constexpr constants parsed`); - continue; - } - for (const name of pair.required || []) { - if (!cpp.has(name)) { - problems.push(`${pair.header}: k${name} not parsed (required)`); - } - } - for (const [cppName, cppValue] of cpp) { - const mirrorName = pair.mirrorName(cppName); - checked += 1; - if (!arkts.has(mirrorName)) { - problems.push( - `${pair.mirror}: missing ${mirrorName} (k${cppName} in ${pair.header})` - ); - continue; - } - const mirrorValue = arkts.get(mirrorName); - if (mirrorValue !== cppValue) { - problems.push( - `${pair.mirror}: ${mirrorName} = ${JSON.stringify(mirrorValue)} but ` + - `${pair.header} k${cppName} = ${JSON.stringify(cppValue)}` - ); - } - } - if (pair.mirrorPrefix) { - const known = new Set(Array.from(cpp.keys()).map(pair.mirrorName)); - for (const name of arkts.keys()) { - if (name.startsWith(pair.mirrorPrefix) && !known.has(name)) { - problems.push(`${pair.mirror}: ${name} has no counterpart in ${pair.header}`); - } - } - } -} - -if (problems.length > 0) { - console.error(`check-constant-parity: ${problems.length} problem(s)`); - for (const problem of problems) { - console.error(` - ${problem}`); - } - process.exit(1); -} -console.log( - `check-constant-parity: ${checked} constants match across ${pairs.length} header/mirror pairs` -); diff --git a/ios/RCTPushy/RCTPushy+Launch.mm b/ios/RCTPushy/RCTPushy+Launch.mm new file mode 100644 index 00000000..99c7299f --- /dev/null +++ b/ios/RCTPushy/RCTPushy+Launch.mm @@ -0,0 +1,185 @@ +// Launch bundle resolution and the identity of the running binary: which +// bundle this launch loads, where downloaded versions live, and the +// packageVersion/buildTime pair that detects a rebuilt binary. +#import "RCTPushyInternal.h" + +// The version whose bundle this process actually loaded (resolved in +// +bundleURL). resetToPackagedBundle must not delete its directory: update +// assets (images/fonts) are read from it on demand at runtime, so wiping it +// under a silent (no-restart) reset would break every image the running app +// has not loaded yet. Guarded by the state lock. +PUSHY_HIDDEN NSString *pushyLaunchVersion = nil; + +#if !DEBUG +static NSString *PushyReadBuildTime(void) { + // The podspec's s.resource lands in the app bundle for a static + // library but inside this pod's own framework bundle under + // use_frameworks!; look in both. + NSString *stampPath = + [[NSBundle mainBundle] pathForResource:@"pushy_build_time" ofType:@"txt"] + ?: [[NSBundle bundleForClass:[RCTPushy class]] pathForResource:@"pushy_build_time" + ofType:@"txt"]; + NSString *stamp = stampPath == nil ? nil + : [[NSString stringWithContentsOfFile:stampPath encoding:NSUTF8StringEncoding error:nil] + stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (stamp.length == 0) { + // Never empty: with "" SyncBinaryVersion would miss a rebuilt + // binary that kept its CFBundleShortVersionString, and a stale + // hot update would outlive an App Store update. Every build + // rewrites Info.plist, so its mtime (integer seconds) stands in. + RCTLogWarn(@"RCTPushy -- pushy_build_time.txt %@; using the Info.plist mtime as buildTime", + stampPath == nil ? @"not found in the main or framework bundle" : @"is empty"); + NSString *infoPlist = + [[NSBundle mainBundle].bundlePath stringByAppendingPathComponent:@"Info.plist"]; + NSDate *modified = [[NSFileManager defaultManager] attributesOfItemAtPath:infoPlist + error:nil].fileModificationDate; + stamp = [NSString stringWithFormat:@"%lld", (long long)[modified timeIntervalSince1970]]; + } + return stamp; +} +#endif + +@implementation RCTPushy (Launch) + +// Resolves the bundle to launch from the persisted state; nil means the +// packaged bundle. Consumes the launch's one-shot state (first_time), so a +// second call is only valid after something re-armed it (a switchVersion). +// purgedVersion is set on tvOS when the installed version's files are gone. ++ (NSURL *)resolveLaunchBundleURL:(NSString **)launchRolledBackVersionOut + purgedVersion:(NSString **)purgedVersionOut +{ + __block NSURL *resolvedURL = nil; + __block NSString *launchRolledBackVersion = nil; + __block NSString *purgedVersion = nil; + @try { + PushyWithStateLock(^{ + NSUserDefaults *defaults = PushyDefaults(); + + NSString *curPackageVersion = [RCTPushy packageVersion]; + NSString *curBuildTime = [RCTPushy buildTime]; + + pushy::state::State state = PushyStateFromDefaults(defaults); + pushy::state::BinaryVersionSyncResult sync = pushy::state::SyncBinaryVersion( + state, + PushyToStdString(curPackageVersion), + PushyToStdString(curBuildTime) + ); + if (sync.changed) { + [defaults setObject:@(YES) forKey:KeyPackageUpdatedMarked()]; + state = sync.state; + PushyApplyStateToDefaults(defaults, state); + } + + if (!state.current_version.empty()) { + std::string const versionBeforeLaunch = state.current_version; + pushy::state::LaunchDecision decision = pushy::state::ResolveLaunchState( + state, + ignoreRollback.load(), + true + ); + state = decision.state; + + if (decision.did_rollback) { + // The crash-protection rollback: the new version never called + // markSuccess. Keep this visible in release logs. + RCTLogWarn(@"RCTPushy -- version %@ was not marked as successful, rolled back to %@", + PushyFromStdString(versionBeforeLaunch), + PushyFromStdString(state.current_version)); + } + if (decision.did_rollback || decision.consumed_first_time) { + PushyApplyStateToDefaults(defaults, state); + } + if (decision.consumed_first_time) { + // bundleURL may be called many times, ignore rollbacks before process restarted again. + ignoreRollback = true; + [defaults setObject:@(YES) forKey:keyFirstLoadMarked]; + } + + NSString *loadVersion = PushyFromStdString(decision.load_version); + // Guard the rollback chain against cycles: a corrupted state + // returning an already-visited version would otherwise spin this + // loop forever during startup (Android has the same guard). + NSMutableSet *visitedVersions = [NSMutableSet set]; + while (loadVersion.length && ![visitedVersions containsObject:loadVersion]) { + [visitedVersions addObject:loadVersion]; + NSString *bundlePath = [PushyVersionDir(loadVersion) stringByAppendingPathComponent:BUNDLE_FILE_NAME]; + if ([[NSFileManager defaultManager] fileExistsAtPath:bundlePath isDirectory:NULL]) { + pushyLaunchVersion = loadVersion; + resolvedURL = [NSURL fileURLWithPath:bundlePath]; + break; + } else { +#if TARGET_OS_TV + // Caches is purgeable on tvOS: a missing bundle is the OS + // reclaiming space, not a bad release. Everything under + // rctpushy went with it, so walking back to last_version + // is pointless, and a rollback mark would make this + // launch's check refuse to reinstall the version. + RCTLogWarn(@"RCTPushy -- bundle version %@ was purged by the system", loadVersion); + purgedVersion = loadVersion; + state = pushy::state::ForgetPurgedVersions(state); + PushyApplyStateToDefaults(defaults, state); + if (decision.consumed_first_time) { + // The first load being consumed never happens: the + // packaged bundle must not report isFirstTime. + ignoreRollback = false; + [defaults removeObjectForKey:keyFirstLoadMarked]; + } + break; +#else + RCTLogError(@"RCTPushy -- bundle version %@ not found, rolling back", loadVersion); + state = pushy::state::Rollback(state); + PushyApplyStateToDefaults(defaults, state); + loadVersion = PushyFromStdString(state.current_version); +#endif + } + } + } + // Capture before constantsToExport consumes this one-shot marker. + // The delayed native check must never forceBoot the version that this + // launch just rolled back. + launchRolledBackVersion = PushyFromStdString(state.rolled_back_version); + }); + } @finally { + *launchRolledBackVersionOut = launchRolledBackVersion; + *purgedVersionOut = purgedVersion; + } + return resolvedURL; +} + ++ (NSString *)downloadDir +{ +#if TARGET_OS_TV + // tvOS apps cannot write Application Support; Caches is the only + // writable, non-temporary location, and the system may purge it while + // the app is not running (see the purged-version path in +bundleURL). + NSSearchPathDirectory base = NSCachesDirectory; +#else + NSSearchPathDirectory base = NSApplicationSupportDirectory; +#endif + NSString *directory = [NSSearchPathForDirectoriesInDomains(base, NSUserDomainMask, YES) firstObject]; + return [directory stringByAppendingPathComponent:@"rctpushy"]; +} + ++ (NSURL *)binaryBundleURL +{ + return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]; +} + ++ (NSString *)packageVersion +{ + static NSString *version = + [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleShortVersionString"]; + return version; +} + ++ (NSString *)buildTime +{ +#if DEBUG + return @"0"; +#else + static NSString *buildTime = PushyReadBuildTime(); + return buildTime; +#endif +} + +@end diff --git a/ios/RCTPushy/RCTPushy.mm b/ios/RCTPushy/RCTPushy.mm index 1e41ab0c..e89d47e8 100644 --- a/ios/RCTPushy/RCTPushy.mm +++ b/ios/RCTPushy/RCTPushy.mm @@ -1,842 +1,50 @@ #import "RCTPushyInternal.h" #import "RCTPushyConfiguration.h" -#import "RCTPushyDownloader.h" -#import "ZipArchive.h" -#include "../../cpp/patch_core/archive_limits.h" -#include "../../cpp/patch_core/archive_patch_core.h" -#include "../../cpp/patch_core/digest.h" -#include "../../cpp/patch_core/hbc_transform_wire.h" -#include "../../cpp/patch_core/install_record.h" #include "../../cpp/patch_core/error_codes.h" +#include "../../cpp/patch_core/hbc_transform_wire.h" #include "../../cpp/patch_core/patch_core.h" -#include "../../cpp/patch_core/state_core.h" -#include "../../cpp/update_flow_core/flow_json.h" -#include "../../cpp/update_flow_core/update_flow_core.h" - -#import // use_frameworks! 构建里 React 头文件只有 框架形式可见,引号形式的 -// __has_include 会探测失败;漏掉这里会静默编入 [self.bridge reload] 兜底,而 -// bridgeless 下 bridge 是 nil/代理,reload 变成无声 no-op(切版本后不重启)。 +// __has_include 会探测失败;漏掉框架形式会让重启变成无声 no-op(切版本后不 +// 重启),所以两种形式都探测不到时直接编译失败。 #if __has_include("RCTReloadCommand.h") #import "RCTReloadCommand.h" -#define PUSHY_HAS_RELOAD_COMMAND 1 #elif __has_include() #import -#define PUSHY_HAS_RELOAD_COMMAND 1 +#else +#error React Native 0.62 or newer is required: RCTReloadCommand.h was not found #endif #ifdef RCT_NEW_ARCH_ENABLED #import "RCTPushySpec.h" #endif -#import -#import -#import -#import - -#include -#include - -// Immutable host-facing snapshot; activated always means NEXT launch. -PUSHY_HIDDEN NSDictionary *PushyHostResult(NSString *status, NSString *reason, - NSString *hash, BOOL activated) { - return @{@"status": status, @"reason": reason ?: @"", - @"hash": hash ?: @"", @"activated": @(activated)}; -} - -static NSString *const keyPushyInfo = @"REACTNATIVECN_PUSHY_INFO_KEY"; -// Binary identity (SyncBinaryVersion input). Prefixed like every other key: -// the unprefixed names collided with generic host-app/SDK defaults, and a -// foreign value there made every launch look like a rebuilt binary and wipe -// the installed update. -static NSString *const paramPackageVersion = @"REACTNATIVECN_PUSHY_PACKAGEVERSION"; -static NSString *const paramBuildTime = @"REACTNATIVECN_PUSHY_BUILDTIME"; -// The unprefixed names earlier SDKs wrote. Read only as a fallback until the -// first state write migrates them (PushyStateFromDefaults / -// PushyApplyStateToDefaults). -static NSString *const legacyParamPackageVersion = @"packageVersion"; -static NSString *const legacyParamBuildTime = @"buildTime"; -static NSString *const paramLastVersion = @"lastVersion"; -static NSString *const paramCurrentVersion = @"currentVersion"; -static NSString *const paramIsFirstTime = @"isFirstTime"; -static NSString *const paramIsFirstLoadOk = @"isFirstLoadOK"; -PUSHY_HIDDEN NSString *const keyUuid = @"REACTNATIVECN_PUSHY_UUID"; -static NSString *const keyHashInfo = @"REACTNATIVECN_PUSHY_HASH_"; -static NSString *const keyFirstLoadMarked = @"REACTNATIVECN_PUSHY_FIRSTLOADMARKED_KEY"; -static NSString *const keyRolledBackMarked = @"REACTNATIVECN_PUSHY_ROLLEDBACKMARKED_KEY"; -// Encoded constants (RCTPushyRevealText) are decoded on first use, not by -// static initializers at image load. -PUSHY_HIDDEN NSString *PushyRevealOnce(NSString *__strong *slot, dispatch_once_t *once, - const char *hex) { - dispatch_once(once, ^{ - *slot = RCTPushyRevealText(hex); - }); - return *slot; -} - -static NSString *KeyPackageUpdatedMarked(void) { - static NSString *value; - static dispatch_once_t once; - return PushyRevealOnce(&value, &once, "0832d5f29aa549710b0939daf88ca058790f3dded7e888b451640d2ec3f69099bb43711523cafa8ea7bb4372132a"); -} -// bundleHash cache: "|" where cacheKey identifies the -// installed binary (packageVersion + embedded bundle size + mtime). Recomputed -// only when the key changes, i.e. once per install. -static NSString *const keyBundleHashCache = @"REACTNATIVECN_PUSHY_BUNDLEHASH_KEY"; -// Raw JSON persisted by JS (syncNativeConfig) for the native cold-start -// update check; parsed on read by the orchestrator. Absent = check disabled. -PUSHY_HIDDEN NSString *const keyNativeConfig = @"REACTNATIVECN_PUSHY_NATIVE_CONFIG_KEY"; -// Raw response cache written by the native cold-start check for the JS side -// to reuse (§10.3), scoped to the request and config that produced it. -PUSHY_HIDDEN NSString *const keyNativeCheckCache = @"REACTNATIVECN_PUSHY_NATIVE_CHECK_RESP_KEY"; -// Set when a native check round starts, cleared when it ends (§11.4). Residue -// on the next launch means the previous process died mid-round (a crash -// rescue was truncated): that launch resumes immediately instead of waiting. -PUSHY_HIDDEN NSString *const keyNativeCheckIncomplete = @"REACTNATIVECN_PUSHY_NATIVE_CHECK_INCOMPLETE_KEY"; -static NSString *const PushyErrorDomain = @"cn.reactnative.pushy"; - -// file def -static NSString * const BUNDLE_FILE_NAME = @"index.bundlejs"; -static NSString * const SOURCE_PATCH_NAME = @"__diff.json"; -static NSString *BUNDLE_PATCH_NAME(void) { - static NSString *value; - static dispatch_once_t once; - return PushyRevealOnce(&value, &once, "3319f0d4b6c56a502c3b10fcdca0de7d4b3307e9"); -} -// Protocol strings and version-info flags read by JS, stored encoded -// (RCTPushyRevealText) so they do not appear as plain binary strings. -PUSHY_HIDDEN NSString *PushyOptionUrlKey(void) { - static NSString *value; - static dispatch_once_t once; - return PushyRevealOnce(&value, &once, "2f07f0d0ba8e5d572e"); -} -static NSString *PushyIpaDeltaSuffix(void) { - static NSString *value; - static dispatch_once_t once; - return PushyRevealOnce(&value, &once, "741ee4d0e09b69512137"); -} -static NSString *PushyPpkDeltaSuffix(void) { - static NSString *value; - static dispatch_once_t once; - return PushyRevealOnce(&value, &once, "7407e4dae09b69512137"); -} -#define VERSION_COMPLETE_FILE_NAME_LITERAL ".pushy-complete" -static NSString * const VERSION_COMPLETE_FILE_NAME = @VERSION_COMPLETE_FILE_NAME_LITERAL; - -// error def — messages are human-readable; the stable cross-platform codes -// live in cpp/patch_core/error_codes.h and travel in PushyErrorCodeKey. -static NSString * const ERROR_OPTIONS = @"options error"; -static NSString * const ERROR_FILE_OPERATION = @"file operation error"; -static NSString * const PushyErrorCodeKey = @"PushyErrorCode"; - -static NSString *PushyCode(const char *code) { - return [NSString stringWithUTF8String:code]; -} - // event def static NSString * const EVENT_PROGRESS_DOWNLOAD = @"RCTPushyDownloadProgress"; static NSString * const PARAM_PROGRESS_HASH = @"hash"; static NSString * const PARAM_PROGRESS_RECEIVED = @"received"; static NSString * const PARAM_PROGRESS_TOTAL = @"total"; -PUSHY_HIDDEN NSTimeInterval PushyMonotonicNow(void) { - return [NSProcessInfo processInfo].systemUptime; -} - -static NSString *PushyStagingDirForVersionDir(NSString *versionDir) { - return [versionDir stringByAppendingString:@(pushy::install_record::kStagingSuffix)]; -} - -// Parsed completion record (cpp/patch_core/install_record.h): nil when the -// file is absent or malformed; an empty dictionary for the legacy empty -// marker written by SDK < 10.53. -static NSDictionary *PushyReadInstallRecord(NSString *versionDir) { - NSString *path = [versionDir stringByAppendingPathComponent:VERSION_COMPLETE_FILE_NAME]; - NSData *data = [NSData dataWithContentsOfFile:path]; - if (data == nil) { - return nil; - } - if (data.length == 0) { - return @{}; - } - if (data.length > 64 * 1024) { - return nil; - } - id object = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; - return [object isKindOfClass:[NSDictionary class]] ? (NSDictionary *)object : nil; -} - -// Whether a non-empty record names this version under the current schema. -static BOOL PushyInstallRecordMatches(NSDictionary *record, NSString *hash) { - return [record[@"schema"] isKindOfClass:[NSNumber class]] - && [record[@"schema"] intValue] == pushy::install_record::kSchema - && [record[@"versionHash"] isKindOfClass:[NSString class]] - && [record[@"versionHash"] isEqualToString:hash]; -} - -// Whether the directory carries a completion record file at all (the legacy -// empty marker included). Only directories WITHOUT one are grandfathered by -// current/last state in switchVersion; a present record is always verified. -static BOOL PushyHasInstallRecordFile(NSString *versionDir) { - return [[NSFileManager defaultManager] fileExistsAtPath: - [versionDir stringByAppendingPathComponent:VERSION_COMPLETE_FILE_NAME]]; -} - -// Presence check (launch / dedup paths, no digest work): bundle present and -// the record exists and, unless legacy-empty, names this version. -PUSHY_HIDDEN BOOL PushyHasCompletedVersionAtPath(NSString *versionDir, NSString *hash) { - NSString *bundlePath = [versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]; - if (![[NSFileManager defaultManager] fileExistsAtPath:bundlePath]) { - return NO; - } - NSDictionary *record = PushyReadInstallRecord(versionDir); - if (record == nil) { - return NO; - } - return record.count == 0 || PushyInstallRecordMatches(record, hash); -} - -// Identity of a file's bytes on disk without reading them: a rewrite or an -// atomic replacement (new inode) changes it. -struct PushyFileStamp { - bool exists = false; - dev_t device = 0; - ino_t inode = 0; - off_t size = 0; - struct timespec modified = {0, 0}; - - bool operator==(const PushyFileStamp &other) const { - return exists == other.exists && device == other.device && inode == other.inode - && size == other.size && modified.tv_sec == other.modified.tv_sec - && modified.tv_nsec == other.modified.tv_nsec; - } - bool operator!=(const PushyFileStamp &other) const { return !(*this == other); } -}; - -static PushyFileStamp PushyStampFile(NSString *path) { - PushyFileStamp stamp; - struct stat info; - if (path != nil && stat(path.fileSystemRepresentation, &info) == 0) { - stamp.exists = true; - stamp.device = info.st_dev; - stamp.inode = info.st_ino; - stamp.size = info.st_size; - stamp.modified = info.st_mtimespec; - } - return stamp; -} - -// Activation check: re-hashes the bundle against the record's digest, which -// schema 1 makes mandatory (only the legacy empty marker has none). -// Returns nil when the directory may be activated, the reason otherwise. -// Slow (a full read of the bundle): never call it under the state lock. On -// success *recordOut and *stampOut describe exactly what was verified, so the -// caller can confirm under the lock that nothing changed since. -static NSString *PushyVerifyInstallForActivation(NSString *versionDir, NSString *hash, - NSDictionary **recordOut, - PushyFileStamp *stampOut) { - NSString *bundlePath = [versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]; - PushyFileStamp before = PushyStampFile(bundlePath); - NSDictionary *record = PushyReadInstallRecord(versionDir); - if (record == nil) { - return [NSString stringWithFormat:@"Bundle version %@ has no valid completion record.", hash]; - } - *recordOut = record; - *stampOut = before; - if (record.count == 0) { - return nil; - } - if (!PushyInstallRecordMatches(record, hash)) { - return [NSString stringWithFormat:@"Bundle version %@ completion record mismatch.", hash]; - } - NSString *expected = record[@"bundleSha256"]; - if (![expected isKindOfClass:[NSString class]] || expected.length == 0) { - return [NSString stringWithFormat:@"Bundle version %@ completion record mismatch.", hash]; - } - std::string actual = pushy::digest::Sha256File(PushyToStdString(bundlePath)); - if (PushyStampFile(bundlePath) != before) { - return [NSString stringWithFormat:@"Bundle version %@ changed while being verified.", hash]; - } - if ([[expected lowercaseString] isEqualToString:[NSString stringWithUTF8String:actual.c_str()]]) { - return nil; - } - return [NSString stringWithFormat:@"Bundle version %@ bundle digest mismatch.", hash]; -} - -// SHA-256 of each in-flight download's archive, keyed by hash; consumed by -// the completion record write. Guarded by @synchronized on the table. -static NSMutableDictionary *PushyArtifactDigests(void) { - static NSMutableDictionary *table; - static dispatch_once_t once; - dispatch_once(&once, ^{ - table = [NSMutableDictionary dictionary]; - }); - return table; -} - -static NSError *PushyErrorWithCode(const char *code, NSString *message); - -// nil when the volume holding `path` can take `bytesToWrite` plus the safety -// margin (or the free space is unknown); a PATCH_FAILED error otherwise. The -// probe is the downloader's (RCTPushyFreeSpaceShortfall), which applies the -// same rule to the announced Content-Length before the first byte lands. -static NSError *PushyEnsureFreeSpace(NSString *path, long long bytesToWrite) { - NSString *shortfall = RCTPushyFreeSpaceShortfall(path, bytesToWrite); - if (shortfall == nil) { - return nil; - } - return PushyErrorWithCode(pushy::error_codes::kDeltaFailed, shortfall); -} - -// SSZipArchive delegate enforcing cpp/patch_core/archive_limits.h while the -// archive is walked (entry count, per-entry size, total size, compression -// ratio) plus two entry-shape rules Android's java.util.zip gets for free: -// - no symlinks: SSZipArchive materialises them with any target regardless -// of preserveAttributes, so `assets -> ../../Documents` followed by -// `assets/x` would write outside the version directory; -// - no ".pushy-" reserved names at any depth: the completion marker is -// written by this SDK only, after the whole install succeeded. An archive -// shipping its own would be trusted as a finished install even if the -// patch step that follows fails. -// A NO from zipArchiveShouldUnzipFileAtIndex: cancels the whole unzip (the -// 2.x contract the podspec pins), and the completion handler turns the -// recorded violation into a hard PATCH_FAILED; the caller then discards the -// staging directory. -@interface PushyUnzipGuard : NSObject -@property (nonatomic, copy) NSString *violation; -@property (nonatomic, assign) long long totalUncompressed; -@end - -@implementation PushyUnzipGuard - -- (void)zipArchiveWillUnzipArchiveAtPath:(NSString *)path zipInfo:(unz_global_info)zipInfo { - if ((long long)zipInfo.number_entry > pushy::archive_limits::kMaxEntries) { - self.violation = [NSString stringWithFormat:@"archive has too many entries (%lu)", - (unsigned long)zipInfo.number_entry]; - } -} - -- (BOOL)zipArchiveShouldUnzipFileAtIndex:(NSInteger)fileIndex - totalFiles:(NSInteger)totalFiles - archivePath:(NSString *)archivePath - fileInfo:(unz_file_info)fileInfo { - if (self.violation != nil) { - return NO; - } - // Unix mode bits travel in the high 16 bits of the external attributes - // (the same field SSZipArchive keys its own symlink branch on). - if (((fileInfo.external_fa >> 16) & S_IFMT) == S_IFLNK) { - self.violation = [NSString stringWithFormat:@"archive entry #%ld is a symlink", - (long)fileIndex]; - return NO; - } - long long size = (long long)fileInfo.uncompressed_size; - long long compressed = (long long)fileInfo.compressed_size; - if (size > pushy::archive_limits::kMaxEntryBytes) { - self.violation = [NSString stringWithFormat:@"archive entry #%ld too large (%lld bytes)", - (long)fileIndex, size]; - return NO; - } - self.totalUncompressed += size; - if (self.totalUncompressed > pushy::archive_limits::kMaxTotalUncompressedBytes) { - self.violation = [NSString stringWithFormat:@"archive expands beyond %lld bytes", - pushy::archive_limits::kMaxTotalUncompressedBytes]; - return NO; - } - if (compressed > 0 && size > pushy::archive_limits::kRatioCheckMinBytes - && size / compressed > pushy::archive_limits::kMaxCompressionRatio) { - self.violation = [NSString stringWithFormat:@"archive entry #%ld compression ratio too high", - (long)fileIndex]; - return NO; - } - return YES; -} - -// The entry name is only exposed once the entry is on disk (shouldUnzip -// runs before the name is read). A reserved name is flagged here; the next -// shouldUnzip refuses (cancelling the unzip) or, for the last entry, the -// completion handler picks the violation up. Either way the caller drops -// the whole staging directory, so the written file never survives. -- (void)zipArchiveDidUnzipFileAtIndex:(NSInteger)fileIndex - totalFiles:(NSInteger)totalFiles - archivePath:(NSString *)archivePath - unzippedFilePath:(NSString *)unzippedFilePath { - NSString *name = unzippedFilePath.lastPathComponent; - if (self.violation == nil && [name hasPrefix:@".pushy-"]) { - self.violation = [NSString stringWithFormat:@"archive contains reserved entry %@", name]; - } -} - -@end - -static NSError *PushyDownloadDeadlineExpiredError(void) { - return [NSError errorWithDomain:PushyErrorDomain - code:-1 - userInfo:@{ - NSLocalizedDescriptionKey: @"download deadline expired before start", - PushyErrorCodeKey: PushyCode(pushy::error_codes::kDownloadFailed), - }]; -} - - -static std::atomic ignoreRollback{false}; // Whether the host asked +bundleURL for the bundle to load (the // isUsingBundleUrl constant, mirroring Android/Harmony). NO means the app // runs a bundle this SDK never resolved, so updates cannot take effect. static std::atomic pushyIsUsingBundleUrl{false}; -// Bumped by resetToPackagedBundle only (config replacement has its own -// pushyNativeConfigGeneration). The cold-start check runs for minutes and -// may already hold a decision when the app resets to the packaged bundle; it -// samples this counter and abandons activation (and its response cache) when -// the value moved, so an in-flight rescue can never resurrect the version the -// app just reset away from. -PUSHY_HIDDEN std::atomic pushyResetGeneration{0}; -// The version whose bundle this process actually loaded (resolved in -// +bundleURL). resetToPackagedBundle must not delete its directory: update -// assets (images/fonts) are read from it on demand at runtime, so wiping it -// under a silent (no-restart) reset would break every image the running app -// has not loaded yet. Guarded by the state lock. -static NSString *pushyLaunchVersion = nil; - -// JS and the bridge-free cold-start engine use different RCTPushy instances, -// but they must still share one download per target hash. Without this -// process-wide registry two NSURLSessionDownloadTasks race over the same -// archive path and the later unzip can delete the first task's valid output. -static NSMutableDictionary *PushyInFlightDownloads(void) { - static NSMutableDictionary *downloads; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - downloads = [NSMutableDictionary dictionary]; - }); - return downloads; -} - -typedef NS_ENUM(NSInteger, PushyDownloadRegistration) { - PushyDownloadRegistrationOwner, - PushyDownloadRegistrationJoined, - PushyDownloadRegistrationDeferred, -}; - -static PushyDownloadRegistration PushyRegisterDownload( - NSString *hash, - NSInteger type, - NSTimeInterval deadlineUptime, - void (^callback)(NSError *), - void (^progress)(long long, long long), - void (^deferredStart)(void) -) { - NSMutableDictionary *downloads = PushyInFlightDownloads(); - @synchronized (downloads) { - NSMutableDictionary *entry = downloads[hash]; - if (entry != nil) { - if ([entry[@"type"] integerValue] == type) { - NSTimeInterval ownerDeadline = [entry[@"deadlineUptime"] doubleValue]; - // A caller with substantially more time must not inherit an - // owner's nearly-exhausted timeout: it observes the current - // transfer and restarts after the owner settles (the - // completion-marker preflight makes a successful owner free). - // The comparison is on remaining budget, not on the absolute - // deadline: a JS caller always computes now+600 a few seconds - // after the owner did, so a strict `>` would defer every - // second caller and turn the shared download back into - // serialized re-downloads. Only a genuinely starved owner - // (less than half the newcomer's budget left) defers. - const NSTimeInterval now = PushyMonotonicNow(); - if (2 * (ownerDeadline - now) < (deadlineUptime - now)) { - if (progress != nil) { - [entry[@"progress"] addObject:[progress copy]]; - } - [entry[@"deferred"] addObject:[deferredStart copy]]; - return PushyDownloadRegistrationDeferred; - } - [entry[@"callbacks"] addObject:[callback copy]]; - if (progress != nil) { - [entry[@"progress"] addObject:[progress copy]]; - } - return PushyDownloadRegistrationJoined; - } - // A diff failure must not settle a joined full request. Queue the - // different artifact type behind the owner; once restarted it - // registers normally (and re-checks the completion marker). - if (progress != nil) { - // The artifact type differs, but it still installs the same - // target hash. Keep the waiting JS UI moving while its own - // transfer is queued behind the current owner. - [entry[@"progress"] addObject:[progress copy]]; - } - [entry[@"deferred"] addObject:[deferredStart copy]]; - return PushyDownloadRegistrationDeferred; - } - NSMutableArray *progressHandlers = [NSMutableArray array]; - if (progress != nil) { - [progressHandlers addObject:[progress copy]]; - } - downloads[hash] = [@{ - @"type": @(type), - @"deadlineUptime": @(deadlineUptime), - @"callbacks": [NSMutableArray arrayWithObject:[callback copy]], - @"progress": progressHandlers, - @"deferred": [NSMutableArray array], - } mutableCopy]; - return PushyDownloadRegistrationOwner; - } -} - -static void PushyReportDownloadProgress( - NSString *hash, long long received, long long total -) { - NSMutableDictionary *downloads = PushyInFlightDownloads(); - NSArray *handlers = nil; - @synchronized (downloads) { - handlers = [downloads[hash][@"progress"] copy]; - } - for (id value in handlers) { - void (^handler)(long long, long long) = - (void (^)(long long, long long))value; - handler(received, total); - } -} - -static void PushyFinishDownload(NSString *hash, NSError *error) { - NSMutableDictionary *downloads = PushyInFlightDownloads(); - NSArray *callbacks = nil; - NSArray *deferred = nil; - @synchronized (downloads) { - callbacks = [downloads[hash][@"callbacks"] copy]; - deferred = [downloads[hash][@"deferred"] copy]; - [downloads removeObjectForKey:hash]; - } - // Establish the next (different-type) owner before waking the completed - // owner's callbacks; otherwise its strategy loop could race the waiter. - for (id value in deferred) { - void (^start)(void) = (void (^)(void))value; - start(); - } - for (id value in callbacks) { - void (^callback)(NSError *) = (void (^)(NSError *))value; - callback(error); - } -} - -// Serializes every read-modify-write of the persisted update state. The state -// machine itself is a pure function (state_core), but callers run on different -// threads (main thread bundleURL, module method queue, _fileQueue), so the -// read→transform→write sequence must be atomic to avoid e.g. markSuccess being -// overwritten by a concurrent bundleURL and the version being rolled back. -static os_unfair_lock pushyStateLock = OS_UNFAIR_LOCK_INIT; - -PUSHY_HIDDEN void PushyWithStateLock(void (NS_NOESCAPE ^block)(void)) { -#if DEBUG - // Not re-entrant: a nested acquisition deadlocks in release, so catch it - // where it is written. - os_unfair_lock_assert_not_owner(&pushyStateLock); -#endif - os_unfair_lock_lock(&pushyStateLock); - @try { - block(); - } @finally { - os_unfair_lock_unlock(&pushyStateLock); - } -} -PUSHY_HIDDEN std::string PushyToStdString(NSString *value) { - if (value == nil) { - return std::string(); - } - return std::string([value UTF8String]); -} - -static NSError *PushyNSErrorFromStatus(const pushy::delta::Status &status) { - return [NSError errorWithDomain:PushyErrorDomain - code:-1 - userInfo:@{ - NSLocalizedDescriptionKey: [NSString stringWithUTF8String:status.message.c_str()], - PushyErrorCodeKey: PushyCode(pushy::error_codes::kDeltaFailed), - }]; -} - -PUSHY_HIDDEN NSUserDefaults *PushyDefaults(void) { - return [NSUserDefaults standardUserDefaults]; -} - -PUSHY_HIDDEN NSString *PushyFromStdString(const std::string &value) { - if (value.empty()) { - return nil; - } - return [NSString stringWithUTF8String:value.c_str()]; -} - -static void PushySetNullableString(NSUserDefaults *defaults, NSString *key, NSString *value) { - if (value != nil) { - [defaults setObject:value forKey:key]; - } else { - [defaults removeObjectForKey:key]; - } -} - -PUSHY_HIDDEN NSString *PushyHashInfoKey(NSString *hash) { - return [keyHashInfo stringByAppendingString:hash ?: @""]; -} - -static NSString *PushyOptionString(NSDictionary *options, NSString *key) { - return [RCTConvert NSString:options[key]]; -} - -static BOOL PushyStringIsBlank(NSString *value) { - if (value == nil || [value isKindOfClass:[NSNull class]]) { - return YES; - } - return [[value stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] length] == 0; -} - -// Server-provided identifiers (hash/originHash) are used as child names under -// the download root; anything that could resolve outside of it (path -// separators, "..", ".") must be rejected before touching the filesystem. -PUSHY_HIDDEN BOOL PushyIsSafePathComponent(NSString *value) { - if (PushyStringIsBlank(value)) { - return NO; - } - if ([value isEqualToString:@"."] || [value isEqualToString:@".."]) { - return NO; - } - if ([value containsString:@"/"] || [value containsString:@"\\"]) { - return NO; - } - if ([value rangeOfString:@"\0"].location != NSNotFound) { - return NO; - } - return YES; -} - -static void PushyRejectError(RCTPromiseRejectBlock reject, NSError *error) { - // Prefer the stable cross-platform code (error_codes.h); fall back to the - // numeric NSError code for system errors that were not classified. - NSString *code = error.userInfo[PushyErrorCodeKey]; - if (code == nil) { - code = [NSString stringWithFormat:@"%ld", (long)error.code]; - } - reject(code, error.localizedDescription, error); -} - -static NSError *PushyErrorWithCode(const char *code, NSString *message) { - return [NSError errorWithDomain:PushyErrorDomain - code:-1 - userInfo:@{ - NSLocalizedDescriptionKey: message ?: @"unknown error", - PushyErrorCodeKey: PushyCode(code), - }]; -} - -static BOOL PushyJsonIsAbsent(id value) { - return value == nil || [value isKindOfClass:[NSNull class]]; -} - -// Builds the patch-core manifest from a parsed __diff.json. Every field is -// type-checked: this runs inside a GCD block on _fileQueue, where the -// unrecognized-selector exception a `copies` array or a numeric `from` would -// raise takes the whole app down (and trips the crash rescue) instead of -// failing the patch. NO with `reason` set for a malformed manifest. -static BOOL PushyPatchManifestFromJson(NSDictionary *json, - pushy::delta::PatchManifest *manifest, - NSString **reason) { - id copies = json[@"copies"]; - if (!PushyJsonIsAbsent(copies)) { - if (![copies isKindOfClass:[NSDictionary class]]) { - *reason = @"delta manifest: copies is not an object"; - return NO; - } - // Content checksum per copy target ("copiesCrc", pdiff manifests - // from CLI >= 2.21.2). The patch core verifies the copy source - // against it and fails the patch on mismatch, so the JS strategy - // chain falls back to the full package instead of copying drifted - // bytes from a rebuilt binary. - NSDictionary *copiesCrc = json[@"copiesCrc"]; - if (![copiesCrc isKindOfClass:[NSDictionary class]]) { - copiesCrc = nil; - } - for (id to in (NSDictionary *)copies) { - id from = ((NSDictionary *)copies)[to]; - if (![to isKindOfClass:[NSString class]] || ![from isKindOfClass:[NSString class]]) { - *reason = @"delta manifest: copies entry is not a string"; - return NO; - } - if ([from length] == 0) { - from = to; - } - pushy::delta::CopyOperation operation; - operation.from = PushyToStdString(from); - operation.to = PushyToStdString(to); - NSNumber *expectedCrc = copiesCrc[to]; - if ([expectedCrc isKindOfClass:[NSNumber class]]) { - operation.has_expected_crc = true; - operation.expected_crc = (uint32_t)[expectedCrc unsignedLongLongValue]; - } - manifest->copies.push_back(operation); - } - } - - // The CLI emits `deletes` as an object keyed by path (Android reads its - // keys); a plain array of paths is accepted as well, like Harmony. - id deletes = json[@"deletes"]; - if (!PushyJsonIsAbsent(deletes)) { - if (![deletes isKindOfClass:[NSDictionary class]] && ![deletes isKindOfClass:[NSArray class]]) { - *reason = @"delta manifest: deletes is not an object or array"; - return NO; - } - for (id path in deletes) { - if (![path isKindOfClass:[NSString class]]) { - *reason = @"delta manifest: deletes entry is not a string"; - return NO; - } - manifest->deletes.push_back(PushyToStdString(path)); - } - } - return YES; -} - -// Binary identity under the prefixed key, or — until the first state write -// migrates it — the value an earlier SDK left under the unprefixed one, so an -// installed update survives the SDK upgrade instead of reading as a rebuilt -// binary. -static NSString *PushyBinaryIdentityValue(NSUserDefaults *defaults, NSString *key, NSString *legacyKey) { - return [defaults stringForKey:key] ?: [defaults stringForKey:legacyKey]; -} - -PUSHY_HIDDEN pushy::state::State PushyStateFromDefaults(NSUserDefaults *defaults) { - pushy::state::State state; - state.package_version = PushyToStdString( - PushyBinaryIdentityValue(defaults, paramPackageVersion, legacyParamPackageVersion)); - state.build_time = PushyToStdString( - PushyBinaryIdentityValue(defaults, paramBuildTime, legacyParamBuildTime)); - NSDictionary *pushyInfo = [defaults dictionaryForKey:keyPushyInfo]; - if (pushyInfo != nil) { - state.current_version = PushyToStdString(pushyInfo[paramCurrentVersion]); - state.last_version = PushyToStdString(pushyInfo[paramLastVersion]); - state.first_time = [pushyInfo[paramIsFirstTime] boolValue]; - id firstLoadOk = pushyInfo[paramIsFirstLoadOk]; - state.first_time_ok = firstLoadOk == nil ? true : [firstLoadOk boolValue]; - } - state.rolled_back_version = PushyToStdString([defaults stringForKey:keyRolledBackMarked]); - return state; -} - -static void PushyApplyStateToDefaults(NSUserDefaults *defaults, const pushy::state::State &state) { - PushySetNullableString(defaults, paramPackageVersion, PushyFromStdString(state.package_version)); - PushySetNullableString(defaults, paramBuildTime, PushyFromStdString(state.build_time)); - // One-time migration: once the prefixed keys hold the identity, the - // legacy ones are retired so a host-app write there is never read again. - [defaults removeObjectForKey:legacyParamPackageVersion]; - [defaults removeObjectForKey:legacyParamBuildTime]; - - BOOL hasPushyInfo = !state.current_version.empty() || !state.last_version.empty() || state.first_time || !state.first_time_ok; - if (hasPushyInfo) { - NSMutableDictionary *newInfo = [[NSMutableDictionary alloc] init]; - if (!state.current_version.empty()) { - newInfo[paramCurrentVersion] = PushyFromStdString(state.current_version); - } - if (!state.last_version.empty()) { - newInfo[paramLastVersion] = PushyFromStdString(state.last_version); - } - newInfo[paramIsFirstTime] = @(state.first_time); - newInfo[paramIsFirstLoadOk] = @(state.first_time_ok); - [defaults setObject:newInfo forKey:keyPushyInfo]; - } else { - [defaults removeObjectForKey:keyPushyInfo]; - } - - PushySetNullableString( - defaults, - keyRolledBackMarked, - PushyFromStdString(state.rolled_back_version)); -} - -// Version switch without acquiring the state lock: the caller must already -// hold it. Lets the cold-start check commit its whole result (version info, -// switch, response cache) inside one lock acquisition, so resetToPackagedBundle -// can never interleave between the generation check and the writes. -PUSHY_HIDDEN void PushySwitchVersionLocked(NSString *hash) { - NSUserDefaults *defaults = PushyDefaults(); - pushy::state::State next = pushy::state::SwitchVersion( - PushyStateFromDefaults(defaults), - PushyToStdString(hash) - ); - PushyApplyStateToDefaults(defaults, next); - // Re-enable first-load consumption and rollback checks for the newly selected bundle. - ignoreRollback = false; -} - - - -// Single-flight for the digest in PushyBundleHashSync: the delayed cold-start -// round (utility queue) and JS getBundleHash (_fileQueue) can both miss the -// cache right after an install; the second caller waits for the first and -// then reads its cache entry instead of hashing the same multi-MB file twice. -static NSLock *PushyBundleHashLock(void) { - static NSLock *lock; - static dispatch_once_t once; - dispatch_once(&once, ^{ - lock = [NSLock new]; +// Triggers a JS reload. React Native re-resolves the bundle URL itself on +// reload (RCTHost asks its bundleURLProvider, RCTBridge.setUp re-asks +// sourceURLForBridge:), so +bundleURL is deliberately NOT called here: it +// consumes one-shot launch state (first_time, the first-load mark, +// ignoreRollback), and consuming it for a reload that never happens would +// make the next cold start roll the freshly switched version back. +static void PushyTriggerReload(NSString *reason) { + dispatch_async(dispatch_get_main_queue(), ^{ + RCTTriggerReloadCommandListeners(reason); }); - return lock; } -static NSString *PushyCachedBundleHash(NSUserDefaults *defaults, NSString *cachedPrefix) { - NSString *cached = [defaults stringForKey:keyBundleHashCache]; - if ([cached hasPrefix:cachedPrefix]) { - return [cached substringFromIndex:cachedPrefix.length]; - } - return nil; -} - -// Shared by the getBundleHash RCT method and the native cold-start check. -// Returns @"" when unknown. With computeIfMissing the sha256 of the embedded -// bundle is taken on a cache miss (once per install; blocking — call off the -// main thread). Without it only the cache is consulted: a crash rescue on -// the main thread has a 3.5s budget in total, and hashing a large bundle on -// a fresh install would spend all of it before the first request. -PUSHY_HIDDEN NSString *PushyBundleHashSync(BOOL computeIfMissing) { - NSString *path = [[RCTPushy binaryBundleURL] path]; - if (path == nil) { - return @""; - } - NSDictionary *attributes = - [[NSFileManager defaultManager] attributesOfItemAtPath:path error:nil]; - if (attributes == nil) { - return @""; - } - NSString *cacheKey = [NSString stringWithFormat:@"%@|%llu|%.0f", - [RCTPushy packageVersion], - attributes.fileSize, - [attributes.fileModificationDate timeIntervalSince1970]]; - - NSUserDefaults *defaults = PushyDefaults(); - NSString *cachedPrefix = [cacheKey stringByAppendingString:@"|"]; - NSString *cached = PushyCachedBundleHash(defaults, cachedPrefix); - if (cached != nil) { - return cached; - } - if (!computeIfMissing) { - return @""; - } - - NSLock *lock = PushyBundleHashLock(); - [lock lock]; - @try { - // The previous holder may have filled the cache while we waited. - cached = PushyCachedBundleHash(defaults, cachedPrefix); - if (cached != nil) { - return cached; - } - NSString *hash = PushyFromStdString( - pushy::digest::Sha256File(PushyToStdString(path))) ?: @""; - if (hash.length > 0) { - [defaults setObject:[cachedPrefix stringByAppendingString:hash] - forKey:keyBundleHashCache]; - } - return hash; - } @finally { - [lock unlock]; - } +// A promise rejection for an exception caught around a state transition. +static void PushyRejectException(RCTPromiseRejectBlock reject, const char *code, + NSException *exception, NSString *fallback) { + PushyRejectError(reject, PushyErrorWithCode(code, exception.reason ?: fallback)); } // Host preparation runs off the caller's thread; the completion is delivered @@ -847,18 +55,15 @@ PUSHY_HIDDEN void PushySwitchVersionLocked(NSString *hash) { // every waiter when the round ends. static void PushyRunHostPreparation(NSDictionary *(^work)(void), RCTPushyBundlePreparationCompletion completion) { - static dispatch_queue_t hostQueue; - static dispatch_once_t once; - dispatch_once(&once, ^{ - hostQueue = dispatch_queue_create("cn.reactnative.pushy.host-sync", DISPATCH_QUEUE_CONCURRENT); - }); + static dispatch_queue_t hostQueue = + dispatch_queue_create("cn.reactnative.pushy.host-sync", DISPATCH_QUEUE_CONCURRENT); dispatch_async(hostQueue, ^{ NSDictionary *result; @try { result = work(); } @catch (NSException *exception) { RCTLogWarn(@"RCTPushy -- native host sync failed: %@", exception.reason); - result = PushyHostResult(@"failed", @"internal_error", nil, NO); + result = PushyHostResult(@"failed", @"internal_error"); } if (completion != nil) { NSDictionary *snapshot = [result copy]; @@ -870,7 +75,6 @@ static void PushyRunHostPreparation(NSDictionary *(^work)(void), } @implementation RCTPushy { - dispatch_queue_t _fileQueue; bool hasListeners; } @@ -905,134 +109,14 @@ + (NSURL *)bundleURL } } -// Resolves the bundle to launch from the persisted state; nil means the -// packaged bundle. Consumes the launch's one-shot state (first_time), so a -// second call is only valid after something re-armed it (a switchVersion). -// purgedVersion is set on tvOS when the installed version's files are gone. -+ (NSURL *)resolveLaunchBundleURL:(NSString **)launchRolledBackVersionOut - purgedVersion:(NSString **)purgedVersionOut -{ - __block NSURL *resolvedURL = nil; - __block NSString *launchRolledBackVersion = nil; - __block NSString *purgedVersion = nil; - @try { - PushyWithStateLock(^{ - NSUserDefaults *defaults = PushyDefaults(); - - NSString *curPackageVersion = [RCTPushy packageVersion]; - NSString *curBuildTime = [RCTPushy buildTime]; - - pushy::state::State state = PushyStateFromDefaults(defaults); - pushy::state::BinaryVersionSyncResult sync = pushy::state::SyncBinaryVersion( - state, - PushyToStdString(curPackageVersion), - PushyToStdString(curBuildTime) - ); - if (sync.changed) { - [defaults setObject:@(YES) forKey:KeyPackageUpdatedMarked()]; - state = sync.state; - PushyApplyStateToDefaults(defaults, state); - } - - if (!state.current_version.empty()) { - std::string const versionBeforeLaunch = state.current_version; - pushy::state::LaunchDecision decision = pushy::state::ResolveLaunchState( - state, - ignoreRollback.load(), - true - ); - state = decision.state; - - if (decision.did_rollback) { - // The crash-protection rollback: the new version never called - // markSuccess. Keep this visible in release logs. - RCTLogWarn(@"RCTPushy -- version %@ was not marked as successful, rolled back to %@", - PushyFromStdString(versionBeforeLaunch), - PushyFromStdString(state.current_version)); - } - if (decision.did_rollback || decision.consumed_first_time) { - PushyApplyStateToDefaults(defaults, state); - } - if (decision.consumed_first_time) { - // bundleURL may be called many times, ignore rollbacks before process restarted again. - ignoreRollback = true; - [defaults setObject:@(YES) forKey:keyFirstLoadMarked]; - } - - NSString *loadVersion = PushyFromStdString(decision.load_version); - NSString *downloadDir = [RCTPushy downloadDir]; - // Guard the rollback chain against cycles: a corrupted state - // returning an already-visited version would otherwise spin this - // loop forever during startup (Android has the same guard). - NSMutableSet *visitedVersions = [NSMutableSet set]; - while (loadVersion.length && ![visitedVersions containsObject:loadVersion]) { - [visitedVersions addObject:loadVersion]; - NSString *bundlePath = [[downloadDir stringByAppendingPathComponent:loadVersion] stringByAppendingPathComponent:BUNDLE_FILE_NAME]; - if ([[NSFileManager defaultManager] fileExistsAtPath:bundlePath isDirectory:NULL]) { - pushyLaunchVersion = loadVersion; - resolvedURL = [NSURL fileURLWithPath:bundlePath]; - break; - } else { -#if TARGET_OS_TV - // Caches is purgeable on tvOS: a missing bundle is the OS - // reclaiming space, not a bad release. Everything under - // rctpushy went with it, so walking back to last_version - // is pointless, and a rollback mark would make this - // launch's check refuse to reinstall the version. - RCTLogWarn(@"RCTPushy -- bundle version %@ was purged by the system", loadVersion); - purgedVersion = loadVersion; - state = pushy::state::ForgetPurgedVersions(state); - PushyApplyStateToDefaults(defaults, state); - if (decision.consumed_first_time) { - // The first load being consumed never happens: the - // packaged bundle must not report isFirstTime. - ignoreRollback = false; - [defaults removeObjectForKey:keyFirstLoadMarked]; - } - break; -#else - RCTLogError(@"RCTPushy -- bundle version %@ not found, rolling back", loadVersion); - state = pushy::state::Rollback(state); - PushyApplyStateToDefaults(defaults, state); - loadVersion = PushyFromStdString(state.current_version); -#endif - } - } - } - // Capture before constantsToExport consumes this one-shot marker. - // The delayed native check must never forceBoot the version that this - // launch just rolled back. - launchRolledBackVersion = PushyFromStdString(state.rolled_back_version); - }); - } @finally { - *launchRolledBackVersionOut = launchRolledBackVersion; - *purgedVersionOut = purgedVersion; - } - return resolvedURL; -} - -+ (NSString *) rollback { - __block NSString *currentVersion = nil; - PushyWithStateLock(^{ - NSUserDefaults *defaults = PushyDefaults(); - pushy::state::State state = pushy::state::Rollback(PushyStateFromDefaults(defaults)); - PushyApplyStateToDefaults(defaults, state); - currentVersion = PushyFromStdString(state.current_version); - }); - return currentVersion; -} - + (void)configure:(NSDictionary *)options completion:(RCTPushyConfigurationCompletion)completion { NSError *validationError = nil; // Snapshot nested mutable caller values before crossing a queue boundary. NSString *config = RCTPushyNormalizeConfiguration(options, &validationError); - static dispatch_queue_t configQueue; - static dispatch_once_t once; - dispatch_once(&once, ^{ - configQueue = dispatch_queue_create("cn.reactnative.pushy.host-config", DISPATCH_QUEUE_SERIAL); - }); + static dispatch_queue_t configQueue = + dispatch_queue_create("cn.reactnative.pushy.host-config", DISPATCH_QUEUE_SERIAL); dispatch_async(configQueue, ^{ NSError *failure = validationError; if (config != nil) { @@ -1093,15 +177,8 @@ - (NSDictionary *)constantsToExport if (currentVersion != nil) { ret[@"currentVersionInfo"] = [defaults objectForKey:PushyHashInfoKey(currentVersion)]; // bundleSha256 from the install record, for crash-report attribution. - NSString *bundleSha256 = @""; - if (PushyIsSafePathComponent(currentVersion)) { - NSDictionary *record = PushyReadInstallRecord( - [[RCTPushy downloadDir] stringByAppendingPathComponent:currentVersion]); - if ([record[@"bundleSha256"] isKindOfClass:[NSString class]]) { - bundleSha256 = record[@"bundleSha256"]; - } - } - ret[@"currentBundleSha256"] = bundleSha256; + ret[@"currentBundleSha256"] = PushyIsSafePathComponent(currentVersion) + ? PushyRecordedBundleSha256(PushyVersionDir(currentVersion)) : @""; } if (ret[@"isFirstTime"]) { @@ -1110,36 +187,18 @@ - (NSDictionary *)constantsToExport if (ret[@"rolledBackVersion"] != nil) { [defaults removeObjectForKey:keyRolledBackMarked]; - [self clearInvalidFiles]; + [RCTPushyInstaller clearInvalidFiles]; } if ([[defaults objectForKey:KeyPackageUpdatedMarked()] boolValue]) { [defaults removeObjectForKey:KeyPackageUpdatedMarked()]; - [self clearInvalidFiles]; + [RCTPushyInstaller clearInvalidFiles]; } }); return ret; } -- (instancetype)init -{ - self = [super init]; - if (self) { - // One process-wide serial queue, not per-instance: a bridge reload can - // briefly keep two RCTPushy instances alive, and destructive file work - // (resetToPackagedBundle's full cleanup) must stay serialized with the - // other instance's unzip/patch jobs. - static dispatch_queue_t sharedFileQueue; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - sharedFileQueue = dispatch_queue_create("cn.reactnative.pushy.file", DISPATCH_QUEUE_SERIAL); - }); - _fileQueue = sharedFileQueue; - } - return self; -} - RCT_EXPORT_METHOD(setUuid:(NSString *)uuid resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { @@ -1164,10 +223,8 @@ - (instancetype)init PushyRejectError(reject, PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS)); return; } - NSData *data = [config dataUsingEncoding:NSUTF8StringEncoding]; NSError *error = nil; - id object = data == nil ? nil : [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; - if (![object isKindOfClass:[NSDictionary class]]) { + if (PushyJsonDictionary(config, &error) == nil) { PushyRejectError(reject, PushyErrorWithCode( pushy::error_codes::kInvalidOptions, error != nil ? error.localizedDescription : ERROR_OPTIONS)); @@ -1177,8 +234,8 @@ - (instancetype)init [RCTPushyOrchestrator persistConfiguration:config]; resolve(@true); } @catch (NSException *exception) { - PushyRejectError(reject, PushyErrorWithCode(pushy::error_codes::kFileOperationFailed, - exception.reason ?: @"Native configuration failed")); + PushyRejectException(reject, pushy::error_codes::kFileOperationFailed, exception, + @"Native configuration failed"); } } @@ -1209,13 +266,9 @@ - (instancetype)init return; } - NSData *data = [value dataUsingEncoding:NSUTF8StringEncoding]; NSError *error = nil; - id object = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; - if (object && [object isKindOfClass:[NSDictionary class]]) { - NSUserDefaults *defaults = PushyDefaults(); - [defaults setObject:value forKey:PushyHashInfoKey(hash)]; - + if (PushyJsonDictionary(value, &error) != nil) { + [PushyDefaults() setObject:value forKey:PushyHashInfoKey(hash)]; resolve(@true); } else { PushyRejectError(reject, PushyErrorWithCode( @@ -1269,7 +322,7 @@ - (instancetype)init rejecter:(RCTPromiseRejectBlock)reject) { NSError *error = nil; - if (![self switchVersion:PushyOptionString(options, @"hash") error:&error]) { + if (![RCTPushyInstaller switchVersion:PushyOptionString(options, @"hash") error:&error]) { PushyRejectError(reject, error); return; } @@ -1282,27 +335,19 @@ - (instancetype)init rejecter:(RCTPromiseRejectBlock)reject) { NSError *error = nil; - if (![self switchVersion:PushyOptionString(options, @"hash") error:&error]) { + if (![RCTPushyInstaller switchVersion:PushyOptionString(options, @"hash") error:&error]) { PushyRejectError(reject, error); return; } - NSError *reloadError = [self restartBridgeWithReason:@"pushy restart"]; - if (reloadError != nil) { - PushyRejectError(reject, reloadError); - return; - } + PushyTriggerReload(@"pushy restart"); resolve(@true); } RCT_EXPORT_METHOD(restartApp:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { - NSError *reloadError = [self restartBridgeWithReason:@"pushy restartApp"]; - if (reloadError != nil) { - PushyRejectError(reject, reloadError); - return; - } + PushyTriggerReload(@"pushy restartApp"); resolve(@true); } @@ -1320,7 +365,7 @@ - (instancetype)init // is running. Mirrors the dev behaviour of buildTime. resolve(@""); #else - dispatch_async(_fileQueue, ^{ + dispatch_async(PushyFileQueue(), ^{ resolve(PushyBundleHashSync(YES)); }); #endif @@ -1346,12 +391,12 @@ - (instancetype)init PushyApplyStateToDefaults(defaults, result.state); }); } @catch (NSException *exception) { - PushyRejectError(reject, PushyErrorWithCode(pushy::error_codes::kMarkSuccessFailed, - exception.reason ?: @"markSuccess failed")); + PushyRejectException(reject, pushy::error_codes::kMarkSuccessFailed, exception, + @"markSuccess failed"); return; } - [self clearInvalidFiles]; + [RCTPushyInstaller clearInvalidFiles]; resolve(@true); #endif } @@ -1397,12 +442,11 @@ - (instancetype)init ignoreRollback = false; }); } @catch (NSException *exception) { - PushyRejectError(reject, PushyErrorWithCode(pushy::error_codes::kResetFailed, - exception.reason ?: @"reset failed")); + PushyRejectException(reject, pushy::error_codes::kResetFailed, exception, @"reset failed"); return; } - dispatch_async(_fileQueue, ^{ + dispatch_async(PushyFileQueue(), ^{ // maxAgeDays=0: remove every downloaded entry except the running // version's directory (cleaned up by the next regular cleanup). The // state was just wiped, so that is the only name left to keep. The @@ -1426,7 +470,6 @@ - (instancetype)init } - #pragma mark - private - (NSArray *)supportedEvents { @@ -1448,9 +491,19 @@ - (void)fetchPackage:(PushyType)type resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject { - [self performFetch:type options:options callback:^(NSError *error) { + NSString *hash = PushyOptionString(options, @"hash") ?: @""; + void (^progress)(long long, long long) = ^(long long receivedBytes, long long totalBytes) { + if (self->hasListeners) { + [self sendEventWithName:EVENT_PROGRESS_DOWNLOAD body:@{ + PARAM_PROGRESS_HASH: hash, + PARAM_PROGRESS_RECEIVED: @(receivedBytes), + PARAM_PROGRESS_TOTAL: @(totalBytes), + }]; + } + }; + [RCTPushyInstaller fetch:type options:options progress:progress callback:^(NSError *error) { if (error != nil) { - if (error.userInfo[PushyErrorCodeKey] == nil) { + if (error.userInfo[RCTPushyErrorCodeKey] == nil) { // Unclassified (system/network) errors from the download // pipeline; keep the original message. error = PushyErrorWithCode(pushy::error_codes::kDownloadFailed, @@ -1463,691 +516,6 @@ - (void)fetchPackage:(PushyType)type }]; } -// Triggers a JS reload; returns nil when one was scheduled, a RESTART_FAILED -// error when this host cannot restart. React Native re-resolves the bundle -// URL itself on reload (RCTHost asks its bundleURLProvider, RCTBridge.setUp -// re-asks sourceURLForBridge:), so +bundleURL is deliberately NOT called -// here: it consumes one-shot launch state (first_time, the first-load mark, -// ignoreRollback), and consuming it for a reload that never happens would -// make the next cold start roll the freshly switched version back. -- (NSError *)restartBridgeWithReason:(NSString *)reason -{ -#if PUSHY_HAS_RELOAD_COMMAND - dispatch_async(dispatch_get_main_queue(), ^{ - RCTTriggerReloadCommandListeners(reason); - }); - return nil; -#else - // No RCTReloadCommand in this React Native: only the legacy bridge can - // reload. Under bridgeless the module's bridge is nil or an RCTBridgeProxy - // whose -reload is a logged no-op (object_getClass avoids messaging the - // proxy) — report that instead of resolving a restart that never happens. - RCTBridge *bridge = self.bridge; - if (bridge == nil || object_getClass(bridge) == NSClassFromString(@"RCTBridgeProxy")) { - return PushyErrorWithCode(pushy::error_codes::kRestartFailed, - @"no bridge available to restart (bridgeless host without the RN restart command)"); - } - dispatch_async(dispatch_get_main_queue(), ^{ - [bridge reload]; - }); - return nil; -#endif -} - -- (void)performFetch:(PushyType)type options:(NSDictionary *)options callback:(void (^)(NSError *error))callback -{ - NSString *updateUrl = PushyOptionString(options, PushyOptionUrlKey()); - NSString *hash = PushyOptionString(options, @"hash"); - - if (PushyStringIsBlank(updateUrl) || !PushyIsSafePathComponent(hash)) { - callback(PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS)); - return; - } - NSString *originHash = PushyOptionString(options, @"originHash"); - if (type == PushyTypePatchFromPpk && !PushyIsSafePathComponent(originHash)) { - callback(PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS)); - return; - } - - NSString *dir = [RCTPushy downloadDir]; - BOOL success = [self ensureDirectoryExistsAtPath:dir]; - if (!success) { - callback(PushyErrorWithCode(pushy::error_codes::kFileOperationFailed, ERROR_FILE_OPERATION)); - return; - } - - NSString *unzipDir = [dir stringByAppendingPathComponent:hash]; - if (PushyHasCompletedVersionAtPath(unzipDir, hash)) { - callback(nil); - return; - } - - NSTimeInterval deadlineUptime = PushyMonotonicNow() + 600; - NSNumber *configuredDeadline = options[@"deadlineUptime"]; - if ([configuredDeadline isKindOfClass:[NSNumber class]]) { - deadlineUptime = configuredDeadline.doubleValue; - } - if (deadlineUptime <= PushyMonotonicNow()) { - callback(PushyDownloadDeadlineExpiredError()); - return; - } - - void (^progress)(long long, long long) = ^(long long receivedBytes, long long totalBytes) { - if (self->hasListeners) { - [self sendEventWithName:EVENT_PROGRESS_DOWNLOAD body:@{ - PARAM_PROGRESS_HASH:hash, - PARAM_PROGRESS_RECEIVED:@(receivedBytes), - PARAM_PROGRESS_TOTAL:@(totalBytes), - }]; - } - }; - void (^deferredStart)(void) = ^{ - [self performFetch:type options:options callback:callback]; - }; - PushyDownloadRegistration registration = PushyRegisterDownload( - hash, type, deadlineUptime, callback, progress, deferredStart); - if (registration != PushyDownloadRegistrationOwner) { - RCTLogInfo( - @"RCTPushy -- %@ in-flight download for %@", - registration == PushyDownloadRegistrationJoined ? @"join" : @"defer", - hash); - return; - } - - NSString *zipFilePath = [dir stringByAppendingPathComponent:[NSString stringWithFormat:@"%@%@",hash, [self zipExtension:type]]]; - - // On failure, remove the partial version directory like Android/Harmony - // do: a half-unzipped/half-patched dir leaks disk and could later be - // mistaken for a complete version. hash is validated non-blank above, so - // this can never resolve to the download root itself. - void (^completion)(NSError *) = ^(NSError *error) { - // Settle every JS/native waiter only after cleanup or the atomic - // completion marker write has run on the process-wide file queue. - dispatch_async(self->_fileQueue, ^{ - NSError *finalError = error; - NSFileManager *fileManager = [NSFileManager defaultManager]; - NSString *staging = PushyStagingDirForVersionDir(unzipDir); - NSString *artifactSha256 = nil; - @synchronized (PushyArtifactDigests()) { - artifactSha256 = PushyArtifactDigests()[hash]; - [PushyArtifactDigests() removeObjectForKey:hash]; - } - if (finalError == nil) { - // Two-phase install (cpp/patch_core/install_record.h): the - // completion record with the final bundle's digest goes into - // the staging directory, which is then renamed over the - // version directory in one atomic step. - NSString *bundlePath = [staging stringByAppendingPathComponent:BUNDLE_FILE_NAME]; - // The digest is mandatory: a version whose bundle could not - // be hashed is never recorded as installed. - std::string bundleSha256; - if (![fileManager fileExistsAtPath:bundlePath]) { - finalError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, - @"bundle missing after install"); - } else if ((bundleSha256 = pushy::digest::Sha256File(PushyToStdString(bundlePath))).empty()) { - finalError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, - @"bundle digest failed after install"); - } else { - NSMutableDictionary *record = [NSMutableDictionary dictionary]; - record[@"schema"] = @(pushy::install_record::kSchema); - record[@"versionHash"] = hash; - record[@"bundleSha256"] = [NSString stringWithUTF8String:bundleSha256.c_str()]; - if (artifactSha256.length > 0) { - record[@"artifactSha256"] = artifactSha256; - } - NSError *recordError = nil; - NSData *recordData = [NSJSONSerialization dataWithJSONObject:record options:0 error:&recordError]; - NSString *marker = [staging stringByAppendingPathComponent:VERSION_COMPLETE_FILE_NAME]; - if (recordData == nil || ![recordData writeToFile:marker - options:NSDataWritingAtomic - error:&recordError]) { - finalError = recordError ?: PushyErrorWithCode( - pushy::error_codes::kFileOperationFailed, - @"failed to write completion record"); - } else { - if ([fileManager fileExistsAtPath:unzipDir]) { - [fileManager removeItemAtPath:unzipDir error:nil]; - } - NSError *moveError = nil; - if (![fileManager moveItemAtPath:staging toPath:unzipDir error:&moveError]) { - finalError = moveError ?: PushyErrorWithCode( - pushy::error_codes::kFileOperationFailed, - @"failed to promote staging directory"); - } - } - } - } - if (finalError != nil) { - // Only the staging directory is ours to drop: the final - // version directory is never touched by a failed install. - [fileManager removeItemAtPath:staging error:nil]; - } - PushyFinishDownload(hash, finalError); - }); - }; - - RCTLogInfo(@"RCTPushy -- download file %@", updateUrl); - NSTimeInterval timeoutSeconds = deadlineUptime - PushyMonotonicNow(); - if (timeoutSeconds <= 0) { - completion(PushyDownloadDeadlineExpiredError()); - return; - } - [RCTPushyDownloader download:updateUrl - savePath:zipFilePath - timeoutInterval:timeoutSeconds - progressHandler:^(long long receivedBytes, long long totalBytes) { - PushyReportDownloadProgress(hash, receivedBytes, totalBytes); - } completionHandler:^(NSString *path, NSError *error) { - if (error != nil) { - completion(error); - return; - } - [self unzipDownloadedPackage:zipFilePath - hash:hash - type:type - originHash:originHash - callback:completion]; - }]; -} - -- (void)unzipDownloadedPackage:(NSString *)zipFilePath - hash:(NSString *)hash - type:(PushyType)type - originHash:(NSString *)originHash - callback:(void (^)(NSError *error))callback -{ - RCTLogInfo(@"RCTPushy -- unzip file %@", zipFilePath); - // Everything lands in .staging; the completion block promotes it. - NSString *unzipFilePath = PushyStagingDirForVersionDir( - [[RCTPushy downloadDir] stringByAppendingPathComponent:hash]); - dispatch_async(_fileQueue, ^{ - // Archive digest for the completion record, taken before the unzip - // consumes the file (same serial queue, so it runs first). - std::string digest = pushy::digest::Sha256File(PushyToStdString(zipFilePath)); - @synchronized (PushyArtifactDigests()) { - PushyArtifactDigests()[hash] = [NSString stringWithUTF8String:digest.c_str()]; - } - }); - [self unzipFileAtPath:zipFilePath - toDestination:unzipFilePath - completionHandler:^(NSError *error) { - dispatch_async(self->_fileQueue, ^{ - if (error != nil) { - callback(error); - return; - } - @try { - [self finishDownloadedPackage:hash type:type originHash:originHash callback:callback]; - } @catch (NSException *exception) { - // An uncaught exception in a GCD block is fatal (and would - // trip the crash rescue); a bad package is a PATCH_FAILED. - callback(PushyErrorWithCode(pushy::error_codes::kDeltaFailed, - exception.reason ?: @"delta failed")); - } - }); - }]; -} - -- (void)finishDownloadedPackage:(NSString *)hash - type:(PushyType)type - originHash:(NSString *)originHash - callback:(void (^)(NSError *error))callback -{ - switch (type) { - case PushyTypePatchFromPackage: - [self applyDeltaForHash:hash - type:type - fromBundle:[[RCTPushy binaryBundleURL] path] - source:[[NSBundle mainBundle] resourcePath] - callback:callback]; - return; - case PushyTypePatchFromPpk: { - NSString *lastVersionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:originHash]; - [self applyDeltaForHash:hash - type:type - fromBundle:[lastVersionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME] - source:lastVersionDir - callback:callback]; - return; - } - case PushyTypeFullDownload: - callback(nil); - return; - } -} - -- (void)applyDeltaForHash:(NSString *)hash - type:(PushyType)type - fromBundle:(NSString *)bundleOrigin - source:(NSString *)sourceOrigin - callback:(void (^)(NSError *error))callback -{ - // Patch work happens in the staging directory (two-phase install). - NSString *unzipDir = PushyStagingDirForVersionDir( - [[RCTPushy downloadDir] stringByAppendingPathComponent:hash]); - NSString *sourcePatch = [unzipDir stringByAppendingPathComponent:SOURCE_PATCH_NAME]; - NSString *bundlePatch = [unzipDir stringByAppendingPathComponent:BUNDLE_PATCH_NAME()]; - - NSString *destination = [unzipDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]; - long long manifestBytes = RCTPushyFileSize(sourcePatch); - if (manifestBytes > pushy::archive_limits::kMaxManifestBytes) { - callback(PushyErrorWithCode(pushy::error_codes::kDeltaFailed, - [NSString stringWithFormat:@"delta manifest too large: %lld bytes", manifestBytes])); - return; - } - NSData *data = [NSData dataWithContentsOfFile:sourcePatch]; - if (data == nil) { - callback(PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @"missing delta manifest")); - return; - } - - NSError *error = nil; - id jsonObject = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingAllowFragments error:&error]; - if (error != nil) { - // Classify as a patch failure like the sibling manifest branches; - // unclassified errors would otherwise be tagged DOWNLOAD_FAILED by the - // fetchPackage fallback even though the download itself succeeded. - callback(PushyErrorWithCode(pushy::error_codes::kDeltaFailed, error.localizedDescription)); - return; - } - if (![jsonObject isKindOfClass:[NSDictionary class]]) { - callback(PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @"invalid delta manifest")); - return; - } - NSDictionary *json = (NSDictionary *)jsonObject; - pushy::delta::PatchManifest manifest; - NSString *manifestReason = nil; - if (!PushyPatchManifestFromJson(json, &manifest, &manifestReason)) { - callback(PushyErrorWithCode(pushy::error_codes::kDeltaFailed, manifestReason)); - return; - } - - std::vector entryNames; - if ([[NSFileManager defaultManager] fileExistsAtPath:sourcePatch isDirectory:NULL]) { - entryNames.push_back(PushyToStdString(SOURCE_PATCH_NAME)); - } - if ([[NSFileManager defaultManager] fileExistsAtPath:bundlePatch isDirectory:NULL]) { - entryNames.push_back(PushyToStdString(BUNDLE_PATCH_NAME())); - } - - pushy::archive_patch::ArchivePatchPlan plan; - pushy::delta::Status planStatus = pushy::archive_patch::BuildArchivePatchPlan( - type == PushyTypePatchFromPackage - ? pushy::archive_patch::ArchivePatchType::kPatchFromPackage - : pushy::archive_patch::ArchivePatchType::kPatchFromPpk, - manifest, - entryNames, - &plan - ); - if (!planStatus.ok) { - callback(PushyNSErrorFromStatus(planStatus)); - return; - } - - pushy::delta::FileSourcePatchOptions options; - pushy::delta::Status optionStatus = pushy::archive_patch::BuildFileSourcePatchOptions( - plan, - PushyToStdString(sourceOrigin), - PushyToStdString(unzipDir), - PushyToStdString(bundleOrigin), - PushyToStdString(bundlePatch), - PushyToStdString(destination), - &options - ); - if (!optionStatus.ok) { - callback(PushyNSErrorFromStatus(optionStatus)); - return; - } - - // __diff.json 的 hbcTransform 元数据(HBC 变换域 patch,hdiffv2 轨道): - // 存在时透传给 patch 内核执行 T(origin) → hpatch → T⁻¹;缺失走现状路径。 - NSDictionary *hbcTransform = json[@"hbcTransform"]; - if ([hbcTransform isKindOfClass:[NSDictionary class]]) { - NSDictionary *meta = hbcTransform[BUNDLE_PATCH_NAME()]; - if ([meta isKindOfClass:[NSDictionary class]]) { - NSError *metaError = nil; - NSData *metaData = [NSJSONSerialization dataWithJSONObject:meta options:0 error:&metaError]; - if (metaData != nil && metaError == nil) { - NSString *metaString = [[NSString alloc] initWithData:metaData encoding:NSUTF8StringEncoding]; - options.bundle_hbc_transform_meta = PushyToStdString(metaString); - } - } - } - - pushy::delta::Status status = pushy::delta::ApplyPatchFromFileSource(options); - if (!status.ok) { - callback(PushyNSErrorFromStatus(status)); - return; - } - - callback(nil); -} - -- (BOOL)switchVersion:(NSString *)hash error:(NSError **)error -{ - if (!PushyIsSafePathComponent(hash)) { - if (error != NULL) { - *error = PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS); - } - return NO; - } - - // Same rule as Android: only a version this SDK recorded as completely - // installed (bundle + marker) may be activated. Markerless versions - // activated before markers existed are grandfathered through current/last - // state; any other markerless directory may be a crash-left partial - // install or a directory something else put there. A directory that does - // carry a record is verified even when it is current/last. - NSString *versionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:hash]; - std::string hashStd = PushyToStdString(hash); - BOOL (^isLegacyActivated)(void) = ^BOOL { - if (PushyHasInstallRecordFile(versionDir)) { - return NO; - } - pushy::state::State state = PushyStateFromDefaults(PushyDefaults()); - return state.current_version == hashStd || state.last_version == hashStd; - }; - NSError *(^incompleteError)(void) = ^NSError * { - BOOL hasBundle = [[NSFileManager defaultManager] fileExistsAtPath: - [versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]]; - return PushyErrorWithCode( - pushy::error_codes::kSwitchVersionFailed, - [NSString stringWithFormat:@"Bundle version %@ %@", hash, - hasBundle ? @"is incomplete." : @"not found."]); - }; - - // The digest check reads the whole bundle, so it runs OUTSIDE the state - // lock: +bundleURL takes that lock on the main thread at launch. - __block BOOL legacyActivated = NO; - PushyWithStateLock(^{ - legacyActivated = isLegacyActivated(); - }); - NSError *switchError = nil; - NSDictionary *verifiedRecord = nil; - PushyFileStamp verifiedStamp; - BOOL verified = NO; - if (!legacyActivated) { - if (!PushyHasCompletedVersionAtPath(versionDir, hash)) { - switchError = incompleteError(); - } else { - // The record's bundle digest must match the bytes on disk before - // the next launch is pointed at them. - NSString *reason = PushyVerifyInstallForActivation( - versionDir, hash, &verifiedRecord, &verifiedStamp); - if (reason != nil) { - switchError = PushyErrorWithCode(pushy::error_codes::kSwitchVersionFailed, reason); - } else { - verified = YES; - } - } - } - - __block NSError *lockedError = switchError; - if (lockedError == nil) { - NSDictionary *record = verifiedRecord; - PushyFileStamp stamp = verifiedStamp; - PushyWithStateLock(^{ - if (!isLegacyActivated()) { - // Re-check cheaply that the directory is still the one that - // was verified: same record, same bundle file (size, mtime, - // inode). - if (!PushyHasCompletedVersionAtPath(versionDir, hash)) { - lockedError = incompleteError(); - return; - } - NSString *bundlePath = [versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]; - if (!verified || ![PushyReadInstallRecord(versionDir) isEqual:record] - || PushyStampFile(bundlePath) != stamp) { - lockedError = PushyErrorWithCode( - pushy::error_codes::kSwitchVersionFailed, - [NSString stringWithFormat:@"Bundle version %@ changed while switching.", hash]); - return; - } - } - PushySwitchVersionLocked(hash); - }); - } - if (lockedError != nil) { - if (error != NULL) { - *error = lockedError; - } - return NO; - } - return YES; -} - -- (BOOL)ensureDirectoryExistsAtPath:(NSString *)path -{ - // No _fileQueue hop here: that queue also runs multi-second unzip/patch - // work, and a dispatch_sync onto it would block the whole module method - // queue for the duration. createDirectoryAtPath is idempotent and - // thread-safe, so checking inline is fine. - NSFileManager *fileManager = [NSFileManager defaultManager]; - BOOL isDirectory = NO; - if ([fileManager fileExistsAtPath:path isDirectory:&isDirectory]) { - if (isDirectory) { - // Directories created by older versions never got the flag. - [RCTPushy excludeFromBackup:path]; - } - return isDirectory; - } - - NSError *error = nil; - BOOL success = [fileManager createDirectoryAtPath:path - withIntermediateDirectories:YES - attributes:nil - error:&error]; - if (!success && error != nil) { - RCTLogWarn(@"Pushy create directory error: %@", error.localizedDescription); - } - if (success) { - [RCTPushy excludeFromBackup:path]; - } - - return success; -} - -// Everything under rctpushy is re-downloadable, and Application Support is -// backed up to iCloud by default — Apple requires such content to be -// excluded from backups. -+ (void)excludeFromBackup:(NSString *)path -{ -#if !TARGET_OS_TV // tvOS stores under Caches, which is never backed up. - NSURL *url = [NSURL fileURLWithPath:path isDirectory:YES]; - NSError *error = nil; - if (![url setResourceValue:@YES - forKey:NSURLIsExcludedFromBackupKey - error:&error]) { - RCTLogWarn(@"Pushy exclude from backup error: %@", error.localizedDescription); - } -#endif -} - -- (void)unzipFileAtPath:(NSString *)path - toDestination:(NSString *)destination - completionHandler:(void (^)(NSError *error))completionHandler -{ - dispatch_async(_fileQueue, ^{ - NSFileManager *fileManager = [NSFileManager defaultManager]; - if ([fileManager fileExistsAtPath:destination]) { - [fileManager removeItemAtPath:destination error:nil]; - } - - // Resource caps before the first byte is extracted - // (cpp/patch_core/archive_limits.h). SSZipArchive only exposes the - // entry count up front (zipArchiveWillUnzipArchiveAtPath:zipInfo:), - // not the uncompressed total, so the disk check uses a 2x-archive - // heuristic; the delegate below enforces the exact caps entry by - // entry. - long long archiveBytes = RCTPushyFileSize(path); - NSError *preflight = nil; - if (archiveBytes > pushy::archive_limits::kMaxArchiveBytes) { - preflight = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, - [NSString stringWithFormat:@"archive too large: %lld bytes", archiveBytes]); - } else { - preflight = PushyEnsureFreeSpace(destination, MAX(0LL, archiveBytes) * 2); - } - if (preflight != nil) { - [fileManager removeItemAtPath:path error:nil]; - [fileManager removeItemAtPath:RCTPushyResumeSidecarPath(path) error:nil]; - if (completionHandler != nil) { - completionHandler(preflight); - } - return; - } - - PushyUnzipGuard *guard = [PushyUnzipGuard new]; - // preserveAttributes:NO — the archive's mode/mtime bits have no use - // here and are one less thing a hostile package controls. - [SSZipArchive unzipFileAtPath:path - toDestination:destination - preserveAttributes:NO - overwrite:YES - nestedZipLevel:0 - password:nil - error:nil - delegate:guard - progressHandler:nil - completionHandler:^(NSString *archivePath, BOOL succeeded, NSError *error) { - [fileManager removeItemAtPath:archivePath error:nil]; - // The resume sidecar dies with its archive (§11.4): whether the - // unzip consumed it or classified it as poisoned, nothing must - // survive to vouch for bytes that are gone. - [fileManager removeItemAtPath:RCTPushyResumeSidecarPath(archivePath) error:nil]; - if (completionHandler == nil) { - return; - } - - NSError *unzipError = error; - if (guard.violation != nil) { - unzipError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, guard.violation); - } else if (!succeeded && unzipError == nil) { - unzipError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @"unzip failed"); - } else if (unzipError != nil && unzipError.userInfo[PushyErrorCodeKey] == nil) { - // SSZipArchive's own NSError (corrupt zip, bad magic, ...) has - // no stable code; without one, fetchPackage's fallback would - // classify it as DOWNLOAD_FAILED even though the download - // succeeded — keep the classification deterministic. - unzipError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, - unzipError.localizedDescription ?: @"unzip failed"); - } - completionHandler(unzipError); - }]; - }); -} - -- (void)clearInvalidFiles -{ - dispatch_async(_fileQueue, ^{ - // Snapshot the state under the lock, but run the (slow) filesystem - // cleanup outside of it so state operations are not blocked. - __block pushy::state::State state; - __block NSString *launchVersion = nil; - PushyWithStateLock(^{ - state = PushyStateFromDefaults(PushyDefaults()); - launchVersion = pushyLaunchVersion; - }); - NSString *downloadDir = [RCTPushy downloadDir]; - // Keep the persisted current/last versions AND the version this - // process booted from: two switches without a restart would otherwise - // evict the running bundle while its on-demand assets are still - // being served. - pushy::delta::Status status = pushy::delta::CleanupOldEntries( - PushyToStdString(downloadDir), - std::vector{ - state.current_version, - state.last_version, - PushyToStdString(launchVersion), - }, - 3 - ); - if (!status.ok) { - RCTLogWarn(@"Pushy cleanup error: %s", status.message.c_str()); - } - }); -} - -- (NSString *)zipExtension:(PushyType)type -{ - switch (type) { - case PushyTypePatchFromPackage: - return PushyIpaDeltaSuffix(); - case PushyTypePatchFromPpk: - return PushyPpkDeltaSuffix(); - case PushyTypeFullDownload: - break; - } - return @".ppk"; -} - -+ (NSString *)downloadDir -{ -#if TARGET_OS_TV - // tvOS apps cannot write Application Support; Caches is the only - // writable, non-temporary location, and the system may purge it while - // the app is not running (see the purged-version path in +bundleURL). - NSSearchPathDirectory base = NSCachesDirectory; -#else - NSSearchPathDirectory base = NSApplicationSupportDirectory; -#endif - NSString *directory = [NSSearchPathForDirectoriesInDomains(base, NSUserDomainMask, YES) firstObject]; - return [directory stringByAppendingPathComponent:@"rctpushy"]; -} - -+ (NSURL *)binaryBundleURL -{ - return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]; -} - -+ (NSString *)packageVersion -{ - static NSString *version = nil; - - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - NSDictionary *infoDictionary = [[NSBundle mainBundle] infoDictionary]; - version = [infoDictionary objectForKey:@"CFBundleShortVersionString"]; - }); - return version; -} - -+ (NSString *)buildTime -{ -#if DEBUG - return @"0"; -#else - static NSString *buildTime; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - // The podspec's s.resource lands in the app bundle for a static - // library but inside this pod's own framework bundle under - // use_frameworks!; look in both. - NSString *stampPath = - [[NSBundle mainBundle] pathForResource:@"pushy_build_time" ofType:@"txt"] - ?: [[NSBundle bundleForClass:[RCTPushy class]] pathForResource:@"pushy_build_time" - ofType:@"txt"]; - NSString *stamp = stampPath == nil ? nil - : [[NSString stringWithContentsOfFile:stampPath encoding:NSUTF8StringEncoding error:nil] - stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; - if (stamp.length == 0) { - // Never empty: with "" SyncBinaryVersion would miss a rebuilt - // binary that kept its CFBundleShortVersionString, and a stale - // hot update would outlive an App Store update. Every build - // rewrites Info.plist, so its mtime (integer seconds) stands in. - RCTLogWarn(@"RCTPushy -- pushy_build_time.txt %@; using the Info.plist mtime as buildTime", - stampPath == nil ? @"not found in the main or framework bundle" : @"is empty"); - NSString *infoPlist = - [[NSBundle mainBundle].bundlePath stringByAppendingPathComponent:@"Info.plist"]; - NSDate *modified = [[NSFileManager defaultManager] attributesOfItemAtPath:infoPlist - error:nil].fileModificationDate; - stamp = [NSString stringWithFormat:@"%lld", (long long)[modified timeIntervalSince1970]]; - } - buildTime = stamp; - }); - return buildTime; -#endif -} - #ifdef RCT_NEW_ARCH_ENABLED - (std::shared_ptr)getTurboModule: (const facebook::react::ObjCTurboModule::InitParams &)params diff --git a/ios/RCTPushy/RCTPushyCommon.h b/ios/RCTPushy/RCTPushyCommon.h new file mode 100644 index 00000000..8084ee34 --- /dev/null +++ b/ios/RCTPushy/RCTPushyCommon.h @@ -0,0 +1,34 @@ +// Internal to the RCTPushy pod and Foundation-only: what every translation +// unit shares, including RCTPushyConfiguration.mm and RCTPushyDownloader.mm, +// which compile without React Native (the purge-restore test host builds +// RCTPushyConfiguration.mm on its own). Not a public header. +#pragma once + +#import + +// Shared across the pod's translation units, never exported from a dynamic +// framework. +#ifndef PUSHY_HIDDEN +#define PUSHY_HIDDEN __attribute__((visibility("hidden"))) +#endif + +// Every NSError this pod creates: the domain, and the userInfo key carrying +// the stable cross-platform code (cpp/patch_core/error_codes.h). +static NSString *const RCTPushyErrorDomain = @"cn.reactnative.pushy"; +static NSString *const RCTPushyErrorCodeKey = @"PushyErrorCode"; + +// Text produced by scripts/encode-native-text.ts, decoded with +// pushy::text::Reveal (cpp/patch_core/obscured_text.h). Keeps service +// addresses, paths and protocol strings out of static string scans of the +// binary; not a secret. +PUSHY_HIDDEN NSString *RCTPushyRevealText(const char *hex); + +// Whether following a redirect from `from` to `to` would leave https for +// plaintext http. Every https request (check and artifact) refuses that +// downgrade before the redirected request is sent. +PUSHY_HIDDEN BOOL RCTPushyIsHttpsDowngrade(NSURL *from, NSURL *to); + +// Removes a downloaded archive together with its resume sidecar +// (RCTPushyResumeSidecarPath): nothing may survive to vouch for bytes that +// are gone. +PUSHY_HIDDEN void RCTPushyDiscardArchive(NSString *path); diff --git a/ios/RCTPushy/RCTPushyConfiguration.h b/ios/RCTPushy/RCTPushyConfiguration.h index c7d00150..8231b68c 100644 --- a/ios/RCTPushy/RCTPushyConfiguration.h +++ b/ios/RCTPushy/RCTPushyConfiguration.h @@ -3,11 +3,3 @@ // Validates and snapshots host options without changing any update state. FOUNDATION_EXPORT NSString * _Nullable RCTPushyNormalizeConfiguration( NSDictionary * _Nonnull options, NSError * _Nullable * _Nullable error); - -// Decodes text produced by scripts/encode-native-text.ts (byte i XORed with -// (0x5A + 0x1D * i) & 0xFF), keeping service addresses and paths out of static -// string scans of the binary. Not a secret. -FOUNDATION_EXPORT NSString * _Nonnull RCTPushyRevealText(const char * _Nonnull hex); - -// Request path appended to an endpoint base. -FOUNDATION_EXPORT NSString * _Nonnull RCTPushyQueryPath(void); diff --git a/ios/RCTPushy/RCTPushyConfiguration.mm b/ios/RCTPushy/RCTPushyConfiguration.mm index 793bbbca..652120fc 100644 --- a/ios/RCTPushy/RCTPushyConfiguration.mm +++ b/ios/RCTPushy/RCTPushyConfiguration.mm @@ -1,143 +1,49 @@ #import "RCTPushyConfiguration.h" - -// Read through a volatile so the optimizer cannot fold the decode of a constant -// argument back into a plain string in the binary. -static volatile unsigned char PushyTextKeyBase = 0x5A; +#import "RCTPushyCommon.h" +#include "../../cpp/patch_core/error_codes.h" +#include "../../cpp/patch_core/obscured_text.h" +#include "../../cpp/update_flow_core/native_config.h" NSString *RCTPushyRevealText(const char *hex) { - size_t length = strlen(hex) / 2; - unsigned char *bytes = (unsigned char *)malloc(length > 0 ? length : 1); - if (bytes == NULL) { - return @""; - } - unsigned char base = PushyTextKeyBase; - for (size_t i = 0; i < length; i++) { - char pair[3] = {hex[i * 2], hex[i * 2 + 1], 0}; - unsigned char byte = (unsigned char)strtoul(pair, NULL, 16); - bytes[i] = byte ^ (unsigned char)(base + 0x1D * i); - } - // One decode into a byte buffer (the texts are ASCII, see - // scripts/encode-native-text.ts) instead of a format call per character. - NSString *text = [[NSString alloc] initWithBytes:bytes - length:length - encoding:NSUTF8StringEncoding]; - free(bytes); - return text ?: @""; -} - -NSString *RCTPushyQueryPath(void) { - static NSString *path; - static dispatch_once_t once; - dispatch_once(&once, ^{ - path = RCTPushyRevealText("7514fcd4ad805d55263e08fc99"); - }); - return path; -} - -static void PushyConfigInvalid(NSString *message) { - @throw [NSException exceptionWithName:NSInvalidArgumentException - reason:[@"Invalid native configuration: " stringByAppendingString:message] - userInfo:nil]; -} - -static NSString *PushyConfigString(id value, NSString *name, BOOL allowEmpty) { - if (![value isKindOfClass:NSString.class] - || (!allowEmpty && [[value stringByTrimmingCharactersInSet: - NSCharacterSet.whitespaceAndNewlineCharacterSet] length] == 0)) { - PushyConfigInvalid([NSString stringWithFormat:@"%@ must be a string%@", - name, allowEmpty ? @"" : @" and must not be blank"]); - } - return value; -} - -static NSArray *PushyConfigUrls(id value, NSString *name, BOOL base) { - if (![value isKindOfClass:NSArray.class] || (base && [value count] == 0)) { - PushyConfigInvalid([NSString stringWithFormat:@"%@ must be %@ array", - name, base ? @"a non-empty" : @"an"]); - } - NSMutableArray *result = [NSMutableArray array]; - static NSRegularExpression *authority; - static dispatch_once_t once; - dispatch_once(&once, ^{ - authority = [NSRegularExpression regularExpressionWithPattern: - @"^https?://(\\[[0-9a-fA-F:]+\\]|[a-zA-Z0-9.-]+)(:[0-9]+)?([/?#]|$)" - options:0 error:nil]; - }); - for (id item in value) { - NSString *url = [PushyConfigString(item, name, NO) stringByTrimmingCharactersInSet: - NSCharacterSet.whitespaceAndNewlineCharacterSet]; - if ([authority firstMatchInString:url options:0 range:NSMakeRange(0, url.length)] == nil - || [url rangeOfCharacterFromSet:NSCharacterSet.whitespaceAndNewlineCharacterSet].location != NSNotFound - || [url containsString:@"\\"] - || (base && ([url containsString:@"?"] || [url containsString:@"#"]))) { - PushyConfigInvalid([NSString stringWithFormat: - @"%@ requires absolute HTTP(S) URLs without credentials%@", - name, base ? @", queries or fragments" : @""]); - } - if (base) { - while ([url hasSuffix:@"/"]) { - url = [url substringToIndex:url.length - 1]; - } - } - if (![result containsObject:url]) { - [result addObject:url]; - } - } - return result; + // The texts are ASCII (scripts/encode-native-text.ts). + std::string text = pushy::text::Reveal(hex); + return [[NSString alloc] initWithBytes:text.data() + length:text.size() + encoding:NSUTF8StringEncoding] ?: @""; } +// Validation and defaults are the shared core's (flowcore::NormalizeConfiguration, +// also used by Android and Harmony); this only bridges the dictionary. NSString *RCTPushyNormalizeConfiguration(NSDictionary *options, NSError **error) { - @try { - if (![options isKindOfClass:NSDictionary.class]) { - PushyConfigInvalid(@"expected an object"); - } - NSArray *keys = @[@"appKey", @"endpoints", @"queryUrls", @"afterDownload", - @"disabled", @"packageVersion", @"rnu", @"rn"]; - for (id key in options) { - if (![keys containsObject:key]) { - PushyConfigInvalid([NSString stringWithFormat:@"unknown option %@", key]); - } + // Anything NSJSONSerialization cannot write is not a JSON object; the core + // reports it as such. + std::string optionsJson; + if ([options isKindOfClass:NSDictionary.class] + && [NSJSONSerialization isValidJSONObject:options]) { + NSData *data = [NSJSONSerialization dataWithJSONObject:options options:0 error:NULL]; + if (data != nil) { + optionsJson.assign(static_cast(data.bytes), data.length); } - NSString *appKey = PushyConfigString(options[@"appKey"], @"appKey", NO); - BOOL customEndpoints = options[@"endpoints"] != nil; - NSArray *endpoints = PushyConfigUrls(customEndpoints ? options[@"endpoints"] - : @[RCTPushyRevealText("3203e0c1bdd1270a372f18f8c2b6de7f4f2607f5b3d5b9817b592947e5cdefbc8a7e"), - RCTPushyRevealText("3203e0c1bdd1270a372f18f8c2b6de7f4f2607f5f0daac9c644a620ae88ca1ad93")], - @"endpoints", YES); - NSArray *queryUrls = PushyConfigUrls(options[@"queryUrls"] ?: (customEndpoints ? @[] - : @[RCTPushyRevealText("3203e0c1bdd1270a253608fcd3fd9362476817f4f0d5a1996342631be3c2a3a9d779552507fdcde8928a6f512f5ce2ccbdc869404d2f1de79daa826d562c0913eec4fa9b7d4426"), - RCTPushyRevealText("3203e0c1bdd1270a213b12b7dca09468462e12f3b0d5bd813d482446f4c6a1be8e79552507fdcda68cd06e5c3710e480a4867048483e55e0c2ab8d7d43030d1ce9c3b183214e2601f2f0d5b782601e2719e8ca")]), - @"queryUrls", NO); - NSString *afterDownload = options[@"afterDownload"] - ? PushyConfigString(options[@"afterDownload"], @"afterDownload", NO) : @"none"; - NSString *nextLaunch = RCTPushyRevealText("2912e0ffab8e6c70323b1dedd3"); - if (![@[@"none", nextLaunch] containsObject:afterDownload]) { - PushyConfigInvalid([@"afterDownload must be none or " stringByAppendingString:nextLaunch]); - } - id disabled = options[@"disabled"] ?: @NO; - if (![disabled isKindOfClass:NSNumber.class] || CFGetTypeID((__bridge CFTypeRef)disabled) != CFBooleanGetTypeID()) { - PushyConfigInvalid(@"disabled must be a boolean"); - } - NSMutableDictionary *result = [@{ - @"appKey": appKey, @"endpoints": endpoints, @"queryUrls": queryUrls, - @"afterDownload": afterDownload, @"disabled": disabled, - @"rnu": options[@"rnu"] ? PushyConfigString(options[@"rnu"], @"rnu", YES) : @"", - @"rn": options[@"rn"] ? PushyConfigString(options[@"rn"], @"rn", YES) : @"" - } mutableCopy]; - if (options[@"packageVersion"] != nil) { - result[@"packageVersion"] = PushyConfigString(options[@"packageVersion"], @"packageVersion", NO); - } - // Stable ordering makes repeated identical configure calls idempotent. - NSData *data = [NSJSONSerialization dataWithJSONObject:result - options:NSJSONWritingSortedKeys error:error]; - return data == nil ? nil : [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; - } @catch (NSException *exception) { - if (error != NULL) { - *error = [NSError errorWithDomain:@"cn.reactnative.pushy" code:1 userInfo:@{ - NSLocalizedDescriptionKey: exception.reason ?: @"Invalid native configuration", - @"PushyErrorCode": @"INVALID_OPTIONS" - }]; - } - return nil; } + std::string config; + std::string message; + if (flowcore::NormalizeConfiguration(optionsJson, &config, &message)) { + NSString *result = [[NSString alloc] initWithBytes:config.data() + length:config.size() + encoding:NSUTF8StringEncoding]; + if (result != nil) { + return result; + } + message = "Invalid native configuration: not UTF-8"; + } + if (error != NULL) { + *error = [NSError errorWithDomain:RCTPushyErrorDomain code:1 userInfo:@{ + NSLocalizedDescriptionKey: [[NSString alloc] initWithBytes:message.data() + length:message.size() + encoding:NSUTF8StringEncoding] + ?: @"Invalid native configuration", + RCTPushyErrorCodeKey: @(pushy::error_codes::kInvalidOptions) + }]; + } + return nil; } diff --git a/ios/RCTPushy/RCTPushyDownloader.mm b/ios/RCTPushy/RCTPushyDownloader.mm index ceb6bc03..a51335c6 100644 --- a/ios/RCTPushy/RCTPushyDownloader.mm +++ b/ios/RCTPushy/RCTPushyDownloader.mm @@ -1,8 +1,7 @@ #import "RCTPushyDownloader.h" +#import "RCTPushyCommon.h" #include "../../cpp/patch_core/archive_limits.h" -static NSString *const RCTPushyDownloaderErrorDomain = @"cn.reactnative.pushy"; - // Cross-launch resumable download (NATIVE_CHECKUPDATE_DESIGN §11.4): a data // task streams the archive straight into savePath (a download task's // temporary file is discarded on failure, which made every partial byte @@ -36,6 +35,16 @@ static void RCTPushyDeleteResumeSidecar(NSString *savePath) { error:nil]; } +void RCTPushyDiscardArchive(NSString *path) { + [[NSFileManager defaultManager] removeItemAtPath:path error:nil]; + RCTPushyDeleteResumeSidecar(path); +} + +BOOL RCTPushyIsHttpsDowngrade(NSURL *from, NSURL *to) { + return [[from.scheme lowercaseString] isEqualToString:@"https"] + && [[to.scheme lowercaseString] isEqualToString:@"http"]; +} + long long RCTPushyFileSize(NSString *path) { NSDictionary *attributes = [[NSFileManager defaultManager] attributesOfItemAtPath:path error:nil]; @@ -174,7 +183,7 @@ - (void)startTransfer { NSURL *url = [NSURL URLWithString:self.urlString]; if (url == nil) { - [self completeWithError:[NSError errorWithDomain:RCTPushyDownloaderErrorDomain + [self completeWithError:[NSError errorWithDomain:RCTPushyErrorDomain code:-1 userInfo:@{ NSLocalizedDescriptionKey: @"invalid download url", @@ -263,13 +272,8 @@ - (void)writeResumeMetaWithResponse:(NSHTTPURLResponse *)response total:(long lo } } -- (void)completeWithError:(NSError *)error +- (void)closeFileHandle { - if (self.finished) { - return; - } - self.finished = YES; - if (self.fileHandle != nil) { @try { [self.fileHandle closeFile]; @@ -277,9 +281,18 @@ - (void)completeWithError:(NSError *)error } self.fileHandle = nil; } +} + +- (void)completeWithError:(NSError *)error +{ + if (self.finished) { + return; + } + self.finished = YES; + + [self closeFileHandle]; if (self.discardPartial) { - [[NSFileManager defaultManager] removeItemAtPath:self.savePath error:nil]; - RCTPushyDeleteResumeSidecar(self.savePath); + RCTPushyDiscardArchive(self.savePath); } void (^completionHandler)(NSString *, NSError *) = self.completionHandler; @@ -297,7 +310,7 @@ - (void)completeWithError:(NSError *)error - (void)failWithDescription:(NSString *)description code:(NSInteger)code { - self.fileError = [NSError errorWithDomain:RCTPushyDownloaderErrorDomain + self.fileError = [NSError errorWithDomain:RCTPushyErrorDomain code:code userInfo:@{NSLocalizedDescriptionKey: description}]; } @@ -317,15 +330,8 @@ - (void)restartFromZero self.encodedBody = NO; self.lastReportedPercentage = 0; self.lastReportedBytes = 0; - if (self.fileHandle != nil) { - @try { - [self.fileHandle closeFile]; - } @catch (NSException *exception) { - } - self.fileHandle = nil; - } - [[NSFileManager defaultManager] removeItemAtPath:self.savePath error:nil]; - RCTPushyDeleteResumeSidecar(self.savePath); + [self closeFileHandle]; + RCTPushyDiscardArchive(self.savePath); [self.session finishTasksAndInvalidate]; self.session = nil; [self startTransfer]; @@ -346,9 +352,7 @@ - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task completionHandler(nil); return; } - NSString *originalScheme = [[NSURL URLWithString:self.urlString].scheme lowercaseString]; - NSString *nextScheme = [request.URL.scheme lowercaseString]; - if ([originalScheme isEqualToString:@"https"] && [nextScheme isEqualToString:@"http"]) { + if (RCTPushyIsHttpsDowngrade([NSURL URLWithString:self.urlString], request.URL)) { [self failWithDescription:[NSString stringWithFormat: @"https download redirected to plaintext http: %@", request.URL.absoluteString] code:-1]; @@ -523,19 +527,18 @@ - (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)data [dataTask cancel]; return; } - if (self.expectedTotal <= 0 - && self.receivedBytes + (long long)data.length > self.freeSpaceReservedUntil) { - // Unknown/encoded length: the response-time check could only - // reserve the margin, so probe before any write that would run past - // the bytes reserved so far, each probe reserving at least the next - // PROBE bytes (more when one callback carries a larger buffer) — no - // write can ever eat into the margin. The archive cap alone (512 - // MiB) is far more than the margin protects. The partial stays: a - // later attempt may find the space. - long long reserve = MAX(pushy::archive_limits::kUnknownLengthFreeSpaceProbeBytes, - (long long)data.length); - self.freeSpaceReservedUntil = self.receivedBytes + reserve; - NSString *shortfall = RCTPushyFreeSpaceShortfall(self.savePath, reserve); + // Unknown/encoded length: the response-time check could only reserve the + // margin, so the disk is probed before any write that would run past the + // bytes reserved so far (archive_limits::PlanUnknownLengthFreeSpaceProbe) + // — no write can ever eat into the margin. The partial stays: a later + // attempt may find the space. + pushy::archive_limits::FreeSpaceProbe plan = + pushy::archive_limits::PlanUnknownLengthFreeSpaceProbe( + self.expectedTotal, self.receivedBytes, (long long)data.length, + self.freeSpaceReservedUntil); + self.freeSpaceReservedUntil = plan.reserved_until; + if (plan.probe) { + NSString *shortfall = RCTPushyFreeSpaceShortfall(self.savePath, plan.reserve); if (shortfall != nil) { [self failWithDescription:shortfall code:-1]; [dataTask cancel]; diff --git a/ios/RCTPushy/RCTPushyInstallRecord.mm b/ios/RCTPushy/RCTPushyInstallRecord.mm new file mode 100644 index 00000000..eacb8c2d --- /dev/null +++ b/ios/RCTPushy/RCTPushyInstallRecord.mm @@ -0,0 +1,61 @@ +// Version directories and the completion record: where an install lands and +// how it is recorded as complete. The record itself (format, atomic write, +// presence and activation checks) is the shared core's +// (cpp/patch_core/install_record.h); switchVersion:error: calls its +// activation check directly. +#import "RCTPushyInternal.h" +#include "../../cpp/patch_core/error_codes.h" +#include "../../cpp/patch_core/install_record.h" + +PUSHY_HIDDEN NSString *const BUNDLE_FILE_NAME = @"index.bundlejs"; + +PUSHY_HIDDEN NSString *PushyVersionDir(NSString *hash) { + return [[RCTPushy downloadDir] stringByAppendingPathComponent:hash]; +} + +// Two-phase install: everything lands in .staging first. +PUSHY_HIDDEN NSString *PushyStagingDir(NSString *hash) { + return [PushyVersionDir(hash) stringByAppendingString:@(pushy::install_record::kStagingSuffix)]; +} + +// Presence check (launch / dedup paths, no digest work): bundle present and +// the record is the legacy marker or names this version. +PUSHY_HIDDEN BOOL PushyHasCompletedVersionAtPath(NSString *versionDir, NSString *hash) { + return pushy::install_record::IsComplete(PushyToStdString(versionDir), PushyToStdString(hash), + PushyToStdString(BUNDLE_FILE_NAME)); +} + +// The record's bundleSha256 (crash attribution); @"" when there is none. +PUSHY_HIDDEN NSString *PushyRecordedBundleSha256(NSString *versionDir) { + return PushyFromStdString( + pushy::install_record::RecordedBundleSha256(PushyToStdString(versionDir))) ?: @""; +} + +// Second phase of an install: seals .staging with the completion +// record (the final bundle's digest is mandatory: a version whose bundle +// could not be hashed is never recorded as installed) and renames it over +// in one atomic step. nil on success. Runs on the file queue. The +// caller drops the staging directory on failure. +PUSHY_HIDDEN NSError *PushyPromoteStagingDir(NSString *hash, NSString *artifactSha256) { + std::string staging = PushyToStdString(PushyStagingDir(hash)); + pushy::install_record::SealResult sealed = pushy::install_record::Seal( + staging, PushyToStdString(hash), PushyToStdString(BUNDLE_FILE_NAME), + PushyToStdString(artifactSha256), std::string()); + if (!sealed.status.ok) { + return PushyErrorWithCode(sealed.file_failure ? pushy::error_codes::kFileOperationFailed + : pushy::error_codes::kDeltaFailed, + PushyFromStdString(sealed.status.message)); + } + NSString *versionDir = PushyVersionDir(hash); + NSFileManager *fileManager = [NSFileManager defaultManager]; + if ([fileManager fileExistsAtPath:versionDir]) { + [fileManager removeItemAtPath:versionDir error:nil]; + } + pushy::install_record::Status promoted = + pushy::install_record::Promote(staging, PushyToStdString(versionDir)); + if (!promoted.ok) { + return PushyErrorWithCode(pushy::error_codes::kFileOperationFailed, + PushyFromStdString(promoted.message)); + } + return nil; +} diff --git a/ios/RCTPushy/RCTPushyInstaller.mm b/ios/RCTPushy/RCTPushyInstaller.mm new file mode 100644 index 00000000..cf11f498 --- /dev/null +++ b/ios/RCTPushy/RCTPushyInstaller.mm @@ -0,0 +1,689 @@ +// The install pipeline shared by JS calls and the native round: download +// (RCTPushyDownloader) → unzip (archive guard) → patch (cpp/patch_core) → +// promote (RCTPushyInstallRecord.mm), plus version activation and cleanup. +#import "RCTPushyInternal.h" +#import "RCTPushyDownloader.h" +#import +#include "../../cpp/patch_core/archive_limits.h" +#include "../../cpp/patch_core/archive_patch_core.h" +#include "../../cpp/patch_core/digest.h" +#include "../../cpp/patch_core/error_codes.h" +#include "../../cpp/patch_core/install_record.h" +#include "../../cpp/patch_core/patch_core.h" + +#include + +// file def +static NSString *const SOURCE_PATCH_NAME = @"__diff.json"; +static NSString *const ERROR_FILE_OPERATION = @"file operation error"; + +// Protocol strings stored encoded (RCTPushyRevealText) so they do not appear +// as plain binary strings; decoded on first use. +static NSString *BUNDLE_PATCH_NAME(void) { + static NSString *value = RCTPushyRevealText("3319f0d4b6c56a502c3b10fcdca0de7d4b3307e9"); + return value; +} +PUSHY_HIDDEN NSString *PushyOptionUrlKey(void) { + static NSString *value = RCTPushyRevealText("2f07f0d0ba8e5d572e"); + return value; +} +static NSString *PushyIpaDeltaSuffix(void) { + static NSString *value = RCTPushyRevealText("741ee4d0e09b69512137"); + return value; +} +static NSString *PushyPpkDeltaSuffix(void) { + static NSString *value = RCTPushyRevealText("7407e4dae09b69512137"); + return value; +} + +static NSString *PushyArchiveExtension(PushyType type) { + switch (type) { + case PushyTypePatchFromPackage: + return PushyIpaDeltaSuffix(); + case PushyTypePatchFromPpk: + return PushyPpkDeltaSuffix(); + case PushyTypeFullDownload: + break; + } + return @".ppk"; +} + +PUSHY_HIDDEN dispatch_queue_t PushyFileQueue(void) { + // One process-wide serial queue: JS calls (from any bridge instance; a + // reload can briefly keep two alive) and the native round share it, so + // destructive file work (resetToPackagedBundle's full cleanup) stays + // serialized with every unzip/patch job. + static dispatch_queue_t queue = + dispatch_queue_create("cn.reactnative.pushy.file", DISPATCH_QUEUE_SERIAL); + return queue; +} + +// nil when the volume holding `path` can take `bytesToWrite` plus the safety +// margin (or the free space is unknown); a PATCH_FAILED error otherwise. The +// probe is the downloader's (RCTPushyFreeSpaceShortfall), which applies the +// same rule to the announced Content-Length before the first byte lands. +static NSError *PushyEnsureFreeSpace(NSString *path, long long bytesToWrite) { + NSString *shortfall = RCTPushyFreeSpaceShortfall(path, bytesToWrite); + if (shortfall == nil) { + return nil; + } + return PushyErrorWithCode(pushy::error_codes::kDeltaFailed, shortfall); +} + +// SSZipArchive delegate feeding cpp/patch_core/archive_limits.h's +// EntryAccumulator while the archive is walked (entry count, sizes, ratios, +// symlinks: SSZipArchive materialises them with any target regardless of +// preserveAttributes, so `assets -> ../../Documents` followed by `assets/x` +// would write outside the version directory). The entry name is only exposed +// once the entry is on disk, so the reserved `.pushy-` names (the completion +// record is the SDK's alone) are checked in the did-unzip callback. +// A NO from zipArchiveShouldUnzipFileAtIndex: cancels the whole unzip (the +// 2.x contract the podspec pins), and the completion handler turns the +// recorded violation into a hard PATCH_FAILED; the caller then discards the +// staging directory. +@interface PushyUnzipGuard : NSObject +@property (nonatomic, copy) NSString *violation; +- (instancetype)initWithArchiveBytes:(long long)archiveBytes destination:(NSString *)destination; +@end + +@implementation PushyUnzipGuard { + std::unique_ptr _accumulator; + NSArray *_destinationComponents; +} + +- (instancetype)initWithArchiveBytes:(long long)archiveBytes destination:(NSString *)destination { + if ((self = [super init])) { + _accumulator.reset(new pushy::archive_limits::EntryAccumulator(archiveBytes)); + // Joined the way SSZipArchive builds each unpacked path + // (stringByAppendingPathComponent:), so a trailing slash compares equal. + NSArray *probe = + [destination stringByAppendingPathComponent:@"x"].pathComponents; + _destinationComponents = [probe subarrayWithRange:NSMakeRange(0, probe.count - 1)]; + } + return self; +} + +- (void)recordStatus:(const pushy::delta::Status &)status { + if (!status.ok && self.violation == nil) { + self.violation = @(status.message.c_str()); + } +} + +- (void)zipArchiveWillUnzipArchiveAtPath:(NSString *)path zipInfo:(unz_global_info)zipInfo { + [self recordStatus:pushy::archive_limits::CheckEntryCount((long long)zipInfo.number_entry)]; +} + +- (BOOL)zipArchiveShouldUnzipFileAtIndex:(NSInteger)fileIndex + totalFiles:(NSInteger)totalFiles + archivePath:(NSString *)archivePath + fileInfo:(unz_file_info)fileInfo { + if (self.violation != nil) { + return NO; + } + // Unix mode bits travel in the high 16 bits of the external attributes + // (the same field SSZipArchive keys its own symlink branch on). + BOOL symlink = ((fileInfo.external_fa >> 16) & S_IFMT) == S_IFLNK; + [self recordStatus:_accumulator->Accept((long long)fileInfo.uncompressed_size, + (long long)fileInfo.compressed_size, + symlink, std::string())]; + return self.violation == nil; +} + +// A reserved name is flagged here (any component of the path below the +// destination); the next shouldUnzip refuses (cancelling the unzip) or, for +// the last entry, the completion handler picks the violation up. Either way +// the caller drops the whole staging directory, so the written file never +// survives. +- (void)zipArchiveDidUnzipFileAtIndex:(NSInteger)fileIndex + totalFiles:(NSInteger)totalFiles + archivePath:(NSString *)archivePath + unzippedFilePath:(NSString *)unzippedFilePath { + if (self.violation != nil) { + return; + } + NSArray *components = unzippedFilePath.pathComponents; + NSUInteger base = _destinationComponents.count; + if (components.count <= base + || ![[components subarrayWithRange:NSMakeRange(0, base)] + isEqualToArray:_destinationComponents]) { + self.violation = [NSString stringWithFormat:@"archive entry #%ld outside the destination", + (long)fileIndex]; + return; + } + NSString *name = [[components subarrayWithRange:NSMakeRange(base, components.count - base)] + componentsJoinedByString:@"/"]; + if (pushy::archive_limits::IsReservedEntryName(PushyToStdString(name))) { + self.violation = [NSString stringWithFormat:@"archive contains reserved entry %@", name]; + } +} + +@end + +// JS (any bridge instance) and the native round share one download per target +// hash. Without this process-wide registry two transfers race over the same +// archive path and the later unzip can delete the first task's valid output. +static NSMutableDictionary *PushyInFlightDownloads(void) { + static NSMutableDictionary *downloads = + [NSMutableDictionary dictionary]; + return downloads; +} + +typedef NS_ENUM(NSInteger, PushyDownloadRegistration) { + PushyDownloadRegistrationOwner, + PushyDownloadRegistrationJoined, + PushyDownloadRegistrationDeferred, +}; + +static PushyDownloadRegistration PushyRegisterDownload( + NSString *hash, + NSInteger type, + NSTimeInterval deadlineUptime, + void (^callback)(NSError *), + void (^progress)(long long, long long), + void (^deferredStart)(void) +) { + NSMutableDictionary *downloads = PushyInFlightDownloads(); + @synchronized (downloads) { + NSMutableDictionary *entry = downloads[hash]; + if (entry != nil) { + if ([entry[@"type"] integerValue] == type) { + NSTimeInterval ownerDeadline = [entry[@"deadlineUptime"] doubleValue]; + // A caller with substantially more time must not inherit an + // owner's nearly-exhausted timeout: it observes the current + // transfer and restarts after the owner settles (the + // completion-marker preflight makes a successful owner free). + // The comparison is on remaining budget, not on the absolute + // deadline: a JS caller always computes now+600 a few seconds + // after the owner did, so a strict `>` would defer every + // second caller and turn the shared download back into + // serialized re-downloads. Only a genuinely starved owner + // (less than half the newcomer's budget left) defers. + const NSTimeInterval now = PushyMonotonicNow(); + if (2 * (ownerDeadline - now) < (deadlineUptime - now)) { + if (progress != nil) { + [entry[@"progress"] addObject:[progress copy]]; + } + [entry[@"deferred"] addObject:[deferredStart copy]]; + return PushyDownloadRegistrationDeferred; + } + [entry[@"callbacks"] addObject:[callback copy]]; + if (progress != nil) { + [entry[@"progress"] addObject:[progress copy]]; + } + return PushyDownloadRegistrationJoined; + } + // A diff failure must not settle a joined full request. Queue the + // different artifact type behind the owner; once restarted it + // registers normally (and re-checks the completion marker). + if (progress != nil) { + // The artifact type differs, but it still installs the same + // target hash. Keep the waiting JS UI moving while its own + // transfer is queued behind the current owner. + [entry[@"progress"] addObject:[progress copy]]; + } + [entry[@"deferred"] addObject:[deferredStart copy]]; + return PushyDownloadRegistrationDeferred; + } + NSMutableArray *progressHandlers = [NSMutableArray array]; + if (progress != nil) { + [progressHandlers addObject:[progress copy]]; + } + downloads[hash] = [@{ + @"type": @(type), + @"deadlineUptime": @(deadlineUptime), + @"callbacks": [NSMutableArray arrayWithObject:[callback copy]], + @"progress": progressHandlers, + @"deferred": [NSMutableArray array], + } mutableCopy]; + return PushyDownloadRegistrationOwner; + } +} + +static void PushyReportDownloadProgress( + NSString *hash, long long received, long long total +) { + NSMutableDictionary *downloads = PushyInFlightDownloads(); + NSArray *handlers = nil; + @synchronized (downloads) { + handlers = [downloads[hash][@"progress"] copy]; + } + for (id value in handlers) { + void (^handler)(long long, long long) = + (void (^)(long long, long long))value; + handler(received, total); + } +} + +static void PushyFinishDownload(NSString *hash, NSError *error) { + NSMutableDictionary *downloads = PushyInFlightDownloads(); + NSArray *callbacks = nil; + NSArray *deferred = nil; + @synchronized (downloads) { + callbacks = [downloads[hash][@"callbacks"] copy]; + deferred = [downloads[hash][@"deferred"] copy]; + [downloads removeObjectForKey:hash]; + } + // Establish the next (different-type) owner before waking the completed + // owner's callbacks; otherwise its strategy loop could race the waiter. + for (id value in deferred) { + void (^start)(void) = (void (^)(void))value; + start(); + } + for (id value in callbacks) { + void (^callback)(NSError *) = (void (^)(NSError *))value; + callback(error); + } +} + +// Everything under rctpushy is re-downloadable, and Application Support is +// backed up to iCloud by default — Apple requires such content to be +// excluded from backups. +static void PushyExcludeFromBackup(NSString *path) { +#if !TARGET_OS_TV // tvOS stores under Caches, which is never backed up. + NSURL *url = [NSURL fileURLWithPath:path isDirectory:YES]; + NSError *error = nil; + if (![url setResourceValue:@YES + forKey:NSURLIsExcludedFromBackupKey + error:&error]) { + RCTLogWarn(@"Pushy exclude from backup error: %@", error.localizedDescription); + } +#endif +} + +static BOOL PushyEnsureDirectoryExists(NSString *path) { + // No file-queue hop here: that queue also runs multi-second unzip/patch + // work, and a dispatch_sync onto it would block the whole module method + // queue for the duration. createDirectoryAtPath is idempotent and + // thread-safe, so checking inline is fine. + NSFileManager *fileManager = [NSFileManager defaultManager]; + BOOL isDirectory = NO; + if ([fileManager fileExistsAtPath:path isDirectory:&isDirectory]) { + if (isDirectory) { + // Directories created by older versions never got the flag. + PushyExcludeFromBackup(path); + } + return isDirectory; + } + + NSError *error = nil; + BOOL success = [fileManager createDirectoryAtPath:path + withIntermediateDirectories:YES + attributes:nil + error:&error]; + if (!success && error != nil) { + RCTLogWarn(@"Pushy create directory error: %@", error.localizedDescription); + } + if (success) { + PushyExcludeFromBackup(path); + } + + return success; +} + +@implementation RCTPushyInstaller + ++ (void)fetch:(PushyType)type + options:(NSDictionary *)options + progress:(void (^)(long long, long long))progress + callback:(void (^)(NSError *error))callback +{ + NSString *updateUrl = PushyOptionString(options, PushyOptionUrlKey()); + NSString *hash = PushyOptionString(options, @"hash"); + + if (PushyStringIsBlank(updateUrl) || !PushyIsSafePathComponent(hash)) { + callback(PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS)); + return; + } + NSString *originHash = PushyOptionString(options, @"originHash"); + if (type == PushyTypePatchFromPpk && !PushyIsSafePathComponent(originHash)) { + callback(PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS)); + return; + } + + NSString *dir = [RCTPushy downloadDir]; + if (!PushyEnsureDirectoryExists(dir)) { + callback(PushyErrorWithCode(pushy::error_codes::kFileOperationFailed, ERROR_FILE_OPERATION)); + return; + } + + if (PushyHasCompletedVersionAtPath(PushyVersionDir(hash), hash)) { + callback(nil); + return; + } + + NSTimeInterval deadlineUptime = PushyMonotonicNow() + 600; + NSNumber *configuredDeadline = options[@"deadlineUptime"]; + if ([configuredDeadline isKindOfClass:[NSNumber class]]) { + deadlineUptime = configuredDeadline.doubleValue; + } + NSError *(^deadlineExpired)(void) = ^NSError * { + return PushyErrorWithCode(pushy::error_codes::kDownloadFailed, + @"download deadline expired before start"); + }; + if (deadlineUptime <= PushyMonotonicNow()) { + callback(deadlineExpired()); + return; + } + + void (^deferredStart)(void) = ^{ + [self fetch:type options:options progress:progress callback:callback]; + }; + PushyDownloadRegistration registration = PushyRegisterDownload( + hash, type, deadlineUptime, callback, progress, deferredStart); + if (registration != PushyDownloadRegistrationOwner) { + RCTLogInfo( + @"RCTPushy -- %@ in-flight download for %@", + registration == PushyDownloadRegistrationJoined ? @"join" : @"defer", + hash); + return; + } + + NSString *zipFilePath = [dir stringByAppendingPathComponent: + [hash stringByAppendingString:PushyArchiveExtension(type)]]; + // SHA-256 of the downloaded archive for the completion record. Written and + // read on the serial file queue only: taken before the unzip consumes the + // file, consumed by the completion block after it. + __block NSString *artifactSha256 = nil; + + // Settles every JS/native waiter only after the staging directory was + // promoted (or dropped) on the process-wide file queue. On failure only + // the staging directory is removed, like Android/Harmony: a half-unzipped + // or half-patched dir leaks disk and could later be mistaken for a + // complete version, while the final version directory is never touched + // by a failed install. hash is validated above, so this can never resolve + // to the download root itself. + void (^completion)(NSError *) = ^(NSError *error) { + dispatch_async(PushyFileQueue(), ^{ + NSError *finalError = error ?: PushyPromoteStagingDir(hash, artifactSha256); + if (finalError != nil) { + [[NSFileManager defaultManager] removeItemAtPath:PushyStagingDir(hash) error:nil]; + } + PushyFinishDownload(hash, finalError); + }); + }; + + RCTLogInfo(@"RCTPushy -- download file %@", updateUrl); + NSTimeInterval timeoutSeconds = deadlineUptime - PushyMonotonicNow(); + if (timeoutSeconds <= 0) { + completion(deadlineExpired()); + return; + } + [RCTPushyDownloader download:updateUrl + savePath:zipFilePath + timeoutInterval:timeoutSeconds + progressHandler:^(long long receivedBytes, long long totalBytes) { + PushyReportDownloadProgress(hash, receivedBytes, totalBytes); + } completionHandler:^(NSString *path, NSError *error) { + if (error != nil) { + completion(error); + return; + } + dispatch_async(PushyFileQueue(), ^{ + artifactSha256 = PushyFromStdString( + pushy::digest::Sha256File(PushyToStdString(zipFilePath))); + }); + [self unzipDownloadedPackage:zipFilePath + hash:hash + type:type + originHash:originHash + callback:completion]; + }]; +} + ++ (void)unzipDownloadedPackage:(NSString *)zipFilePath + hash:(NSString *)hash + type:(PushyType)type + originHash:(NSString *)originHash + callback:(void (^)(NSError *error))callback +{ + RCTLogInfo(@"RCTPushy -- unzip file %@", zipFilePath); + // Everything lands in .staging; the completion block promotes it. + [self unzipFileAtPath:zipFilePath + toDestination:PushyStagingDir(hash) + completionHandler:^(NSError *error) { + dispatch_async(PushyFileQueue(), ^{ + if (error != nil) { + callback(error); + return; + } + @try { + [self finishDownloadedPackage:hash type:type originHash:originHash callback:callback]; + } @catch (NSException *exception) { + // An uncaught exception in a GCD block is fatal (and would + // trip the crash rescue); a bad package is a PATCH_FAILED. + callback(PushyErrorWithCode(pushy::error_codes::kDeltaFailed, + exception.reason ?: @"delta failed")); + } + }); + }]; +} + ++ (void)finishDownloadedPackage:(NSString *)hash + type:(PushyType)type + originHash:(NSString *)originHash + callback:(void (^)(NSError *error))callback +{ + switch (type) { + case PushyTypePatchFromPackage: + callback([self applyDeltaForHash:hash + type:type + fromBundle:[[RCTPushy binaryBundleURL] path] + source:[[NSBundle mainBundle] resourcePath]]); + return; + case PushyTypePatchFromPpk: { + NSString *lastVersionDir = PushyVersionDir(originHash); + callback([self applyDeltaForHash:hash + type:type + fromBundle:[lastVersionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME] + source:lastVersionDir]); + return; + } + case PushyTypeFullDownload: + callback(nil); + return; + } +} + +// Applies the unpacked patch in the staging directory (two-phase install); +// nil on success, a PATCH_FAILED error otherwise. __diff.json is handed to +// the patch core as text (cpp/patch_core/patch_manifest.h parses it). ++ (NSError *)applyDeltaForHash:(NSString *)hash + type:(PushyType)type + fromBundle:(NSString *)bundleOrigin + source:(NSString *)sourceOrigin +{ + NSString *unzipDir = PushyStagingDir(hash); + NSString *sourcePatch = [unzipDir stringByAppendingPathComponent:SOURCE_PATCH_NAME]; + NSString *bundlePatch = [unzipDir stringByAppendingPathComponent:BUNDLE_PATCH_NAME()]; + NSFileManager *fileManager = [NSFileManager defaultManager]; + + pushy::archive_patch::ArchivePatchRequest request; + request.archive.type = type == PushyTypePatchFromPackage + ? pushy::archive_patch::ArchivePatchType::kPatchFromPackage + : pushy::archive_patch::ArchivePatchType::kPatchFromPpk; + if ([fileManager fileExistsAtPath:sourcePatch isDirectory:NULL]) { + long long manifestBytes = RCTPushyFileSize(sourcePatch); + if (manifestBytes > pushy::archive_limits::kMaxManifestBytes) { + return PushyErrorWithCode(pushy::error_codes::kDeltaFailed, + [NSString stringWithFormat:@"delta manifest too large: %lld bytes", manifestBytes]); + } + NSData *data = [NSData dataWithContentsOfFile:sourcePatch]; + if (data == nil) { + return PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @"unreadable delta manifest"); + } + request.archive.entry_names.push_back(PushyToStdString(SOURCE_PATCH_NAME)); + request.archive.manifest_json.assign(static_cast(data.bytes), data.length); + } + if ([fileManager fileExistsAtPath:bundlePatch isDirectory:NULL]) { + request.archive.entry_names.push_back(PushyToStdString(BUNDLE_PATCH_NAME())); + } + request.source_root = PushyToStdString(sourceOrigin); + request.target_root = PushyToStdString(unzipDir); + request.origin_bundle_path = PushyToStdString(bundleOrigin); + request.bundle_patch_path = PushyToStdString(bundlePatch); + request.bundle_output_path = PushyToStdString( + [unzipDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]); + + pushy::delta::Status status = pushy::archive_patch::ApplyArchivePatch(request, nullptr); + if (!status.ok) { + return PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @(status.message.c_str())); + } + return nil; +} + ++ (void)unzipFileAtPath:(NSString *)path + toDestination:(NSString *)destination + completionHandler:(void (^)(NSError *error))completionHandler +{ + dispatch_async(PushyFileQueue(), ^{ + NSFileManager *fileManager = [NSFileManager defaultManager]; + if ([fileManager fileExistsAtPath:destination]) { + [fileManager removeItemAtPath:destination error:nil]; + } + + // Resource caps before the first byte is extracted + // (cpp/patch_core/archive_limits.h). SSZipArchive only exposes the + // entry count up front (zipArchiveWillUnzipArchiveAtPath:zipInfo:), + // not the uncompressed total, so the disk check uses a 2x-archive + // heuristic; the guard enforces the exact caps entry by entry. + long long archiveBytes = RCTPushyFileSize(path); + NSError *preflight = nil; + if (archiveBytes > pushy::archive_limits::kMaxArchiveBytes) { + preflight = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, + [NSString stringWithFormat:@"archive too large: %lld bytes", archiveBytes]); + } else { + preflight = PushyEnsureFreeSpace(destination, MAX(0LL, archiveBytes) * 2); + } + if (preflight != nil) { + RCTPushyDiscardArchive(path); + completionHandler(preflight); + return; + } + + PushyUnzipGuard *guard = [[PushyUnzipGuard alloc] initWithArchiveBytes:archiveBytes + destination:destination]; + // preserveAttributes:NO — the archive's mode/mtime bits have no use + // here and are one less thing a hostile package controls. + [SSZipArchive unzipFileAtPath:path + toDestination:destination + preserveAttributes:NO + overwrite:YES + nestedZipLevel:0 + password:nil + error:nil + delegate:guard + progressHandler:nil + completionHandler:^(NSString *archivePath, BOOL succeeded, NSError *error) { + // The resume sidecar dies with its archive (§11.4): whether the + // unzip consumed it or classified it as poisoned, nothing must + // survive to vouch for bytes that are gone. + RCTPushyDiscardArchive(archivePath); + + NSError *unzipError = error; + if (guard.violation != nil) { + unzipError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, guard.violation); + } else if (!succeeded && unzipError == nil) { + unzipError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @"unzip failed"); + } else if (unzipError != nil && unzipError.userInfo[RCTPushyErrorCodeKey] == nil) { + // SSZipArchive's own NSError (corrupt zip, bad magic, ...) has + // no stable code; without one, fetchPackage's fallback would + // classify it as DOWNLOAD_FAILED even though the download + // succeeded — keep the classification deterministic. + unzipError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, + unzipError.localizedDescription ?: @"unzip failed"); + } + completionHandler(unzipError); + }]; + }); +} + ++ (BOOL)switchVersion:(NSString *)hash error:(NSError **)error +{ + if (!PushyIsSafePathComponent(hash)) { + if (error != NULL) { + *error = PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS); + } + return NO; + } + + // Same gate as Android and Harmony (install_record::CheckActivation): + // only a version this SDK recorded as completely installed, whose + // record's bundle digest matches the bytes on disk, may be activated. + // Versions activated before records existed are grandfathered through + // current/last state, but only while their directory has no record file. + std::string versionDir = PushyToStdString(PushyVersionDir(hash)); + std::string hashStd = PushyToStdString(hash); + std::string bundleName = PushyToStdString(BUNDLE_FILE_NAME); + BOOL (^isCurrentOrLast)(void) = ^BOOL { + pushy::state::State state = PushyStateFromDefaults(PushyDefaults()); + return state.current_version == hashStd || state.last_version == hashStd; + }; + + // The digest check reads the whole bundle, so it runs OUTSIDE the state + // lock: +bundleURL takes that lock on the main thread at launch. + __block BOOL grandfathered = NO; + PushyWithStateLock(^{ + grandfathered = isCurrentOrLast(); + }); + pushy::install_record::Activation activation; + pushy::install_record::Status verified = pushy::install_record::CheckActivation( + versionDir, hashStd, bundleName, grandfathered, &activation); + + __block NSError *lockedError = verified.ok + ? nil + : PushyErrorWithCode(pushy::error_codes::kSwitchVersionFailed, + PushyFromStdString(verified.message)); + if (lockedError == nil) { + PushyWithStateLock(^{ + // Re-check cheaply that the directory is still the one that was + // verified: same grandfathering, same record, same bundle file. + if (!pushy::install_record::Unchanged(versionDir, bundleName, isCurrentOrLast(), + activation)) { + lockedError = PushyErrorWithCode( + pushy::error_codes::kSwitchVersionFailed, + [NSString stringWithFormat:@"Bundle version %@ changed while switching.", hash]); + return; + } + PushySwitchVersionLocked(hash); + }); + } + if (lockedError != nil) { + if (error != NULL) { + *error = lockedError; + } + return NO; + } + return YES; +} + ++ (void)clearInvalidFiles +{ + dispatch_async(PushyFileQueue(), ^{ + // Snapshot the state under the lock, but run the (slow) filesystem + // cleanup outside of it so state operations are not blocked. + __block pushy::state::State state; + __block NSString *launchVersion = nil; + PushyWithStateLock(^{ + state = PushyStateFromDefaults(PushyDefaults()); + launchVersion = pushyLaunchVersion; + }); + // Keep the persisted current/last versions AND the version this + // process booted from: two switches without a restart would otherwise + // evict the running bundle while its on-demand assets are still + // being served. + pushy::delta::Status status = pushy::delta::CleanupOldEntries( + PushyToStdString([RCTPushy downloadDir]), + std::vector{ + state.current_version, + state.last_version, + PushyToStdString(launchVersion), + }, + 3 + ); + if (!status.ok) { + RCTLogWarn(@"Pushy cleanup error: %s", status.message.c_str()); + } + }); +} + +@end diff --git a/ios/RCTPushy/RCTPushyInternal.h b/ios/RCTPushy/RCTPushyInternal.h index 28c02a42..21835857 100644 --- a/ios/RCTPushy/RCTPushyInternal.h +++ b/ios/RCTPushy/RCTPushyInternal.h @@ -1,9 +1,20 @@ -// Internal to the RCTPushy pod: what RCTPushy.mm (module, install pipeline, -// launch resolution) and RCTPushyOrchestrator.mm (native round) share. Not a -// public header. +// Internal to the RCTPushy pod: what the module's translation units share. +// Not a public header; it pulls in C++ headers, so it must stay out of the +// module umbrella. +// +// RCTPushy.mm RN exports, constants, host API entry points +// RCTPushy+Launch.mm launch bundle resolution, paths, binary identity +// RCTPushyState.mm persisted keys, state lock, State <-> defaults, +// string/error/JSON helpers, bundle hash +// RCTPushyInstallRecord.mm version directories, completion record +// (over cpp/patch_core/install_record.h) +// RCTPushyInstaller.mm download/unzip/patch pipeline, switchVersion, +// cleanup (owns the process-wide file queue) +// RCTPushyOrchestrator.mm the native round #pragma once #import "RCTPushy.h" +#import "RCTPushyCommon.h" #import #import @@ -19,58 +30,109 @@ #define _RCTLog(lvl, ...) _RCTLogNativeInternal(lvl, __FILE_NAME__, __LINE__, __VA_ARGS__) #endif -// Shared across the pod's translation units, never exported from a dynamic -// framework. -#define PUSHY_HIDDEN __attribute__((visibility("hidden"))) - typedef NS_ENUM(NSInteger, PushyType) { PushyTypeFullDownload = 1, PushyTypePatchFromPackage = 2, PushyTypePatchFromPpk = 3, }; -@interface RCTPushy () -- (void)fetchPackage:(PushyType)type - options:(NSDictionary *)options - resolver:(RCTPromiseResolveBlock)resolve - rejecter:(RCTPromiseRejectBlock)reject; -- (void)performFetch:(PushyType)type - options:(NSDictionary *)options - callback:(void (^)(NSError *error))callback; -- (NSError *)restartBridgeWithReason:(NSString *)reason; -- (void)unzipDownloadedPackage:(NSString *)zipFilePath - hash:(NSString *)hash - type:(PushyType)type - originHash:(NSString *)originHash - callback:(void (^)(NSError *error))callback; -- (void)finishDownloadedPackage:(NSString *)hash - type:(PushyType)type - originHash:(NSString *)originHash - callback:(void (^)(NSError *error))callback; -- (void)applyDeltaForHash:(NSString *)hash - type:(PushyType)type - fromBundle:(NSString *)bundleOrigin - source:(NSString *)sourceOrigin - callback:(void (^)(NSError *error))callback; -- (BOOL)switchVersion:(NSString *)hash error:(NSError **)error; -- (BOOL)ensureDirectoryExistsAtPath:(NSString *)path; -+ (void)excludeFromBackup:(NSString *)path; -- (void)unzipFileAtPath:(NSString *)path - toDestination:(NSString *)destination - completionHandler:(void (^)(NSError *error))completionHandler; +#pragma mark - RCTPushyState.mm + +extern PUSHY_HIDDEN NSString *const keyPushyInfo; +extern PUSHY_HIDDEN NSString *const paramCurrentVersion; +extern PUSHY_HIDDEN NSString *const keyUuid; +extern PUSHY_HIDDEN NSString *const keyHashInfo; +extern PUSHY_HIDDEN NSString *const keyFirstLoadMarked; +extern PUSHY_HIDDEN NSString *const keyRolledBackMarked; +extern PUSHY_HIDDEN NSString *const keyNativeCheckCache; +extern PUSHY_HIDDEN NSString *const ERROR_OPTIONS; +// Consumed by the first +bundleURL of a process; re-armed by a version switch. +extern PUSHY_HIDDEN std::atomic ignoreRollback; +// Bumped by resetToPackagedBundle only; see RCTPushyState.mm. +extern PUSHY_HIDDEN std::atomic pushyResetGeneration; + +PUSHY_HIDDEN NSString *KeyPackageUpdatedMarked(void); +PUSHY_HIDDEN NSString *PushyHashInfoKey(NSString *hash); +// Immutable host-facing snapshot; activated always means NEXT launch. +PUSHY_HIDDEN NSDictionary *PushyHostResult(NSString *status, NSString *reason, + NSString *hash = nil, BOOL activated = NO); +PUSHY_HIDDEN NSTimeInterval PushyMonotonicNow(void); +PUSHY_HIDDEN NSUserDefaults *PushyDefaults(void); +PUSHY_HIDDEN void PushyWithStateLock(void (NS_NOESCAPE ^block)(void)); +PUSHY_HIDDEN std::string PushyToStdString(NSString *value); +PUSHY_HIDDEN NSString *PushyFromStdString(const std::string &value); +PUSHY_HIDDEN NSString *PushyOptionString(NSDictionary *options, NSString *key); +PUSHY_HIDDEN BOOL PushyStringIsBlank(NSString *value); +PUSHY_HIDDEN BOOL PushyIsSafePathComponent(NSString *value); +PUSHY_HIDDEN NSError *PushyErrorWithCode(const char *code, NSString *message); +PUSHY_HIDDEN void PushyRejectError(RCTPromiseRejectBlock reject, NSError *error); +// The parsed JSON object in `json` (NSString or NSData); nil when it does not +// parse or is not an object. *error carries the parse error, if any. +PUSHY_HIDDEN NSDictionary *PushyJsonDictionary(id json, NSError **error); +// `object` serialized as a JSON string; nil when it cannot be. +PUSHY_HIDDEN NSString *PushyJsonString(id object); +PUSHY_HIDDEN pushy::state::State PushyStateFromDefaults(NSUserDefaults *defaults); +PUSHY_HIDDEN void PushyApplyStateToDefaults(NSUserDefaults *defaults, + const pushy::state::State &state); +PUSHY_HIDDEN void PushySwitchVersionLocked(NSString *hash); +PUSHY_HIDDEN NSString *PushyBundleHashSync(BOOL computeIfMissing); + +#pragma mark - RCTPushyInstallRecord.mm + +extern PUSHY_HIDDEN NSString *const BUNDLE_FILE_NAME; + +PUSHY_HIDDEN NSString *PushyVersionDir(NSString *hash); +PUSHY_HIDDEN NSString *PushyStagingDir(NSString *hash); +PUSHY_HIDDEN BOOL PushyHasCompletedVersionAtPath(NSString *versionDir, NSString *hash); +PUSHY_HIDDEN NSString *PushyRecordedBundleSha256(NSString *versionDir); +PUSHY_HIDDEN NSError *PushyPromoteStagingDir(NSString *hash, NSString *artifactSha256); + +#pragma mark - RCTPushyInstaller.mm + +// Protocol key of the artifact URL in download options. +PUSHY_HIDDEN NSString *PushyOptionUrlKey(void); +// Process-wide serial queue for every destructive file operation (unzip, +// patch, promote, cleanup), shared by JS calls and the native round. +PUSHY_HIDDEN dispatch_queue_t PushyFileQueue(void); + +// The download → unzip → patch → install pipeline and version activation. No +// bridge needed: JS calls and the native round share it. +@interface RCTPushyInstaller : NSObject +// options: url (PushyOptionUrlKey), hash, originHash (diff only), optional +// deadlineUptime. progress may be nil. callback runs on the file queue, or +// synchronously when nothing has to be downloaded (invalid options, version +// already installed, deadline already passed). ++ (void)fetch:(PushyType)type + options:(NSDictionary *)options + progress:(void (^)(long long received, long long total))progress + callback:(void (^)(NSError *error))callback; ++ (BOOL)switchVersion:(NSString *)hash error:(NSError **)error; ++ (void)clearInvalidFiles; +@end + +#pragma mark - RCTPushy+Launch.mm + +// The version whose bundle this process actually loaded; guarded by the state +// lock. See RCTPushy+Launch.mm. +extern PUSHY_HIDDEN NSString *pushyLaunchVersion; + +@interface RCTPushy (Launch) ++ (NSURL *)resolveLaunchBundleURL:(NSString **)launchRolledBackVersionOut + purgedVersion:(NSString **)purgedVersionOut; + (NSString *)downloadDir; + (NSURL *)binaryBundleURL; + (NSString *)packageVersion; + (NSString *)buildTime; @end +#pragma mark - RCTPushyOrchestrator.mm + // Native cold-start update check (NATIVE_CHECKUPDATE_DESIGN §10): runs once // per process, a few seconds after launch, entirely independent of the app // bundle — this is what lets a bricked hot update be replaced on the next // launch. Decisions come from cpp/update_flow_core; this class is IO glue. @interface RCTPushyOrchestrator : NSObject + (void)persistConfiguration:(NSString *)config; -+ (BOOL)hasRunnableConfig; + (NSDictionary *)prepareBundle; + (NSDictionary *)prepareBundleWithin:(NSTimeInterval)timeout; + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion; @@ -79,39 +141,4 @@ typedef NS_ENUM(NSInteger, PushyType) { rolledBack:(NSString *)launchRolledBackVersion; #endif + (void)recordJsRound:(NSString *)config; -+ (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime; -+ (BOOL)claimRound; -+ (void)runClaimedRoundWithDeadline:(NSTimeInterval)deadlineUptime; -+ (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)deadlineUptime; -+ (void)runHoldRoundWithDeadline:(NSTimeInterval)deadlineUptime; -+ (BOOL)commitRoundWithGeneration:(uint64_t)generation - configGeneration:(uint64_t)configGeneration - hashInfo:(NSDictionary *)hashInfoEntry - activate:(NSString *)hashToActivate - responseText:(NSString *)responseText - request:(NSString *)requestBody - config:(NSString *)configJson - responseAt:(long long)responseAtSeconds - activated:(BOOL *)activatedOut; @end - -extern PUSHY_HIDDEN NSString *const keyUuid; -extern PUSHY_HIDDEN NSString *const keyNativeConfig; -extern PUSHY_HIDDEN NSString *const keyNativeCheckCache; -extern PUSHY_HIDDEN NSString *const keyNativeCheckIncomplete; -extern PUSHY_HIDDEN std::atomic pushyResetGeneration; -PUSHY_HIDDEN NSDictionary *PushyHostResult(NSString *status, NSString *reason, NSString *hash, BOOL activated); -PUSHY_HIDDEN NSTimeInterval PushyMonotonicNow(void); -PUSHY_HIDDEN std::string PushyToStdString(NSString *value); -PUSHY_HIDDEN NSString *PushyFromStdString(const std::string &value); -PUSHY_HIDDEN NSUserDefaults *PushyDefaults(void); -PUSHY_HIDDEN void PushyWithStateLock(void (NS_NOESCAPE ^block)(void)); -PUSHY_HIDDEN NSString *PushyHashInfoKey(NSString *hash); -PUSHY_HIDDEN BOOL PushyIsSafePathComponent(NSString *value); -PUSHY_HIDDEN BOOL PushyHasCompletedVersionAtPath(NSString *versionDir, NSString *hash); -PUSHY_HIDDEN void PushySwitchVersionLocked(NSString *hash); -PUSHY_HIDDEN NSString *PushyBundleHashSync(BOOL computeIfMissing); -PUSHY_HIDDEN NSString *PushyOptionUrlKey(void); -PUSHY_HIDDEN NSString *PushyRevealOnce(NSString *__strong *slot, dispatch_once_t *once, const char *hex); - -PUSHY_HIDDEN pushy::state::State PushyStateFromDefaults(NSUserDefaults *defaults); diff --git a/ios/RCTPushy/RCTPushyOrchestrator.mm b/ios/RCTPushy/RCTPushyOrchestrator.mm index 7ebcdee4..67b0972a 100644 --- a/ios/RCTPushy/RCTPushyOrchestrator.mm +++ b/ios/RCTPushy/RCTPushyOrchestrator.mm @@ -1,33 +1,39 @@ #import "RCTPushyInternal.h" -#import "RCTPushyConfiguration.h" #include "../../cpp/patch_core/error_codes.h" -#include "../../cpp/patch_core/hbc_transform_wire.h" #include "../../cpp/update_flow_core/flow_json.h" +#include "../../cpp/update_flow_core/native_config.h" +#include "../../cpp/update_flow_core/native_round.h" #include "../../cpp/update_flow_core/update_flow_core.h" -#include #if TARGET_OS_TV #import #include #include #endif -// Version-info flags and the no-op status read by JS, stored encoded -// (RCTPushyRevealText) so they do not appear as plain binary strings. +// Raw JSON persisted by JS (syncNativeConfig) or the host (configure) for the +// native round; parsed on read (PushyLoadRunnableConfig). Absent = no round. +static NSString *const keyNativeConfig = @"REACTNATIVECN_PUSHY_NATIVE_CONFIG_KEY"; +// Set when a native check round starts, cleared when it ends (§11.4). Residue +// on the next launch means the previous process died mid-round (a crash +// rescue was truncated): that launch resumes immediately instead of waiting. +static NSString *const keyNativeCheckIncomplete = @"REACTNATIVECN_PUSHY_NATIVE_CHECK_INCOMPLETE_KEY"; + +// The crash-hold version-info flag and the no-op status read by JS, stored +// encoded (RCTPushyRevealText) so they do not appear as plain binary strings. static NSString *PushyInfoCrashHold(void) { - static NSString *value; - static dispatch_once_t once; - return PushyRevealOnce(&value, &once, "3905f5c2a6b96d56212a19"); -} -static NSString *PushyInfoForceBoot(void) { - static NSString *value; - static dispatch_once_t once; - return PushyRevealOnce(&value, &once, "3c18e6d2aba9674a360d19ead5a695"); + static NSString *value = RCTPushyRevealText("3905f5c2a6b96d56212a19"); + return value; } static NSString *PushyStatusNone(void) { - static NSString *value; - static dispatch_once_t once; - return PushyRevealOnce(&value, &once, "3418c1c1aa8a7c40"); + static NSString *value = RCTPushyRevealText("3418c1c1aa8a7c40"); + return value; +} + +// YES when `semaphore` was signalled within `seconds`. +static BOOL PushyWaitSeconds(dispatch_semaphore_t semaphore, NSTimeInterval seconds) { + return dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(seconds * NSEC_PER_SEC))) == 0; } // cInfo.os label prefix; must match the JS side (core.ts) so the server @@ -130,7 +136,7 @@ static BOOL PushyHasNetworkPath(void) { } }); nw_path_monitor_start(monitor); - dispatch_semaphore_wait(answered, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC / 2)); + PushyWaitSeconds(answered, 0.5); nw_path_monitor_cancel(monitor); __block nw_path_status_t result; dispatch_sync(queue, ^{ @@ -143,6 +149,10 @@ static BOOL PushyHasNetworkPath(void) { // past the longest round (8 query requests, then two 600s download phases). static const NSTimeInterval kPushyHostPrepareDefaultTimeout = 1800; +@interface RCTPushyOrchestrator () ++ (void)runHoldRoundWithDeadline:(NSTimeInterval)deadlineUptime; +@end + static void PushyMaybeHoldProcess(void) { // Once per process; a second crashing thread passes straight through // instead of waiting behind the first (§11.3: prefer under-rescuing over @@ -179,8 +189,7 @@ static void PushyMaybeHoldProcess(void) { } dispatch_semaphore_signal(done); }); - if (dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, - (int64_t)(budget * NSEC_PER_SEC))) != 0) { + if (!PushyWaitSeconds(done, budget)) { NSLog(@"RCTPushy -- crash hold: budget exhausted, letting go"); } } @@ -203,11 +212,12 @@ static void PushyCrashHoldExceptionHandler(NSException *exception) { } static void PushyInstallCrashHoldHandler(void) { - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ + static const bool installed = [] { pushyPreviousExceptionHandler = NSGetUncaughtExceptionHandler(); NSSetUncaughtExceptionHandler(&PushyCrashHoldExceptionHandler); - }); + return true; + }(); + (void)installed; } #pragma mark - native cold-start check orchestration @@ -216,7 +226,8 @@ static void PushyInstallCrashHoldHandler(void) { // it the POST body (appKey, uuid, versions) — leaves the device. The shared // session would follow first and only the response could be rejected; ATS // blocks plaintext by default, this covers apps that opted out with -// NSAllowsArbitraryLoads. Same rule as RCTPushyDownloader. Returning nil +// NSAllowsArbitraryLoads. Same rule as RCTPushyDownloader +// (RCTPushyIsHttpsDowngrade). Returning nil // delivers the 3xx itself as the final response, which the status check in // PushyHttpRequest then treats as a failed endpoint. @interface PushyRequestRedirectGuard : NSObject @@ -229,13 +240,7 @@ - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task newRequest:(NSURLRequest *)request completionHandler:(void (^)(NSURLRequest *))completionHandler { - NSString *originalScheme = [task.originalRequest.URL.scheme lowercaseString]; - NSString *nextScheme = [request.URL.scheme lowercaseString]; - if ([originalScheme isEqualToString:@"https"] && [nextScheme isEqualToString:@"http"]) { - completionHandler(nil); - return; - } - completionHandler(request); + completionHandler(RCTPushyIsHttpsDowngrade(task.originalRequest.URL, request.URL) ? nil : request); } @end @@ -244,14 +249,10 @@ - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task // sees the 3xx before it is followed; completion handlers still receive the // final response. Never invalidated: it lives as long as the process. static NSURLSession *PushyRequestSession(void) { - static NSURLSession *session; - static dispatch_once_t once; - dispatch_once(&once, ^{ - session = [NSURLSession - sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] - delegate:[PushyRequestRedirectGuard new] - delegateQueue:nil]; - }); + static NSURLSession *session = [NSURLSession + sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] + delegate:[PushyRequestRedirectGuard new] + delegateQueue:nil]; return session; } @@ -300,169 +301,41 @@ - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task dispatch_semaphore_signal(sem); }]; [task resume]; - if (dispatch_semaphore_wait( - sem, dispatch_time(DISPATCH_TIME_NOW, - (int64_t)((timeout + 5) * NSEC_PER_SEC))) != 0) { + if (!PushyWaitSeconds(sem, timeout + 5)) { [task cancel]; return nil; } return result; } -static NSString *PushyNormalizeEndpointBase(NSString *base) { - while ([base hasSuffix:@"/"]) { - base = [base substringToIndex:base.length - 1]; - } - return base; -} - -static BOOL PushyIsValidResponse(NSString *responseText) { - if (responseText == nil) { - return NO; - } - // Shared schema rule (update_flow_core::IsValidResponse): a 200 with - // `{"error": ...}` is a failed endpoint, not a verdict. - return flowcore::IsValidResponse(PushyToStdString(responseText)) ? YES : NO; -} - -// Canonical config equality, shared with the JS, Android and Harmony sides: -// two config JSON strings are equivalent iff their canonical forms deep-equal. -// Canonical form: the parsed object with absent keys defaulted -// (disabled:false, queryUrls:[], rnu:"", rn:"", afterDownload:"none") and the -// endpoints/queryUrls entries stripped of trailing '/' and deduplicated in -// first-occurrence order; key order is irrelevant. Unparseable is never -// equivalent. Lets a host-owned config (sorted keys) and the same config as JS -// serializes it count as one, instead of a "change" that cancels the round. -static BOOL PushyJsonIsBool(id value) { - return [value isKindOfClass:NSNumber.class] - && CFGetTypeID((__bridge CFTypeRef)value) == CFBooleanGetTypeID(); -} - -static BOOL PushyJsonEqual(id a, id b) { - if (PushyJsonIsBool(a) != PushyJsonIsBool(b)) { - return NO; // false is not 0 - } - if ([a isKindOfClass:NSDictionary.class] && [b isKindOfClass:NSDictionary.class]) { - if ([a count] != [b count]) { - return NO; - } - for (id key in (NSDictionary *)a) { - id other = ((NSDictionary *)b)[key]; - if (other == nil || !PushyJsonEqual(((NSDictionary *)a)[key], other)) { - return NO; - } - } - return YES; - } - if ([a isKindOfClass:NSArray.class] && [b isKindOfClass:NSArray.class]) { - if ([a count] != [b count]) { - return NO; - } - for (NSUInteger i = 0; i < [a count]; i++) { - if (!PushyJsonEqual(((NSArray *)a)[i], ((NSArray *)b)[i])) { - return NO; - } - } - return YES; - } - return [a isEqual:b]; -} - -static id PushyCanonicalUrlList(id value) { - if (![value isKindOfClass:NSArray.class]) { - return value; - } - NSMutableArray *result = [NSMutableArray array]; - for (id item in (NSArray *)value) { - id entry = [item isKindOfClass:NSString.class] ? PushyNormalizeEndpointBase(item) : item; - BOOL seen = NO; - for (id existing in result) { - if (PushyJsonEqual(existing, entry)) { - seen = YES; - break; - } - } - if (!seen) { - [result addObject:entry]; - } - } - return result; -} - -static NSDictionary *PushyCanonicalConfig(NSString *json) { - NSData *data = [json dataUsingEncoding:NSUTF8StringEncoding]; - id object = data == nil ? nil - : [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; - if (![object isKindOfClass:NSDictionary.class]) { - return nil; - } - NSMutableDictionary *config = [object mutableCopy]; - NSDictionary *defaults = @{@"disabled": @NO, @"queryUrls": @[], @"rnu": @"", - @"rn": @"", @"afterDownload": @"none"}; - for (NSString *key in defaults) { - if (config[key] == nil) { - config[key] = defaults[key]; - } - } - for (NSString *key in @[@"endpoints", @"queryUrls"]) { - if (config[key] != nil) { - config[key] = PushyCanonicalUrlList(config[key]); - } - } - return config; -} - +// Contract C1 (flowcore::ConfigsEquivalent, shared with Android and Harmony; +// JS has its own copy): the same config in another serialization — key order, +// trailing slashes, defaulted keys — is not a change. Lets a host-owned +// config and the same config as JS serializes it count as one, instead of a +// "change" that cancels the round. nil (no stored config) never matches. static BOOL PushyConfigsEquivalent(NSString *a, NSString *b) { - NSDictionary *left = a == nil ? nil : PushyCanonicalConfig(a); - NSDictionary *right = b == nil ? nil : PushyCanonicalConfig(b); - return left != nil && right != nil && PushyJsonEqual(left, right); + return a != nil && b != nil + && flowcore::ConfigsEquivalent(PushyToStdString(a), PushyToStdString(b)); } // The persisted config a round may run with. Every gate (crash-hold handler // install, round start, prepareBundle, the round itself) reads it through -// PushyLoadRunnableConfig, so they agree on what "configured" and "disabled" -// mean. disabled follows JS truthiness, like the flow core. -struct PushyRunnableConfig { - NSString *json; - flowjson::Value value; - NSString *appKey; -}; - -// YES when a round may run; otherwise *reason is not_configured, disabled or -// invalid_config. -static BOOL PushyLoadRunnableConfig(PushyRunnableConfig *config, NSString **reason) { - NSString *failure = nil; - NSString *json = [PushyDefaults() stringForKey:keyNativeConfig]; - config->json = [json copy]; - if (json.length == 0) { - failure = @"not_configured"; - } else { - bool ok = false; - flowjson::Value value = flowjson::Parse(PushyToStdString(json), &ok); - NSString *appKey = ok && value.IsObject() - ? PushyFromStdString(value.Get("appKey").AsString()) : nil; - if (!ok || !value.IsObject()) { - failure = @"invalid_config"; - } else if (value.Get("disabled").Truthy()) { - failure = @"disabled"; - } else if (appKey.length == 0) { - failure = @"invalid_config"; - } else { - config->value = value; - config->appKey = appKey; - } +// PushyLoadRunnableConfig, which applies the shared gate +// (flowcore::ParseRunnableConfig), so they agree on what "configured" and +// "disabled" mean. YES when a round may run (*json is the config text); +// otherwise *rejection is the host-facing result (skipped: +// not_configured/disabled, failed: invalid_config). +static BOOL PushyLoadRunnableConfig(NSString **json, NSDictionary **rejection) { + NSString *text = [PushyDefaults() stringForKey:keyNativeConfig]; + if (json != NULL) { + *json = [text copy]; } - if (reason != NULL) { - *reason = failure; + flowcore::RunnableConfig gate = flowcore::ParseRunnableConfig(PushyToStdString(text)); + if (!gate.runnable() && rejection != NULL) { + *rejection = PushyHostResult(PushyFromStdString(gate.status), + PushyFromStdString(gate.reason)); } - return failure == nil; -} - -// Host-facing result for a config that cannot run: absent or disabled is a -// skip, a malformed one a failure. -static NSDictionary *PushyUnrunnableConfigResult(NSString *reason) { - BOOL skip = [reason isEqualToString:@"not_configured"] || [reason isEqualToString:@"disabled"]; - return PushyHostResult(skip ? @"skipped" : @"failed", reason, nil, NO); + return gate.runnable(); } @implementation RCTPushyOrchestrator @@ -494,15 +367,13 @@ + (void)persistConfiguration:(NSString *)config { } + (BOOL)hasRunnableConfig { - PushyRunnableConfig config; - return PushyLoadRunnableConfig(&config, NULL); + return PushyLoadRunnableConfig(NULL, NULL); } // Process-wide round state, set up by whichever launch path needs a round // first: the delayed cold-start check or the tvOS purged-version restore. + (void)prepareProcess:(NSString *)launchRolledBackVersion { - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ + static const bool prepared = [launchRolledBackVersion] { pushyRoundDone = dispatch_semaphore_create(0); pushyHostRoundGroup = dispatch_group_create(); dispatch_group_enter(pushyHostRoundGroup); @@ -512,10 +383,12 @@ + (void)prepareProcess:(NSString *)launchRolledBackVersion { // The crash-hold rescue shares the orchestrator's rollout gate: no // runnable config, no handler (§11.3). persistConfiguration installs // it once a runnable config arrives later in the process. - if ([self hasRunnableConfig]) { + if ([RCTPushyOrchestrator hasRunnableConfig]) { PushyInstallCrashHoldHandler(); } - }); + return true; + }(); + (void)prepared; } + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion { @@ -524,23 +397,26 @@ + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion { // cold-start critical path (§7 R5) — its result targets the NEXT launch. // Unless the previous process died mid-round (residual incomplete // marker), in which case every launch second counts (§11.4). - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - [self prepareProcess:launchRolledBackVersion]; + // No `self` inside the once-lambdas: a block created in a lambda that + // captured `self` by reference keeps that (dangling) reference. + static const bool scheduled = [launchRolledBackVersion] { + [RCTPushyOrchestrator prepareProcess:launchRolledBackVersion]; NSUserDefaults *defaults = PushyDefaults(); int64_t delaySeconds = [defaults objectForKey:keyNativeCheckIncomplete] != nil ? 0 : 5; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delaySeconds * NSEC_PER_SEC), dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ - if ([self hasJsRound]) { + if ([RCTPushyOrchestrator hasJsRound]) { // Not consuming the round: a later crash rescue may still // need it. NSLog(@"RCTPushy -- native sync skipped: JS already queried in this process"); return; } - [self startRoundWithDeadline:0]; + [RCTPushyOrchestrator startRoundWithDeadline:0]; }); - }); + return true; + }(); + (void)scheduled; #endif } @@ -586,8 +462,7 @@ + (BOOL)restorePurgedLaunch:(NSString *)purgedVersion [self startRoundWithDeadline:deadline]; dispatch_semaphore_signal(done); }); - BOOL timedOut = dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, - (int64_t)(budget * NSEC_PER_SEC))) != 0; + BOOL timedOut = !PushyWaitSeconds(done, budget); // The round's deadline does not bound a slow response (the request // timeout is an idle timeout) nor a version already on disk, so the close // is what stops a late activation — not the deadline. @@ -614,17 +489,16 @@ + (NSDictionary *)prepareBundle { // launch); the host gets failed/timeout instead of a result. + (NSDictionary *)prepareBundleWithin:(NSTimeInterval)timeout { #if DEBUG - return PushyHostResult(@"skipped", @"debug", nil, NO); + return PushyHostResult(@"skipped", @"debug"); #else if (!pushySyncReady.load()) { - return PushyHostResult(@"skipped", @"not_initialized", nil, NO); + return PushyHostResult(@"skipped", @"not_initialized"); } - PushyRunnableConfig runnable; - NSString *reason = nil; - if (!PushyLoadRunnableConfig(&runnable, &reason)) { - return PushyUnrunnableConfigResult(reason); + NSString *configJson = nil; + NSDictionary *rejection = nil; + if (!PushyLoadRunnableConfig(&configJson, &rejection)) { + return rejection; } - NSString *configJson = runnable.json; // Run the round on its own queue rather than this caller's thread, so the // wait below can be bounded. if ([self claimRound]) { @@ -635,24 +509,24 @@ + (NSDictionary *)prepareBundleWithin:(NSTimeInterval)timeout { // A group is broadcast-style. Sharing the rescue semaphore would let one // waiter consume the only signal and leave the other waiting forever. if (!pushyRoundStarted.load()) { - return PushyHostResult(@"skipped", @"config_changed", nil, NO); + return PushyHostResult(@"skipped", @"config_changed"); } if (!(timeout > 0)) { timeout = 0; // also NaN } if (dispatch_group_wait(pushyHostRoundGroup, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(MIN(timeout, (NSTimeInterval)(INT64_MAX / NSEC_PER_SEC)) * NSEC_PER_SEC))) != 0) { - return PushyHostResult(@"failed", @"timeout", nil, NO); + return PushyHostResult(@"failed", @"timeout"); } if (pushyHostRoundConfigGeneration != pushyNativeConfigGeneration.load() || !PushyConfigsEquivalent(configJson, pushyHostRoundConfig) || !PushyConfigsEquivalent(configJson, [PushyDefaults() stringForKey:keyNativeConfig])) { - return PushyHostResult(@"cancelled", @"config_changed", nil, NO); + return PushyHostResult(@"cancelled", @"config_changed"); } if (pushyHostRoundGeneration != pushyResetGeneration.load()) { - return PushyHostResult(@"cancelled", @"reset", nil, NO); + return PushyHostResult(@"cancelled", @"reset"); } - return pushyHostRoundResult ?: PushyHostResult(@"failed", @"internal_error", nil, NO); + return pushyHostRoundResult ?: PushyHostResult(@"failed", @"internal_error"); #endif } @@ -703,7 +577,7 @@ + (void)runClaimedRoundWithDeadline:(NSTimeInterval)deadlineUptime { } @catch (NSException *exception) { // The rescue path must never take the app down with it. RCTLogWarn(@"RCTPushy -- native sync crashed: %@", exception.reason); - pushyHostRoundResult = PushyHostResult(@"failed", @"internal_error", nil, NO); + pushyHostRoundResult = PushyHostResult(@"failed", @"internal_error"); } @finally { pushyRoundCompleted.store(true); dispatch_semaphore_signal(pushyRoundDone); @@ -722,8 +596,7 @@ + (void)runHoldRoundWithDeadline:(NSTimeInterval)deadlineUptime { if (pushyRoundStarted.load() && !pushyRoundCompleted.load()) { NSTimeInterval remaining = deadlineUptime - PushyMonotonicNow(); if (remaining > 0) { - dispatch_semaphore_wait(pushyRoundDone, dispatch_time(DISPATCH_TIME_NOW, - (int64_t)(remaining * NSEC_PER_SEC))); + PushyWaitSeconds(pushyRoundDone, remaining); } } [self activatePendingVersion]; @@ -748,17 +621,14 @@ + (void)activatePendingVersion { // validate the version directory; never point the next launch at bytes // that are gone (e.g. wiped by a reset whose generation bump we would // catch below anyway, or by cleanup). - NSString *versionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:hash]; - if (!PushyHasCompletedVersionAtPath(versionDir, hash)) { + if (!PushyHasCompletedVersionAtPath(PushyVersionDir(hash), hash)) { NSLog(@"RCTPushy -- crash hold: version %@ no longer on disk, dropping activation", hash); return; } NSDictionary *hashInfoEntry = nil; - NSString *existingInfo = [PushyDefaults() stringForKey:PushyHashInfoKey(hash)]; - NSData *existingData = [existingInfo dataUsingEncoding:NSUTF8StringEncoding]; - id parsed = existingData == nil ? nil : - [NSJSONSerialization JSONObjectWithData:existingData options:0 error:nil]; - if ([parsed isKindOfClass:[NSDictionary class]]) { + NSDictionary *parsed = + PushyJsonDictionary([PushyDefaults() stringForKey:PushyHashInfoKey(hash)], NULL); + if (parsed != nil) { NSMutableDictionary *merged = [parsed mutableCopy]; merged[PushyInfoCrashHold()] = @YES; hashInfoEntry = @{@"hash": hash, @"info": merged}; @@ -767,10 +637,7 @@ + (void)activatePendingVersion { configGeneration:configGeneration hashInfo:hashInfoEntry activate:hash - responseText:nil - request:nil - config:nil - responseAt:0 + cache:nil activated:NULL]; if (committed) { @synchronized (self) { @@ -782,18 +649,6 @@ + (void)activatePendingVersion { } } -// A bare module instance drives the existing download/patch pipeline without -// a bridge: the file queue is process-global and progress events are gated on -// hasListeners (never set without a bridge). -+ (RCTPushy *)engine { - static RCTPushy *engine; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - engine = [[RCTPushy alloc] init]; - }); - return engine; -} - + (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)deadlineUptime { // Generations first, config second: a config replaced in between is // caught by the commit's generation check. @@ -801,23 +656,21 @@ + (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)dea const uint64_t configGeneration = pushyNativeConfigGeneration.load(); pushyHostRoundGeneration = resetGeneration; pushyHostRoundConfigGeneration = configGeneration; - pushyHostRoundResult = PushyHostResult(@"failed", @"check_failed", nil, NO); + pushyHostRoundResult = PushyHostResult(@"failed", @"check_failed"); NSUserDefaults *defaults = PushyDefaults(); - PushyRunnableConfig config; - NSString *reason = nil; - BOOL runnable = PushyLoadRunnableConfig(&config, &reason); - pushyHostRoundConfig = config.json; + NSString *configJson = nil; + NSDictionary *rejection = nil; + BOOL runnable = PushyLoadRunnableConfig(&configJson, &rejection); + pushyHostRoundConfig = configJson; if (!runnable) { - pushyHostRoundResult = PushyUnrunnableConfigResult(reason); + pushyHostRoundResult = rejection; return; } // Preserve the interrupted-round breadcrumb and reset-safe atomic commit. [defaults setObject:@YES forKey:keyNativeCheckIncomplete]; @try { - [self runConfiguredRound:config.value - configJson:config.json - appKey:config.appKey - launchRolledBackVersion:launchRolledBackVersion + [self runConfiguredRound:configJson + launchRolledBackVersion:launchRolledBackVersion resetGeneration:resetGeneration configGeneration:configGeneration deadline:deadlineUptime]; @@ -826,112 +679,92 @@ + (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)dea } } -+ (void)runConfiguredRound:(const flowjson::Value &)config - configJson:(NSString *)configJson - appKey:(NSString *)appKey ++ (void)runConfiguredRound:(NSString *)configJson launchRolledBackVersion:(NSString *)launchRolledBackVersion resetGeneration:(uint64_t)resetGeneration configGeneration:(uint64_t)configGeneration deadline:(NSTimeInterval)deadlineUptime { - NSUserDefaults *defaults = PushyDefaults(); - NSString *packageVersion = - PushyFromStdString(config.Get("packageVersion").AsString()); - if (packageVersion.length == 0) { - packageVersion = [RCTPushy packageVersion]; - } - __block NSString *currentVersion = nil; PushyWithStateLock(^{ pushy::state::State state = PushyStateFromDefaults(PushyDefaults()); currentVersion = PushyFromStdString(state.current_version); }); - NSString *rolledBackVersion = launchRolledBackVersion; - NSString *uuid = [defaults stringForKey:keyUuid] ?: @""; - - flowjson::Value identity = flowjson::Value::Object(); - identity.Set("packageVersion", - flowjson::Value::String(PushyToStdString(packageVersion))); - if (currentVersion != nil) { - identity.Set("currentVersion", - flowjson::Value::String(PushyToStdString(currentVersion))); - } - identity.Set("uuid", flowjson::Value::String(PushyToStdString(uuid))); - if (rolledBackVersion != nil) { - identity.Set("rolledBackVersion", - flowjson::Value::String(PushyToStdString(rolledBackVersion))); - } - - flowjson::Value cInfo = flowjson::Value::Object(); - cInfo.Set("rnu", config.Get("rnu")); - cInfo.Set("rn", config.Get("rn")); - cInfo.Set("os", flowjson::Value::String(PushyToStdString([NSString - stringWithFormat:@"%@ %@", PushyOsName, PushyOsVersion()]))); - cInfo.Set("uuid", flowjson::Value::String(PushyToStdString(uuid))); - - flowjson::Value input = flowjson::Value::Object(); - input.Set("packageVersion", identity.Get("packageVersion")); - if (currentVersion != nil) { - input.Set("currentVersion", identity.Get("currentVersion")); - } - input.Set("buildTime", - flowjson::Value::String(PushyToStdString([RCTPushy buildTime]))); - input.Set("cInfo", cInfo); - input.Set("supportedDiffVersion", - flowjson::Value::Number(pushy::hbc::kSupportedDiffVersion)); + // This launch as the shared core sees it (native_round.h); nil values are + // left out — a version-less launch has no currentVersion, as the JS + // constant does not. + flowjson::Value env = flowjson::Value::Object(); + const auto put = [&env](const char *key, NSString *value) { + if (value != nil) { + env.Set(key, flowjson::Value::String(PushyToStdString(value))); + } + }; + put("packageVersion", [RCTPushy packageVersion]); + put("currentVersion", currentVersion); + put("uuid", [PushyDefaults() stringForKey:keyUuid]); + put("rolledBackVersion", launchRolledBackVersion); + put("buildTime", [RCTPushy buildTime]); + put("os", [NSString stringWithFormat:@"%@ %@", PushyOsName, PushyOsVersion()]); // Cache-only while a crash is being held (§11.3 budget) or the launch is // blocked on the tvOS purged-version restore; computed (and shared with JS // getBundleHash) on the ordinary delayed round. - input.Set("bundleHash", - flowjson::Value::String(PushyToStdString(PushyBundleHashSync( - !(pushyCrashHoldActive.load() || pushyPurgeRestoreActive.load()))))); + put("bundleHash", PushyBundleHashSync( + !(pushyCrashHoldActive.load() || pushyPurgeRestoreActive.load()))); - std::string bodyJson = - flowjson::Stringify(flowcore::BuildRequestBody(input)); - NSString *body = [NSString stringWithUTF8String:bodyJson.c_str()]; + const std::string config = PushyToStdString(configJson); + const flowjson::Value round = + flowcore::PrepareRound(config, env, arc4random() / 4294967296.0); + NSString *body = PushyFromStdString(round.Get("body").AsString()); if (body == nil) { - RCTLogWarn(@"RCTPushy -- native sync: request body is not valid UTF-8"); - pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_request", nil, NO); + RCTLogWarn(@"RCTPushy -- native sync: no request to send"); + pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_request"); return; } - NSString *responseText = [self runQueryRequest:config - appKey:appKey + NSString *responseText = [self runQueryRequest:round.Get("query") body:body deadline:deadlineUptime]; if (responseText == nil) { RCTLogInfo(@"RCTPushy -- native sync: no endpoint reachable, giving up until next launch"); return; } - long long responseAtSeconds = (long long)[[NSDate date] timeIntervalSince1970]; + // Planned attempts, hashInfo and the response cache (anchored to the + // response's arrival) all come from the shared core. + flowjson::Value decision = flowcore::HandleRoundResponse( + PushyToStdString(responseText), config, env, PushyToStdString(body), + (long long)[[NSDate date] timeIntervalSince1970]); + if (!decision.IsObject()) { + pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_response"); + return; + } + NSString *cache = PushyFromStdString(decision.Get("cache").AsString()); + NSDictionary *cancelledByReset = PushyHostResult(@"cancelled", @"reset"); + // Commits only the response cache (nothing to install or activate) and + // yields `result`, unless a reset or config change since the round + // started won. + NSDictionary *(^commitResponseOnly)(NSDictionary *) = ^NSDictionary *(NSDictionary *result) { + BOOL committed = [self commitRoundWithGeneration:resetGeneration + configGeneration:configGeneration + hashInfo:nil + activate:nil + cache:cache + activated:NULL]; + return committed ? result : cancelledByReset; + }; - flowjson::Value decision = flowcore::HandleResponse( - PushyToStdString(responseText), identity, false, - config.Get("afterDownload").AsString()); if (decision.Get("action").AsString() != "download") { - BOOL committed = [self commitRoundWithGeneration:resetGeneration - configGeneration:configGeneration - hashInfo:nil - activate:nil - responseText:responseText - request:body - config:configJson - responseAt:responseAtSeconds - activated:NULL]; - pushyHostRoundResult = committed - ? PushyHostResult(PushyStatusNone(), PushyFromStdString(decision.Get("reason").AsString()), nil, NO) - : PushyHostResult(@"cancelled", @"reset", nil, NO); + pushyHostRoundResult = commitResponseOnly(PushyHostResult( + PushyStatusNone(), PushyFromStdString(decision.Get("reason").AsString()))); RCTLogInfo(@"RCTPushy -- native sync: nothing to do (%s)", decision.Get("reason").AsString().c_str()); return; } NSString *hash = PushyFromStdString(decision.Get("hash").AsString()); if (!PushyIsSafePathComponent(hash)) { - pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_response", nil, NO); + pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_response"); return; } - NSString *versionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:hash]; - BOOL downloaded = PushyHasCompletedVersionAtPath(versionDir, hash); + BOOL downloaded = PushyHasCompletedVersionAtPath(PushyVersionDir(hash), hash); if (!downloaded) { downloaded = [self performAttempts:decision.Get("attempts") hash:hash @@ -939,40 +772,18 @@ + (void)runConfiguredRound:(const flowjson::Value &)config deadline:deadlineUptime]; } if (!downloaded) { - BOOL committed = [self commitRoundWithGeneration:resetGeneration - configGeneration:configGeneration - hashInfo:nil - activate:nil - responseText:responseText - request:body - config:configJson - responseAt:responseAtSeconds - activated:NULL]; - pushyHostRoundResult = committed - ? PushyHostResult(@"failed", @"download_failed", nil, NO) - : PushyHostResult(@"cancelled", @"reset", nil, NO); + pushyHostRoundResult = commitResponseOnly(PushyHostResult(@"failed", @"download_failed")); return; } - // Persist name/description/metaInfo alongside the version, mirroring the - // JS side's setLocalHashInfo after a successful download. - const flowjson::Value &info = decision.Get("info"); - NSMutableDictionary *versionInfo = [NSMutableDictionary dictionary]; - for (const char *key : {"name", "description", "metaInfo"}) { - if (info.Get(key).IsString()) { - versionInfo[@(key)] = PushyFromStdString(info.Get(key).AsString()) ?: @""; - } - } - // A forceBoot activation is the brick-rescue path: mark it in the - // persisted info so JS can report force_boot_rescue when this version - // survives to markSuccess. Only the server-sent directive counts — a - // silent-strategy activation is ordinary delivery. - if (info.Get("config").Get("forceBoot").Truthy()) { - versionInfo[PushyInfoForceBoot()] = @YES; - } + // The version info to persist alongside it (the JS side's + // setLocalHashInfo, the forceBoot rescue marked by the core). + flowjson::Value info = decision.Get("hashInfo"); if (pushyCrashHoldActive.load()) { - versionInfo[PushyInfoCrashHold()] = @YES; + info.Set(PushyToStdString(PushyInfoCrashHold()), flowjson::Value::Bool(true)); } + NSDictionary *versionInfo = + PushyJsonDictionary(PushyFromStdString(flowjson::Stringify(info)), NULL); // Silent strategies or a server-marked forceBoot version (per-version // remote override — the brick rescue) activate for the next launch; // otherwise activation stays with the JS side (§6/§10.1). Unless a crash @@ -987,10 +798,7 @@ + (void)runConfiguredRound:(const flowjson::Value &)config configGeneration:configGeneration hashInfo:@{@"hash": hash, @"info": versionInfo} activate:activate ? hash : nil - responseText:responseText - request:body - config:configJson - responseAt:responseAtSeconds + cache:cache activated:&activated]; activate = activated; if (!committed) { @@ -1012,7 +820,7 @@ + (void)runConfiguredRound:(const flowjson::Value &)config } pushyHostRoundResult = committed ? PushyHostResult(@"downloaded", @"", hash, activate) - : PushyHostResult(@"cancelled", @"reset", nil, NO); + : cancelledByReset; } // Everything a round persists — version info, the activation, the response @@ -1024,23 +832,10 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation configGeneration:(uint64_t)configGeneration hashInfo:(NSDictionary *)hashInfoEntry activate:(NSString *)hashToActivate - responseText:(NSString *)responseText - request:(NSString *)requestBody - config:(NSString *)configJson - responseAt:(long long)responseAtSeconds + cache:(NSString *)cache activated:(BOOL *)activatedOut { - // responseText is nil for the crash handler's activation-only commit + // cache is nil for the crash handler's activation-only commit // (activatePendingVersion): no round ran, so there is no cache to write. - NSData *cacheData = nil; - if (responseText != nil) { - NSDictionary *cacheEntry = @{ - @"ts": @(responseAtSeconds), - @"body": responseText, - @"request": requestBody, - @"config": configJson, - }; - cacheData = [NSJSONSerialization dataWithJSONObject:cacheEntry options:0 error:nil]; - } __block BOOL committed = NO; __block BOOL activated = NO; PushyWithStateLock(^{ @@ -1058,7 +853,7 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation // crash handler's activation-only one): it may take over this launch // only while +bundleURL still waits for it; after that the version is // left to JS unless a crash is being held (JS is dead then). - if (responseText != nil && pushyPurgeRestoreActive.load()) { + if (cache != nil && pushyPurgeRestoreActive.load()) { if (pushyPurgeRestoreWindowOpen) { if (activation != nil && info != nil) { NSMutableDictionary *marked = [info mutableCopy]; @@ -1070,22 +865,16 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation } } NSUserDefaults *defaults = PushyDefaults(); - if (hashInfoEntry != nil) { - NSData *infoData = [NSJSONSerialization dataWithJSONObject:info - options:0 - error:nil]; - if (infoData != nil) { - [defaults setObject:[[NSString alloc] initWithData:infoData encoding:NSUTF8StringEncoding] - forKey:PushyHashInfoKey(hashInfoEntry[@"hash"])]; - } + NSString *infoJson = hashInfoEntry == nil ? nil : PushyJsonString(info); + if (infoJson != nil) { + [defaults setObject:infoJson forKey:PushyHashInfoKey(hashInfoEntry[@"hash"])]; } if (activation != nil) { PushySwitchVersionLocked(activation); activated = YES; } - if (cacheData != nil) { - [defaults setObject:[[NSString alloc] initWithData:cacheData encoding:NSUTF8StringEncoding] - forKey:keyNativeCheckCache]; + if (cache != nil) { + [defaults setObject:cache forKey:keyNativeCheckCache]; } committed = YES; }); @@ -1095,10 +884,6 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation return committed; } -// Sequential fallback over the ordered candidates (§5.1): one request at a -// time with its own timeout; after the configured round fails, queryUrls -// discovery merges remote candidates (excluding the already-tried) for one -// more round. No hedged race on purpose — this path is latency-insensitive. // Per-request timeout, capped to the crash-rescue budget when one is active. // <= 0 means the budget is gone and the round must stop issuing requests. static NSTimeInterval PushyQueryRequestTimeout(NSTimeInterval deadlineUptime) { @@ -1108,85 +893,43 @@ static NSTimeInterval PushyQueryRequestTimeout(NSTimeInterval deadlineUptime) { return MIN(10, deadlineUptime - PushyMonotonicNow()); } -+ (NSString *)runQueryRequest:(const flowjson::Value &)config - appKey:(NSString *)appKey +// Sequential fallback over the endpoints (§5.1), one request at a time with +// its own timeout, as the shared core walks it (flowcore::NextQueryStep: +// order, list discovery, https-only remote bases, the request budget, +// response validity). No hedged race on purpose — this path is +// latency-insensitive. ++ (NSString *)runQueryRequest:(const flowjson::Value &)query body:(NSString *)body deadline:(NSTimeInterval)deadlineUptime { - NSMutableSet *tried = [NSMutableSet set]; - const NSUInteger maxHttpAttempts = 8; - __block NSUInteger httpAttempts = 0; - // Spends one attempt of the shared budget. NO once the attempt cap or the - // deadline is exhausted (the round must stop issuing requests); the - // per-request timeout lands in *timeout otherwise. - BOOL (^takeAttempt)(NSTimeInterval *) = ^BOOL(NSTimeInterval *timeout) { - if (httpAttempts++ >= maxHttpAttempts) { - return NO; - } - *timeout = PushyQueryRequestTimeout(deadlineUptime); - return *timeout > 0; - }; - // Tries each not-yet-tried endpoint base in order and returns the first - // valid response; *stop is set when the budget ran out. - NSString *(^queryEndpoints)(const flowjson::Value &, BOOL *) = - ^NSString *(const flowjson::Value &endpoints, BOOL *stop) { - for (const auto &endpoint : endpoints.elements()) { - NSString *base = PushyNormalizeEndpointBase( - PushyFromStdString(endpoint.AsString())); - if (base.length == 0 || [tried containsObject:base]) { - continue; - } - NSTimeInterval timeout = 0; - if (!takeAttempt(&timeout)) { - *stop = YES; - return nil; - } - [tried addObject:base]; - NSString *response = PushyHttpRequest( - [NSString stringWithFormat:@"%@%@%@", base, RCTPushyQueryPath(), appKey], - @"POST", body, timeout); - if (PushyIsValidResponse(response)) { - return response; - } - } - return nil; - }; - - BOOL stop = NO; - double sample = arc4random() / 4294967296.0; - NSString *response = queryEndpoints( - flowcore::OrderEndpointCandidates(config.Get("endpoints"), sample), &stop); - if (response != nil || stop) { - return response; - } - for (const auto &queryUrl : config.Get("queryUrls").elements()) { - NSString *listUrl = PushyFromStdString(queryUrl.AsString()); - if (listUrl == nil) { - continue; - } - NSTimeInterval listTimeout = 0; - if (!takeAttempt(&listTimeout)) { + flowjson::Value state = query; + NSString *text = nil; + while (true) { + state = flowcore::NextQueryStep(state, text == nil + ? flowjson::Value::Undefined() + : flowjson::Value::String(PushyToStdString(text))); + if (state.Get("found").Truthy()) { + return text; + } + NSString *url = PushyFromStdString(state.Get("url").AsString()); + NSTimeInterval timeout = PushyQueryRequestTimeout(deadlineUptime); + if (url == nil || timeout <= 0) { return nil; } - NSString *listText = PushyHttpRequest(listUrl, @"GET", nil, listTimeout); - if (listText == nil) { - continue; - } - bool ok = false; - flowjson::Value remote = flowjson::Parse(PushyToStdString(listText), &ok); - if (!ok || !remote.IsArray()) { - continue; - } - // One successfully fetched remote list is enough. - return queryEndpoints(remote, &stop); + BOOL post = state.Get("post").Truthy(); + text = PushyHttpRequest(url, post ? @"POST" : @"GET", post ? body : nil, timeout); } - return nil; } +// Strategies in order (diff, pdiff, full), each over its mirrors, already +// planned for this device by the core (no diff without a running version, no +// cleartext URL under an https-only config). A failure +// that is not mirror-retryable (PATCH_FAILED: the bytes arrived but could not +// be applied, and every mirror serves the same bytes) moves on to the next +// strategy instead of downloading the same artifact again, like the JS side. + (BOOL)performAttempts:(const flowjson::Value &)attempts hash:(NSString *)hash originHash:(NSString *)originHash deadline:(NSTimeInterval)holdDeadline { - RCTPushy *engine = [self engine]; // Crash-rescue budget caps every phase; 0 keeps the normal 10min windows. NSTimeInterval incrementalDeadline = PushyMonotonicNow() + 600; if (holdDeadline > 0) { @@ -1198,9 +941,6 @@ + (BOOL)performAttempts:(const flowjson::Value &)attempts PushyType pushyType = type == "diff" ? PushyTypePatchFromPpk : type == "pdiff" ? PushyTypePatchFromPackage : PushyTypeFullDownload; - if (pushyType == PushyTypePatchFromPpk && originHash.length == 0) { - continue; // diff patches from the running version; none running - } BOOL isFullAttempt = pushyType == PushyTypeFullDownload; if (isFullAttempt && fullDeadline == 0) { // diff/pdiff cannot starve the last-resort full download. @@ -1233,12 +973,11 @@ + (BOOL)performAttempts:(const flowjson::Value &)attempts } dispatch_semaphore_t sem = dispatch_semaphore_create(0); __block NSError *resultError = nil; - [engine performFetch:pushyType options:options callback:^(NSError *error) { + [RCTPushyInstaller fetch:pushyType options:options progress:nil callback:^(NSError *error) { resultError = error; dispatch_semaphore_signal(sem); }]; - if (dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, - (int64_t)(remaining * NSEC_PER_SEC))) != 0) { + if (!PushyWaitSeconds(sem, remaining)) { RCTLogWarn(@"RCTPushy -- native sync: %s attempt timed out", type.c_str()); if (isFullAttempt) { return NO; @@ -1250,6 +989,10 @@ + (BOOL)performAttempts:(const flowjson::Value &)attempts } RCTLogInfo(@"RCTPushy -- native sync: %s attempt failed: %@", type.c_str(), resultError.localizedDescription); + if (!flowcore::IsMirrorRetryableCode( + PushyToStdString(resultError.userInfo[RCTPushyErrorCodeKey]))) { + break; + } } } return NO; diff --git a/ios/RCTPushy/RCTPushyState.mm b/ios/RCTPushy/RCTPushyState.mm new file mode 100644 index 00000000..e01c70ae --- /dev/null +++ b/ios/RCTPushy/RCTPushyState.mm @@ -0,0 +1,314 @@ +// Persisted update state: the defaults keys, the state lock, the State <-> +// defaults mapping, the string/error/JSON helpers every other file uses, and +// the embedded bundle's hash. +#import "RCTPushyInternal.h" +#include "../../cpp/patch_core/digest.h" + +#import +#import + +PUSHY_HIDDEN NSString *const keyPushyInfo = @"REACTNATIVECN_PUSHY_INFO_KEY"; +// Binary identity (SyncBinaryVersion input). Prefixed like every other key: +// the unprefixed names collided with generic host-app/SDK defaults, and a +// foreign value there made every launch look like a rebuilt binary and wipe +// the installed update. +static NSString *const paramPackageVersion = @"REACTNATIVECN_PUSHY_PACKAGEVERSION"; +static NSString *const paramBuildTime = @"REACTNATIVECN_PUSHY_BUILDTIME"; +// The unprefixed names earlier SDKs wrote. Read only as a fallback until the +// first state write migrates them (PushyStateFromDefaults / +// PushyApplyStateToDefaults). +static NSString *const legacyParamPackageVersion = @"packageVersion"; +static NSString *const legacyParamBuildTime = @"buildTime"; +static NSString *const paramLastVersion = @"lastVersion"; +PUSHY_HIDDEN NSString *const paramCurrentVersion = @"currentVersion"; +static NSString *const paramIsFirstTime = @"isFirstTime"; +static NSString *const paramIsFirstLoadOk = @"isFirstLoadOK"; +PUSHY_HIDDEN NSString *const keyUuid = @"REACTNATIVECN_PUSHY_UUID"; +PUSHY_HIDDEN NSString *const keyHashInfo = @"REACTNATIVECN_PUSHY_HASH_"; +PUSHY_HIDDEN NSString *const keyFirstLoadMarked = @"REACTNATIVECN_PUSHY_FIRSTLOADMARKED_KEY"; +PUSHY_HIDDEN NSString *const keyRolledBackMarked = @"REACTNATIVECN_PUSHY_ROLLEDBACKMARKED_KEY"; +// Encoded constants (RCTPushyRevealText) are decoded on first use, not by +// static initializers at image load. +PUSHY_HIDDEN NSString *KeyPackageUpdatedMarked(void) { + static NSString *value = RCTPushyRevealText("0832d5f29aa549710b0939daf88ca058790f3dded7e888b451640d2ec3f69099bb43711523cafa8ea7bb4372132a"); + return value; +} +// bundleHash cache: "|" where cacheKey identifies the +// installed binary (packageVersion + embedded bundle size + mtime). Recomputed +// only when the key changes, i.e. once per install. +static NSString *const keyBundleHashCache = @"REACTNATIVECN_PUSHY_BUNDLEHASH_KEY"; +// Raw response cache written by the native cold-start check for the JS side +// to reuse (§10.3), scoped to the request and config that produced it. +PUSHY_HIDDEN NSString *const keyNativeCheckCache = @"REACTNATIVECN_PUSHY_NATIVE_CHECK_RESP_KEY"; + +// error def — messages are human-readable; the stable cross-platform codes +// live in cpp/patch_core/error_codes.h and travel in RCTPushyErrorCodeKey. +PUSHY_HIDDEN NSString *const ERROR_OPTIONS = @"options error"; + +PUSHY_HIDDEN std::atomic ignoreRollback{false}; +// Bumped by resetToPackagedBundle only (config replacement has its own +// pushyNativeConfigGeneration). The cold-start check runs for minutes and +// may already hold a decision when the app resets to the packaged bundle; it +// samples this counter and abandons activation (and its response cache) when +// the value moved, so an in-flight rescue can never resurrect the version the +// app just reset away from. +PUSHY_HIDDEN std::atomic pushyResetGeneration{0}; + +PUSHY_HIDDEN NSDictionary *PushyHostResult(NSString *status, NSString *reason, + NSString *hash, BOOL activated) { + return @{@"status": status, @"reason": reason ?: @"", + @"hash": hash ?: @"", @"activated": @(activated)}; +} + +PUSHY_HIDDEN NSTimeInterval PushyMonotonicNow(void) { + return [NSProcessInfo processInfo].systemUptime; +} + +// Serializes every read-modify-write of the persisted update state. The state +// machine itself is a pure function (state_core), but callers run on different +// threads (main thread bundleURL, module method queue, the file queue), so the +// read→transform→write sequence must be atomic to avoid e.g. markSuccess being +// overwritten by a concurrent bundleURL and the version being rolled back. +static os_unfair_lock pushyStateLock = OS_UNFAIR_LOCK_INIT; + +PUSHY_HIDDEN void PushyWithStateLock(void (NS_NOESCAPE ^block)(void)) { +#if DEBUG + // Not re-entrant: a nested acquisition deadlocks in release, so catch it + // where it is written. + os_unfair_lock_assert_not_owner(&pushyStateLock); +#endif + os_unfair_lock_lock(&pushyStateLock); + @try { + block(); + } @finally { + os_unfair_lock_unlock(&pushyStateLock); + } +} + +PUSHY_HIDDEN std::string PushyToStdString(NSString *value) { + if (value == nil) { + return std::string(); + } + return std::string([value UTF8String]); +} + +PUSHY_HIDDEN NSUserDefaults *PushyDefaults(void) { + return [NSUserDefaults standardUserDefaults]; +} + +PUSHY_HIDDEN NSString *PushyFromStdString(const std::string &value) { + if (value.empty()) { + return nil; + } + return [NSString stringWithUTF8String:value.c_str()]; +} + +static void PushySetNullableString(NSUserDefaults *defaults, NSString *key, NSString *value) { + if (value != nil) { + [defaults setObject:value forKey:key]; + } else { + [defaults removeObjectForKey:key]; + } +} + +PUSHY_HIDDEN NSString *PushyHashInfoKey(NSString *hash) { + return [keyHashInfo stringByAppendingString:hash ?: @""]; +} + +PUSHY_HIDDEN NSString *PushyOptionString(NSDictionary *options, NSString *key) { + return [RCTConvert NSString:options[key]]; +} + +PUSHY_HIDDEN BOOL PushyStringIsBlank(NSString *value) { + if (value == nil || [value isKindOfClass:[NSNull class]]) { + return YES; + } + return [[value stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] length] == 0; +} + +// Server-provided identifiers (hash/originHash) are used as child names under +// the download root; anything that could resolve outside of it (path +// separators, "..", ".") must be rejected before touching the filesystem. +PUSHY_HIDDEN BOOL PushyIsSafePathComponent(NSString *value) { + if (PushyStringIsBlank(value)) { + return NO; + } + if ([value isEqualToString:@"."] || [value isEqualToString:@".."]) { + return NO; + } + if ([value containsString:@"/"] || [value containsString:@"\\"]) { + return NO; + } + if ([value rangeOfString:@"\0"].location != NSNotFound) { + return NO; + } + return YES; +} + +PUSHY_HIDDEN NSError *PushyErrorWithCode(const char *code, NSString *message) { + return [NSError errorWithDomain:RCTPushyErrorDomain + code:-1 + userInfo:@{ + NSLocalizedDescriptionKey: message ?: @"unknown error", + RCTPushyErrorCodeKey: @(code), + }]; +} + +PUSHY_HIDDEN void PushyRejectError(RCTPromiseRejectBlock reject, NSError *error) { + // Prefer the stable cross-platform code (error_codes.h); fall back to the + // numeric NSError code for system errors that were not classified. + NSString *code = error.userInfo[RCTPushyErrorCodeKey]; + if (code == nil) { + code = [NSString stringWithFormat:@"%ld", (long)error.code]; + } + reject(code, error.localizedDescription, error); +} + +PUSHY_HIDDEN NSDictionary *PushyJsonDictionary(id json, NSError **error) { + NSData *data = [json isKindOfClass:[NSString class]] + ? [(NSString *)json dataUsingEncoding:NSUTF8StringEncoding] + : ([json isKindOfClass:[NSData class]] ? (NSData *)json : nil); + id object = data == nil ? nil + : [NSJSONSerialization JSONObjectWithData:data options:0 error:error]; + return [object isKindOfClass:[NSDictionary class]] ? (NSDictionary *)object : nil; +} + +PUSHY_HIDDEN NSString *PushyJsonString(id object) { + NSData *data = object == nil ? nil + : [NSJSONSerialization dataWithJSONObject:object options:0 error:nil]; + return data == nil ? nil : [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; +} + +// Binary identity under the prefixed key, or — until the first state write +// migrates it — the value an earlier SDK left under the unprefixed one, so an +// installed update survives the SDK upgrade instead of reading as a rebuilt +// binary. +static NSString *PushyBinaryIdentityValue(NSUserDefaults *defaults, NSString *key, NSString *legacyKey) { + return [defaults stringForKey:key] ?: [defaults stringForKey:legacyKey]; +} + +PUSHY_HIDDEN pushy::state::State PushyStateFromDefaults(NSUserDefaults *defaults) { + pushy::state::State state; + state.package_version = PushyToStdString( + PushyBinaryIdentityValue(defaults, paramPackageVersion, legacyParamPackageVersion)); + state.build_time = PushyToStdString( + PushyBinaryIdentityValue(defaults, paramBuildTime, legacyParamBuildTime)); + NSDictionary *pushyInfo = [defaults dictionaryForKey:keyPushyInfo]; + if (pushyInfo != nil) { + state.current_version = PushyToStdString(pushyInfo[paramCurrentVersion]); + state.last_version = PushyToStdString(pushyInfo[paramLastVersion]); + state.first_time = [pushyInfo[paramIsFirstTime] boolValue]; + id firstLoadOk = pushyInfo[paramIsFirstLoadOk]; + state.first_time_ok = firstLoadOk == nil ? true : [firstLoadOk boolValue]; + } + state.rolled_back_version = PushyToStdString([defaults stringForKey:keyRolledBackMarked]); + return state; +} + +PUSHY_HIDDEN void PushyApplyStateToDefaults(NSUserDefaults *defaults, const pushy::state::State &state) { + PushySetNullableString(defaults, paramPackageVersion, PushyFromStdString(state.package_version)); + PushySetNullableString(defaults, paramBuildTime, PushyFromStdString(state.build_time)); + // One-time migration: once the prefixed keys hold the identity, the + // legacy ones are retired so a host-app write there is never read again. + [defaults removeObjectForKey:legacyParamPackageVersion]; + [defaults removeObjectForKey:legacyParamBuildTime]; + + BOOL hasPushyInfo = !state.current_version.empty() || !state.last_version.empty() || state.first_time || !state.first_time_ok; + if (hasPushyInfo) { + NSMutableDictionary *newInfo = [[NSMutableDictionary alloc] init]; + if (!state.current_version.empty()) { + newInfo[paramCurrentVersion] = PushyFromStdString(state.current_version); + } + if (!state.last_version.empty()) { + newInfo[paramLastVersion] = PushyFromStdString(state.last_version); + } + newInfo[paramIsFirstTime] = @(state.first_time); + newInfo[paramIsFirstLoadOk] = @(state.first_time_ok); + [defaults setObject:newInfo forKey:keyPushyInfo]; + } else { + [defaults removeObjectForKey:keyPushyInfo]; + } + + PushySetNullableString( + defaults, + keyRolledBackMarked, + PushyFromStdString(state.rolled_back_version)); +} + +// Version switch without acquiring the state lock: the caller must already +// hold it. Lets the cold-start check commit its whole result (version info, +// switch, response cache) inside one lock acquisition, so resetToPackagedBundle +// can never interleave between the generation check and the writes. +PUSHY_HIDDEN void PushySwitchVersionLocked(NSString *hash) { + NSUserDefaults *defaults = PushyDefaults(); + pushy::state::State next = pushy::state::SwitchVersion( + PushyStateFromDefaults(defaults), + PushyToStdString(hash) + ); + PushyApplyStateToDefaults(defaults, next); + // Re-enable first-load consumption and rollback checks for the newly selected bundle. + ignoreRollback = false; +} + +static NSString *PushyCachedBundleHash(NSUserDefaults *defaults, NSString *cachedPrefix) { + NSString *cached = [defaults stringForKey:keyBundleHashCache]; + if ([cached hasPrefix:cachedPrefix]) { + return [cached substringFromIndex:cachedPrefix.length]; + } + return nil; +} + +// Shared by the getBundleHash RCT method and the native cold-start check. +// Returns @"" when unknown. With computeIfMissing the sha256 of the embedded +// bundle is taken on a cache miss (once per install; blocking — call off the +// main thread). Without it only the cache is consulted: a crash rescue on +// the main thread has a 3.5s budget in total, and hashing a large bundle on +// a fresh install would spend all of it before the first request. +PUSHY_HIDDEN NSString *PushyBundleHashSync(BOOL computeIfMissing) { + NSString *path = [[RCTPushy binaryBundleURL] path]; + if (path == nil) { + return @""; + } + NSDictionary *attributes = + [[NSFileManager defaultManager] attributesOfItemAtPath:path error:nil]; + if (attributes == nil) { + return @""; + } + NSString *cacheKey = [NSString stringWithFormat:@"%@|%llu|%.0f", + [RCTPushy packageVersion], + attributes.fileSize, + [attributes.fileModificationDate timeIntervalSince1970]]; + + NSUserDefaults *defaults = PushyDefaults(); + NSString *cachedPrefix = [cacheKey stringByAppendingString:@"|"]; + NSString *cached = PushyCachedBundleHash(defaults, cachedPrefix); + if (cached != nil) { + return cached; + } + if (!computeIfMissing) { + return @""; + } + + // Single-flight for the digest: the delayed cold-start round (utility + // queue) and JS getBundleHash (file queue) can both miss the cache right + // after an install; the second caller waits for the first and then reads + // its cache entry instead of hashing the same multi-MB file twice. + static NSLock *lock = [NSLock new]; + [lock lock]; + @try { + // The previous holder may have filled the cache while we waited. + cached = PushyCachedBundleHash(defaults, cachedPrefix); + if (cached != nil) { + return cached; + } + NSString *hash = PushyFromStdString( + pushy::digest::Sha256File(PushyToStdString(path))) ?: @""; + if (hash.length > 0) { + [defaults setObject:[cachedPrefix stringByAppendingString:hash] + forKey:keyBundleHashCache]; + } + return hash; + } @finally { + [lock unlock]; + } +} diff --git a/package.json b/package.json index 6b53ba34..28689505 100644 --- a/package.json +++ b/package.json @@ -15,13 +15,11 @@ "android/build.gradle", "android/proguard.pro", "ios", - "cpp/patch_core", - "cpp/update_flow_core", + "cpp", "!cpp/**/tests", "harmony/pushy.har", "harmony/hvigor-plugin.ts", "harmony/pushy/src", - "!harmony/pushy/src/test", "react-native-update.podspec", "react-native.config.js", "expo-module.config.json", @@ -31,7 +29,7 @@ ], "scripts": { "prepack": "bun submodule && bun lint", - "lint": "biome check src && tsc --noEmit && node scripts/check-harmony-types.js && node scripts/check-native-spec-parity.js", + "lint": "biome check src && tsc --noEmit && node scripts/check-harmony-types.js && node scripts/check-native-spec-parity.js && node scripts/check-constant-parity.js", "lint:harmony": "node scripts/check-harmony-types.js", "submodule": "git submodule update --init --recursive", "test": "bun test src/__tests__", @@ -41,14 +39,11 @@ "build:harmony-har": "node scripts/build-harmony-har.js", "build:harmony-e2e": "bash Example/e2etest/scripts/build-harmony-e2e.sh", "build:so": "bun submodule && bash scripts/build-android-so.sh", - "build:ios-debug": "cd Example/e2etest && bun && bunx detox build --configuration ios.sim.debug", "build:ios-release": "cd Example/e2etest && bun && bunx detox build --configuration ios.sim.release", - "test:ios-debug": "cd Example/e2etest && E2E_PLATFORM=ios bunx detox test --configuration ios.sim.debug", "test:ios-release": "cd Example/e2etest && E2E_PLATFORM=ios bunx detox test --configuration ios.sim.release", "build:android-debug": "cd Example/e2etest && bun && bunx detox build --configuration android.emu.debug", "build:android-release": "cd Example/e2etest && bun && bunx detox build --configuration android.emu.release", "test:android-release": "cd Example/e2etest && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all", - "test:android-debug": "cd Example/e2etest && E2E_PLATFORM=android bunx detox test --configuration android.emu.debug --headless --record-logs all", "smoke:android-debug": "cd Example/e2etest && bunx detox test --configuration android.emu.debug --config e2e/smoke/jest.config.js", "e2e:ios": "bun build:ios-release && bun test:ios-release", "e2e:android": "bun build:android-release && bun test:android-release" diff --git a/react-native-update.podspec b/react-native-update.podspec index 225b61b9..3ca5cf6b 100644 --- a/react-native-update.podspec +++ b/react-native-update.podspec @@ -1,8 +1,6 @@ require 'json' require 'rubygems' # Required for version comparison - -new_arch_enabled = ENV['RCT_NEW_ARCH_ENABLED'] == '1' package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) podspec_dir = File.dirname(__FILE__) @@ -20,37 +18,40 @@ Pod::Spec.new do |s| # Silently ignore errors during check end - # expo-modules-core is a dependency of expo, not of the app: resolve it from - # expo's own location. A bare require from this podspec's directory misses - # it when npm nests it under expo/node_modules (seen with react-native-tvos), - # which left EXPO_SUPPORTS_BUNDLEURL unset; the createBridge fallback that - # selects no longer exists in ExpoModulesCore (SDK 57), so the build failed. - expo_modules_core_package_json_js = - "require.resolve('expo-modules-core/package.json', " \ - "{ paths: [require('path').dirname(require.resolve('expo/package.json'))] })" - - # Determine final validity by checking Podfile presence AND Expo version - valid_expo_project = false # Default + # One node call answers every Expo question: expo's version, and where + # expo-modules-core lives and its version. expo-modules-core is a dependency + # of expo, not of the app: resolve it from expo's own location. A bare + # require from this podspec's directory misses it when npm nests it under + # expo/node_modules (seen with react-native-tvos), which left + # EXPO_SUPPORTS_BUNDLEURL unset; the createBridge fallback that selects no + # longer exists in ExpoModulesCore (SDK 57), so the build failed. + expo_info = {} if is_expo_in_podfile - # Only check expo version if use_expo_modules! is present - is_version_sufficient = false + expo_info_js = <<~JS + const path = require('path'); + const info = {}; + try { + const expoJson = require.resolve('expo/package.json'); + info.expoVersion = require(expoJson).version; + const coreJson = require.resolve('expo-modules-core/package.json', + { paths: [path.dirname(expoJson)] }); + info.coreDir = path.dirname(coreJson); + info.coreVersion = require(coreJson).version; + } catch (e) {} + console.log(JSON.stringify(info)); + JS begin - expo_version_str = `node --print \"require('expo/package.json').version\"`.strip - if expo_version_str && !expo_version_str.empty? - match = expo_version_str.match(/^\d+/) - if match - major_version = match[0].to_i - is_version_sufficient = major_version >= 50 - end - end - rescue - # Node command failed, version remains insufficient + output = IO.popen(['node', '-e', expo_info_js], &:read) + expo_info = JSON.parse(output) if $?.success? + rescue StandardError + # node missing or unparseable output: not a supported Expo project. end - - # Final check - valid_expo_project = is_version_sufficient end + # use_expo_modules! in the Podfile and Expo SDK >= 50. + valid_expo_project = + is_expo_in_podfile && expo_info['expoVersion'].to_s[/^\d+/].to_i >= 50 + # Set platform based on whether it's a valid Expo project and if we can parse its target # React Native's own minimum (defined by react_native_pods.rb, which the # Podfile loads before evaluating podspecs). The pod platform version also @@ -74,12 +75,8 @@ Pod::Spec.new do |s| # --- Try to find and parse ExpoModulesCore.podspec only if it's an Expo project --- parsed_expo_ios_target = nil parsed_expo_tvos_target = nil - expo_modules_core_podspec_path = begin - package_json_path = `node -p "#{expo_modules_core_package_json_js}"`.strip - File.join(File.dirname(package_json_path), 'ExpoModulesCore.podspec') if $?.success? && package_json_path && !package_json_path.empty? - rescue - nil - end + expo_modules_core_podspec_path = + expo_info['coreDir'] && File.join(expo_info['coreDir'], 'ExpoModulesCore.podspec') if expo_modules_core_podspec_path && File.exist?(expo_modules_core_podspec_path) begin @@ -89,11 +86,6 @@ Pod::Spec.new do |s| parsed_expo_ios_target = match[1] tvos_match = content.match(/s\.platforms\s*=\s*\{[\s\S]*?:tvos\s*=>\s*'([^\']+)'/) parsed_expo_tvos_target = tvos_match[1] if tvos_match - else - match = content.match(/s\.platform\s*=\s*:ios,\s*'([^\']+)'/) # Fallback to s.platform = :ios, 'version' - if match && match[1] - parsed_expo_ios_target = match[1] - end end rescue => e # Pod::UI.warn "Failed to read or parse ExpoModulesCore.podspec content: #{e.message}" @@ -108,7 +100,7 @@ Pod::Spec.new do |s| end # tvOS (react-native-tvos) shares the iOS sources; the only platform - # differences live behind TARGET_OS_TV in RCTPushy.mm (download directory, + # differences live behind TARGET_OS_TV in ios/RCTPushy (download directory, # purged-cache recovery). s.platforms = { :ios => final_ios_deployment_target, :tvos => final_tvos_deployment_target } @@ -135,9 +127,9 @@ Pod::Spec.new do |s| # DEFINES_MODULE writes the umbrella under Headers/Public/react_native_update/ # (underscores). CocoaPods copies public headers to react-native-update/ # (hyphens). Include the latter so the umbrella can #import "ImportReact.h". - s.pod_target_xcconfig = { - 'USER_HEADER_SEARCH_PATHS' => "\"$(PODS_ROOT)/Headers/Public/react-native-update\" \"$(PODS_ROOT)/Headers/Public/SSZipArchive\" \"$(PODS_ROOT)/Headers/Public/React-Codegen/RCTPushySpec\"", - "DEFINES_MODULE" => "YES" + s.pod_target_xcconfig = { + 'USER_HEADER_SEARCH_PATHS' => "\"$(PODS_ROOT)/Headers/Public/react-native-update\"", + "DEFINES_MODULE" => "YES" } # buildTime for binary-rebuild detection (SyncBinaryVersion) and the check # request. The stamp is refreshed on every build and ships as a resource. @@ -168,7 +160,6 @@ Pod::Spec.new do |s| # static and dynamic integrations both carry it. s.resource_bundles = { 'PushyPrivacy' => ['ios/PrivacyInfo.xcprivacy'] } - s.dependency 'React' s.dependency "React-Core" # The unzip guard relies on the 2.x delegate contract # (zipArchiveShouldUnzipFileAtIndex:... cancels, _sanitizedPath); a future @@ -190,15 +181,12 @@ Pod::Spec.new do |s| # Objective-C classes in the RCTPushy subspec keep default visibility, which # the host app and the Expo module need to link against RCTPushy. s.subspec 'Core' do |ss| - ss.source_files = ['cpp/update_flow_core/flow_json.{h,cpp}', - 'cpp/update_flow_core/update_flow_core.{h,cpp}', - 'cpp/patch_core/archive_patch_core.{h,cpp}', - 'cpp/patch_core/digest.{h,cpp}', - 'cpp/patch_core/hbc_transform.{h,cpp}', - 'cpp/patch_core/hbc_transform_wire.{h,cpp}', - 'cpp/patch_core/patch_core.{h,cpp}', - 'cpp/patch_core/state_core.{h,cpp}', - 'android/jni/hpatch.{h,c}', + # The cores are picked up by directory (non-recursive: tests/ stays out, + # and the Android JNI glue lives in cpp/android_jni), so a new + # platform-neutral source needs no edit here. + ss.source_files = ['cpp/patch_core/*.{h,cpp}', + 'cpp/update_flow_core/*.{h,cpp}', + 'cpp/hpatch/*.{h,c}', 'android/jni/HDiffPatch/libHDiffPatch/HPatch/*.{h,c}', 'android/jni/HDiffPatch/file_for_patch.{h,c}', 'android/jni/lzma/C/LzmaDec.{h,c}', @@ -215,26 +203,18 @@ Pod::Spec.new do |s| ss.public_header_files = ['ios/RCTPushy/RCTPushy.h', 'ios/RCTPushy/RCTPushyConfiguration.h', 'ios/RCTPushy/RCTPushyDownloader.h'] - # RCTPushyInternal.h is shared between RCTPushy.mm and - # RCTPushyOrchestrator.mm only; it pulls in C++ headers, so it must stay - # out of the module umbrella. - ss.private_header_files = ['ios/RCTPushy/RCTPushyInternal.h'] + # Shared by the pod's own sources only (RCTPushyInternal.h pulls in C++ + # headers), so they must stay out of the module umbrella. + ss.private_header_files = ['ios/RCTPushy/RCTPushyCommon.h', + 'ios/RCTPushy/RCTPushyInternal.h'] end # Conditionally add Expo subspec and check ExpoModulesCore version if valid_expo_project supports_bundle_url_final = false # Default - # 1. Try executing node to get the version string - expo_modules_core_version_str = begin - `node --print "require(#{expo_modules_core_package_json_js}).version"` # Execute, keep raw output - rescue - # Node command failed (e.g., node not found, package not found). Return empty string. - '' - end - - # 2. Process the obtained version string (if not empty) - if expo_modules_core_version_str && !expo_modules_core_version_str.empty? + expo_modules_core_version_str = expo_info['coreVersion'].to_s + if !expo_modules_core_version_str.empty? begin # Compare versions using Gem::Version (handles trailing newline) installed_version = Gem::Version.new(expo_modules_core_version_str) @@ -253,23 +233,9 @@ Pod::Spec.new do |s| end end + # React Native >= 0.71 (react_native_pods.rb) wires codegen, the new + # architecture and its dependencies. if defined?(install_modules_dependencies()) != nil - install_modules_dependencies(s); - else - if new_arch_enabled - folly_compiler_flags = '-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1 -Wno-comma -Wno-shorten-64-to-32' - - s.compiler_flags = folly_compiler_flags + " -DRCT_NEW_ARCH_ENABLED=1" - - s.pod_target_xcconfig = { - "HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/boost\"", - "CLANG_CXX_LANGUAGE_STANDARD" => "c++17" - }.merge(s.pod_target_xcconfig) - s.dependency "React-Codegen" - s.dependency "RCT-Folly" - s.dependency "RCTRequired" - s.dependency "RCTTypeSafety" - s.dependency "ReactCommon/turbomodule/core" - end + install_modules_dependencies(s) end end diff --git a/scripts/build-harmony-har.js b/scripts/build-harmony-har.js index cf7bf07f..22621092 100644 --- a/scripts/build-harmony-har.js +++ b/scripts/build-harmony-har.js @@ -6,22 +6,11 @@ const { spawnSync } = require('child_process'); const projectRoot = path.resolve(__dirname, '..'); const androidJniDir = path.join(projectRoot, 'android', 'jni'); +const hpatchDir = path.join(projectRoot, 'cpp', 'hpatch'); const patchCoreDir = path.join(projectRoot, 'cpp', 'patch_core'); const updateFlowCoreDir = path.join(projectRoot, 'cpp', 'update_flow_core'); const harmonyModuleDir = path.join(projectRoot, 'harmony', 'pushy'); const harmonyBuildDir = path.join(harmonyModuleDir, 'build'); -const harmonyNativeStageDir = path.join( - harmonyModuleDir, - 'src', - 'main', - 'cpp', - 'android-generated' -); -const harmonyNativeStageJniDir = path.join(harmonyNativeStageDir, 'jni'); -const harmonyNativeStagePatchCoreDir = path.join( - harmonyNativeStageDir, - 'patch_core' -); const wrapperProjectDir = path.join(projectRoot, 'harmony', 'har-wrapper'); const defaultOutputPath = path.join(projectRoot, 'harmony', 'pushy.har'); const wrapperProjectFiles = [ @@ -31,6 +20,19 @@ const wrapperProjectFiles = [ path.join('AppScope', 'app.json5'), 'build-profile.json5', ]; +// The shared native sources harmony/pushy/src/main/cpp/CMakeLists.txt +// compiles in place from the repository root (submodules included). +const requiredNativeSources = [ + [path.join(hpatchDir, 'hpatch.c'), 'shared hpatch source'], + [path.join(hpatchDir, 'hpatch.h'), 'shared hpatch source'], + [path.join(androidJniDir, 'HDiffPatch'), 'HDiffPatch submodule'], + [path.join(androidJniDir, 'lzma', 'C'), 'lzma submodule'], + [path.join(patchCoreDir, 'patch_core.cpp'), 'shared patch core source'], + [ + path.join(updateFlowCoreDir, 'update_flow_core.cpp'), + 'shared update flow core source', + ], +]; const args = parseArgs(process.argv.slice(2)); const buildMode = normalizeBuildMode( @@ -59,36 +61,9 @@ try { } function main() { - let buildError = null; - - // Setup runs inside the guard too: a failed version sync or staging copy - // must still clean the android-generated staging directory (its own or a - // stale one from an earlier build) instead of leaving it behind. - try { - syncOhPackageVersion(); - syncHarmonyNativeSources(); - buildHar(); - } catch (error) { - buildError = error; - } - - try { - cleanupHarmonyNativeSources(); - } catch (error) { - if (!buildError) { - buildError = error; - } else { - console.warn( - `Warning: failed to clean staged Harmony native sources: ${ - error instanceof Error ? error.message : String(error) - }` - ); - } - } - - if (buildError) { - throw buildError; - } + syncOhPackageVersion(); + ensureNativeSources(); + buildHar(); } function buildHar() { @@ -136,22 +111,14 @@ function buildHar() { } } + // The wrapper project declares no dependencies of its own; only the + // module (RNOH) needs installing. if (!skipInstall) { - if (hasDependencies(harmonyModuleDir)) { - runCommand(ohpmPath, ['install'], { - cwd: harmonyModuleDir, - env, - label: 'Install Harmony dependencies', - }); - } - - if (hasDependencies(wrapperProjectDir)) { - runCommand(ohpmPath, ['install'], { - cwd: wrapperProjectDir, - env, - label: 'Install wrapper project dependencies', - }); - } + runCommand(ohpmPath, ['install'], { + cwd: harmonyModuleDir, + env, + label: 'Install Harmony dependencies', + }); } const hvigorArgs = ['assembleHar']; @@ -222,89 +189,14 @@ function syncOhPackageVersion() { ); } -function syncHarmonyNativeSources() { - ensureFileExists( - path.join(androidJniDir, 'hpatch.c'), - `Missing Android native source: ${relativeToProject( - path.join(androidJniDir, 'hpatch.c') - )}` - ); - ensureFileExists( - path.join(androidJniDir, 'hpatch.h'), - `Missing Android native source: ${relativeToProject( - path.join(androidJniDir, 'hpatch.h') - )}` - ); - ensureFileExists( - path.join(androidJniDir, 'HDiffPatch'), - `Missing Android native source directory: ${relativeToProject( - path.join(androidJniDir, 'HDiffPatch') - )}` - ); - ensureFileExists( - path.join(androidJniDir, 'lzma', 'C'), - `Missing Android native source directory: ${relativeToProject( - path.join(androidJniDir, 'lzma', 'C') - )}` - ); - ensureFileExists( - path.join(patchCoreDir, 'patch_core.cpp'), - `Missing shared patch core source: ${relativeToProject( - path.join(patchCoreDir, 'patch_core.cpp') - )}` - ); - ensureFileExists( - path.join(updateFlowCoreDir, 'update_flow_core.cpp'), - `Missing shared update flow core source: ${relativeToProject( - path.join(updateFlowCoreDir, 'update_flow_core.cpp') - )}` - ); - - fs.rmSync(harmonyNativeStageDir, { recursive: true, force: true }); - fs.mkdirSync(path.join(harmonyNativeStageJniDir, 'lzma'), { - recursive: true, - }); - - copyPath( - path.join(androidJniDir, 'hpatch.c'), - path.join(harmonyNativeStageJniDir, 'hpatch.c') - ); - copyPath( - path.join(androidJniDir, 'hpatch.h'), - path.join(harmonyNativeStageJniDir, 'hpatch.h') - ); - copyPath( - path.join(androidJniDir, 'HDiffPatch'), - path.join(harmonyNativeStageJniDir, 'HDiffPatch') - ); - copyPath( - path.join(androidJniDir, 'lzma', 'C'), - path.join(harmonyNativeStageJniDir, 'lzma', 'C') - ); - copyPath(patchCoreDir, harmonyNativeStagePatchCoreDir); - copyPath( - updateFlowCoreDir, - path.join(harmonyNativeStageDir, 'update_flow_core') - ); -} - -function cleanupHarmonyNativeSources() { - fs.rmSync(harmonyNativeStageDir, { recursive: true, force: true }); -} - -function copyPath(sourcePath, targetPath) { - const stats = fs.statSync(sourcePath); - if (stats.isDirectory()) { - fs.cpSync(sourcePath, targetPath, { - recursive: true, - force: true, - filter: (entry) => path.basename(entry) !== '.git', - }); - return; +function ensureNativeSources() { + for (const [sourcePath, label] of requiredNativeSources) { + ensureFileExists( + sourcePath, + `Missing ${label}: ${relativeToProject(sourcePath)} ` + + '(run `git submodule update --init --recursive`)' + ); } - - fs.mkdirSync(path.dirname(targetPath), { recursive: true }); - fs.copyFileSync(sourcePath, targetPath); } function ensureWrapperProject() { @@ -492,25 +384,3 @@ function relativeToProject(filePath) { function fail(message) { throw new Error(message); } - -function hasDependencies(dir) { - try { - const pkgPath = path.join(dir, 'oh-package.json5'); - if (!fs.existsSync(pkgPath)) { - return false; - } - const pkgContent = fs.readFileSync(pkgPath, 'utf8'); - const cleanJson = pkgContent.replace( - /\/\*[\s\S]*?\*\/|([^\\:]|^)\/\/.*$/gm, - '' - ); - const pkg = eval('(' + cleanJson + ')'); - const hasDeps = - pkg.dependencies && Object.keys(pkg.dependencies).length > 0; - const hasDevDeps = - pkg.devDependencies && Object.keys(pkg.devDependencies).length > 0; - return !!(hasDeps || hasDevDeps); - } catch (e) { - return true; - } -} diff --git a/scripts/check-constant-parity.js b/scripts/check-constant-parity.js new file mode 100644 index 00000000..6f729bb1 --- /dev/null +++ b/scripts/check-constant-parity.js @@ -0,0 +1,253 @@ +#!/usr/bin/env node +// Asserts that the "hand-kept mirrors" of the shared C++ headers (ArkTS, +// Java, JS) still carry the same values. Plain Node, no SDK: runs in +// `bun lint` on every PR so a limit or code bumped in cpp/patch_core cannot +// silently drift on Harmony, Android or the JS layer. +// +// cpp/patch_core/archive_limits.h <-> harmony/.../ArchiveLimits.ts +// android/.../ArchiveLimits.java +// (only the caps a host still +// compares against itself) +// cpp/patch_core/install_record.h <-> harmony/.../InstallRecord.ts +// android/.../InstallRecord.java +// (only the constants a pair lists +// under `only`) +// cpp/patch_core/error_codes.h <-> harmony/.../ErrorCodes.ts +// android/.../ErrorCodes.java +// src/error.ts (every native code must +// be a known UpdateErrorCode) +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(__dirname, '..'); +const read = (relative) => fs.readFileSync(path.join(root, relative), 'utf8'); + +function collect(re, source, nameGroup, valueGroup) { + const values = new Map(); + let match; + while ((match = re.exec(source)) !== null) { + const name = match[nameGroup]; + values.set(name, evaluate(match[valueGroup].trim(), name)); + } + return values; +} + +// `constexpr kName = ;` — the value is either a string literal or +// an integer expression built from literals, `*`, `+`, parentheses and the +// LL suffix. +const parseCppConstants = (source) => + collect( + /(?:constexpr|inline)\s+(?:const\s+)?(?:long\s+long|int|char\s*\*)\s*(?:const\s+)?k([A-Za-z0-9]+)\s*=\s*([^;]+);/g, + source, + 1, + 2 + ); + +// `export const NAME = ;` +const parseArkTsConstants = (source) => + collect( + /export\s+const\s+([A-Z][A-Z0-9_]*)\s*(?::\s*[a-z]+)?\s*=\s*([^;]+);/g, + source, + 1, + 2 + ); + +// `static final NAME = ;` +const parseJavaConstants = (source) => + collect( + /static\s+final\s+(?:long|int|String)\s+([A-Z][A-Z0-9_]*)\s*=\s*([^;]+);/g, + source, + 1, + 2 + ); + +// Every quoted UPPER_SNAKE string literal, whatever the surrounding shape +// (union type, Set, `as const` array): the JS layer only has to *know* each +// native code; it also carries JS-only codes of its own. +function parseJsStringLiterals(source) { + const re = /(["'`])([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)\1/g; + const values = new Map(); + let match; + while ((match = re.exec(source)) !== null) { + values.set(match[2], match[2]); + } + return values; +} + +// Encoded text (obscured_text.h / Texts.ts / Texts.java, +// scripts/encode-native-text.ts): byte i XORed with (0x5A + 0x1D * i) & 0xFF. +function reveal(hex) { + let text = ''; + for (let i = 0; i < hex.length / 2; i++) { + const byte = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + text += String.fromCharCode(byte ^ ((0x5a + 0x1d * i) & 0xff)); + } + return text; +} + +function evaluate(expression, name) { + const text = expression.replace(/\s+/g, ' ').trim(); + const encoded = text.match( + /^(?:text::RevealStatic|revealText|Texts\.reveal|reveal)\((["'])([0-9a-f]+)\1\)$/ + ); + if (encoded) { + return reveal(encoded[2]); + } + const stringLiteral = text.match(/^(["'])((?:\\.|(?!\1).)*)\1$/); + if (stringLiteral) { + return stringLiteral[2]; + } + // C++ `LL` / Java `L` integer suffixes. + const numeric = text.replace(/(\d)L{1,2}\b/g, '$1'); + if (!/^[\d\s()*+]+$/.test(numeric)) { + throw new Error(`check-constant-parity: cannot evaluate ${name} = ${text}`); + } + // Only digits and arithmetic operators survive the regex above. + return Function(`"use strict"; return (${numeric});`)(); +} + +// kMaxArchiveBytes -> MAX_ARCHIVE_BYTES +function screamingSnake(name) { + return name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase(); +} + +const javaDir = 'android/src/main/java/cn/reactnative/modules/update'; +// Must always be parsed from the header (guards the parser itself: a regex +// regression would otherwise silently drop the pair's coverage). +const archiveLimitsRequired = ['UnknownLengthFreeSpaceProbeBytes', 'MaxArchiveBytes']; +// The accounting itself lives in archive_limits.cpp; each host mirrors only +// the caps it still compares against on its own (download, manifest, +// streamed-bytes backstop, free-space margin). +const archiveLimitsMirroredByArkTs = [ + 'MaxArchiveBytes', + 'MaxManifestBytes', + 'FreeDiskMarginBytes', +]; +const archiveLimitsMirroredByJava = [ + 'MaxArchiveBytes', + 'MaxTotalUncompressedBytes', + 'MaxEntryBytes', + 'MaxManifestBytes', + 'FreeDiskMarginBytes', +]; +const errorCodesRequired = ['DeltaFailed', 'InvalidOptions']; +// The record logic itself lives in install_record.cpp; the hosts only mirror +// the staging suffix they still build their unpack directory from. +const installRecordMirrored = ['StagingSuffix']; + +const pairs = [ + { + header: 'cpp/patch_core/archive_limits.h', + mirror: 'harmony/pushy/src/main/ets/ArchiveLimits.ts', + parse: parseArkTsConstants, + mirrorName: screamingSnake, + required: archiveLimitsRequired, + only: archiveLimitsMirroredByArkTs, + }, + { + header: 'cpp/patch_core/archive_limits.h', + mirror: `${javaDir}/ArchiveLimits.java`, + parse: parseJavaConstants, + mirrorName: screamingSnake, + required: archiveLimitsRequired, + only: archiveLimitsMirroredByJava, + }, + { + header: 'cpp/patch_core/install_record.h', + mirror: 'harmony/pushy/src/main/ets/InstallRecord.ts', + parse: parseArkTsConstants, + mirrorName: screamingSnake, + only: installRecordMirrored, + }, + { + header: 'cpp/patch_core/install_record.h', + mirror: `${javaDir}/InstallRecord.java`, + parse: parseJavaConstants, + mirrorName: screamingSnake, + only: installRecordMirrored, + }, + { + header: 'cpp/patch_core/error_codes.h', + mirror: 'harmony/pushy/src/main/ets/ErrorCodes.ts', + parse: parseArkTsConstants, + mirrorName: (cppName) => `ERROR_${screamingSnake(cppName)}`, + required: errorCodesRequired, + // Every ERROR_* export must exist in the header too (no invented codes). + mirrorPrefix: 'ERROR_', + }, + { + header: 'cpp/patch_core/error_codes.h', + mirror: `${javaDir}/ErrorCodes.java`, + parse: parseJavaConstants, + mirrorName: screamingSnake, + required: errorCodesRequired, + // Every constant in the class must exist in the header too. + mirrorPrefix: '', + }, + { + header: 'cpp/patch_core/error_codes.h', + mirror: 'src/error.ts', + parse: parseJsStringLiterals, + required: errorCodesRequired, + // The literal *is* the code: look each one up by the header's value. + byValue: true, + }, +]; + +const problems = []; +let checked = 0; + +for (const pair of pairs) { + const cpp = parseCppConstants(read(pair.header)); + const mirror = pair.parse(read(pair.mirror)); + if (cpp.size === 0) { + problems.push(`${pair.header}: no constexpr constants parsed`); + continue; + } + for (const name of [...(pair.required || []), ...(pair.only || [])]) { + if (!cpp.has(name)) { + problems.push(`${pair.header}: k${name} not parsed (required)`); + } + } + for (const [cppName, cppValue] of cpp) { + if (pair.only && !pair.only.includes(cppName)) { + continue; + } + const mirrorName = pair.byValue ? cppValue : pair.mirrorName(cppName); + checked += 1; + if (!mirror.has(mirrorName)) { + problems.push( + `${pair.mirror}: missing ${mirrorName} (k${cppName} in ${pair.header})` + ); + continue; + } + const mirrorValue = mirror.get(mirrorName); + if (mirrorValue !== cppValue) { + problems.push( + `${pair.mirror}: ${mirrorName} = ${JSON.stringify(mirrorValue)} but ` + + `${pair.header} k${cppName} = ${JSON.stringify(cppValue)}` + ); + } + } + if (pair.mirrorPrefix !== undefined) { + const known = new Set(Array.from(cpp.keys()).map(pair.mirrorName)); + for (const name of mirror.keys()) { + if (name.startsWith(pair.mirrorPrefix) && !known.has(name)) { + problems.push(`${pair.mirror}: ${name} has no counterpart in ${pair.header}`); + } + } + } +} + +if (problems.length > 0) { + console.error(`check-constant-parity: ${problems.length} problem(s)`); + for (const problem of problems) { + console.error(` - ${problem}`); + } + process.exit(1); +} +console.log( + `check-constant-parity: ${checked} constants match across ${pairs.length} header/mirror pairs` +); diff --git a/scripts/check-harmony-types.js b/scripts/check-harmony-types.js index 04264061..90234e67 100644 --- a/scripts/check-harmony-types.js +++ b/scripts/check-harmony-types.js @@ -1,13 +1,18 @@ #!/usr/bin/env node /** - * Type-checks the HarmonyOS TS sources (harmony/pushy/src/main/ets) with tsc. + * Type-checks the HarmonyOS TS sources (harmony/pushy/src/main/ets/*.ts) with + * a strict tsc pass. * * The root tsconfig excludes harmony/ because these files need the HarmonyOS - * SDK type declarations (@ohos.* / @kit.*) and the RNOH types from oh_modules. - * This script locates a locally installed DevEco SDK, generates a tsconfig - * with the right path mappings, and runs tsc against it. When no SDK is - * available (e.g. CI runners), the check is skipped with a notice instead of - * failing, so it never blocks environments that cannot have the SDK. + * SDK type declarations (@ohos.* / @kit.*); the RNOH surface and the NAPI + * module are stubbed in harmony/types, so oh_modules is not needed. This + * script locates an OpenHarmony SDK, generates a tsconfig with the right + * path mappings and runs tsc against it. + * + * Without an SDK (developer machines without DevEco) the check is skipped + * with a notice. With HARMONY_TYPECHECK_REQUIRED=1 (the harmony-build CI + * container) a missing SDK fails instead, and the search also walks the + * container's install roots. */ const fs = require('fs'); const path = require('path'); @@ -15,8 +20,58 @@ const { spawnSync } = require('child_process'); const repoRoot = path.resolve(__dirname, '..'); const harmonyDir = path.join(repoRoot, 'harmony'); -const ohModules = path.join(harmonyDir, 'pushy', 'oh_modules'); const generatedConfigPath = path.join(harmonyDir, '.tsconfig.harmony.json'); +const required = process.env.HARMONY_TYPECHECK_REQUIRED === '1'; + +// Install roots of the CI image, whose SDK is not laid out like a DevEco +// install. Only searched (first) when the check is required: walking them +// is slow. +const containerRoots = [ + process.env.DEVECO_SDK_HOME, + '/opt', + '/usr/local', + '/usr/lib', + '/root', + '/home', + '/github/home', +]; +const CONTAINER_SEARCH_DEPTH = 8; + +// Every `/openharmony/ets/api` directory under root, at most `depth` +// levels down (symlinks are not followed). The @ohos.*/@kit.* declarations +// live under the OpenHarmony SDK; the sibling hms/ets tree only holds HMS +// kits and must not win. +function findOpenHarmonyApiDirs(root, depth) { + const found = []; + const walk = (dir, level) => { + if (level > depth) { + return; + } + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (e) { + return; + } + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const child = path.join(dir, entry.name); + if ( + entry.name === 'api' && + path.basename(dir) === 'ets' && + path.basename(path.dirname(dir)) === 'openharmony' + ) { + found.push(child); + continue; + } + walk(child, level + 1); + } + }; + walk(root, 1); + return found.sort(); +} function findSdkEtsDir() { // CI containers lay the SDK out differently from DevEco; let them point @@ -33,6 +88,19 @@ function findSdkEtsDir() { 'ignoring it and probing the default SDK locations.' ); } + if (required) { + // The CI image: the first OpenHarmony declaration root under its + // install roots, in root order. + for (const root of containerRoots) { + if (!root || !fs.existsSync(root)) { + continue; + } + const [apiDir] = findOpenHarmonyApiDirs(root, CONTAINER_SEARCH_DEPTH); + if (apiDir) { + return path.dirname(apiDir); + } + } + } const bases = []; if (process.env.DEVECO_SDK_HOME) { bases.push(process.env.DEVECO_SDK_HOME); @@ -63,10 +131,8 @@ function findSdkEtsDir() { return null; } -// On developer machines without DevEco the check skips; in the HarmonyOS CI -// container (HARMONY_TYPECHECK_REQUIRED=1) a missing SDK or oh_modules is a -// misconfiguration and must fail instead of silently passing. -const required = process.env.HARMONY_TYPECHECK_REQUIRED === '1'; +// A missing SDK skips the check, except where it is required (CI), where it +// is a misconfiguration and must fail instead of silently passing. const skipOrFail = (reason) => { if (required) { console.error(`check-harmony-types: ${reason} (required in CI).`); @@ -79,12 +145,11 @@ const skipOrFail = (reason) => { const sdkEts = findSdkEtsDir(); if (!sdkEts) { skipOrFail( - 'no DevEco/OpenHarmony SDK found (set DEVECO_SDK_HOME to enable)' + 'no DevEco/OpenHarmony SDK found (set DEVECO_SDK_HOME or ' + + 'HARMONY_SDK_ETS_DIR to enable)' ); } -if (!fs.existsSync(ohModules)) { - skipOrFail('harmony/pushy/oh_modules not installed'); -} +console.log(`check-harmony-types: using SDK declarations in ${sdkEts}`); const config = { compilerOptions: { diff --git a/scripts/check-native-spec-parity.js b/scripts/check-native-spec-parity.js index 1bf769b0..b91e1a69 100644 --- a/scripts/check-native-spec-parity.js +++ b/scripts/check-native-spec-parity.js @@ -32,29 +32,54 @@ if (methods.length < 10) { // Bridge plumbing that RN itself calls; not part of the Pushy surface. const plumbing = new Set(['addListener', 'removeListeners']); -const oldarch = read( - 'android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java' +// One UpdateModule serves both Android architectures. The old bridge finds +// its methods through @ReactMethod on UpdateModule itself; under the old +// architecture it extends the hand-written NativePushySpec stand-in, which +// must declare every method the codegen spec does. +const androidModule = read( + 'android/src/main/java/cn/reactnative/modules/update/UpdateModule.java' ); -const newarch = read( - 'android/src/newarch/cn/reactnative/modules/update/UpdateModule.java' +const oldarchSpec = read( + 'android/src/oldarch/cn/reactnative/modules/update/NativePushySpec.java' ); const ios = read('ios/RCTPushy/RCTPushy.mm'); const harmonyCpp = read('harmony/pushy/src/main/cpp/PushyTurboModule.cpp'); const harmonyTs = read('harmony/pushy/src/main/ets/PushyTurboModule.ts'); -const has = (source, re) => re.test(source); +// The Harmony method table: `X(name, argCount, SYNC|ASYNC)` rows of the +// PUSHY_METHODS X-macro in PushyTurboModule.cpp. +const harmonyTable = new Map(); const problems = []; +const tableRe = /\bX\(\s*([A-Za-z]+)\s*,\s*(\d+)\s*,\s*(SYNC|ASYNC)\s*\)/g; +while ((m = tableRe.exec(harmonyCpp)) !== null) { + if (harmonyTable.has(m[1])) { + problems.push(`harmony/cpp: ${m[1]} listed twice in PUSHY_METHODS`); + } + harmonyTable.set(m[1], m[3]); +} +if (harmonyTable.size < 10) { + console.error( + 'check-native-spec-parity: failed to parse the PUSHY_METHODS table in ' + + 'harmony/pushy/src/main/cpp/PushyTurboModule.cpp' + ); + process.exit(1); +} + +const has = (source, re) => re.test(source); for (const { name, returns } of methods) { if (plumbing.has(name)) { continue; } - const javaRe = new RegExp(`public\\s+\\S+\\s+${name}\\s*\\(`); - if (!has(oldarch, javaRe)) { - problems.push(`android/oldarch: ${name} not exported`); + const javaRe = new RegExp( + `@ReactMethod\\s+@Override\\s+public\\s+\\S+\\s+${name}\\s*\\(` + ); + if (!has(androidModule, javaRe)) { + problems.push(`android: ${name} not exported with @ReactMethod`); } - if (!has(newarch, javaRe)) { - problems.push(`android/newarch: ${name} not exported`); + const specRe = new RegExp(`public\\s+abstract\\s+\\S+\\s+${name}\\s*\\(`); + if (!has(oldarchSpec, specRe)) { + problems.push(`android/oldarch: ${name} missing from NativePushySpec`); } if ( !has(ios, new RegExp(`RCT_(EXPORT|REMAP)_METHOD\\(\\s*${name}\\b`)) && @@ -62,32 +87,25 @@ for (const { name, returns } of methods) { ) { problems.push(`ios: ${name} not exported`); } - const syncReg = new RegExp(`PUSHY_SYNC_METHOD\\(${name}\\)`); - const asyncReg = new RegExp(`PUSHY_ASYNC_METHOD\\(${name}\\)`); - const registered = new RegExp(`registerMethod\\(\\s*"${name}"`); - if (!has(harmonyCpp, registered)) { - problems.push(`harmony/cpp: ${name} not in registerMethod table`); + const kind = harmonyTable.get(name); + if (!kind) { + problems.push(`harmony/cpp: ${name} not in the PUSHY_METHODS table`); } const isPromise = /^Promise { + if (part === '**' || part === '**/') { + return '(?:.*/)?'; + } + if (part === '*') { + return '[^/]*'; + } + return part.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); + }) + .join(''); + return new RegExp(`^${source}(/|$)`); +} + +// Derived from package.json `files` (the single list of what ships), plus +// package.json which npm always packs. Negated entries must not show up. +const allowed = ['package.json', ...filesField.filter((f) => !f.startsWith('!'))].map( + entryPattern +); +const excluded = filesField + .filter((f) => f.startsWith('!')) + .map((f) => ({ entry: f, re: entryPattern(f.slice(1)) })); const forbidden = [ /(^|\/)(tests?|__tests__)\//, @@ -48,19 +55,32 @@ const required = [ 'src/NativePushy.ts', 'android/build.gradle', 'android/src/main/java/cn/reactnative/modules/update/UpdateContext.java', - 'android/jni/hpatch.c', 'ios/RCTPushy/RCTPushy.mm', 'ios/RCTPushy/RCTPushyOrchestrator.mm', 'react-native-update.podspec', + // The shared cores iOS (podspec Core) and Harmony (CMakeLists.txt) compile + // from the package by directory. 'cpp/patch_core/patch_core.cpp', 'cpp/patch_core/archive_limits.h', 'cpp/patch_core/install_record.h', 'cpp/update_flow_core/update_flow_core.cpp', + 'cpp/hpatch/hpatch.c', + 'cpp/hpatch/hpatch.h', 'harmony/pushy/src/main/ets/PushyTurboModule.ts', 'harmony/hvigor-plugin.ts', 'expo-module.config.json', 'react-native.config.js', - ...(release ? ['harmony/pushy.har'] : []), + ...(release + ? [ + 'harmony/pushy.har', + // Submodule sources hpatch.c decodes with (lint CI checks out + // without submodules; the publish jobs check them out). + 'android/jni/HDiffPatch/libHDiffPatch/HPatch/patch.c', + 'android/jni/HDiffPatch/file_for_patch.c', + 'android/jni/lzma/C/LzmaDec.c', + 'android/jni/lzma/C/Lzma2Dec.c', + ] + : []), ]; const requiredAbis = ['arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64']; @@ -78,9 +98,13 @@ const set = new Set(files); const problems = []; for (const file of files) { - if (!allowedPrefixes.some((p) => file === p || file.startsWith(p))) { + if (!allowed.some((re) => re.test(file))) { problems.push(`not whitelisted: ${file}`); } + const hit = excluded.find(({ re }) => re.test(file)); + if (hit) { + problems.push(`excluded by "${hit.entry}" but packed: ${file}`); + } if (forbidden.some((re) => re.test(file))) { problems.push(`forbidden: ${file}`); } diff --git a/scripts/lib/cpp-test-common.sh b/scripts/lib/cpp-test-common.sh new file mode 100644 index 00000000..a144a7aa --- /dev/null +++ b/scripts/lib/cpp-test-common.sh @@ -0,0 +1,48 @@ +# shellcheck shell=sh +# Shared settings for the cpp/ core test scripts (test-patch-core.sh, +# test-update-flow-core.sh). POSIX sh; sourced, not executed. Expects +# BUILD_DIR to be set by the caller. + +# The cpp/ cores must build warning-free (-Werror). +# shellcheck disable=SC2034 # used by the sourcing script +CORE_WARNINGS="-Wall -Wextra -Werror" + +# Opt-in AddressSanitizer + UndefinedBehaviorSanitizer build: the cores +# process untrusted (downloaded) data, so running the tests under sanitizers +# catches memory/UB regressions. Enable with SANITIZE=1. +# cpp_test_sanitize_flags "" +# sets SANITIZE_FLAGS (empty unless SANITIZE=1). +cpp_test_sanitize_flags() { + SANITIZE_FLAGS="" + if [ "${SANITIZE:-0}" = "1" ]; then + SANITIZE_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -g" + echo "Building $1 with AddressSanitizer + UBSan" + fi +} + +# Opt-in libFuzzer runs (FUZZ=1; FUZZ_SECONDS bounds each run, default 20) +# need clang with the libFuzzer runtime (libclang_rt.fuzzer). Apple clang +# ships without it, so Homebrew LLVM is tried too; FUZZ_CXX picks a compiler +# explicitly. Without a usable one the run is skipped, not failed. +# if cpp_test_fuzz_compiler; then "$FUZZ_CXX" ...; fi +# returns 0 and sets FUZZ_CXX when FUZZ=1 and a compiler links a fuzzer. +cpp_test_fuzz_compiler() { + [ "${FUZZ:-0}" = "1" ] || return 1 + fuzz_probe="$BUILD_DIR/fuzz_probe.cpp" + printf '#include \n#include \nextern "C" int LLVMFuzzerTestOneInput(const uint8_t*, size_t) { return 0; }\n' > "$fuzz_probe" + found_clang=0 + for candidate in ${FUZZ_CXX:-} clang++ /opt/homebrew/opt/llvm/bin/clang++ /usr/local/opt/llvm/bin/clang++; do + command -v "$candidate" >/dev/null 2>&1 || continue + found_clang=1 + if "$candidate" -fsanitize=fuzzer,address,undefined "$fuzz_probe" -o "$BUILD_DIR/fuzz_probe" >/dev/null 2>&1; then + FUZZ_CXX="$candidate" + return 0 + fi + done + if [ "$found_clang" = "0" ]; then + echo "FUZZ=1: clang++ not found, skipping the libFuzzer run" + else + echo "FUZZ=1: clang++ has no libFuzzer runtime (libclang_rt.fuzzer), skipping the libFuzzer run" + fi + return 1 +} diff --git a/scripts/test-ios-purge-restore.sh b/scripts/test-ios-purge-restore.sh index f9a806fa..70fc487b 100755 --- a/scripts/test-ios-purge-restore.sh +++ b/scripts/test-ios-purge-restore.sh @@ -30,12 +30,16 @@ build() { else python3 "$TEST_DIR/extract.py" "$ROOT_DIR/ios/RCTPushy" "$destination" --mutation "$variant" fi - # Compile the real production bodies and state_core; only their external I/O - # collaborators live in the test host. No RN, CocoaPods, simulator or network. + # Compile the real production bodies, state_core and the completion record + # core (the extractor's copy of install_record.cpp, which the record mutants + # alter; -I cpp/patch_core resolves its file-relative includes); only their + # external I/O collaborators live in the test host. No RN, CocoaPods, + # simulator or network. xcrun clang++ -std=c++17 -fobjc-arc -fblocks $WARNING_FLAGS $SANITIZE_FLAGS \ - -I"$ROOT_DIR" -I"$destination" \ + -I"$ROOT_DIR" -I"$destination" -I"$ROOT_DIR/cpp/patch_core" \ "$TEST_DIR/purge_restore_test.mm" "$ROOT_DIR/cpp/patch_core/state_core.cpp" \ - "$ROOT_DIR/cpp/patch_core/digest.cpp" \ + "$ROOT_DIR/cpp/patch_core/digest.cpp" "$destination/install_record.cpp" \ + "$ROOT_DIR"/cpp/update_flow_core/*.cpp \ "$ROOT_DIR/ios/RCTPushy/RCTPushyConfiguration.mm" \ -framework Foundation -o "$destination/purge_restore_test" } diff --git a/scripts/test-patch-core.sh b/scripts/test-patch-core.sh index 6634056f..ef008964 100755 --- a/scripts/test-patch-core.sh +++ b/scripts/test-patch-core.sh @@ -1,50 +1,46 @@ #!/bin/sh +# Flag variables ($SANITIZE_FLAGS, $CORE_WARNINGS, ...) must split into +# compiler arguments. +# shellcheck disable=SC2086 set -eu +# Intentional one-command CDPATH assignment. +# shellcheck disable=SC1007 ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)" BUILD_DIR="$ROOT_DIR/.tmp/patch-core-tests" mkdir -p "$BUILD_DIR" -# Opt-in AddressSanitizer + UndefinedBehaviorSanitizer build. The patch core -# processes untrusted (downloaded) patch data, so running the tests under -# sanitizers catches memory/UB regressions. Enable with: SANITIZE=1 npm run test:patch-core -SANITIZE_FLAGS="" -if [ "${SANITIZE:-0}" = "1" ]; then - SANITIZE_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -g" - echo "Building patch core tests with AddressSanitizer + UBSan" -fi - -# The cpp/ cores must build warning-free (-Werror); the vendored HDiffPatch / -# lzma C sources are compiled with -Wall -Wextra only. -CORE_WARNINGS="-Wall -Wextra -Werror" +# SANITIZE=1 / FUZZ=1 conventions and CORE_WARNINGS. +# shellcheck source-path=SCRIPTDIR source=lib/cpp-test-common.sh +. "$ROOT_DIR/scripts/lib/cpp-test-common.sh" +cpp_test_sanitize_flags "patch core tests" +# The cores and hpatch include everything file-relative; only the test itself +# includes HDiffPatch's patch.h by name. COMMON_INCLUDES=" - -I$ROOT_DIR/cpp/patch_core - -I$ROOT_DIR/android/jni - -I$ROOT_DIR/android/jni/HDiffPatch -I$ROOT_DIR/android/jni/HDiffPatch/libHDiffPatch/HPatch - -I$ROOT_DIR/android/jni/lzma/C " -cc -Wall -Wextra $SANITIZE_FLAGS $COMMON_INCLUDES -c "$ROOT_DIR/android/jni/hpatch.c" -o "$BUILD_DIR/hpatch.o" +# hpatch.c and the vendored HDiffPatch / lzma C sources are compiled with +# -Wall -Wextra only (no -Werror). +cc -Wall -Wextra $SANITIZE_FLAGS $COMMON_INCLUDES -c "$ROOT_DIR/cpp/hpatch/hpatch.c" -o "$BUILD_DIR/hpatch.o" cc -Wall -Wextra $SANITIZE_FLAGS $COMMON_INCLUDES -c "$ROOT_DIR/android/jni/HDiffPatch/libHDiffPatch/HPatch/patch.c" -o "$BUILD_DIR/patch.o" cc -Wall -Wextra $SANITIZE_FLAGS $COMMON_INCLUDES -c "$ROOT_DIR/android/jni/HDiffPatch/file_for_patch.c" -o "$BUILD_DIR/file_for_patch.o" cc -Wall -Wextra $SANITIZE_FLAGS $COMMON_INCLUDES -c "$ROOT_DIR/android/jni/lzma/C/LzmaDec.c" -o "$BUILD_DIR/LzmaDec.o" cc -Wall -Wextra $SANITIZE_FLAGS $COMMON_INCLUDES -c "$ROOT_DIR/android/jni/lzma/C/Lzma2Dec.c" -o "$BUILD_DIR/Lzma2Dec.o" +# Every patch core source, picked up by directory like the platform builds +# (tests/ is a subdirectory and stays out), plus the flow core's JSON parser +# the manifest parser reads __diff.json with (every platform links both). c++ \ -std=c++17 \ $CORE_WARNINGS \ $SANITIZE_FLAGS \ $COMMON_INCLUDES \ "$ROOT_DIR/cpp/patch_core/tests/patch_core_test.cpp" \ - "$ROOT_DIR/cpp/patch_core/archive_patch_core.cpp" \ - "$ROOT_DIR/cpp/patch_core/digest.cpp" \ - "$ROOT_DIR/cpp/patch_core/patch_core.cpp" \ - "$ROOT_DIR/cpp/patch_core/state_core.cpp" \ - "$ROOT_DIR/cpp/patch_core/hbc_transform.cpp" \ - "$ROOT_DIR/cpp/patch_core/hbc_transform_wire.cpp" \ + "$ROOT_DIR"/cpp/patch_core/*.cpp \ + "$ROOT_DIR/cpp/update_flow_core/flow_json.cpp" \ "$BUILD_DIR/hpatch.o" \ "$BUILD_DIR/patch.o" \ "$BUILD_DIR/file_for_patch.o" \ @@ -64,35 +60,56 @@ c++ \ "$BUILD_DIR/patch_core_test" "$ROOT_DIR/cpp/patch_core/tests/fixtures" "$BUILD_DIR/hbc_transform_test" "$ROOT_DIR/cpp/patch_core/tests/fixtures" -# Opt-in libFuzzer run of the hbcTransform wire parser (untrusted __diff.json -# metadata): FUZZ=1 npm run test:patch-core. Needs clang with the libFuzzer -# runtime (libclang_rt.fuzzer); without it the step is skipped, not failed. -# FUZZ_SECONDS bounds the run (default 20). -if [ "${FUZZ:-0}" = "1" ]; then - FUZZ_PROBE="$BUILD_DIR/fuzz_probe.cpp" - printf '#include \n#include \nextern "C" int LLVMFuzzerTestOneInput(const uint8_t*, size_t) { return 0; }\n' > "$FUZZ_PROBE" - if ! command -v clang++ >/dev/null 2>&1; then - echo "FUZZ=1: clang++ not found, skipping the libFuzzer run" - elif ! clang++ -fsanitize=fuzzer,address,undefined "$FUZZ_PROBE" -o "$BUILD_DIR/fuzz_probe" >/dev/null 2>&1; then - echo "FUZZ=1: clang++ has no libFuzzer runtime (libclang_rt.fuzzer), skipping the libFuzzer run" - else - echo "Fuzzing hbcTransform wire parser for ${FUZZ_SECONDS:-20}s" - clang++ \ - -std=c++17 \ - $CORE_WARNINGS \ - -g -O1 -fno-omit-frame-pointer \ - -fsanitize=fuzzer,address,undefined \ - "$ROOT_DIR/cpp/patch_core/tests/fuzz_hbc_transform_wire.cpp" \ - "$ROOT_DIR/cpp/patch_core/hbc_transform.cpp" \ - "$ROOT_DIR/cpp/patch_core/hbc_transform_wire.cpp" \ - -o "$BUILD_DIR/fuzz_hbc_transform_wire" - CORPUS_DIR="$BUILD_DIR/fuzz-corpus-hbc-transform-wire" - mkdir -p "$CORPUS_DIR" - cp "$ROOT_DIR/cpp/patch_core/tests/fixtures/v96.meta.json" "$CORPUS_DIR/seed-v96-meta.json" - "$BUILD_DIR/fuzz_hbc_transform_wire" \ - -max_total_time="${FUZZ_SECONDS:-20}" \ - -max_len=8192 \ - -print_final_stats=1 \ - "$CORPUS_DIR" - fi +# Opt-in libFuzzer runs of the hbcTransform wire parser (untrusted __diff.json +# metadata) and of the whole __diff.json parser: FUZZ=1 bun run test:patch-core. +if cpp_test_fuzz_compiler; then + echo "Fuzzing hbcTransform wire parser for ${FUZZ_SECONDS:-20}s ($FUZZ_CXX)" + "$FUZZ_CXX" \ + -std=c++17 \ + $CORE_WARNINGS \ + -g -O1 -fno-omit-frame-pointer \ + -fsanitize=fuzzer,address,undefined \ + "$ROOT_DIR/cpp/patch_core/tests/fuzz_hbc_transform_wire.cpp" \ + "$ROOT_DIR/cpp/patch_core/hbc_transform.cpp" \ + "$ROOT_DIR/cpp/patch_core/hbc_transform_wire.cpp" \ + -o "$BUILD_DIR/fuzz_hbc_transform_wire" + CORPUS_DIR="$BUILD_DIR/fuzz-corpus-hbc-transform-wire" + mkdir -p "$CORPUS_DIR" + cp "$ROOT_DIR/cpp/patch_core/tests/fixtures/v96.meta.json" "$CORPUS_DIR/seed-v96-meta.json" + "$BUILD_DIR/fuzz_hbc_transform_wire" \ + -max_total_time="${FUZZ_SECONDS:-20}" \ + -max_len=8192 \ + -print_final_stats=1 \ + "$CORPUS_DIR" + + # The __diff.json parser (patch_manifest.h) links the whole core, so the + # hpatch/HDiffPatch/lzma objects built above (uninstrumented) come along. + echo "Fuzzing __diff.json manifest parser for ${FUZZ_SECONDS:-20}s ($FUZZ_CXX)" + "$FUZZ_CXX" \ + -std=c++17 \ + $CORE_WARNINGS \ + -g -O1 -fno-omit-frame-pointer \ + -fsanitize=fuzzer,address,undefined \ + "$ROOT_DIR/cpp/patch_core/tests/fuzz_patch_manifest.cpp" \ + "$ROOT_DIR"/cpp/patch_core/*.cpp \ + "$ROOT_DIR/cpp/update_flow_core/flow_json.cpp" \ + "$BUILD_DIR/hpatch.o" \ + "$BUILD_DIR/patch.o" \ + "$BUILD_DIR/file_for_patch.o" \ + "$BUILD_DIR/LzmaDec.o" \ + "$BUILD_DIR/Lzma2Dec.o" \ + -o "$BUILD_DIR/fuzz_patch_manifest" + CORPUS_DIR="$BUILD_DIR/fuzz-corpus-patch-manifest" + mkdir -p "$CORPUS_DIR" + # Seeds: option byte + manifest text (server ppk and package shapes, the + # HarmonyOS form, and a transform-domain manifest). + printf '\000{"copies":{"assets/b.png":"assets/a.png"},"deletes":{"assets/old.png":1,"assets/gone/":1}}' > "$CORPUS_DIR/seed-ppk.json" + printf '\000{"copies":{"assets/a.png":"","assets/b.png":"assets/a.png"},"copiesCrc":{"assets/a.png":3421780262,"assets/b.png":3421780262}}' > "$CORPUS_DIR/seed-package.json" + printf '\003{"copies":{"assets/i.png":"resources/rawfile/assets/i.png"},"deletes":["x"],"hbcTransform":{"bundle.harmony.js.patch":{"v":1}}}' > "$CORPUS_DIR/seed-harmony.json" + { printf '\000{"hbcTransform":{"index.bundlejs.patch":'; cat "$ROOT_DIR/cpp/patch_core/tests/fixtures/v96.meta.json"; printf '}}'; } > "$CORPUS_DIR/seed-hbc.json" + "$BUILD_DIR/fuzz_patch_manifest" \ + -max_total_time="${FUZZ_SECONDS:-20}" \ + -max_len=8192 \ + -print_final_stats=1 \ + "$CORPUS_DIR" fi diff --git a/scripts/test-update-flow-core.sh b/scripts/test-update-flow-core.sh index e84524d5..917f5883 100755 --- a/scripts/test-update-flow-core.sh +++ b/scripts/test-update-flow-core.sh @@ -1,4 +1,7 @@ #!/bin/sh +# Flag variables ($SANITIZE_FLAGS, $CORE_WARNINGS) must split into compiler +# arguments. +# shellcheck disable=SC2086 set -eu # Intentional one-command CDPATH assignment. @@ -8,63 +11,48 @@ BUILD_DIR="$ROOT_DIR/.tmp/update-flow-core-tests" mkdir -p "$BUILD_DIR" -# Opt-in sanitizers, same convention as test-patch-core.sh: -# SANITIZE=1 npm run test:flow-core -SANITIZE_FLAGS="" -if [ "${SANITIZE:-0}" = "1" ]; then - SANITIZE_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -g" - echo "Building update flow core tests with AddressSanitizer + UBSan" -fi - -# The cpp/ cores must build warning-free. -CORE_WARNINGS="-Wall -Wextra -Werror" +# SANITIZE=1 / FUZZ=1 conventions and CORE_WARNINGS. +# shellcheck source-path=SCRIPTDIR source=lib/cpp-test-common.sh +. "$ROOT_DIR/scripts/lib/cpp-test-common.sh" +cpp_test_sanitize_flags "update flow core tests" -# SANITIZE_FLAGS must split into compiler arguments. -# shellcheck disable=SC2086 c++ \ -std=c++17 \ $CORE_WARNINGS \ $SANITIZE_FLAGS \ - "$ROOT_DIR/cpp/update_flow_core/flow_json.cpp" \ - "$ROOT_DIR/cpp/update_flow_core/update_flow_core.cpp" \ + "$ROOT_DIR"/cpp/update_flow_core/*.cpp \ "$ROOT_DIR/cpp/update_flow_core/tests/update_flow_core_test.cpp" \ -o "$BUILD_DIR/update_flow_core_test" "$BUILD_DIR/update_flow_core_test" \ - "$ROOT_DIR/cpp/update_flow_core/tests/flow_vectors.json" + "$ROOT_DIR/cpp/update_flow_core/tests/flow_vectors.json" \ + "$ROOT_DIR/cpp/update_flow_core/tests/config-equivalence-vectors.json" \ + "$ROOT_DIR/cpp/update_flow_core/tests/native-config-vectors.json" \ + "$ROOT_DIR/cpp/update_flow_core/tests/native-round-vectors.json" # Opt-in libFuzzer run of the JSON parser + decision pipeline (raw server -# bytes): FUZZ=1 npm run test:flow-core. Needs clang with the libFuzzer -# runtime (libclang_rt.fuzzer); without it the step is skipped, not failed. -# FUZZ_SECONDS bounds the run (default 20). -if [ "${FUZZ:-0}" = "1" ]; then - FUZZ_PROBE="$BUILD_DIR/fuzz_probe.cpp" - printf '#include \n#include \nextern "C" int LLVMFuzzerTestOneInput(const uint8_t*, size_t) { return 0; }\n' > "$FUZZ_PROBE" - if ! command -v clang++ >/dev/null 2>&1; then - echo "FUZZ=1: clang++ not found, skipping the libFuzzer run" - elif ! clang++ -fsanitize=fuzzer,address,undefined "$FUZZ_PROBE" -o "$BUILD_DIR/fuzz_probe" >/dev/null 2>&1; then - echo "FUZZ=1: clang++ has no libFuzzer runtime (libclang_rt.fuzzer), skipping the libFuzzer run" - else - echo "Fuzzing flow JSON parser + decision pipeline for ${FUZZ_SECONDS:-20}s" - # shellcheck disable=SC2086 - clang++ \ - -std=c++17 \ - $CORE_WARNINGS \ - -g -O1 -fno-omit-frame-pointer \ - -fsanitize=fuzzer,address,undefined \ - "$ROOT_DIR/cpp/update_flow_core/flow_json.cpp" \ - "$ROOT_DIR/cpp/update_flow_core/update_flow_core.cpp" \ - "$ROOT_DIR/cpp/update_flow_core/tests/fuzz_flow_json.cpp" \ - -o "$BUILD_DIR/fuzz_flow_json" - CORPUS_DIR="$BUILD_DIR/fuzz-corpus-flow-json" - mkdir -p "$CORPUS_DIR" - printf '{"update":true,"hash":"h2","full":"h2.ppk","diff":"d.hdiff","paths":["cdn.x.com","https://m.x.com/"],"name":"v2","config":{"forceBoot":true}}' > "$CORPUS_DIR/seed-download.json" - printf '{"update":true,"hash":"root","expVersion":{"hash":"gray","full":"g.ppk","config":{"rollout":{"2.3.4":63}}},"paths":["cdn"]}' > "$CORPUS_DIR/seed-rollout.json" - printf '{"upToDate":true,"s":"\\ud83d\\ude00 \\u00e9 \\n","n":[1,-0.5,1e2,null,false]}' > "$CORPUS_DIR/seed-scalars.json" - "$BUILD_DIR/fuzz_flow_json" \ - -max_total_time="${FUZZ_SECONDS:-20}" \ - -max_len=4096 \ - -print_final_stats=1 \ - "$CORPUS_DIR" - fi +# bytes): FUZZ=1 bun run test:flow-core. +if cpp_test_fuzz_compiler; then + echo "Fuzzing flow JSON parser + decision pipeline for ${FUZZ_SECONDS:-20}s ($FUZZ_CXX)" + "$FUZZ_CXX" \ + -std=c++17 \ + $CORE_WARNINGS \ + -g -O1 -fno-omit-frame-pointer \ + -fsanitize=fuzzer,address,undefined \ + "$ROOT_DIR"/cpp/update_flow_core/*.cpp \ + "$ROOT_DIR/cpp/update_flow_core/tests/fuzz_flow_json.cpp" \ + -o "$BUILD_DIR/fuzz_flow_json" + CORPUS_DIR="$BUILD_DIR/fuzz-corpus-flow-json" + mkdir -p "$CORPUS_DIR" + printf '{"update":true,"hash":"h2","full":"h2.ppk","diff":"d.hdiff","paths":["cdn.x.com","https://m.x.com/"],"name":"v2","config":{"forceBoot":true}}' > "$CORPUS_DIR/seed-download.json" + printf '{"update":true,"hash":"root","expVersion":{"hash":"gray","full":"g.ppk","config":{"rollout":{"2.3.4":63}}},"paths":["cdn"]}' > "$CORPUS_DIR/seed-rollout.json" + printf '{"upToDate":true,"s":"\\ud83d\\ude00 \\u00e9 \\n","n":[1,-0.5,1e2,null,false]}' > "$CORPUS_DIR/seed-scalars.json" + printf '{"appKey":" k ","endpoints":["https://a.example/","https://a.example"],"disabled":false,"rn":""}' > "$CORPUS_DIR/seed-config.json" + printf '{"appKey":"k","endpoints":["https://a","http://b",7],"queryUrls":["https://l",""],"packageVersion":"2.3.4"}' > "$CORPUS_DIR/seed-round.json" + printf '["https://a","http://b/",1,"https://c//"]' > "$CORPUS_DIR/seed-list.json" + "$BUILD_DIR/fuzz_flow_json" \ + -max_total_time="${FUZZ_SECONDS:-20}" \ + -max_len=4096 \ + -print_final_stats=1 \ + "$CORPUS_DIR" fi diff --git a/scripts/tests/ios-purge-restore/README.md b/scripts/tests/ios-purge-restore/README.md index d44f7738..18acff13 100644 --- a/scripts/tests/ios-purge-restore/README.md +++ b/scripts/tests/ios-purge-restore/README.md @@ -1,8 +1,8 @@ # Deterministic native purge-restore ordering tests Follow-up to #646. These tests exercise the tvOS Release branch of the real -`RCTPushy.mm` / `RCTPushyOrchestrator.mm` startup/commit/reset code in a macOS -Foundation-only executable. +`ios/RCTPushy` startup/commit/reset/switch code in a macOS Foundation-only +executable. There are no production code changes, React Native mocks to install, CocoaPods, simulator builds, network requests, or sleeps. @@ -26,16 +26,18 @@ python3 -m unittest discover -s scripts/tests/ios-purge-restore -p test_extract. ## Production code, not a second state machine -`extract.py` reads the checkout's `ios/RCTPushy/RCTPushy.mm` and -`RCTPushyOrchestrator.mm` on every build (each definition must exist exactly -once across them; pod-internal `PUSHY_HIDDEN` definitions count like `static` -ones). It copies the exact definitions (with `#line` locations) of `bundleURL`, -`resolveLaunchBundleURL`, `restorePurgedLaunch`, `commitRoundWithGeneration`, -`resetToPackagedBundle`, their state/defaults helpers, the install record -activation check (`PushyVerifyInstallForActivation`) with `switchVersion:error:`, -and the relevant globals -into build-only `.inc` files. These are compiled unchanged with the actual -`cpp/patch_core/state_core.cpp`. +`extract.py` reads the checkout's `ios/RCTPushy` sources listed in its +`SOURCES` (the module's `.mm` files plus `RCTPushyInternal.h`) on every build +(each definition must exist exactly once across them; pod-internal +`PUSHY_HIDDEN` definitions count like `static` ones, the header's `extern` +declarations never match). It copies the exact definitions (with `#line` +locations) of `bundleURL`, `resolveLaunchBundleURL`, `restorePurgedLaunch`, +`commitRoundWithGeneration`, `resetToPackagedBundle`, their state/defaults +helpers, `+[RCTPushyInstaller switchVersion:error:]` and the relevant globals +into build-only `.inc` files, and copies the shared completion record core +(`cpp/patch_core/install_record.cpp`, whose `CheckActivation`/`Unchanged` +switchVersion calls) into the build directory. These are compiled unchanged +with the actual `cpp/patch_core/state_core.cpp` and `digest.cpp`. Extraction ignores braces in comments and literals, skips forward declarations, and rejects missing or duplicate definitions. No generated implementation is @@ -89,8 +91,9 @@ preferences domain. `config_equivalence` additionally replays the cross-platform canonical config equality vectors in `cpp/update_flow_core/tests/config-equivalence-vectors.json` -(contract C1, shared with the JS/Harmony bun test and the Android JUnit test) -against the extracted `PushyConfigsEquivalent`, in both argument orders. +(contract C1, also replayed by the JS bun test and the flow-core C++ test) +through the extracted `PushyConfigsEquivalent` binding over +`flowcore::ConfigsEquivalent`, in both argument orders. A real GCD worker captures the request generation and blocks at a controlled response boundary. Semaphores establish each ordering. Reset races are exercised @@ -108,9 +111,9 @@ since process start. - Remove the closed-window activation veto: `late_commit` must fail. - Return `NO` after timeout instead of re-resolving: `commit_before_signal` must fail. - Remove the reset-generation guard: `reset_wins` must fail. -- Trust a schema-1 install record without `bundleSha256`: - `install_record_activation` must fail. -- Grandfather current/last versions that do carry a record: +- Trust a schema-1 install record without `bundleSha256` (in the copy of + `install_record.cpp`): `install_record_activation` must fail. +- Grandfather current/last versions that do carry a record (same copy): `switch_version_records` must fail. Each mutant must compile and fail its specific state assertion; unrelated diff --git a/scripts/tests/ios-purge-restore/extract.py b/scripts/tests/ios-purge-restore/extract.py index eb210270..b0dd51fc 100644 --- a/scripts/tests/ios-purge-restore/extract.py +++ b/scripts/tests/ios-purge-restore/extract.py @@ -2,7 +2,7 @@ """Compile production Objective-C++ methods in a Foundation-only test host. No generated source is checked in. Bodies, signatures and source locations come -from RCTPushy.mm on every run. Extraction fails on missing/ambiguous definitions; +from the ios/RCTPushy sources (SOURCES) on every run. Extraction fails on missing/ambiguous definitions; strings/comments cannot affect brace matching. Only the test host supplies I/O. """ @@ -47,7 +47,9 @@ def definition(source: str, pattern: str, label: str) -> tuple[int, str]: # File-local (static) or shared within the pod (PUSHY_HIDDEN, see -# RCTPushyInternal.h); the test host compiles everything in one unit. +# RCTPushyCommon.h); the test host compiles everything in one unit. The +# header's `extern PUSHY_HIDDEN` declarations never match: they start with +# `extern`. LINKAGE = r'(?:static|PUSHY_HIDDEN)' @@ -59,10 +61,6 @@ def method(source: str, name: str) -> tuple[int, str]: return definition(source, rf'^\+\s*\([^\n)]*\)\s*{re.escape(name)}\b', name) -def instance_method(source: str, name: str) -> tuple[int, str]: - return definition(source, rf'^-\s*\([^\n)]*\)\s*{re.escape(name)}\b', name) - - def exported_method(source: str, name: str) -> tuple[int, str]: return definition(source, rf'^RCT_EXPORT_METHOD\(\s*{re.escape(name)}\s*:', name) @@ -87,29 +85,32 @@ def declaration(source: str, name: str) -> tuple[int, str]: 'legacyParamPackageVersion', 'legacyParamBuildTime', 'paramLastVersion', 'paramCurrentVersion', 'paramIsFirstTime', 'paramIsFirstLoadOk', 'keyUuid', 'keyHashInfo', 'keyFirstLoadMarked', 'keyRolledBackMarked', - 'keyNativeCheckCache', 'BUNDLE_FILE_NAME', 'VERSION_COMPLETE_FILE_NAME', 'ERROR_OPTIONS', + 'keyNativeCheckCache', 'BUNDLE_FILE_NAME', 'ERROR_OPTIONS', 'pushyStateLock', 'ignoreRollback', 'pushyIsUsingBundleUrl', 'pushyResetGeneration', 'pushyNativeConfigGeneration', 'pushyLaunchVersion', 'pushyCrashHoldActive', 'pushyPurgeRestoreActive', 'pushyPurgeRestoreWindowOpen', 'pushyHostRoundResult', 'kPushyPurgeRestoreBudget', 'kPushyPurgeRestoreMinimumBudget', ] HELPERS = [ - 'PushyRevealOnce', 'KeyPackageUpdatedMarked', 'PushyWithStateLock', 'PushyToStdString', 'PushyFromStdString', + 'KeyPackageUpdatedMarked', 'PushyWithStateLock', 'PushyToStdString', 'PushyFromStdString', + 'PushyJsonDictionary', 'PushyJsonString', 'PushyRejectException', 'PushyFileQueue', 'PushySetNullableString', 'PushyHashInfoKey', 'PushyBinaryIdentityValue', 'PushyStateFromDefaults', 'PushyApplyStateToDefaults', 'PushySwitchVersionLocked', - # Canonical config equality (cross-platform contract), tested directly. - 'PushyNormalizeEndpointBase', 'PushyJsonIsBool', 'PushyJsonEqual', - 'PushyCanonicalUrlList', 'PushyCanonicalConfig', 'PushyConfigsEquivalent', - # Install record activation check (cpp/patch_core/install_record.h) and - # what switchVersion:error: needs around it. - 'PushyReadInstallRecord', 'PushyInstallRecordMatches', 'PushyHasInstallRecordFile', - 'PushyHasCompletedVersionAtPath', 'PushyStampFile', 'PushyVerifyInstallForActivation', - 'PushyStringIsBlank', 'PushyIsSafePathComponent', + # Canonical config equality (contract C1): the binding over + # flowcore::ConfigsEquivalent, tested directly. + 'PushyConfigsEquivalent', + # What switchVersion:error: needs around the shared record core + # (cpp/patch_core/install_record.cpp, compiled from CORE_SOURCE). + 'PushyVersionDir', 'PushyStringIsBlank', 'PushyIsSafePathComponent', ] # Emitted ahead of HELPERS, which use them. -STRUCTS = ['PushyFileStamp'] -MUTATIONS = ('late-activation', 'skip-reresolve', 'ignore-reset', 'trust-digestless', - 'grandfather-recorded') +STRUCTS: list[str] = [] +# The completion record core switchVersion:error: calls. Copied (with #line) +# into the build directory so the record mutants below can alter it; the +# test script compiles the copy instead of the checkout's file. +CORE_SOURCE = Path('cpp/patch_core/install_record.cpp') +CORE_MUTATIONS = ('trust-digestless', 'grandfather-recorded') +MUTATIONS = ('late-activation', 'skip-reresolve', 'ignore-reset') + CORE_MUTATIONS def mutate(text: str, mutation: str) -> str: @@ -120,11 +121,13 @@ def mutate(text: str, mutation: str) -> str: 'ignore-reset': ( r'if \(pushyResetGeneration\.load\(\) != generation\)\s*\{\s*return;\s*\}', '(void)generation;'), + # Trust a schema-1 record without bundleSha256. 'trust-digestless': ( - r'(expected\.length == 0\) \{\s*)return \[NSString[^;]*record mismatch[^;]*;', - r'\1return nil;'), + r'(if \(record\.bundle_sha256\.empty\(\)\) \{\s*)return RecordMismatch\(version_hash\);', + r'\1return Status::Ok();'), + # Grandfather current/last versions even when they carry a record. 'grandfather-recorded': ( - r'if \(PushyHasInstallRecordFile\(versionDir\)\)\s*\{\s*return NO;\s*\}', ''), + r'if \(grandfathered && state == Record::State::kAbsent\)', 'if (grandfathered)'), } pattern, replacement = patterns[mutation] mutated, count = re.subn(pattern, replacement, text) @@ -133,9 +136,9 @@ def mutate(text: str, mutation: str) -> str: return mutated -# The sources the module is split across (RCTPushy.mm: module and launch -# resolution; RCTPushyOrchestrator.mm: the native round). -SOURCES = ('RCTPushy.mm', 'RCTPushyOrchestrator.mm') +# The sources the module is split across (see RCTPushyInternal.h). +SOURCES = ('RCTPushyInternal.h', 'RCTPushy.mm', 'RCTPushy+Launch.mm', 'RCTPushyState.mm', + 'RCTPushyInstallRecord.mm', 'RCTPushyInstaller.mm', 'RCTPushyOrchestrator.mm') def locate(sources: list[tuple[Path, str]], extractor, name: str) -> tuple[Path, int, str]: @@ -160,16 +163,13 @@ def generate(source_dir: Path, output: Path, mutation: str | None = None) -> Non restore = locate(sources, method, 'restorePurgedLaunch') commit = locate(sources, method, 'commitRoundWithGeneration') helpers = [locate(sources, function, name) for name in HELPERS] - switch_version = locate(sources, instance_method, 'switchVersion') - if mutation == 'grandfather-recorded': - switch_version = (switch_version[0], switch_version[1], - mutate(switch_version[2], mutation)) + switch_version = locate(sources, method, 'switchVersion') + core_path = (source_dir / '..' / '..' / CORE_SOURCE).resolve() + core = core_path.read_text(encoding='utf-8') + if mutation in CORE_MUTATIONS: + core = mutate(core, mutation) elif mutation == 'skip-reresolve': restore = (restore[0], restore[1], mutate(restore[2], mutation)) - elif mutation == 'trust-digestless': - index = HELPERS.index('PushyVerifyInstallForActivation') - path, line, text = helpers[index] - helpers[index] = (path, line, mutate(text, mutation)) elif mutation: commit = (commit[0], commit[1], mutate(commit[2], mutation)) sections = { @@ -177,9 +177,11 @@ def generate(source_dir: Path, output: Path, mutation: str | None = None) -> Non 'helpers.inc': [locate(sources, struct, name) for name in STRUCTS] + helpers, 'pushy.inc': [locate(sources, method, 'bundleURL'), locate(sources, method, 'resolveLaunchBundleURL'), - locate(sources, exported_method, 'resetToPackagedBundle'), - switch_version], - 'orchestrator.inc': [restore, commit], + locate(sources, exported_method, 'resetToPackagedBundle')], + 'installer.inc': [switch_version], + # The launch wait helper precedes restorePurgedLaunch so the host's + # dispatch seams apply to it as well. + 'orchestrator.inc': [locate(sources, function, 'PushyWaitSeconds'), restore, commit], } output.mkdir(parents=True, exist_ok=True) for name, snippets in sections.items(): @@ -187,6 +189,8 @@ def generate(source_dir: Path, output: Path, mutation: str | None = None) -> Non f'#line {line} {json.dumps(str(path.resolve()))}\n{text}\n\n' for path, line, text in snippets ), encoding='utf-8') + output.joinpath(CORE_SOURCE.name).write_text( + f'#line 1 {json.dumps(str(core_path))}\n{core}', encoding='utf-8') def main() -> None: diff --git a/scripts/tests/ios-purge-restore/purge_restore_test.mm b/scripts/tests/ios-purge-restore/purge_restore_test.mm index b23e14b8..5496d7e8 100644 --- a/scripts/tests/ios-purge-restore/purge_restore_test.mm +++ b/scripts/tests/ios-purge-restore/purge_restore_test.mm @@ -1,6 +1,7 @@ // Foundation-only host for the UNMODIFIED production methods extracted at build // time. The state lock, defaults transitions, launch resolution, restore window, -// commit and reset bodies all come from RCTPushy.mm; see README.md for the seams. +// commit, reset and switchVersion bodies all come from ios/RCTPushy; see +// README.md for the seams. #import #import #import @@ -16,7 +17,8 @@ #include "cpp/patch_core/error_codes.h" #include "cpp/patch_core/digest.h" #include "cpp/patch_core/install_record.h" -#include "ios/RCTPushy/RCTPushyConfiguration.h" +#include "cpp/update_flow_core/native_config.h" +#include "ios/RCTPushy/RCTPushyCommon.h" #undef TARGET_OS_TV #define TARGET_OS_TV 1 @@ -31,12 +33,9 @@ static void TestLog(NSString *, ...) {} #define RCTLogWarn TestLog #define RCTLogError TestLog -// Pod-internal linkage (RCTPushyInternal.h); one translation unit here. +// Pod-internal linkage (RCTPushyCommon.h); one translation unit here. +#undef PUSHY_HIDDEN #define PUSHY_HIDDEN - -// RCTPushy.mm's own #define, not extracted; InstallRecordActivation checks it -// against install_record.h. -#define VERSION_COMPLETE_FILE_NAME_LITERAL ".pushy-complete" #include "globals.inc" static const char *testName = "setup"; @@ -165,9 +164,7 @@ Status CleanupOldEntries(const std::string&, const std::vector&, } }} -@interface RCTPushy : NSObject { - dispatch_queue_t _fileQueue; -} +@interface RCTPushy : NSObject + (NSURL *)bundleURL; + (NSURL *)resolveLaunchBundleURL:(NSString **)rolledBack purgedVersion:(NSString **)purged; + (NSURL *)binaryBundleURL; @@ -175,7 +172,10 @@ + (NSString *)packageVersion; + (NSString *)buildTime; + (NSString *)downloadDir; - (void)resetToPackagedBundle:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject; -- (BOOL)switchVersion:(NSString *)hash error:(NSError **)error; +@end + +@interface RCTPushyInstaller : NSObject ++ (BOOL)switchVersion:(NSString *)hash error:(NSError **)error; @end @interface RCTPushyOrchestrator : NSObject @@ -188,10 +188,7 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation configGeneration:(uint64_t)configGeneration hashInfo:(NSDictionary *)hashInfo activate:(NSString *)hash - responseText:(NSString *)response - request:(NSString *)request - config:(NSString *)config - responseAt:(long long)responseAt + cache:(NSString *)cache activated:(BOOL *)activated; @end @@ -273,11 +270,6 @@ static dispatch_time_t TestLaunchDeadline(dispatch_time_t when, int64_t delta) { } @implementation RCTPushy -- (instancetype)init { - self = [super init]; - if (self) { _fileQueue = dispatch_queue_create("pushy.test.files", DISPATCH_QUEUE_SERIAL); } - return self; -} + (NSURL *)binaryBundleURL { return [NSURL fileURLWithPath:[root stringByAppendingPathComponent:@"packaged.bundle"]]; } + (NSString *)packageVersion { return @"1.0"; } + (NSString *)buildTime { return @"123"; } @@ -285,6 +277,10 @@ + (NSString *)downloadDir { return [root stringByAppendingPathComponent:@"update #include "pushy.inc" @end +@implementation RCTPushyInstaller +#include "installer.inc" +@end + @implementation RCTPushyOrchestrator + (void)prepareProcess:(NSString *)rolledBack { (void)rolledBack; } + (BOOL)hasRunnableConfig { return YES; } @@ -302,8 +298,7 @@ + (void)startRoundWithDeadline:(NSTimeInterval)deadline { roundCommitted = [self commitRoundWithGeneration:requestGeneration configGeneration:pushyNativeConfigGeneration.load() hashInfo:@{@"hash": @"B", @"info": @{@"name": @"B", @"forceBootRescue": @YES}} - activate:@"B" responseText:@"{\"update\":true,\"hash\":\"B\"}" - request:@"request" config:@"config" responseAt:123 activated:&roundActivated]; + activate:@"B" cache:@"{\"ts\":123,\"body\":\"{}\"}" activated:&roundActivated]; pushyHostRoundResult = @{@"status": @"test", @"reason": @"", @"hash": @"B"}; Signal(commitFinished); actor = Actor::None; @@ -518,10 +513,11 @@ static void DefaultsIsolation(void) { }); } -// Canonical config equality shared with JS/Android/Harmony: equal canonical -// forms are the same config (persistConfiguration no-op, hasJsRound match). -// Replays the cross-platform vectors every implementation consumes; the file -// is located from this source file (/scripts/tests/ios-purge-restore/). +// Canonical config equality (contract C1, flowcore::ConfigsEquivalent): equal +// canonical forms are the same config (persistConfiguration no-op, +// hasJsRound match). Replays the shared vectors through the NSString binding; +// the file is located from this source file +// (/scripts/tests/ios-purge-restore/). static void ConfigEquivalence(void) { NSString *repo = [[[[@__FILE__ stringByDeletingLastPathComponent] stringByDeletingLastPathComponent] stringByDeletingLastPathComponent] @@ -544,9 +540,10 @@ static void ConfigEquivalence(void) { std::printf("[PASS] config_equivalence: %lu shared vectors\n", (unsigned long)cases.count); } -// Two-phase install record (cpp/patch_core/install_record.h): the production -// activation check against real files. Schema 1 makes bundleSha256 mandatory; -// only the legacy EMPTY marker (SDK < 10.53) activates without a digest. +// Two-phase install record (cpp/patch_core/install_record.h): the shared +// activation check switchVersion:error: calls, against real files. Schema 1 +// makes bundleSha256 mandatory; only the legacy EMPTY marker (SDK < 10.53) +// activates without a digest. static NSString *Sha256Hex(NSData *data) { unsigned char digest[CC_SHA256_DIGEST_LENGTH]; CC_SHA256(data.bytes, (CC_LONG)data.length, digest); @@ -556,8 +553,6 @@ static void ConfigEquivalence(void) { } static void InstallRecordActivation(void) { - Expect([VERSION_COMPLETE_FILE_NAME isEqualToString:@(pushy::install_record::kFileName)], - "record file name matches install_record.h"); NSString *hash = @"0123456789abcdef"; NSString *dir = [NSTemporaryDirectory() stringByAppendingPathComponent: [NSString stringWithFormat:@"pushy-install-record-%@", NSUUID.UUID.UUIDString]]; @@ -566,14 +561,19 @@ static void InstallRecordActivation(void) { NSData *bundle = [@"var a = 1;" dataUsingEncoding:NSUTF8StringEncoding]; Expect([bundle writeToFile:[dir stringByAppendingPathComponent:BUNDLE_FILE_NAME] atomically:YES], "write bundle"); - NSString *marker = [dir stringByAppendingPathComponent:VERSION_COMPLETE_FILE_NAME]; + NSString *marker = [dir stringByAppendingPathComponent:@(pushy::install_record::kFileName)]; + auto check = ^NSString *(void) { + pushy::install_record::Activation activation; + pushy::install_record::Status status = pushy::install_record::CheckActivation( + PushyToStdString(dir), PushyToStdString(hash), PushyToStdString(BUNDLE_FILE_NAME), + false, &activation); + return status.ok ? nil : PushyFromStdString(status.message); + }; auto verify = ^NSString *(id record) { NSData *data = [record isKindOfClass:NSData.class] ? record : [NSJSONSerialization dataWithJSONObject:record options:0 error:nil]; Expect([data writeToFile:marker atomically:YES], "write record"); - NSDictionary *verified = nil; - PushyFileStamp stamp; - return PushyVerifyInstallForActivation(dir, hash, &verified, &stamp); + return check(); }; auto rejected = ^bool(NSString *reason, NSString *what) { return reason != nil && [reason containsString:what]; @@ -603,12 +603,11 @@ static void InstallRecordActivation(void) { Expect(rejected(verify(@{@"schema": schema, @"versionHash": @"other", @"bundleSha256": Sha256Hex(bundle)}), @"completion record mismatch"), "record for another version must not activate"); - [NSFileManager.defaultManager removeItemAtPath:marker error:nil]; - NSDictionary *verified = nil; - PushyFileStamp stamp; - Expect(rejected(PushyVerifyInstallForActivation(dir, hash, &verified, &stamp), + Expect(rejected(verify([@"{oops" dataUsingEncoding:NSUTF8StringEncoding]), @"no valid completion record"), - "missing record must not activate"); + "malformed record must not activate"); + [NSFileManager.defaultManager removeItemAtPath:marker error:nil]; + Expect(rejected(check(), @"is incomplete"), "missing record must not activate"); [NSFileManager.defaultManager removeItemAtPath:dir error:nil]; } @@ -625,7 +624,7 @@ static void SwitchVersionRecords(void) { NSData *bundle = [@"C" dataUsingEncoding:NSUTF8StringEncoding]; Expect([bundle writeToFile:[dir stringByAppendingPathComponent:BUNDLE_FILE_NAME] atomically:YES], "write C bundle"); - NSString *marker = [dir stringByAppendingPathComponent:VERSION_COMPLETE_FILE_NAME]; + NSString *marker = [dir stringByAppendingPathComponent:@(pushy::install_record::kFileName)]; NSNumber *schema = @(pushy::install_record::kSchema); auto setRecord = ^(NSDictionary *record) { [NSFileManager.defaultManager removeItemAtPath:marker error:nil]; @@ -645,7 +644,7 @@ static void SwitchVersionRecords(void) { }; auto rejection = ^NSString *(void) { NSError *error = nil; - if ([engine switchVersion:hash error:&error]) { + if ([RCTPushyInstaller switchVersion:hash error:&error]) { return nil; } Expect([error.domain isEqualToString:@(pushy::error_codes::kSwitchVersionFailed)], diff --git a/scripts/tests/ios-purge-restore/test_extract.py b/scripts/tests/ios-purge-restore/test_extract.py index 38ff7f18..0bc2a6d8 100644 --- a/scripts/tests/ios-purge-restore/test_extract.py +++ b/scripts/tests/ios-purge-restore/test_extract.py @@ -1,7 +1,7 @@ import unittest -from extract import (declaration, exported_method, function, instance_method, mask_literals, - method, mutate, struct) +from extract import (declaration, exported_method, function, mask_literals, method, mutate, + struct) class ExtractionTests(unittest.TestCase): @@ -24,9 +24,17 @@ def test_objc_declaration_and_definition(self): source = '+ (BOOL)restore:(id)value;\n+ (BOOL)restore:(id)value { return YES; }' self.assertEqual(method(source, 'restore'), (2, source.splitlines()[1])) - def test_objc_instance_method_is_not_a_class_method(self): - source = '+ (BOOL)switchVersion:(id)h { return NO; }\n- (BOOL)switchVersion:(id)h { return YES; }' - self.assertEqual(instance_method(source, 'switchVersion'), (2, source.splitlines()[1])) + def test_objc_class_method_is_not_an_instance_method(self): + source = '- (BOOL)switchVersion:(id)h { return NO; }\n+ (BOOL)switchVersion:(id)h { return YES; }' + self.assertEqual(method(source, 'switchVersion'), (2, source.splitlines()[1])) + + def test_header_extern_is_not_a_definition(self): + source = ('extern PUSHY_HIDDEN NSString *const key;\n' + 'PUSHY_HIDDEN NSString *Run(void);\n' + 'PUSHY_HIDDEN NSString *const key = @"k";\n' + 'PUSHY_HIDDEN NSString *Run(void) {\n return key;\n}') + self.assertEqual(declaration(source, 'key')[0], 3) + self.assertEqual(function(source, 'Run')[0], 4) def test_objc_no_argument_method(self): self.assertEqual(method('+ (id)bundleURL\n{ return nil; }', 'bundleURL')[0], 1) @@ -58,9 +66,12 @@ def test_mutations_fail_on_source_drift(self): self.assertEqual(mutate('activation = nil;', 'late-activation'), '(void)activation;') self.assertEqual(mutate('return YES;', 'skip-reresolve'), 'return !timedOut;') self.assertEqual( - mutate('expected.length == 0) {\n return [NSString stringWithFormat:@"%@ record mismatch.", h];', + mutate('if (record.bundle_sha256.empty()) {\n return RecordMismatch(version_hash);', 'trust-digestless'), - 'expected.length == 0) {\n return nil;') + 'if (record.bundle_sha256.empty()) {\n return Status::Ok();') + self.assertEqual( + mutate('if (grandfathered && state == Record::State::kAbsent) {', 'grandfather-recorded'), + 'if (grandfathered) {') for source in ('', 'activation = nil; activation = nil;'): with self.assertRaises(ValueError): mutate(source, 'late-activation') diff --git a/scripts/verify-android-so.js b/scripts/verify-android-so.js index b894a6c4..0fd82189 100644 --- a/scripts/verify-android-so.js +++ b/scripts/verify-android-so.js @@ -3,7 +3,7 @@ * Verify the prebuilt Android native libraries shipped in android/lib/ are * present for every ABI and export the JNI symbols the Java layer binds. This * guards against publishing an npm package whose committed librnpushy.so is - * stale/missing after a cpp/patch_core change (which would crash consumers at + * stale/missing after a cpp/ core change (which would crash consumers at * runtime with UnsatisfiedLinkError while CI stays green). * * Reads the ELF .dynsym table directly so it needs no external tools — the @@ -28,7 +28,7 @@ const ABI_MACHINE = { x86_64: 0x3e, // EM_X86_64 }; -// Native methods are registered from JNI_OnLoad (cpp/patch_core/ +// Native methods are registered from JNI_OnLoad (cpp/android_jni/ // jni_registration.cpp), so JNI_OnLoad must be the library's only JNI export. // A leftover Java_* export means a stale .so built before that change. const REQUIRED_SYMBOLS = ['JNI_OnLoad']; diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index e652fb42..8e232e23 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -1,13 +1,20 @@ import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test'; +import { + createClient, + createJsonResponse, + importFreshClient, + mockCore, + mockI18n, + mockPlatform, + restoreDevAfterEach, +} from './helpers'; +import { realProjectModules } from './setup'; -const importFreshClient = (cacheKey: string) => import(`../client?${cacheKey}`); - -const originalDev = (globalThis as any).__DEV__; const originalErrorUtils = (globalThis as any).ErrorUtils; // Module mocks and mock.restore() are handled globally in setup.ts. +restoreDevAfterEach(); afterEach(() => { - (globalThis as any).__DEV__ = originalDev; if (originalErrorUtils === undefined) { delete (globalThis as any).ErrorUtils; } else { @@ -15,14 +22,6 @@ afterEach(() => { } }); -const createJsonResponse = (payload: unknown) => - ({ - ok: true, - status: 200, - text: async () => JSON.stringify(payload), - json: async () => payload, - }) as Response; - const setupClientMocks = ({ isFirstTime = false, markSuccess = mock(() => {}), @@ -45,6 +44,8 @@ const setupClientMocks = ({ getNativeCheckCache = undefined, markJsCheckCompleted = undefined, setLocalHashInfo = mock(() => {}), + downloadAndInstallApk = mock(() => Promise.resolve()), + platform = 'ios', }: { isFirstTime?: boolean; markSuccess?: ReturnType; @@ -62,24 +63,13 @@ const setupClientMocks = ({ getNativeCheckCache?: ReturnType; markJsCheckCompleted?: ReturnType; setLocalHashInfo?: ReturnType; + downloadAndInstallApk?: ReturnType; + platform?: 'ios' | 'android'; } = {}) => { (globalThis as any).__DEV__ = false; - mock.module('react-native', () => ({ - Platform: { - OS: 'ios', - Version: 17, - }, - DeviceEventEmitter: { - addListener: mock(() => ({ remove: mock(() => {}) })), - }, - NativeEventEmitter: class { - addListener = mock(() => ({ remove: mock(() => {}) })); - removeAllListeners = mock(() => {}); - }, - })); - - mock.module('../core', () => ({ + mockPlatform({ OS: platform, Version: platform === 'ios' ? 17 : 30 }); + mockCore({ PushyModule: { markSuccess, reloadUpdate, @@ -87,99 +77,30 @@ const setupClientMocks = ({ downloadPatchFromPpk, downloadPatchFromPackage, downloadFullUpdate, - downloadAndInstallApk: mock(() => Promise.resolve()), + downloadAndInstallApk, restartApp, resetToPackagedBundle, ...(syncNativeConfig ? { syncNativeConfig } : {}), ...(getNativeCheckCache ? { getNativeCheckCache } : {}), ...(markJsCheckCompleted ? { markJsCheckCompleted } : {}), }, - buildTime: '2023-01-01', - cInfo: { - rnu: '10.0.0', - rn: '0.73.0', - os: 'ios', - uuid: 'uuid', - }, - currentVersion: 'hash', - currentVersionInfo: {}, + cInfo: { rnu: '10.0.0', rn: '0.73.0', os: platform, uuid: 'uuid' }, isFirstTime, - isRolledBack: false, - packageVersion: '1.0.0', pushyNativeEventEmitter: { addListener: addProgressListener, }, - rolledBackVersion: '', setLocalHashInfo, supportedDiffVersion, getBundleHash, - })); - - mock.module('../i18n', () => ({ - default: { - t: (key: string) => key, - setLocale: mock(() => {}), - }, - })); -}; - -const setupAndroidApkMocks = ( - downloadAndInstallApk: ReturnType -) => { - (globalThis as any).__DEV__ = false; - - mock.module('react-native', () => ({ - Platform: { - OS: 'android', - Version: 30, - }, - DeviceEventEmitter: { - addListener: mock(() => ({ remove: mock(() => {}) })), - }, - NativeEventEmitter: class { - addListener = mock(() => ({ remove: mock(() => {}) })); - removeAllListeners = mock(() => {}); - }, - })); - - mock.module('../core', () => ({ - PushyModule: { - markSuccess: mock(() => {}), - reloadUpdate: mock(() => Promise.resolve()), - setNeedUpdate: mock(() => Promise.resolve()), - downloadPatchFromPpk: mock(() => Promise.resolve()), - downloadPatchFromPackage: mock(() => Promise.resolve()), - downloadFullUpdate: mock(() => Promise.resolve()), - downloadAndInstallApk, - restartApp: mock(() => Promise.resolve()), - }, - buildTime: '2023-01-01', - cInfo: { rnu: '10.0.0', rn: '0.73.0', os: 'android', uuid: 'uuid' }, - currentVersion: 'hash', - currentVersionInfo: {}, - isFirstTime: false, - isRolledBack: false, - packageVersion: '1.0.0', - pushyNativeEventEmitter: { - addListener: mock(() => ({ remove: mock(() => {}) })), - }, - rolledBackVersion: '', - setLocalHashInfo: mock(() => {}), - supportedDiffVersion: 2, - getBundleHash: mock(() => ''), - })); - - mock.module('../i18n', () => ({ - default: { t: (key: string) => key, setLocale: mock(() => {}) }, - })); + }); + mockI18n(); }; describe('Pushy server config', () => { test('uses preset main endpoints directly as configured endpoints', async () => { setupClientMocks(); - const { Pushy } = await importFreshClient('preset-main'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(client.getConfiguredCheckEndpoints()).toEqual([ 'https://update.react-native.cn/api', @@ -187,7 +108,7 @@ describe('Pushy server config', () => { ]); }); - test('filters remote endpoints against all configured main endpoints', async () => { + test('dedupes remote endpoints (the fallback drops configured ones)', async () => { setupClientMocks(); (globalThis as any).fetch = mock(async () => createJsonResponse([ @@ -199,11 +120,12 @@ describe('Pushy server config', () => { ]) ); - const { Pushy } = await importFreshClient('remote-main-array'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.getRemoteEndpoints()).toEqual([ + 'https://update.react-native.cn/api', 'https://edge-a.example.com', + 'https://update.reactnative.cn/api', 'https://edge-b.example.com', ]); }); @@ -211,8 +133,7 @@ describe('Pushy server config', () => { test('clones custom server config when setOptions overrides server', async () => { setupClientMocks(); - const { Pushy } = await importFreshClient('clone-server-config'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const server = { main: ['https://a.example.com', 'https://b.example.com'], queryUrls: ['https://q.example.com'], @@ -233,8 +154,7 @@ describe('Pushy server config', () => { setupClientMocks(); const logger = mock(() => {}); - const { Pushy } = await importFreshClient('report-late-logger'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const reportPromise = (client as any).report({ type: 'markSuccess' }); expect(logger).not.toHaveBeenCalled(); @@ -252,9 +172,7 @@ describe('Pushy server config', () => { const beforeCheckUpdate = mock(() => false); const afterCheckUpdate = mock(() => {}); - const { Pushy } = await importFreshClient('after-check-update-skipped'); - const client = new Pushy({ - appKey: 'demo-app', + const { client } = await createClient({ beforeCheckUpdate, afterCheckUpdate, }); @@ -278,9 +196,7 @@ describe('Pushy server config', () => { createJsonResponse(checkResult) ); - const { Pushy } = await importFreshClient('after-check-update-completed'); - const client = new Pushy({ - appKey: 'demo-app', + const { client } = await createClient({ afterCheckUpdate, }); @@ -296,8 +212,8 @@ describe('Pushy server config', () => { const fetchMock = mock(async () => createJsonResponse({ update: false })); (globalThis as any).fetch = fetchMock; - const { Pushy } = await importFreshClient('check-body-diffv'); - await new Pushy({ appKey: 'demo-app' }).checkUpdate(); + const { client } = await createClient(); + await client.checkUpdate(); const body = JSON.parse((fetchMock.mock.calls[0] as any[])[1].body); expect(body.diffV).toBe(2); @@ -308,8 +224,8 @@ describe('Pushy server config', () => { const fetchMock = mock(async () => createJsonResponse({ update: false })); (globalThis as any).fetch = fetchMock; - const { Pushy } = await importFreshClient('check-body-no-diffv'); - await new Pushy({ appKey: 'demo-app' }).checkUpdate(); + const { client } = await createClient(); + await client.checkUpdate(); const body = JSON.parse((fetchMock.mock.calls[0] as any[])[1].body); expect(body).not.toHaveProperty('diffV'); @@ -320,8 +236,8 @@ describe('Pushy server config', () => { const fetchMock = mock(async () => createJsonResponse({ update: false })); (globalThis as any).fetch = fetchMock; - const { Pushy } = await importFreshClient('check-body-bundlehash'); - await new Pushy({ appKey: 'demo-app' }).checkUpdate(); + const { client } = await createClient(); + await client.checkUpdate(); const body = JSON.parse((fetchMock.mock.calls[0] as any[])[1].body); expect(body.bundleHash).toBe('a'.repeat(64)); @@ -335,8 +251,8 @@ describe('Pushy server config', () => { const fetchMock = mock(async () => createJsonResponse({ update: false })); (globalThis as any).fetch = fetchMock; - const { Pushy } = await importFreshClient('check-body-no-bundlehash'); - await new Pushy({ appKey: 'demo-app' }).checkUpdate(); + const { client } = await createClient(); + await client.checkUpdate(); const body = JSON.parse((fetchMock.mock.calls[0] as any[])[1].body); expect(body).not.toHaveProperty('bundleHash'); @@ -348,10 +264,8 @@ describe('Pushy server config', () => { createJsonResponse({ upToDate: true, bundleStatus: 'unknownBundle' }) ); - const { Pushy } = await importFreshClient('check-unknown-bundle'); const events: any[] = []; - const client = new Pushy({ - appKey: 'demo-app', + const { client } = await createClient({ logger: (event: any) => events.push(event), }); const result = await client.checkUpdate(); @@ -368,10 +282,8 @@ describe('Pushy server config', () => { createJsonResponse({ upToDate: true, bundleStatus: 'matched' }) ); - const { Pushy } = await importFreshClient('check-matched-bundle'); const events: any[] = []; - const client = new Pushy({ - appKey: 'demo-app', + const { client } = await createClient({ logger: (event: any) => events.push(event), }); await client.checkUpdate(); @@ -387,9 +299,7 @@ describe('Pushy server config', () => { throw fetchError; }); - const { Pushy } = await importFreshClient('after-check-update-error'); - const client = new Pushy({ - appKey: 'demo-app', + const { client } = await createClient({ throwError: true, afterCheckUpdate, }); @@ -412,9 +322,7 @@ describe('Pushy server config', () => { downloadFullUpdate, }); - const { Pushy } = await importFreshClient('skip-current-hash-download'); - const client = new Pushy({ - appKey: 'demo-app', + const { client } = await createClient({ logger, }); @@ -451,11 +359,7 @@ describe('Pushy server config', () => { markSuccess: nativeMarkSuccess, }); - const { Pushy, sharedState } = await importFreshClient( - 'mark-success-awaits-native' - ); - const client = new Pushy({ - appKey: 'demo-app', + const { client, sharedState } = await createClient({ logger, }); @@ -485,10 +389,7 @@ describe('Pushy server config', () => { ); const logger = mock(() => {}); setupClientMocks({ isFirstTime: true, markSuccess: nativeMarkSuccess }); - const { Pushy, sharedState } = await importFreshClient( - 'mark-success-concurrent' - ); - const client = new Pushy({ appKey: 'demo-app', logger }); + const { client, sharedState } = await createClient({ logger }); const first = client.markSuccess(); const second = client.markSuccess(); @@ -509,9 +410,7 @@ describe('Pushy server config', () => { ++calls === 1 ? Promise.reject(Error('busy')) : Promise.resolve() ); setupClientMocks({ isFirstTime: true, markSuccess: nativeMarkSuccess }); - const { Pushy, sharedState } = - await importFreshClient('mark-success-retry'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client, sharedState } = await createClient(); const errors: any[] = []; client.onError((e: any) => { errors.push(e); @@ -546,14 +445,10 @@ describe('Pushy server config', () => { }); setupClientMocks({ reloadUpdate }); - const { Pushy, sharedState } = await importFreshClient( - 'before-reload-switch-version' - ); - sharedState.downloadedHash = 'next-hash'; - const client = new Pushy({ - appKey: 'demo-app', + const { client, sharedState } = await createClient({ beforeReload, }); + sharedState.downloadedHash = 'next-hash'; await client.switchVersion('next-hash'); @@ -567,14 +462,10 @@ describe('Pushy server config', () => { const beforeReload = mock(() => false); setupClientMocks({ reloadUpdate }); - const { Pushy, sharedState } = await importFreshClient( - 'before-reload-skip-switch' - ); - sharedState.downloadedHash = 'next-hash'; - const client = new Pushy({ - appKey: 'demo-app', + const { client, sharedState } = await createClient({ beforeReload, }); + sharedState.downloadedHash = 'next-hash'; await client.switchVersion('next-hash'); @@ -597,9 +488,7 @@ describe('Pushy server config', () => { }); setupClientMocks({ restartApp }); - const { Pushy } = await importFreshClient('before-reload-restart-app'); - const client = new Pushy({ - appKey: 'demo-app', + const { client } = await createClient({ beforeReload, }); @@ -621,8 +510,7 @@ describe('JS exception reporting', () => { } ); - const { Pushy } = await importFreshClient('js-exception-report'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const error = Object.assign(new Error('boom'), { name: 'TypeError', stack: 'TypeError: boom\n at index.bundlejs:12:34', @@ -673,8 +561,7 @@ describe('JS exception reporting', () => { }, }; - const { Pushy } = await importFreshClient('js-exception-global'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(current).not.toBe(previous); const cleanup = client.enableErrorReporting(); const error = new Error('fatal'); @@ -700,9 +587,7 @@ describe('JS exception reporting', () => { }, }; - const { Pushy } = await importFreshClient('js-exception-opt-out'); - const client = new Pushy({ - appKey: 'demo-app', + const { client } = await createClient({ disableErrorReporting: true, }); expect(current).toBe(previous); @@ -724,8 +609,7 @@ describe('JS exception reporting', () => { setupClientMocks(); const fetch = mock(async () => createJsonResponse({ ok: 1 })); (globalThis as any).fetch = fetch; - const { Pushy } = await importFreshClient('js-exception-disabled'); - const client = new Pushy({ appKey: 'demo-app', disableTelemetry: true }); + const { client } = await createClient({ disableTelemetry: true }); client.captureException(new Error('ignored')); await Promise.resolve(); expect(fetch).not.toHaveBeenCalled(); @@ -740,8 +624,7 @@ describe('error pipeline (onError + stable codes, EH-1/EH-2/EH-3)', () => { throw new Error('offline'); }); - const { Pushy } = await importFreshClient('pipeline-check-failed'); - const client = new Pushy({ appKey: 'demo-app', logger }); + const { client } = await createClient({ logger }); const seen: any[] = []; client.onError((e: any, eventType: string) => { seen.push({ e, eventType }); @@ -768,8 +651,7 @@ describe('error pipeline (onError + stable codes, EH-1/EH-2/EH-3)', () => { throw new Error('offline'); }); - const { Pushy } = await importFreshClient('pipeline-unsubscribe'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const listener = mock(() => {}); const unsubscribe = client.onError(listener); unsubscribe(); @@ -783,12 +665,8 @@ describe('error pipeline (onError + stable codes, EH-1/EH-2/EH-3)', () => { const logger = mock(() => {}); setupClientMocks({ reloadUpdate }); - const { Pushy, sharedState } = await importFreshClient( - 'pipeline-switch-version-failed' - ); + const { client, sharedState } = await createClient({ logger }); sharedState.downloadedHash = 'next-hash'; - sharedState.applyingUpdate = false; - const client = new Pushy({ appKey: 'demo-app', logger }); const seen: any[] = []; client.onError((e: any, eventType: string) => { seen.push({ e, eventType }); @@ -822,12 +700,8 @@ describe('error pipeline (onError + stable codes, EH-1/EH-2/EH-3)', () => { const reloadUpdate = mock(() => Promise.reject(nativeError)); setupClientMocks({ reloadUpdate }); - const { Pushy, sharedState } = await importFreshClient( - 'pipeline-native-code-preserved' - ); + const { client, sharedState } = await createClient(); sharedState.downloadedHash = 'next-hash'; - sharedState.applyingUpdate = false; - const client = new Pushy({ appKey: 'demo-app' }); const seen: any[] = []; client.onError((e: any, eventType: string) => { seen.push({ e, eventType }); @@ -843,11 +717,8 @@ describe('error pipeline (onError + stable codes, EH-1/EH-2/EH-3)', () => { test('a beforeReload hook throw gets USER_HOOK_ERROR, distinct from pipeline failures (JS2-3)', async () => { setupClientMocks(); - const { Pushy, sharedState } = await importFreshClient( - 'pipeline-user-hook-error' - ); + const { Pushy, sharedState } = await importFreshClient(); sharedState.downloadedHash = 'next-hash'; - sharedState.applyingUpdate = false; const client = new Pushy({ appKey: 'demo-app', beforeReload: () => { @@ -871,12 +742,44 @@ describe('error pipeline (onError + stable codes, EH-1/EH-2/EH-3)', () => { expect(sharedState.applyingUpdate).toBe(false); }); + test('an onError listener retrying switchVersion after a beforeReload throw is not ignored', async () => { + const reloadUpdate = mock(() => Promise.resolve()); + setupClientMocks({ reloadUpdate }); + const { Pushy, sharedState } = await importFreshClient(); + sharedState.downloadedHash = 'next-hash'; + let hookCalls = 0; + const client = new Pushy({ + appKey: 'demo-app', + beforeReload: () => { + hookCalls += 1; + if (hookCalls === 1) { + throw new Error('hook exploded'); + } + return true; + }, + }); + let retry: Promise | undefined; + client.onError(() => { + // Synchronous retry: applyingUpdate must already be released. + retry ??= client.switchVersion('next-hash'); + }); + + await expect(client.switchVersion('next-hash')).rejects.toThrow( + 'hook exploded' + ); + + expect(retry).toBeDefined(); + expect(await retry).toBe(true); + expect(hookCalls).toBe(2); + expect(reloadUpdate).toHaveBeenCalledTimes(1); + }); + test('concurrent checkUpdate reusing a failing in-flight check keeps its contracts (JS2-1)', async () => { setupClientMocks(); (globalThis as any).fetch = mock(async () => { throw new Error('offline'); }); - const { Pushy } = await importFreshClient('pipeline-check-cache-error'); + const { Pushy } = await importFreshClient(); const states: any[] = []; const client = new Pushy({ appKey: 'demo-app', @@ -903,12 +806,12 @@ describe('error pipeline (onError + stable codes, EH-1/EH-2/EH-3)', () => { Promise.reject(Error('disk full')) ); const logger = mock(() => {}); - setupAndroidApkMocks(downloadAndInstallApk); + setupClientMocks({ + platform: 'android', + downloadAndInstallApk: downloadAndInstallApk, + }); - const { Pushy, sharedState } = - await importFreshClient('pipeline-apk-cause'); - sharedState.apkStatus = null; - const client = new Pushy({ appKey: 'demo-app', logger }); + const { client } = await createClient({ logger }); const seen: any[] = []; client.onError((e: any, eventType: string) => { seen.push({ e, eventType }); @@ -957,12 +860,8 @@ describe('switchVersion operation ownership', () => { }) as unknown as typeof clearTimeout; setupClientMocks({ reloadUpdate: mock(() => Promise.resolve()) }); - const { Pushy, sharedState } = await importFreshClient( - 'switch-watchdog-ownership' - ); const logger = mock((_event: any) => {}); - const client = new Pushy({ - appKey: 'demo-app', + const { client, sharedState } = await createClient({ logger, disableTelemetry: true, disableErrorReporting: true, @@ -1016,11 +915,7 @@ describe('switchVersion operation ownership', () => { }) ); setupClientMocks({ reloadUpdate }); - const { Pushy, sharedState } = await importFreshClient( - 'switch-rejection-ownership' - ); - const client = new Pushy({ - appKey: 'demo-app', + const { client, sharedState } = await createClient({ disableTelemetry: true, disableErrorReporting: true, }); @@ -1082,7 +977,7 @@ describe('downloadUpdate fallback chain', () => { // Mock testUrls to return urls directly (skip actual HEAD ping) mock.module('../utils', () => ({ - __esModule: true, + ...realProjectModules['../utils'], assertWeb: () => true, computeProgress: (received: number, total: number) => total > 0 @@ -1091,8 +986,6 @@ describe('downloadUpdate fallback chain', () => { DEFAULT_FETCH_TIMEOUT_MS: 5000, fetchWithTimeout, info: mock(() => {}), - joinUrls: (paths: string[], fileName?: string) => - fileName ? paths.map((p) => `${p}/${fileName}`) : undefined, log: mock(() => {}), noop: () => {}, promiseAny: mock(() => Promise.resolve()), @@ -1130,9 +1023,7 @@ describe('downloadUpdate fallback chain', () => { test('uses diff when available', async () => { const { downloadPatchFromPpk } = setupDownloadMocks(); - const { Pushy, sharedState } = await importFreshClient('dl-diff-ok'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const hash = await client.downloadUpdate(updateInfo); @@ -1163,9 +1054,7 @@ describe('downloadUpdate fallback chain', () => { const { downloadPatchFromPackage, downloadFullUpdate } = setupDownloadMocks( { downloadPatchFromPpk } ); - const { Pushy, sharedState } = await importFreshClient('dl-mirror-next'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 0 }); + const { client } = await createClient({ maxRetries: 0 }); expect(await client.downloadUpdate(twoMirrors)).toBe('new-hash'); expect(downloadPatchFromPpk).toHaveBeenCalledTimes(2); @@ -1186,9 +1075,7 @@ describe('downloadUpdate fallback chain', () => { const { downloadPatchFromPackage } = setupDownloadMocks({ downloadPatchFromPpk, }); - const { Pushy, sharedState } = await importFreshClient('dl-mirror-patch'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 0 }); + const { client } = await createClient({ maxRetries: 0 }); expect(await client.downloadUpdate(twoMirrors)).toBe('new-hash'); // Same bytes on every mirror: one attempt, then straight to pdiff. @@ -1206,11 +1093,7 @@ describe('downloadUpdate fallback chain', () => { listener?.({ hash: 'new-hash', received: 50, total: 100 }); }); setupDownloadMocks({ downloadPatchFromPpk, addProgressListener }); - const { Pushy, sharedState } = await importFreshClient( - 'dl-progress-isolated' - ); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 0 }); + const { client } = await createClient({ maxRetries: 0 }); const seen: number[] = []; const first = client.downloadUpdate(updateInfo, () => { @@ -1229,9 +1112,7 @@ describe('downloadUpdate fallback chain', () => { test('a version is not "downloaded" until its hash info is persisted', async () => { const setLocalHashInfo = mock(() => Promise.reject(Error('commit failed'))); setupDownloadMocks({ setLocalHashInfo }); - const { Pushy, sharedState } = await importFreshClient('dl-hashinfo-fail'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 0 }); + const { client, sharedState } = await createClient({ maxRetries: 0 }); const seen: any[] = []; client.onError((e: any, eventType: string) => { seen.push({ e, eventType }); @@ -1253,10 +1134,7 @@ describe('downloadUpdate fallback chain', () => { downloadFullUpdate, } = setupDownloadMocks(); const logger = mock(() => {}); - const { Pushy, sharedState } = await importFreshClient('dl-no-artifact'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ - appKey: 'demo-app', + const { client } = await createClient({ logger, disableTelemetry: true, }); @@ -1305,9 +1183,7 @@ describe('downloadUpdate fallback chain', () => { }); }), }); - const { Pushy, sharedState } = await importFreshClient('dl-progress'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); await client.downloadUpdate(updateInfo, onDownloadProgress); @@ -1324,9 +1200,7 @@ describe('downloadUpdate fallback chain', () => { setupDownloadMocks({ downloadPatchFromPpk: mock(() => Promise.reject(Error('diff fail'))), }); - const { Pushy, sharedState } = await importFreshClient('dl-fallback-pdiff'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const hash = await client.downloadUpdate(updateInfo); @@ -1344,9 +1218,7 @@ describe('downloadUpdate fallback chain', () => { downloadPatchFromPpk: mock(() => Promise.reject(Error('diff fail'))), downloadPatchFromPackage: mock(() => Promise.reject(Error('pdiff fail'))), }); - const { Pushy, sharedState } = await importFreshClient('dl-fallback-full'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const hash = await client.downloadUpdate(updateInfo); @@ -1362,9 +1234,7 @@ describe('downloadUpdate fallback chain', () => { downloadPatchFromPackage: mock(() => Promise.reject(Error('pdiff fail'))), downloadFullUpdate: mock(() => Promise.reject(Error('full fail'))), }); - const { Pushy, sharedState } = await importFreshClient('dl-all-fail'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 0 }); + const { client } = await createClient({ maxRetries: 0 }); await expect(client.downloadUpdate(updateInfo)).rejects.toThrow( 'error_full_patch_failed' @@ -1377,11 +1247,7 @@ describe('downloadUpdate fallback chain', () => { downloadPatchFromPackage: mock(() => Promise.reject(Error('pdiff fail'))), downloadFullUpdate: mock(() => Promise.reject(Error('full fail'))), }); - const { Pushy, sharedState } = await importFreshClient( - 'dl-negative-retries' - ); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: -1 }); + const { client } = await createClient({ maxRetries: -1 }); await expect(client.downloadUpdate(updateInfo)).rejects.toThrow( 'error_full_patch_failed' @@ -1402,9 +1268,7 @@ describe('downloadUpdate fallback chain', () => { return Promise.resolve(); }), }); - const { Pushy, sharedState } = await importFreshClient('dl-retry-ok'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 2 }); + const { client } = await createClient({ maxRetries: 2 }); const hash = await client.downloadUpdate(updateInfo); @@ -1426,9 +1290,7 @@ describe('downloadUpdate fallback chain', () => { : Promise.resolve(); }), }); - const { Pushy, sharedState } = await importFreshClient('dl-rank-once'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 3 }); + const { client } = await createClient({ maxRetries: 3 }); expect(await client.downloadUpdate(twoMirrors)).toBe('new-hash'); // diff, pdiff, full: one HEAD race each, not one per attempt. @@ -1447,10 +1309,7 @@ describe('downloadUpdate fallback chain', () => { downloadPatchFromPackage: mock(() => Promise.reject(Error('pdiff fail'))), downloadFullUpdate: mock(() => Promise.reject(Error('full fail'))), }); - const { Pushy, sharedState } = - await importFreshClient('dl-default-retries'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); await expect(client.downloadUpdate(updateInfo)).rejects.toThrow(); // 1 initial + 3 retries = 4 calls @@ -1463,9 +1322,7 @@ describe('downloadUpdate fallback chain', () => { downloadPatchFromPackage: mock(() => Promise.reject(Error('pdiff fail'))), downloadFullUpdate: mock(() => Promise.reject(Error('full fail'))), }); - const { Pushy, sharedState } = await importFreshClient('dl-retry-exhaust'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 2 }); + const { client } = await createClient({ maxRetries: 2 }); await expect(client.downloadUpdate(updateInfo)).rejects.toThrow( 'error_full_patch_failed' @@ -1478,9 +1335,7 @@ describe('downloadUpdate fallback chain', () => { downloadPatchFromPackage: mock(() => Promise.reject(Error('pdiff fail'))), downloadFullUpdate: mock(() => Promise.reject(Error('full fail'))), }); - const { Pushy, sharedState } = await importFreshClient('dl-failed-code'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 0 }); + const { client } = await createClient({ maxRetries: 0 }); const seen: any[] = []; client.onError((e: any, eventType: string) => { seen.push({ e, eventType }); @@ -1507,11 +1362,7 @@ describe('downloadUpdate fallback chain', () => { downloadPatchFromPackage: mock(() => Promise.reject(Error('pdiff fail'))), downloadFullUpdate: mock(() => Promise.reject(patchError)), }); - const { Pushy, sharedState } = await importFreshClient( - 'dl-patch-failed-code' - ); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 0 }); + const { client } = await createClient({ maxRetries: 0 }); const seen: any[] = []; client.onError((e: any, eventType: string) => { seen.push({ e, eventType }); @@ -1538,10 +1389,6 @@ describe('downloadUpdate fallback chain', () => { }) ); setupDownloadMocks({ downloadFullUpdate }); - const { Pushy, sharedState } = await importFreshClient( - 'dl-concurrent-dedup' - ); - sharedState.downloadedHash = undefined; // Only a full URL so the full strategy is the one exercised. const fullOnlyInfo = { update: true as const, @@ -1553,7 +1400,7 @@ describe('downloadUpdate fallback chain', () => { }; // No onDownloadProgress passed — old code only deduped when a progress // handler was registered, so this exercises the new promise-table dedup. - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const first = client.downloadUpdate(fullOnlyInfo); const second = client.downloadUpdate(fullOnlyInfo); @@ -1581,11 +1428,7 @@ describe('downloadUpdate fallback chain', () => { ), downloadFullUpdate: mock(() => Promise.reject(Error('full offline'))), }); - const { Pushy, sharedState } = await importFreshClient( - 'dl-exhausted-strategy' - ); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 2 }); + const { client } = await createClient({ maxRetries: 2 }); await expect(client.downloadUpdate(updateInfo)).rejects.toThrow(); @@ -1611,9 +1454,7 @@ describe('downloadUpdate fallback chain', () => { downloadPatchFromPackage: mock(reject), downloadFullUpdate: mock(reject), }); - const { Pushy, sharedState } = await importFreshClient('dl-all-exhausted'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 3 }); + const { client } = await createClient({ maxRetries: 3 }); const err: any = await client .downloadUpdate(updateInfo) @@ -1635,11 +1476,10 @@ describe('downloadUpdate fallback chain', () => { ), }); const logger = mock(() => {}); - const { Pushy, sharedState } = await importFreshClient( - 'dl-fallback-patch-code' - ); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 0, logger }); + const { client } = await createClient({ + maxRetries: 0, + logger, + }); expect(await client.downloadUpdate(updateInfo)).toBe('new-hash'); @@ -1661,11 +1501,7 @@ describe('downloadUpdate fallback chain', () => { const { fetchWithTimeout } = setupDownloadMocks({ downloadPatchFromPpk: mock(() => Promise.reject(timeout)), }); - const { Pushy, sharedState } = await importFreshClient( - 'dl-fallback-transport-report' - ); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 0 }); + const { client } = await createClient({ maxRetries: 0 }); expect(await client.downloadUpdate(updateInfo)).toBe('new-hash'); @@ -1683,11 +1519,7 @@ describe('downloadUpdate fallback chain', () => { downloadPatchFromPpk: mock(() => Promise.reject(patchError)), downloadPatchFromPackage: mock(() => Promise.reject(timeout)), }); - const { Pushy, sharedState } = await importFreshClient( - 'dl-fallback-patch-report' - ); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 0 }); + const { client } = await createClient({ maxRetries: 0 }); expect(await client.downloadUpdate(updateInfo)).toBe('new-hash'); @@ -1710,11 +1542,10 @@ describe('downloadUpdate fallback chain', () => { downloadPatchFromPpk, }); const logger = mock(() => {}); - const { Pushy, sharedState } = await importFreshClient( - 'dl-prefixed-patch-code' - ); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 0, logger }); + const { client } = await createClient({ + maxRetries: 0, + logger, + }); expect(await client.downloadUpdate(twoMirrors)).toBe('new-hash'); @@ -1733,8 +1564,7 @@ describe('Cresc class', () => { test('uses Cresc server endpoints', async () => { setupClientMocks(); - const { Cresc } = await importFreshClient('cresc-endpoints'); - const client = new Cresc({ appKey: 'demo-app' }); + const { client } = await createClient({}, 'Cresc'); expect(client.getConfiguredCheckEndpoints()).toEqual([ 'https://api.cresc.dev', @@ -1746,14 +1576,9 @@ describe('Cresc class', () => { setupClientMocks(); // Override i18n mock AFTER setupClientMocks to avoid being overwritten const setLocale = mock(() => {}); - mock.module('../i18n', () => ({ - default: { - t: (key: string) => key, - setLocale, - }, - })); + mockI18n({ setLocale }); - const { Cresc } = await importFreshClient('cresc-locale'); + const { Cresc } = await importFreshClient(); const client = new Cresc({ appKey: 'demo-app' }); expect(client.clientType).toBe('Cresc'); @@ -1763,14 +1588,9 @@ describe('Cresc class', () => { test('explicit locale option overrides clientType default', async () => { setupClientMocks(); const setLocale = mock(() => {}); - mock.module('../i18n', () => ({ - default: { - t: (key: string) => key, - setLocale, - }, - })); + mockI18n({ setLocale }); - const { Pushy } = await importFreshClient('pushy-locale-override'); + const { Pushy } = await importFreshClient(); // Pushy defaults to 'zh'; an explicit 'en' must win. new Pushy({ appKey: 'demo-app', locale: 'en' }); @@ -1780,7 +1600,7 @@ describe('Cresc class', () => { test('Cresc is instance of Pushy', async () => { setupClientMocks(); - const { Cresc, Pushy } = await importFreshClient('cresc-instanceof'); + const { Cresc, Pushy } = await importFreshClient(); const client = new Cresc({ appKey: 'demo-app' }); expect(client).toBeInstanceOf(Pushy); @@ -1790,14 +1610,15 @@ describe('Cresc class', () => { test('Cresc custom server overrides default endpoints', async () => { setupClientMocks(); - const { Cresc } = await importFreshClient('cresc-custom-server'); - const client = new Cresc({ - appKey: 'demo-app', - server: { - main: ['https://custom.example.com'], - queryUrls: ['https://q.example.com'], + const { client } = await createClient( + { + server: { + main: ['https://custom.example.com'], + queryUrls: ['https://q.example.com'], + }, }, - }); + 'Cresc' + ); expect(client.getConfiguredCheckEndpoints()).toEqual([ 'https://custom.example.com', @@ -1811,10 +1632,9 @@ describe('resetToPackagedBundle', () => { const resetToPackagedBundle = mock(() => Promise.resolve()); const restartApp = mock(() => Promise.resolve()); setupClientMocks({ resetToPackagedBundle, restartApp }); - const { Pushy, sharedState } = await importFreshClient('reset-basic'); + const { client, sharedState } = await createClient(); sharedState.downloadedHash = 'stale-hash'; sharedState.marked = true; - const client = new Pushy({ appKey: 'demo-app' }); const result = await client.resetToPackagedBundle(); @@ -1830,8 +1650,7 @@ describe('resetToPackagedBundle', () => { const resetToPackagedBundle = mock(() => Promise.resolve()); const restartApp = mock(() => Promise.resolve()); setupClientMocks({ resetToPackagedBundle, restartApp }); - const { Pushy } = await importFreshClient('reset-restart'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); await client.resetToPackagedBundle({ restart: true }); @@ -1843,8 +1662,7 @@ describe('resetToPackagedBundle', () => { // Simulates new JS arriving via hot update onto an older binary. Like the // other update-flow APIs this must not throw by default. setupClientMocks({ resetToPackagedBundle: null }); - const { Pushy } = await importFreshClient('reset-unsupported'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const seen: any[] = []; client.onError((err: any) => seen.push(err)); @@ -1857,8 +1675,7 @@ describe('resetToPackagedBundle', () => { test('throwError option makes an unsupported reset throw', async () => { setupClientMocks({ resetToPackagedBundle: null }); - const { Pushy } = await importFreshClient('reset-unsupported-throw'); - const client = new Pushy({ appKey: 'demo-app', throwError: true }); + const { client } = await createClient({ throwError: true }); await expect(client.resetToPackagedBundle()).rejects.toMatchObject({ code: 'RESET_FAILED', @@ -1870,9 +1687,8 @@ describe('resetToPackagedBundle', () => { Promise.reject(Error('disk full')) ); setupClientMocks({ resetToPackagedBundle }); - const { Pushy, sharedState } = await importFreshClient('reset-native-fail'); + const { client, sharedState } = await createClient(); sharedState.downloadedHash = 'stale-hash'; - const client = new Pushy({ appKey: 'demo-app' }); const seen: any[] = []; client.onError((err: any) => seen.push(err)); @@ -1898,10 +1714,11 @@ describe('downloadAndInstallApk apkStatus (JS-3)', () => { } return Promise.resolve(); }); - setupAndroidApkMocks(downloadAndInstallApk); - const { Pushy, sharedState } = await importFreshClient('apk-retry'); - sharedState.apkStatus = null; - const client = new Pushy({ appKey: 'demo-app' }); + setupClientMocks({ + platform: 'android', + downloadAndInstallApk: downloadAndInstallApk, + }); + const { client, sharedState } = await createClient(); await client.downloadAndInstallApk('https://example.com/app.apk'); // Failure must leave apkStatus reset (null), not stuck at 'downloaded'. @@ -1917,7 +1734,7 @@ describe('downloadAndInstallApk apkStatus (JS-3)', () => { describe('client singleton', () => { test('a second client with a different appKey throws SINGLETON_VIOLATION', async () => { setupClientMocks(); - const { Pushy } = await importFreshClient('singleton-different-appkey'); + const { Pushy } = await importFreshClient(); const client1 = new Pushy({ appKey: 'app-1' }); expect(client1.options.appKey).toBe('app-1'); @@ -1932,7 +1749,7 @@ describe('client singleton', () => { test('a Cresc client after a Pushy client throws SINGLETON_VIOLATION', async () => { setupClientMocks(); - const { Pushy, Cresc } = await importFreshClient('singleton-cross-type'); + const { Pushy, Cresc } = await importFreshClient(); new Pushy({ appKey: 'app-1' }); let error: any; @@ -1946,7 +1763,7 @@ describe('client singleton', () => { test('re-creating the same client is idempotent and applies the new options', async () => { setupClientMocks(); - const { Pushy } = await importFreshClient('singleton-idempotent'); + const { Pushy } = await importFreshClient(); const client1 = new Pushy({ appKey: 'app-1', debug: true }); // e.g. dev fast refresh re-running the module that builds the client const client2 = new Pushy({ @@ -1961,8 +1778,7 @@ describe('client singleton', () => { test('setOptions bumps optionsVersion and notifies subscribers', async () => { setupClientMocks(); - const { Pushy } = await importFreshClient('singleton-options-version'); - const client = new Pushy({ appKey: 'app-1' }); + const { client } = await createClient({ appKey: 'app-1' }); const initialVersion = client.optionsVersion; const listener = mock(() => {}); const unsubscribe = client.onOptionsChange(listener); @@ -1978,8 +1794,7 @@ describe('client singleton', () => { test('a second concurrent provider mount claim throws SINGLETON_VIOLATION', async () => { setupClientMocks(); - const { Pushy } = await importFreshClient('singleton-provider-claim'); - const client = new Pushy({ appKey: 'app-1' }); + const { client } = await createClient({ appKey: 'app-1' }); const release = client.claimProviderMount(); let error: any; @@ -2000,7 +1815,7 @@ describe('syncNativeConfig', () => { test('persists the native check config on construction (silent strategy activates)', async () => { const syncNativeConfig = mock(() => Promise.resolve()); setupClientMocks({ syncNativeConfig }); - const { Pushy } = await importFreshClient('sync-config-1'); + const { Pushy } = await importFreshClient(); new Pushy({ appKey: 'demo-app', updateStrategy: 'silentAndLater' }); expect(syncNativeConfig).toHaveBeenCalled(); @@ -2023,7 +1838,7 @@ describe('syncNativeConfig', () => { // only the server's explicit forceBoot may still activate. const syncNativeConfig = mock(() => Promise.resolve()); setupClientMocks({ syncNativeConfig }); - const { Pushy } = await importFreshClient('sync-config-no-autocheck'); + const { Pushy } = await importFreshClient(); new Pushy({ appKey: 'demo-app', updateStrategy: 'silentAndNow', @@ -2039,7 +1854,7 @@ describe('syncNativeConfig', () => { test('silent strategy with automatic checks still activates', async () => { const syncNativeConfig = mock(() => Promise.resolve()); setupClientMocks({ syncNativeConfig }); - const { Pushy } = await importFreshClient('sync-config-autocheck'); + const { Pushy } = await importFreshClient(); new Pushy({ appKey: 'demo-app', updateStrategy: 'silentAndNow', @@ -2055,7 +1870,7 @@ describe('syncNativeConfig', () => { test('disableNativeCheck writes a disabling config the natives bail on', async () => { const syncNativeConfig = mock(() => Promise.resolve()); setupClientMocks({ syncNativeConfig }); - const { Pushy } = await importFreshClient('sync-config-optout'); + const { Pushy } = await importFreshClient(); new Pushy({ appKey: 'demo-app', updateStrategy: 'silentAndNow', @@ -2074,7 +1889,7 @@ describe('syncNativeConfig', () => { test('alert strategies keep activation with JS (afterDownload none)', async () => { const syncNativeConfig = mock(() => Promise.resolve()); setupClientMocks({ syncNativeConfig }); - const { Pushy } = await importFreshClient('sync-config-2'); + const { Pushy } = await importFreshClient(); new Pushy({ appKey: 'demo-app' }); const config = JSON.parse( @@ -2086,8 +1901,7 @@ describe('syncNativeConfig', () => { test('setOptions re-syncs when the strategy changes', async () => { const syncNativeConfig = mock(() => Promise.resolve()); setupClientMocks({ syncNativeConfig }); - const { Pushy } = await importFreshClient('sync-config-3'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); client.setOptions({ updateStrategy: 'silentAndNow' }); await Promise.resolve(); await Promise.resolve(); @@ -2106,8 +1920,7 @@ describe('syncNativeConfig', () => { // disableNativeCheck writes the disabled state. const syncNativeConfig = mock(() => Promise.resolve()); setupClientMocks({ syncNativeConfig }); - const { Pushy } = await importFreshClient('sync-config-transient'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); @@ -2142,9 +1955,7 @@ describe('syncNativeConfig', () => { test('persists and exposes the effective overridden package version', async () => { const syncNativeConfig = mock(() => Promise.resolve()); setupClientMocks({ syncNativeConfig }); - const { Pushy } = await importFreshClient('sync-config-package-override'); - const client = new Pushy({ - appKey: 'demo-app', + const { client } = await createClient({ overridePackageVersion: '9.9.9', }); @@ -2158,8 +1969,7 @@ describe('syncNativeConfig', () => { test('skips duplicate config writes after the same value succeeds', async () => { const syncNativeConfig = mock(() => Promise.resolve()); setupClientMocks({ syncNativeConfig }); - const { Pushy } = await importFreshClient('sync-config-dedupe'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); @@ -2178,8 +1988,7 @@ describe('syncNativeConfig', () => { }) ); setupClientMocks({ syncNativeConfig }); - const { Pushy } = await importFreshClient('sync-config-serialized'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); client.setOptions({ updateStrategy: 'silentAndNow' }); expect(syncNativeConfig).toHaveBeenCalledTimes(1); @@ -2205,8 +2014,7 @@ describe('syncNativeConfig', () => { }) ); setupClientMocks({ syncNativeConfig }); - const { Pushy } = await importFreshClient('sync-config-revert-race'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); // Finish the initial alert-strategy value (A). resolvers[0](); @@ -2242,8 +2050,7 @@ describe('syncNativeConfig', () => { : Promise.resolve(); }); setupClientMocks({ syncNativeConfig }); - const { Pushy } = await importFreshClient('sync-config-retry'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); @@ -2256,8 +2063,7 @@ describe('syncNativeConfig', () => { test('switching ownership to native and back writes the config again', async () => { const syncNativeConfig = mock(() => Promise.resolve()); setupClientMocks({ syncNativeConfig }); - const { Pushy } = await importFreshClient('sync-config-owner-roundtrip'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); @@ -2274,9 +2080,23 @@ describe('syncNativeConfig', () => { ); }); + test('native ownership suppresses constructor and setOptions writes until handed back', async () => { + const syncNativeConfig = mock(() => Promise.resolve()); + setupClientMocks({ syncNativeConfig }); + const { client } = await createClient({ + nativeConfigSource: 'native', + }); + client.setOptions({ updateStrategy: 'silentAndLater' }); + await Promise.resolve(); + expect(syncNativeConfig).not.toHaveBeenCalled(); + + client.setOptions({ nativeConfigSource: 'javascript' }); + expect(syncNativeConfig).toHaveBeenCalledTimes(1); + }); + test('an older native module without the method is skipped silently', async () => { setupClientMocks(); - const { Pushy } = await importFreshClient('sync-config-4'); + const { Pushy } = await importFreshClient(); // Must not throw even though PushyModule lacks syncNativeConfig. new Pushy({ appKey: 'demo-app' }); }); @@ -2316,8 +2136,7 @@ describe('native check cache reuse', () => { setupClientMocks({ getNativeCheckCache, syncNativeConfig }); // setup.ts's default fetch throws, so any network attempt would surface // as a failed check (undefined) instead of the cached result. - const { Pushy } = await importFreshClient('native-cache-fresh'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.checkUpdate()).toEqual(cachedResult); expect(getNativeCheckCache).toHaveBeenCalled(); @@ -2344,8 +2163,7 @@ describe('native check cache reuse', () => { (globalThis as any).fetch = mock(async () => createJsonResponse(networkResult) ); - const { Pushy } = await importFreshClient('native-cache-stale'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.checkUpdate()).toEqual(networkResult); }); @@ -2358,8 +2176,7 @@ describe('native check cache reuse', () => { (globalThis as any).fetch = mock(async () => createJsonResponse(networkResult) ); - const { Pushy } = await importFreshClient('native-cache-bad'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.checkUpdate()).toEqual(networkResult); }); @@ -2385,8 +2202,7 @@ describe('native check cache reuse', () => { (globalThis as any).fetch = mock(async () => createJsonResponse(networkResult) ); - const { Pushy } = await importFreshClient('native-cache-request-key'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.checkUpdate({ toHash: 'requested-hash' })).toEqual( networkResult @@ -2422,8 +2238,7 @@ describe('native check cache reuse', () => { ) ); setupClientMocks({ getNativeCheckCache, syncNativeConfig }); - const { Pushy } = await importFreshClient('native-cache-key-order'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.checkUpdate()).toEqual(cachedResult); }); @@ -2446,8 +2261,7 @@ describe('native check cache reuse', () => { ) ); setupClientMocks({ getNativeCheckCache, syncNativeConfig }); - const { Pushy } = await importFreshClient('native-cache-undefined-extra'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.checkUpdate({ omitted: undefined })).toEqual( cachedResult @@ -2471,8 +2285,7 @@ describe('native check cache reuse', () => { (globalThis as any).fetch = mock(async () => createJsonResponse(networkResult) ); - const { Pushy } = await importFreshClient('native-cache-config-key'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.checkUpdate()).toEqual(networkResult); }); @@ -2495,8 +2308,7 @@ describe('native check cache reuse', () => { (globalThis as any).fetch = mock(async () => createJsonResponse(networkResult) ); - const { Pushy } = await importFreshClient('native-cache-expired-once'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const parse = spyOn(JSON, 'parse'); let parsedCacheFields: unknown[][] = []; @@ -2525,8 +2337,7 @@ describe('native check cache reuse', () => { (globalThis as any).fetch = mock(async () => createJsonResponse(networkResult) ); - const { Pushy } = await importFreshClient('native-cache-absent-once'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); await client.checkUpdate(); await client.checkUpdate({ toHash: 'other' }); @@ -2560,8 +2371,7 @@ describe('native check cache reuse', () => { ); }); setupClientMocks({ getNativeCheckCache, syncNativeConfig }); - const { Pushy } = await importFreshClient('native-cache-canonical-config'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.checkUpdate()).toEqual(cachedResult); }); @@ -2576,8 +2386,7 @@ describe('native check cache reuse', () => { (globalThis as any).fetch = mock(async () => createJsonResponse({ upToDate: true }) ); - const { Pushy } = await importFreshClient('mark-js-check-once'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const flushAll = async () => { for (let i = 0; i < 5; i++) { await Promise.resolve(); @@ -2624,8 +2433,7 @@ describe('native check cache reuse', () => { (globalThis as any).fetch = mock(async () => createJsonResponse(networkResult) ); - const { Pushy } = await importFreshClient('native-cache-future'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.checkUpdate()).toEqual(networkResult); }); @@ -2641,8 +2449,7 @@ describe('checkUpdate request identity', () => { setupClientMocks(); const fetchMock = mock(async () => createJsonResponse({ upToDate: true })); (globalThis as any).fetch = fetchMock; - const { Pushy } = await importFreshClient('check-identity-same'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); await client.checkUpdate({ channel: 'beta' }); await client.checkUpdate({ channel: 'beta' }); @@ -2657,8 +2464,7 @@ describe('checkUpdate request identity', () => { return createJsonResponse({ update: true, hash: `hash-${body.channel}` }); }); (globalThis as any).fetch = fetchMock; - const { Pushy } = await importFreshClient('check-identity-extra'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const a = await client.checkUpdate({ channel: 'a' }); const b = await client.checkUpdate({ channel: 'b' }); @@ -2676,8 +2482,7 @@ describe('checkUpdate request identity', () => { return createJsonResponse({ upToDate: true }); }); (globalThis as any).fetch = fetchMock; - const { Pushy } = await importFreshClient('check-identity-forge'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); await client.checkUpdate({ packageVersion: '9.9.9', hash: 'forged' }); @@ -2696,8 +2501,7 @@ describe('checkUpdate request identity', () => { return createJsonResponse({ update: true, hash: 'old-good' }); }); (globalThis as any).fetch = fetchMock; - const { Pushy } = await importFreshClient('check-identity-stale'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(((await client.checkUpdate()) as any).hash).toBe('old-good'); @@ -2723,8 +2527,7 @@ describe('JS -> native check completion signal', () => { (globalThis as any).fetch = mock(async () => createJsonResponse({ upToDate: true }) ); - const { Pushy } = await importFreshClient('js-check-completed'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); await client.checkUpdate(); await Promise.resolve(); @@ -2743,8 +2546,7 @@ describe('JS -> native check completion signal', () => { (globalThis as any).fetch = mock(async () => { throw new Error('offline'); }); - const { Pushy } = await importFreshClient('js-check-failed'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.checkUpdate()).toBeUndefined(); expect(markJsCheckCompleted).not.toHaveBeenCalled(); @@ -2756,12 +2558,12 @@ describe('JS -> native check completion signal', () => { (globalThis as any).fetch = mock(async () => createJsonResponse({ upToDate: true }) ); - const { Pushy } = await importFreshClient('js-check-reject'); + const { Pushy } = await importFreshClient(); const client = new Pushy({ appKey: 'demo-app' }); expect(await client.checkUpdate()).toEqual({ upToDate: true }); setupClientMocks(); - const { Pushy: OldPushy } = await importFreshClient('js-check-old-native'); + const { Pushy: OldPushy } = await importFreshClient(); const old = new OldPushy({ appKey: 'demo-app' }); expect(await old.checkUpdate()).toEqual({ upToDate: true }); }); @@ -2780,8 +2582,7 @@ describe('check response schema gate', () => { } return createJsonResponse([]); }); - const { Pushy } = await importFreshClient('schema-gate-fallback'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.checkUpdate()).toEqual({ upToDate: true }); expect(checks).toBeGreaterThanOrEqual(2); @@ -2794,8 +2595,7 @@ describe('check response schema gate', () => { String(url).includes('/checkUpdate/') ? { error: 'internal' } : [] ) ); - const { Pushy } = await importFreshClient('schema-gate-all'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const seen: any[] = []; client.onError((e: any) => { seen.push(e); @@ -2812,11 +2612,8 @@ describe('setOptions (1.3 / 1.11)', () => { test('applies a locale change at runtime', async () => { const setLocale = mock(() => {}); setupClientMocks(); - mock.module('../i18n', () => ({ - default: { t: (key: string) => key, setLocale }, - })); - const { Pushy } = await importFreshClient('set-options-locale'); - const client = new Pushy({ appKey: 'demo-app', locale: 'en' }); + mockI18n({ setLocale }); + const { client } = await createClient({ locale: 'en' }); expect(setLocale).toHaveBeenLastCalledWith('en'); client.setOptions({ locale: 'zh' }); @@ -2825,7 +2622,7 @@ describe('setOptions (1.3 / 1.11)', () => { test('debug gates the verbose log output in release builds', async () => { setupClientMocks(); - const { Pushy } = await importFreshClient('set-options-debug'); + const { Pushy } = await importFreshClient(); const { log } = await import('../utils'); const logSpy = spyOn(console, 'log').mockImplementation(() => {}); try { @@ -2864,8 +2661,7 @@ describe('getRemoteEndpoints (1.4 / 6.8)', () => { await new Promise((resolve) => setTimeout(resolve, 10)); return createJsonResponse(['https://edge.example.com']); }); - const { Pushy } = await importFreshClient('remote-endpoints-ok-gate'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.getRemoteEndpoints()).toEqual([ 'https://edge.example.com', @@ -2884,8 +2680,7 @@ describe('getRemoteEndpoints (1.4 / 6.8)', () => { null, ]) ); - const { Pushy } = await importFreshClient('remote-endpoints-https-only'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.getRemoteEndpoints()).toEqual([ 'https://edge.example.com', @@ -2898,8 +2693,7 @@ describe('getRemoteEndpoints (1.4 / 6.8)', () => { (globalThis as any).fetch = mock(async () => createJsonResponse({ endpoints: ['https://edge.example.com'] }) ); - const { Pushy } = await importFreshClient('remote-endpoints-not-list'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.getRemoteEndpoints()).toEqual([]); }); @@ -2932,8 +2726,7 @@ describe('native check cache schema gate (1.5)', () => { setupClientMocks({ getNativeCheckCache, syncNativeConfig }); const fetchMock = mock(async () => createJsonResponse({ upToDate: true })); (globalThis as any).fetch = fetchMock; - const { Pushy } = await importFreshClient('native-cache-schema-gate'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.checkUpdate()).toEqual({ upToDate: true }); expect(getNativeCheckCache).toHaveBeenCalled(); @@ -2954,10 +2747,8 @@ describe('switchVersion result and reload watchdog (1.7)', () => { globalThis.setTimeout = ((fn: (...args: any[]) => void) => realSetTimeout(fn, 0)) as unknown as typeof setTimeout; const logger = mock(() => {}); - const { Pushy, sharedState } = await importFreshClient('switch-watchdog'); + const { client, sharedState } = await createClient({ logger }); sharedState.downloadedHash = 'next-hash'; - sharedState.applyingUpdate = false; - const client = new Pushy({ appKey: 'demo-app', logger }); expect(await client.switchVersion('next-hash')).toBe(true); expect(sharedState.applyingUpdate).toBe(true); @@ -2981,10 +2772,7 @@ describe('switchVersion result and reload watchdog (1.7)', () => { test('resolves false when there is nothing to switch to', async () => { setupClientMocks(); - const { Pushy, sharedState } = await importFreshClient('switch-nothing'); - sharedState.downloadedHash = undefined; - sharedState.applyingUpdate = false; - const client = new Pushy({ appKey: 'demo-app' }); + const { client, sharedState } = await createClient(); expect(await client.switchVersion('next-hash')).toBe(false); expect(sharedState.applyingUpdate).toBe(false); @@ -2995,12 +2783,8 @@ describe('switchVersion result and reload watchdog (1.7)', () => { Promise.reject(new Error('[INVALID_OPTIONS] empty hash')) ); setupClientMocks({ reloadUpdate }); - const { Pushy, sharedState } = await importFreshClient( - 'switch-prefixed-code' - ); + const { client, sharedState } = await createClient(); sharedState.downloadedHash = 'next-hash'; - sharedState.applyingUpdate = false; - const client = new Pushy({ appKey: 'demo-app' }); const seen: any[] = []; client.onError((e: any) => { seen.push(e); @@ -3031,8 +2815,7 @@ describe('check dedup slot ownership (P4)', () => { } return createJsonResponse({ upToDate: true }); }); - const { Pushy } = await importFreshClient('dedup-slot'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const first = client.checkUpdate({ toHash: 'slow-fail' }); const second = client.checkUpdate({ toHash: 'fast-ok' }); @@ -3052,13 +2835,7 @@ describe('check dedup slot ownership (P4)', () => { describe('non-2xx check responses (P4)', () => { test('the error text is truncated to 200 characters', async () => { setupClientMocks(); - mock.module('../i18n', () => ({ - default: { - t: (key: string, params?: Record) => - params ? `${key}${JSON.stringify(params)}` : key, - setLocale: mock(() => {}), - }, - })); + mockI18n({ withParams: true }); (globalThis as any).fetch = mock(async (url: string) => { if (!url.includes('/checkUpdate/')) { throw new Error('no mirrors'); @@ -3070,8 +2847,7 @@ describe('non-2xx check responses (P4)', () => { json: async () => ({}), }; }); - const { Pushy } = await importFreshClient('http-error-truncated'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const seen: any[] = []; client.onError((e: any) => { seen.push(e); @@ -3088,21 +2864,12 @@ describe('non-2xx check responses (P4)', () => { describe('appKey requirement (P4)', () => { const setupPlatform = (os: string) => { setupClientMocks(); - mock.module('react-native', () => ({ - Platform: { OS: os, Version: 1 }, - DeviceEventEmitter: { - addListener: mock(() => ({ remove: mock(() => {}) })), - }, - NativeEventEmitter: class { - addListener = mock(() => ({ remove: mock(() => {}) })); - removeAllListeners = mock(() => {}); - }, - })); + mockPlatform({ OS: os, Version: 1 }); }; test('harmony without an appKey fails fast with APPKEY_REQUIRED', async () => { setupPlatform('harmony'); - const { Pushy } = await importFreshClient('appkey-harmony'); + const { Pushy } = await importFreshClient(); let error: any; try { @@ -3117,7 +2884,7 @@ describe('appKey requirement (P4)', () => { setupPlatform('web'); const consoleWarn = spyOn(console, 'warn').mockImplementation(() => {}); try { - const { Pushy } = await importFreshClient('appkey-web'); + const { Pushy } = await importFreshClient(); expect(() => new Pushy({ appKey: '' })).not.toThrow(); } finally { consoleWarn.mockRestore(); diff --git a/src/__tests__/configEquivalenceVectors.test.ts b/src/__tests__/configEquivalenceVectors.test.ts index d8e2a141..71445584 100644 --- a/src/__tests__/configEquivalenceVectors.test.ts +++ b/src/__tests__/configEquivalenceVectors.test.ts @@ -1,15 +1,11 @@ import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import { URL } from 'node:url'; -import { runInNewContext } from 'node:vm'; import vectorsFile from '../../cpp/update_flow_core/tests/config-equivalence-vectors.json'; import { nativeConfigsEquivalent } from '../updateFlowCore'; -// Contract C1 (native config equivalence) is implemented four times: here, -// Harmony UpdateContext.ts, Android ConfigEquivalence.java and iOS -// PushyConfigsEquivalent. All four replay the same committed vectors; this -// file covers the two TypeScript ones, ConfigEquivalenceTest (JUnit) and -// scripts/test-ios-purge-restore.sh the natives. +// Contract C1 (native config equivalence) is implemented twice: here, and in +// the shared native core (cpp/update_flow_core ConfigsEquivalent, used by +// Android, iOS and Harmony). Both replay the same committed vectors; this file +// covers the TypeScript one, scripts/test-update-flow-core.sh the core. interface Vector { name: string; a: string; @@ -18,29 +14,6 @@ interface Vector { } const vectors = vectorsFile.cases as Vector[]; -// UpdateContext.ts imports @ohos modules, so it is evaluated with its imports -// stripped (as in nativeConfiguration.test.ts); the C1 functions only depend -// on the file's own source. -function harmonyEquivalence(): (a: string, b: string) => boolean { - const source = readFileSync( - new URL( - '../../harmony/pushy/src/main/ets/UpdateContext.ts', - import.meta.url - ), - 'utf8' - ) - .replace(/^import[\s\S]*?;\r?\n/gm, '') - .replace(/^export\s*\{[^}]*\}(?:\s*from\s*[^;]*)?;\r?\n/gm, '') - .replace(/^export /gm, ''); - const js = new Bun.Transpiler({ loader: 'ts' }).transformSync(source); - return runInNewContext(`${js}\nnativeConfigsEquivalent;`, {}); -} - -const implementations: Array<[string, (a: string, b: string) => boolean]> = [ - ['js', nativeConfigsEquivalent], - ['harmony', harmonyEquivalence()], -]; - describe('C1 config equivalence vectors', () => { test('vector file is well formed', () => { expect(vectors.length).toBeGreaterThanOrEqual(30); @@ -49,14 +22,14 @@ describe('C1 config equivalence vectors', () => { expect(vectors.some((v) => !v.equivalent)).toBe(true); }); - for (const [platform, equivalent] of implementations) { - describe(platform, () => { - for (const vector of vectors) { - test(vector.name, () => { - expect(equivalent(vector.a, vector.b)).toBe(vector.equivalent); - expect(equivalent(vector.b, vector.a)).toBe(vector.equivalent); - }); - } + for (const vector of vectors) { + test(vector.name, () => { + expect(nativeConfigsEquivalent(vector.a, vector.b)).toBe( + vector.equivalent + ); + expect(nativeConfigsEquivalent(vector.b, vector.a)).toBe( + vector.equivalent + ); }); } }); diff --git a/src/__tests__/context.test.ts b/src/__tests__/context.test.ts index 21a09c81..2e4b0717 100644 --- a/src/__tests__/context.test.ts +++ b/src/__tests__/context.test.ts @@ -1,10 +1,10 @@ -import { afterAll, describe, expect, test } from 'bun:test'; +import { describe, expect, test } from 'bun:test'; import React from 'react'; import TestRenderer from 'react-test-renderer'; +import { setDevForFile } from './helpers'; // Must set __DEV__ before importing context.ts -const _origDEV = (globalThis as any).__DEV__; -(globalThis as any).__DEV__ = true; +setDevForFile(true); const { useUpdate, UpdateContext } = await import('../context'); const { default: i18n } = await import('../i18n'); @@ -22,10 +22,6 @@ const renderHook = (hook: () => T) => { }; describe('context', () => { - afterAll(() => { - (globalThis as any).__DEV__ = _origDEV; - }); - test('useUpdate throws error when used outside UpdateProvider in __DEV__', () => { expect(() => renderHook(() => useUpdate())).toThrow( i18n.t('error_use_update_outside_provider') diff --git a/src/__tests__/core.test.ts b/src/__tests__/core.test.ts index e9489624..38778b1c 100644 --- a/src/__tests__/core.test.ts +++ b/src/__tests__/core.test.ts @@ -1,4 +1,6 @@ -import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test'; +import { describe, expect, mock, spyOn, test } from 'bun:test'; +import { importFreshClient, mockI18n, restoreDevAfterEach } from './helpers'; +import { baseReactNative } from './setup'; // In Bun, top-level imports are cached. // We can use mock.module to change the implementation of a module, @@ -8,13 +10,13 @@ import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test'; // Actually, bun test runs each file in its own environment usually, // BUT if we run multiple test files in one process, they might share the cache. const importFreshCore = (cacheKey: string) => import(`../core?${cacheKey}`); -const importFreshClient = (cacheKey: string) => import(`../client?${cacheKey}`); describe('core info parsing', () => { test('should call error when currentVersionInfo is invalid JSON', async () => { const consoleError = spyOn(console, 'error').mockImplementation(() => {}); mock.module('react-native', () => ({ + ...baseReactNative(), Platform: { OS: 'ios', Version: 13, @@ -63,6 +65,7 @@ describe('core info parsing', () => { const mockSetLocalHashInfo = mock(() => {}); mock.module('react-native', () => ({ + ...baseReactNative(), Platform: { OS: 'ios', Version: 13, @@ -104,6 +107,7 @@ describe('core info parsing', () => { describe('client os label', () => { const mockPlatform = (platform: Record) => { mock.module('react-native', () => ({ + ...baseReactNative(), Platform: platform, DeviceEventEmitter: { addListener: mock(() => ({ remove: mock(() => {}) })), @@ -143,11 +147,55 @@ describe('client os label', () => { }); }); -describe('web platform', () => { - const origDev = (globalThis as any).__DEV__; - afterEach(() => { - (globalThis as any).__DEV__ = origDev; +describe('bundle hash prefetch', () => { + const mockGetBundleHash = (getBundleHash: () => unknown) => { + mock.module('react-native', () => ({ + ...baseReactNative(), + Platform: { OS: 'ios', Version: '17.5' }, + DeviceEventEmitter: { + addListener: mock(() => ({ remove: mock(() => {}) })), + }, + NativeModules: { + Pushy: { + currentVersionInfo: '{}', + downloadRootDir: '/tmp', + packageVersion: '1.0.0', + currentVersion: '', + isFirstTime: false, + rolledBackVersion: '', + buildTime: '1', + uuid: 'existing-uuid', + setLocalHashInfo: mock(() => {}), + getLocalHashInfo: mock(() => Promise.resolve('{}')), + setUuid: mock(() => {}), + getBundleHash, + }, + }, + NativeEventEmitter: class { + addListener = mock(() => ({ remove: mock(() => {}) })); + }, + })); + }; + + test('a synchronous native throw fails the module load (broken integration)', async () => { + mockGetBundleHash(() => { + throw new Error('bridge broken'); + }); + await expect(importFreshCore('bundle-hash-sync-throw')).rejects.toThrow( + 'bridge broken' + ); + }); + + test('an async native failure only leaves the hash unknown', async () => { + mockGetBundleHash(() => Promise.reject(new Error('not computed'))); + const core = await importFreshCore('bundle-hash-async-reject'); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(core.getBundleHash()).toBe(''); }); +}); + +describe('web platform', () => { + restoreDevAfterEach(); test('constants are explicit empties, no native calls, no rollback telemetry (1.1)', async () => { // On web PushyModule is a Proxy of noops; reading the constants off it @@ -156,6 +204,7 @@ describe('web platform', () => { const setUuid = mock(() => {}); const getBundleHash = mock(() => Promise.resolve('sha')); mock.module('react-native', () => ({ + ...baseReactNative(), Platform: { OS: 'web', Version: undefined, @@ -199,14 +248,12 @@ describe('web platform', () => { // fix every page load POSTed a fake rollback to /report/{appKey}. (globalThis as any).__DEV__ = false; mock.module('../core', () => ({ ...core })); - mock.module('../i18n', () => ({ - default: { t: (key: string) => key, setLocale: () => {} }, - })); + mockI18n(); const fetchMock = mock(async () => { throw new Error('unexpected network request'); }); (globalThis as any).fetch = fetchMock; - const { Pushy } = await importFreshClient('web-constants'); + const { Pushy } = await importFreshClient(); const client = new Pushy({ appKey: 'web-app' }); await new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/src/__tests__/endpoint.test.ts b/src/__tests__/endpoint.test.ts index c4600de5..b3faf651 100644 --- a/src/__tests__/endpoint.test.ts +++ b/src/__tests__/endpoint.test.ts @@ -1,9 +1,9 @@ import { describe, expect, mock, test } from 'bun:test'; import { - dedupeEndpoints, executeEndpointFallback, selectFastestSuccessfulEndpoint, } from '../endpoint'; +import { dedupeEndpoints } from '../updateFlowCore'; const delay = (ms: number) => new Promise((resolve) => { diff --git a/src/__tests__/harmonyArchiveLimits.test.ts b/src/__tests__/harmonyArchiveLimits.test.ts new file mode 100644 index 00000000..13190594 --- /dev/null +++ b/src/__tests__/harmonyArchiveLimits.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, test } from 'bun:test'; +import { + createPushyError, + ERROR_DELTA_FAILED, + ERROR_FILE_OPERATION_FAILED, + getErrorMessage, + type PushyError, +} from '../../harmony/pushy/src/main/ets/ErrorCodes'; +import { evalHarmonyModule, source } from './nativeSources'; + +// Harmony ArchiveLimits.ts is a thin binding over the shared extraction +// accounting (cpp/patch_core/archive_limits.h, replayed against +// tests/archive-limits-vectors.json by the C++ test). What is left to test +// here: the getOriginalSize gate (Harmony only), what it hands the NAPI +// module, and the stable error code it maps refusals to. +interface ArchiveLimits { + ORIGINAL_SIZE_UNAVAILABLE: string; + checkOriginalSize(size: number | undefined, failure?: string): number; + checkUncompressedSize(archiveBytes: number, uncompressedBytes: number): void; + checkExtractedDirectory( + directory: string, + archiveBytes: number + ): Promise; +} + +function load(refusal?: string) { + const calls: Array<[string, unknown[]]> = []; + const NativePatchCore = { + checkUncompressedSize: (...args: unknown[]) => { + calls.push(['checkUncompressedSize', args]); + return refusal; + }, + checkExtractedDirectory: async (...args: unknown[]) => { + calls.push(['checkExtractedDirectory', args]); + if (refusal !== undefined) { + throw new Error(refusal); + } + }, + }; + const limits = evalHarmonyModule( + 'ArchiveLimits.ts', + `({ ORIGINAL_SIZE_UNAVAILABLE, checkOriginalSize, checkUncompressedSize, + checkExtractedDirectory });`, + { + NativePatchCore, + ERROR_DELTA_FAILED, + ERROR_FILE_OPERATION_FAILED, + createPushyError, + getErrorMessage, + } + ) as ArchiveLimits; + return { limits, calls }; +} + +function refusal(run: () => unknown): PushyError | null { + try { + run(); + } catch (e) { + return e as PushyError; + } + return null; +} + +// getOriginalSize unavailable: refuse (never pass through), with its own detail. +describe('harmony checkOriginalSize', () => { + const { limits } = load(); + + test('passes a finite non-negative size through', () => { + expect(limits.checkOriginalSize(0)).toBe(0); + expect(limits.checkOriginalSize(12345)).toBe(12345); + }); + + test('refuses when getOriginalSize threw, with its own detail', () => { + const error = refusal(() => limits.checkOriginalSize(undefined, 'boom')); + expect(error?.code).toBe(ERROR_DELTA_FAILED); + expect(error?.message).toContain(limits.ORIGINAL_SIZE_UNAVAILABLE); + expect(error?.message).toContain('getOriginalSize failed: boom'); + }); + + test('refuses an unusable result, distinct from the thrown case', () => { + for (const size of [undefined, Number.NaN, -1, Number.POSITIVE_INFINITY]) { + const error = refusal(() => limits.checkOriginalSize(size)); + expect(error?.code).toBe(ERROR_DELTA_FAILED); + expect(error?.message).toContain(limits.ORIGINAL_SIZE_UNAVAILABLE); + expect(error?.message).toContain('getOriginalSize returned'); + } + }); +}); + +describe('harmony archive limit bindings', () => { + test('hand the sizes and the directory to the core', async () => { + const { limits, calls } = load(); + expect(refusal(() => limits.checkUncompressedSize(1024, 4096))).toBeNull(); + await limits.checkExtractedDirectory('/data/_update/abc.staging', 1024); + expect(calls).toEqual([ + ['checkUncompressedSize', [1024, 4096]], + ['checkExtractedDirectory', ['/data/_update/abc.staging', 1024]], + ]); + }); + + test('a refusal is PATCH_FAILED with the core message', async () => { + const reason = 'archive compression ratio exceeds 100:1 (2097152/20000)'; + const { limits } = load(reason); + const sync = refusal(() => limits.checkUncompressedSize(20000, 2097152)); + expect(sync?.code).toBe(ERROR_DELTA_FAILED); + expect(sync?.message).toBe(`[${ERROR_DELTA_FAILED}] ${reason}`); + let walked: PushyError | null = null; + try { + await limits.checkExtractedDirectory('/data/_update/abc.staging', 20000); + } catch (e) { + walked = e as PushyError; + } + expect(walked?.code).toBe(ERROR_DELTA_FAILED); + expect(walked?.message).toBe(`[${ERROR_DELTA_FAILED}] ${reason}`); + }); + + test('the download loop plans its disk probes in the core', () => { + const task = source('harmony/pushy/src/main/ets/DownloadTask.ts'); + expect(task).toContain('NativePatchCore.planFreeSpaceProbe('); + expect(task).toContain('await checkExtractedDirectory('); + }); +}); diff --git a/src/__tests__/harmonyDownloadTask.test.ts b/src/__tests__/harmonyDownloadTask.test.ts new file mode 100644 index 00000000..23890390 --- /dev/null +++ b/src/__tests__/harmonyDownloadTask.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from 'bun:test'; +import { DownloadTaskParams } from '../../harmony/pushy/src/main/ets/DownloadTaskParams'; +import { BUNDLE_DELTA_ENTRY } from '../../harmony/pushy/src/main/ets/Texts'; +import { evalHarmonyModule } from './nativeSources'; + +// The pure parsers of Harmony DownloadTask.ts, evaluated from source with its +// platform imports stripped (the download/patch class itself needs a device). +// InstallRecord.ts imports the NAPI module, so its bundle name is inlined. +// __diff.json is parsed by the native core only +// (cpp/patch_core/tests/patch-manifest-vectors.json). +const HARMONY_BUNDLE_FILE_NAME = 'bundle.harmony.js'; + +const { parseContentRangeTotal } = evalHarmonyModule( + 'DownloadTask.ts', + '({ parseContentRangeTotal });', + { HARMONY_BUNDLE_FILE_NAME, BUNDLE_DELTA_ENTRY } +) as { + parseContentRangeTotal(header: string, expectedStart: number): number; +}; + +// A 206's Content-Range decides whether appended bytes can be trusted. +describe('harmony parseContentRangeTotal', () => { + test('returns the total for a well-formed range at the expected start', () => { + expect(parseContentRangeTotal('bytes 100-199/200', 100)).toBe(200); + expect(parseContentRangeTotal('bytes 0-9/10', 0)).toBe(10); + expect(parseContentRangeTotal('bytes 100-100/200', 100)).toBe(200); + }); + + test('tolerates surrounding whitespace', () => { + expect(parseContentRangeTotal('bytes 100-199 / 200 ', 100)).toBe(200); + }); + + test('returns 0 for an unknown total (*)', () => { + expect(parseContentRangeTotal('bytes 100-199/*', 100)).toBe(0); + }); + + test('rejects a start that does not match the local partial', () => { + expect(parseContentRangeTotal('bytes 50-199/200', 100)).toBe(-1); + expect(parseContentRangeTotal('bytes 0-199/200', 100)).toBe(-1); + }); + + test('rejects missing or foreign units', () => { + expect(parseContentRangeTotal('', 0)).toBe(-1); + expect(parseContentRangeTotal('100-199/200', 100)).toBe(-1); + expect(parseContentRangeTotal('items 100-199/200', 100)).toBe(-1); + }); + + test('rejects malformed ranges', () => { + expect(parseContentRangeTotal('bytes 100-199', 100)).toBe(-1); + expect(parseContentRangeTotal('bytes 100/200', 100)).toBe(-1); + expect(parseContentRangeTotal('bytes */200', 0)).toBe(-1); + expect(parseContentRangeTotal('bytes abc-199/200', 100)).toBe(-1); + }); + + test('rejects non-positive or non-numeric totals', () => { + expect(parseContentRangeTotal('bytes 100-199/0', 100)).toBe(-1); + expect(parseContentRangeTotal('bytes 100-199/-5', 100)).toBe(-1); + expect(parseContentRangeTotal('bytes 100-199/abc', 100)).toBe(-1); + }); + + // A numeric total must exceed end (RFC 9110 §14.4); end cannot precede start. + test('rejects a range that contradicts its total', () => { + expect(parseContentRangeTotal('bytes 100-199/199', 100)).toBe(-1); + expect(parseContentRangeTotal('bytes 100-199/150', 100)).toBe(-1); + expect(parseContentRangeTotal('bytes 100-99/200', 100)).toBe(-1); + expect(parseContentRangeTotal('bytes 100-99/*', 100)).toBe(-1); + expect(parseContentRangeTotal('bytes 100-x/200', 100)).toBe(-1); + }); +}); + +describe('harmony DownloadTaskParams', () => { + test('defaults to a full-package task with empty paths', () => { + const params = new DownloadTaskParams(); + expect(params.type).toBe(DownloadTaskParams.TASK_TYPE_PATCH_FULL); + expect(params.url).toBe(''); + expect(params.hash).toBe(''); + expect(params.originHash).toBe(''); + expect(params.targetFile).toBe(''); + expect(params.unzipDirectory).toBe(''); + expect(params.originDirectory).toBe(''); + expect(params.deadlineUptimeMs).toBe(0); + }); + + test('task types are distinct', () => { + expect( + new Set([ + DownloadTaskParams.TASK_TYPE_PATCH_FULL, + DownloadTaskParams.TASK_TYPE_PATCH_FROM_APP, + DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK, + ]).size + ).toBe(3); + }); +}); diff --git a/src/__tests__/harmonyErrorCodes.test.ts b/src/__tests__/harmonyErrorCodes.test.ts new file mode 100644 index 00000000..e58530f8 --- /dev/null +++ b/src/__tests__/harmonyErrorCodes.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'bun:test'; +import { + createPushyError, + ERROR_DELTA_FAILED, + ERROR_DOWNLOAD_FAILED, + getErrorMessage, + PushyError, + toPushyError, +} from '../../harmony/pushy/src/main/ets/ErrorCodes'; + +// Stable Harmony error codes: a `code` property plus a `[CODE] ` message +// prefix (the RNOH bridge may drop the property; JS parses the prefix). +describe('harmony ErrorCodes', () => { + test('createPushyError sets the code and prefixes the message', () => { + const error = createPushyError(ERROR_DELTA_FAILED, 'boom'); + expect(error).toBeInstanceOf(Error); + expect(error.code).toBe(ERROR_DELTA_FAILED); + expect(error.message).toBe('[PATCH_FAILED] boom'); + }); + + test('toPushyError keeps an existing code and never double-prefixes', () => { + const original = createPushyError(ERROR_DELTA_FAILED, 'boom'); + const wrapped = toPushyError(original, ERROR_DOWNLOAD_FAILED); + expect(wrapped).toBe(original); + expect(wrapped.message).toBe('[PATCH_FAILED] boom'); + }); + + test('toPushyError assigns the default code to plain errors and values', () => { + const fromError = toPushyError(new Error('plain'), ERROR_DOWNLOAD_FAILED); + expect(fromError.code).toBe(ERROR_DOWNLOAD_FAILED); + expect(fromError.message).toBe('[DOWNLOAD_FAILED] plain'); + const fromString = toPushyError('raw', ERROR_DOWNLOAD_FAILED); + expect(fromString).toBeInstanceOf(PushyError); + expect(fromString.message).toBe('[DOWNLOAD_FAILED] raw'); + }); + + test('getErrorMessage reads errors, message objects and primitives', () => { + expect(getErrorMessage(new Error('e'))).toBe('e'); + expect(getErrorMessage({ message: 'm', code: 42 })).toBe('m'); + expect(getErrorMessage('s')).toBe('s'); + expect(getErrorMessage(42)).toBe('42'); + expect(getErrorMessage(null)).toBe('null'); + expect(getErrorMessage(undefined)).toBe('undefined'); + }); +}); diff --git a/src/__tests__/harmonyInstallRecord.test.ts b/src/__tests__/harmonyInstallRecord.test.ts index 6a8ec39c..8dbedaa8 100644 --- a/src/__tests__/harmonyInstallRecord.test.ts +++ b/src/__tests__/harmonyInstallRecord.test.ts @@ -1,207 +1,175 @@ import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import { URL } from 'node:url'; -import { runInNewContext } from 'node:vm'; +import { + createPushyError, + ERROR_DELTA_FAILED, + ERROR_SWITCH_VERSION_FAILED, + getErrorMessage, + type PushyError, +} from '../../harmony/pushy/src/main/ets/ErrorCodes'; +import { evalHarmonyModule, source, stripComments } from './nativeSources'; -// Harmony InstallRecord.ts (ArkTS mirror of cpp/patch_core/install_record.h) -// imports @ohos modules, so it is evaluated with its imports stripped and its -// collaborators (file system, native digest, error factory) supplied as -// in-memory stubs. The on-device hypium suite (InstallRecord.test.ets) only -// runs in harmony CI; this keeps the record contract under `bun run test`. - -class PushyError extends Error { - code: string; - constructor(code: string, message: string) { - super(`[${code}] ${message}`); - this.code = code; - } -} +// Harmony InstallRecord.ts is a thin binding over the shared completion +// record core (cpp/patch_core/install_record.h, replayed against +// tests/install-record-vectors.json by the C++ test). What is left to test +// here: the paths it builds, what it hands the NAPI module, the stable error +// codes it maps rejections to, and that every NAPI binding it relies on is +// exported by pushy.cpp. interface InstallRecordModule { - buildInstallRecord( - versionHash: string, - bundleSha256: string, - artifactSha256: string - ): string; - expectedBundleSha256ForActivation( - record: Record | null, - versionHash: string - ): string; + STAGING_SUFFIX: string; + HARMONY_BUNDLE_FILE_NAME: string; + stagingDirectoryFor(versionDir: string): string; + sealInstallRecord(dir: string, hash: string, artifact: string): Promise; + promoteStaging(staging: string, version: string): Promise; + isInstallComplete(dir: string, hash: string): boolean; + recordedBundleSha256(dir: string): string; verifyInstallForActivation( - versionDir: string, - versionHash: string, - bundlePath: string + dir: string, + hash: string, + grandfathered: boolean ): Promise; } -function loadInstallRecord( - files: Map, - digests: Map -): InstallRecordModule { - const source = readFileSync( - new URL( - '../../harmony/pushy/src/main/ets/InstallRecord.ts', - import.meta.url - ), - 'utf8' - ) - .replace(/^import[\s\S]*?;\r?\n/gm, '') - .replace(/^export /gm, ''); - const js = new Bun.Transpiler({ loader: 'ts' }).transformSync(source); - const fileIo = { - accessSync: (path: string) => files.has(path), - statSync: (path: string) => ({ size: files.get(path)?.length ?? 0 }), - readTextSync: (path: string) => files.get(path) ?? '', - }; - const NativePatchCore = { - sha256HexFileAsync: async (path: string) => { - const digest = digests.get(path); - if (digest === undefined) { - throw new Error('sha256HexFileAsync: cannot read file'); +function load(rejection?: string) { + const calls: Array<[string, unknown[]]> = []; + const call = + (name: string, result: unknown) => + (...args: unknown[]) => { + calls.push([name, args]); + return result; + }; + const settle = + (name: string) => + async (...args: unknown[]) => { + calls.push([name, args]); + if (rejection !== undefined) { + throw new Error(rejection); } - return digest; - }, + }; + const NativePatchCore = { + sealInstallRecord: settle('sealInstallRecord'), + promoteStaging: settle('promoteStaging'), + checkActivation: settle('checkActivation'), + isInstallComplete: call('isInstallComplete', true), + recordedBundleSha256: call('recordedBundleSha256', 'ab'), }; - return runInNewContext( - `${js}\n({ buildInstallRecord, expectedBundleSha256ForActivation, verifyInstallForActivation });`, + const module = evalHarmonyModule( + 'InstallRecord.ts', + `({ STAGING_SUFFIX, HARMONY_BUNDLE_FILE_NAME, stagingDirectoryFor, + sealInstallRecord, promoteStaging, + isInstallComplete, recordedBundleSha256, verifyInstallForActivation });`, { - fileIo, NativePatchCore, - ERROR_DELTA_FAILED: 'PATCH_FAILED', - ERROR_SWITCH_VERSION_FAILED: 'SWITCH_VERSION_FAILED', - createPushyError: (code: string, message: string) => - new PushyError(code, message), - util: {}, + ERROR_DELTA_FAILED, + ERROR_SWITCH_VERSION_FAILED, + createPushyError, + getErrorMessage, } - ); + ) as InstallRecordModule; + return { module, calls }; } -const HASH = 'abc'; -const DIR = `/data/_update/${HASH}`; -const RECORD = `${DIR}/.pushy-complete`; -const BUNDLE = `${DIR}/bundle.harmony.js`; -const DIGEST = 'ab'.repeat(32); +const DIR = '/data/_update/abc'; +const BUNDLE = 'bundle.harmony.js'; -function captureError(fn: () => unknown): PushyError { +async function rejectionOf(promise: Promise): Promise { try { - fn(); + await promise; } catch (e) { return e as PushyError; } - throw new Error('expected a throw'); + throw new Error('expected a rejection'); } -describe('harmony install record writer', () => { - const { buildInstallRecord } = loadInstallRecord(new Map(), new Map()); +describe('harmony install record paths', () => { + const { module } = load(); - test('writes the mandatory bundle digest', () => { - expect(JSON.parse(buildInstallRecord(HASH, DIGEST, 'ff'))).toEqual({ - schema: 1, - versionHash: HASH, - bundleSha256: DIGEST, - artifactSha256: 'ff', - }); - expect(JSON.parse(buildInstallRecord(HASH, DIGEST, ''))).toEqual({ - schema: 1, - versionHash: HASH, - bundleSha256: DIGEST, - }); - }); - - test('refuses a record without a bundle digest (install fails)', () => { - const error = captureError(() => buildInstallRecord(HASH, '', 'ff')); - expect(error.code).toBe('PATCH_FAILED'); - expect(error.message).toContain('no bundle digest'); + test('the staging directory is a sibling of the version directory', () => { + expect(module.stagingDirectoryFor(DIR)).toBe( + `${DIR}${module.STAGING_SUFFIX}` + ); + expect(module.HARMONY_BUNDLE_FILE_NAME).toBe(BUNDLE); }); }); -describe('harmony install record activation', () => { - test('a legacy empty record stays trusted without hashing', async () => { - const files = new Map([[RECORD, '']]); - const { verifyInstallForActivation } = loadInstallRecord(files, new Map()); - await verifyInstallForActivation(DIR, HASH, BUNDLE); - }); - - test('a matching digest activates, case-insensitively', async () => { - const files = new Map([ - [ - RECORD, - JSON.stringify({ - schema: 1, - versionHash: HASH, - bundleSha256: DIGEST.toUpperCase(), - }), - ], +describe('harmony install record bindings', () => { + test('presence and crash attribution are synchronous pass-throughs', () => { + const { module, calls } = load(); + expect(module.isInstallComplete(DIR, 'abc')).toBe(true); + expect(module.recordedBundleSha256(DIR)).toBe('ab'); + expect(calls).toEqual([ + ['isInstallComplete', [DIR, 'abc', BUNDLE]], + ['recordedBundleSha256', [DIR]], ]); - const { verifyInstallForActivation } = loadInstallRecord( - files, - new Map([[BUNDLE, DIGEST]]) - ); - await verifyInstallForActivation(DIR, HASH, BUNDLE); }); - test('a tampered bundle is rejected', async () => { - const files = new Map([ - [ - RECORD, - JSON.stringify({ schema: 1, versionHash: HASH, bundleSha256: DIGEST }), - ], + test('seal, promote and activation hand the core the Harmony bundle name', async () => { + const { module, calls } = load(); + await module.sealInstallRecord(`${DIR}.staging`, 'abc', 'ff'); + await module.promoteStaging(`${DIR}.staging`, DIR); + await module.verifyInstallForActivation(DIR, 'abc', true); + expect(calls).toEqual([ + ['sealInstallRecord', [`${DIR}.staging`, 'abc', BUNDLE, 'ff']], + ['promoteStaging', [`${DIR}.staging`, DIR]], + ['checkActivation', [DIR, 'abc', BUNDLE, true]], ]); - const { verifyInstallForActivation } = loadInstallRecord( - files, - new Map([[BUNDLE, 'cd'.repeat(32)]]) - ); - await expect(verifyInstallForActivation(DIR, HASH, BUNDLE)).rejects.toThrow( - 'bundle digest mismatch' - ); }); - for (const [name, record] of [ - ['no', { schema: 1, versionHash: HASH }], - ['an empty', { schema: 1, versionHash: HASH, bundleSha256: '' }], - ['a null', { schema: 1, versionHash: HASH, bundleSha256: null }], - ['a non-string', { schema: 1, versionHash: HASH, bundleSha256: 123 }], - [ - 'only an artifact', - { schema: 1, versionHash: HASH, artifactSha256: DIGEST }, - ], - ] as const) { - test(`a schema 1 record with ${name} bundle digest is rejected`, async () => { - const files = new Map([[RECORD, JSON.stringify(record)]]); - // The bundle hashes fine: rejection must come from the record alone. - const { verifyInstallForActivation, expectedBundleSha256ForActivation } = - loadInstallRecord(files, new Map([[BUNDLE, DIGEST]])); - const error = captureError(() => - expectedBundleSha256ForActivation(record, HASH) + test('install failures are PATCH_FAILED with the core message', async () => { + const { module } = load('Bundle version abc has no bundle digest.'); + for (const promise of [ + module.sealInstallRecord(`${DIR}.staging`, 'abc', ''), + module.promoteStaging(`${DIR}.staging`, DIR), + ]) { + const error = await rejectionOf(promise); + expect(error.code).toBe(ERROR_DELTA_FAILED); + expect(error.message).toBe( + `[${ERROR_DELTA_FAILED}] Bundle version abc has no bundle digest.` ); - expect(error.code).toBe('SWITCH_VERSION_FAILED'); - expect(error.message).toContain('completion record mismatch'); - await expect( - verifyInstallForActivation(DIR, HASH, BUNDLE) - ).rejects.toThrow('completion record mismatch'); - }); - } + } + }); - test('a missing or foreign record is rejected', async () => { - const { verifyInstallForActivation } = loadInstallRecord( - new Map(), - new Map() + test('activation failures are SWITCH_VERSION_FAILED with the core reason', async () => { + const { module } = load('Bundle version abc completion record mismatch.'); + const error = await rejectionOf( + module.verifyInstallForActivation(DIR, 'abc', false) ); - await expect(verifyInstallForActivation(DIR, HASH, BUNDLE)).rejects.toThrow( - 'no valid completion record' + expect(error.code).toBe(ERROR_SWITCH_VERSION_FAILED); + expect(error.message).toBe( + `[${ERROR_SWITCH_VERSION_FAILED}] Bundle version abc completion record mismatch.` ); - const foreign = new Map([ - [ - RECORD, - JSON.stringify({ schema: 1, versionHash: 'def', bundleSha256: DIGEST }), - ], - ]); - await expect( - loadInstallRecord(foreign, new Map()).verifyInstallForActivation( - DIR, - HASH, - BUNDLE + }); + + test('NativePatchCore declares exactly what pushy.cpp exports', () => { + const typings = stripComments( + source('harmony/pushy/src/main/ets/NativePatchCore.ts') + ); + const body = typings.slice( + typings.indexOf('interface NativePatchCoreBindings {'), + typings.indexOf( + '\n}\n', + typings.indexOf('interface NativePatchCoreBindings {') ) - ).rejects.toThrow('completion record mismatch'); + ); + const declared = [...body.matchAll(/^ {2}(\w+)\(/gm)] + .map((m) => m[1]) + .sort(); + const exported = [ + ...source('harmony/pushy/src/main/cpp/pushy.cpp').matchAll( + /ExportFunction\(env, exports, "(\w+)"/g + ), + ] + .map((m) => m[1]) + .sort(); + expect(declared).toEqual(exported); + for (const name of [ + 'isInstallComplete', + 'recordedBundleSha256', + 'sealInstallRecord', + 'promoteStaging', + 'checkActivation', + ]) { + expect(exported).toContain(name); + } }); }); diff --git a/src/__tests__/harmonySyncAttempts.test.ts b/src/__tests__/harmonySyncAttempts.test.ts new file mode 100644 index 00000000..dcf2dce9 --- /dev/null +++ b/src/__tests__/harmonySyncAttempts.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from 'bun:test'; +import { + createPushyError, + ERROR_DELTA_FAILED, + ERROR_DOWNLOAD_FAILED, + PushyError, +} from '../../harmony/pushy/src/main/ets/ErrorCodes'; +import { evalHarmonyModule } from './nativeSources'; + +// The download strategy loop of the Harmony cold-start orchestrator +// (SyncCoordinator.performAttempts), evaluated from source with its platform +// imports stripped. A patch that downloaded but failed to apply is not +// retried on the other mirrors (update_flow_core IsMirrorRetryableCode). + +interface Attempt { + type: string; + urls: string[]; +} + +// failures: url -> error code (a coded PushyError) or any thrown value. +function harness(failures: Map) { + const calls: string[] = []; + const fail = async (url: string) => { + calls.push(url); + const failure = failures.get(url); + if (typeof failure === 'string') { + throw createPushyError(failure, `failed ${url}`); + } + if (failure !== undefined) { + throw failure; + } + }; + const context = { + downloadPatchFromPpk: (url: string) => fail(url), + downloadPatchFromPackage: (url: string) => fail(url), + downloadFullUpdate: (url: string) => fail(url), + }; + const performAttempts = evalHarmonyModule( + 'SyncCoordinator.ts', + 'performAttempts;', + { + PushyError, + ERROR_DELTA_FAILED, + ERROR_DOWNLOAD_FAILED, + createPushyError, + getErrorMessage: (error: unknown) => String(error), + logger: { info() {}, warn() {}, error() {} }, + monotonicNowMs: () => performance.now(), + setTimeout, + clearTimeout, + BundlePreparationRound: class {}, + bundlePreparationResult: () => ({}), + } + ) as ( + ctx: typeof context, + attempts: Attempt[], + hash: string, + originHash: string + ) => Promise; + return { + calls, + run: (attempts: Attempt[], originHash = 'origin') => + performAttempts(context, attempts, 'next', originHash), + }; +} + +describe('harmony download attempts', () => { + test('a patch that fails to apply skips the remaining mirrors', async () => { + const h = harness( + new Map([ + ['https://a/diff', ERROR_DELTA_FAILED], + ['https://b/diff', ERROR_DELTA_FAILED], + ]) + ); + const ok = await h.run([ + { type: 'diff', urls: ['https://a/diff', 'https://b/diff'] }, + { type: 'full', urls: ['https://a/full', 'https://b/full'] }, + ]); + expect(ok).toBe(true); + expect(h.calls).toEqual(['https://a/diff', 'https://a/full']); + }); + + test('a download failure still tries the next mirror', async () => { + const h = harness(new Map([['https://a/pdiff', ERROR_DOWNLOAD_FAILED]])); + const ok = await h.run([ + { type: 'pdiff', urls: ['https://a/pdiff', 'https://b/pdiff'] }, + { type: 'full', urls: ['https://a/full'] }, + ]); + expect(ok).toBe(true); + expect(h.calls).toEqual(['https://a/pdiff', 'https://b/pdiff']); + }); + + test('an uncoded error is retryable', async () => { + const h = harness( + new Map([['https://a/full', new Error('socket closed')]]) + ); + const ok = await h.run([ + { type: 'full', urls: ['https://a/full', 'https://b/full'] }, + ]); + expect(ok).toBe(true); + expect(h.calls).toEqual(['https://a/full', 'https://b/full']); + }); + + test('every strategy failing to apply reports failure', async () => { + const h = harness( + new Map([ + ['https://a/full', ERROR_DELTA_FAILED], + ['https://b/full', ERROR_DELTA_FAILED], + ]) + ); + expect( + await h.run([ + { type: 'full', urls: ['https://a/full', 'https://b/full'] }, + ]) + ).toBe(false); + expect(h.calls).toEqual(['https://a/full']); + }); +}); diff --git a/src/__tests__/harmonySyncQuery.test.ts b/src/__tests__/harmonySyncQuery.test.ts new file mode 100644 index 00000000..686a031c --- /dev/null +++ b/src/__tests__/harmonySyncQuery.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from 'bun:test'; +import { evalHarmonyModule } from './nativeSources'; + +// The endpoint walk of the Harmony cold-start orchestrator +// (SyncCoordinator.runQueryRequest), evaluated from source with its platform +// imports stripped and HTTP replaced by a scripted fake. The walk's policy — +// order, list discovery, https-only remote bases, the request budget, +// response validity — is the shared core's NextQueryStep, pinned by +// cpp/update_flow_core/tests/native-round-vectors.json; this checks that the +// glue follows the core's steps. + +const VALID = '{"upToDate":true}'; + +interface Step { + url?: string; + post?: boolean; + found?: boolean; +} + +function run( + steps: Array, + responses: Record +) { + const requests: Array<[string, string | undefined]> = []; + const calls: Array<[string, string | undefined]> = []; + let next = 0; + const runQueryRequest = evalHarmonyModule( + 'SyncCoordinator.ts', + 'httpRequest = fakeHttp;\nrunQueryRequest;', + { + logger: { info() {}, warn() {}, error() {} }, + BundlePreparationRound: class {}, + bundlePreparationResult: () => ({}), + NativePatchCore: { + nextQueryStep: (state: string, text: string | undefined) => { + calls.push([state, text]); + const step = steps[next++]; + return step === undefined + ? undefined + : JSON.stringify({ ...step, n: next }); + }, + }, + fakeHttp: async (url: string, body?: string) => { + requests.push([url, body]); + return responses[url]; + }, + } + ) as (query: string, body: string) => Promise; + return runQueryRequest('{"n":0}', 'BODY').then((result) => ({ + result, + requests, + calls, + })); +} + +describe('harmony query endpoint walk', () => { + test('issues each step, passes its text back and returns the found one', async () => { + const { result, requests, calls } = await run( + [ + { url: 'https://a/q', post: true }, + { url: 'https://l', post: false }, + { url: 'https://c/q', post: true }, + { found: true }, + ], + { 'https://l': '["https://c"]', 'https://c/q': VALID } + ); + expect(result).toBe(VALID); + expect(requests).toEqual([ + ['https://a/q', 'BODY'], + ['https://l', undefined], + ['https://c/q', 'BODY'], + ]); + // Each call carries the previous state and the last request's text. + expect(calls.map(([state]) => JSON.parse(state).n)).toEqual([0, 1, 2, 3]); + expect(calls.map(([, text]) => text)).toEqual([ + undefined, + undefined, + '["https://c"]', + VALID, + ]); + }); + + test('a step without a url ends the walk with no response', async () => { + const { result, requests } = await run( + [{ url: 'https://a/q', post: true }, {}], + { 'https://a/q': '{"error":"x"}' } + ); + expect(result).toBeUndefined(); + expect(requests).toEqual([['https://a/q', 'BODY']]); + }); + + test('a state the core rejects ends the walk', async () => { + const { result, requests } = await run([undefined], {}); + expect(result).toBeUndefined(); + expect(requests).toEqual([]); + }); +}); diff --git a/src/__tests__/harmonyValidation.test.ts b/src/__tests__/harmonyValidation.test.ts new file mode 100644 index 00000000..8541af6a --- /dev/null +++ b/src/__tests__/harmonyValidation.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from 'bun:test'; +import { + createPushyError, + ERROR_INVALID_HASH_INFO, + type PushyError, +} from '../../harmony/pushy/src/main/ets/ErrorCodes'; +import { EventHub } from '../../harmony/pushy/src/main/ets/EventHub'; +import { + assertSafePathComponent, + isSafePathComponent, +} from '../../harmony/pushy/src/main/ets/PathUtils'; +import { evalHarmonyModule } from './nativeSources'; + +// Harmony input validation (path components, hash info JSON) and the +// download-progress event hub. + +// validateHashInfo lives in PushyTurboModule.ts, which extends RNOH's +// UITurboModule: evaluate it with its imports stripped and a stub base class. +const validateHashInfo = evalHarmonyModule( + 'PushyTurboModule.ts', + 'validateHashInfo;', + { + UITurboModule: class {}, + ERROR_INVALID_HASH_INFO, + createPushyError, + } +) as (info: string) => void; + +function refusal(run: () => unknown): PushyError | null { + try { + run(); + } catch (e) { + return e as PushyError; + } + return null; +} + +describe('harmony isSafePathComponent', () => { + test('accepts plain hash-like names', () => { + expect(isSafePathComponent('a1b2c3d4')).toBe(true); + expect(isSafePathComponent('origin_hash-1.ppk')).toBe(true); + expect(assertSafePathComponent('a1b2c3d4')).toBe('a1b2c3d4'); + }); + + test('rejects empty, dot and separator-bearing names', () => { + for (const name of ['', '.', '..', '../etc', 'a/b', 'a\\b', 'a\0b']) { + expect(isSafePathComponent(name)).toBe(false); + expect(refusal(() => assertSafePathComponent(name))?.code).toBe( + 'INVALID_OPTIONS' + ); + } + }); +}); + +describe('harmony validateHashInfo', () => { + test('accepts JSON objects', () => { + expect(refusal(() => validateHashInfo('{"name":"v1"}'))).toBeNull(); + expect(refusal(() => validateHashInfo('{}'))).toBeNull(); + }); + + test('rejects arrays, primitives, null and invalid JSON', () => { + for (const info of ['[1,2,3]', '"hello"', 'null', '{invalid}']) { + expect(refusal(() => validateHashInfo(info))?.code).toBe( + ERROR_INVALID_HASH_INFO + ); + } + }); +}); + +describe('harmony EventHub', () => { + test('is a singleton', () => { + expect(EventHub.getInstance()).toBe(EventHub.getInstance()); + }); + + test('emitting without an RN instance is a no-op', () => { + const hub = EventHub.getInstance(); + hub.setRNInstance(undefined); + expect(() => hub.emit('test', { foo: 'bar' })).not.toThrow(); + }); + + test('forwards events to the RN instance', () => { + const hub = EventHub.getInstance(); + const events: Array<[string, object]> = []; + hub.setRNInstance({ + emitDeviceEvent: (event: string, data: object) => { + events.push([event, data]); + }, + }); + hub.emit('RCTPushyDownloadProgress', { received: 100, total: 200 }); + expect(events).toEqual([ + ['RCTPushyDownloadProgress', { received: 100, total: 200 }], + ]); + hub.setRNInstance(undefined); + }); + + test('clearRNInstance only drops the instance it is given', () => { + const hub = EventHub.getInstance(); + let calls = 0; + const current = { emitDeviceEvent: () => calls++ }; + const stale = { emitDeviceEvent: () => calls++ }; + hub.setRNInstance(current); + hub.clearRNInstance(stale); + hub.emit('RCTPushyDownloadProgress', { received: 1, total: 2 }); + expect(calls).toBe(1); + hub.clearRNInstance(current); + hub.emit('RCTPushyDownloadProgress', { received: 2, total: 2 }); + expect(calls).toBe(1); + }); +}); diff --git a/src/__tests__/helpers.ts b/src/__tests__/helpers.ts new file mode 100644 index 00000000..05ddb199 --- /dev/null +++ b/src/__tests__/helpers.ts @@ -0,0 +1,124 @@ +// Shared test scaffolding for the JS client/provider tests. Module mocks +// installed here follow setup.ts's rule (they live for one test) and always +// pass the real module namespace through, so a mock that outlives its test +// can never leave a module with missing exports behind. +import { afterAll, afterEach, mock } from 'bun:test'; +import { baseReactNative, realProjectModules } from './setup'; + +export const createJsonResponse = (payload: unknown) => + ({ + ok: true, + status: 200, + text: async () => JSON.stringify(payload), + json: async () => payload, + }) as Response; + +/** Lets pending promise callbacks and zero-delay timers run. */ +export const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +const devGlobal = globalThis as { __DEV__?: boolean }; + +/** + * Sets __DEV__ for the calling test file (call at its top level, before the + * modules under test are imported) and restores it after the file's tests. + */ +export const setDevForFile = (value: boolean) => { + const original = devGlobal.__DEV__; + devGlobal.__DEV__ = value; + afterAll(() => { + devGlobal.__DEV__ = original; + }); +}; + +/** Restores __DEV__ after every test of the calling file. */ +export const restoreDevAfterEach = () => { + const original = devGlobal.__DEV__; + afterEach(() => { + devGlobal.__DEV__ = original; + }); +}; + +/** setup.ts's react-native stand-in with another Platform. */ +export const mockPlatform = (Platform: Record) => { + mock.module('react-native', () => ({ ...baseReactNative(), Platform })); +}; + +/** + * i18n whose `t` returns the key, plus the JSON-encoded params when + * `withParams` is set. Returns the setLocale mock. + */ +export const mockI18n = ({ + withParams = false, + setLocale = mock(() => {}), +}: { + withParams?: boolean; + setLocale?: ReturnType; +} = {}) => { + mock.module('../i18n', () => ({ + default: { + t: (key: string, params?: Record) => + withParams && params ? `${key}${JSON.stringify(params)}` : key, + setLocale, + }, + })); + return setLocale; +}; + +/** + * ../core for a client under test: the real module namespace with the native + * module, the native constants and nativeMethod (bound to that native + * module) replaced. `overrides` replace any other export. + */ +export const mockCore = ({ + PushyModule = {}, + ...overrides +}: { PushyModule?: Record } & Record< + string, + unknown +> = {}) => { + const realCore = realProjectModules['../core']; + mock.module('../core', () => ({ + ...realCore, + buildTime: '2023-01-01', + cInfo: { rnu: '10.0.0', rn: '0.73.0', os: 'ios', uuid: 'uuid' }, + currentVersion: 'hash', + currentVersionInfo: {}, + isFirstTime: false, + isRolledBack: false, + packageVersion: '1.0.0', + pushyNativeEventEmitter: { + addListener: mock(() => ({ remove: mock(() => {}) })), + }, + rolledBackVersion: '', + setLocalHashInfo: mock(() => {}), + supportedDiffVersion: 2, + getBundleHash: mock(() => ''), + getCurrentVersionInfo: async () => ({}), + ...overrides, + PushyModule, + UpdateModule: PushyModule, + nativeMethod: (name: string) => + realCore.bindNativeMethod(PushyModule, name), + })); +}; + +let freshImports = 0; + +/** + * A fresh client module per call: the client is a process-level singleton + * (module state), so every test gets its own instance of it and of + * sharedState. Picks up the module mocks installed before the call. Typed + * loosely on purpose: tests reach into private members. + */ +export const importFreshClient = (): Promise => + import(`../client?fresh-${++freshImports}`); + +/** A client from a fresh client module; appKey defaults to 'demo-app'. */ +export const createClient = async ( + options: Record = {}, + type: 'Pushy' | 'Cresc' = 'Pushy' +) => { + const module = await importFreshClient(); + const client = new module[type]({ appKey: 'demo-app', ...options }); + return { client, sharedState: module.sharedState, module }; +}; diff --git a/src/__tests__/i18n.test.ts b/src/__tests__/i18n.test.ts index 3613fc9e..761c3580 100644 --- a/src/__tests__/i18n.test.ts +++ b/src/__tests__/i18n.test.ts @@ -1,99 +1,38 @@ import { describe, expect, test } from 'bun:test'; -describe('I18n', () => { - // Use a helper function to import the real implementation - // since `../i18n` is globally mocked in `setup.ts` - const getRealI18n = async () => { - // Generate a random cache-buster so we get a fresh instance if needed, - // although singleton is usually fine, the `setup.ts` mock needs to be bypassed. - const cacheKey = 'real'; - const module = await import(`../i18n?${cacheKey}`); - return { I18n: module.I18n, i18nSingleton: module.default }; - }; +describe('i18n', () => { + // `../i18n` is globally mocked in setup.ts; a cache-busting specifier loads + // the real module (a fresh instance per key). + const getRealI18n = async (cacheKey: string) => + (await import(`../i18n?${cacheKey}`)) + .default as typeof import('../i18n').default; - test('setLocale and getLocale', async () => { - const { I18n } = await getRealI18n(); - const i18n = new I18n(); - - expect(i18n.getLocale()).toBe('en'); // Default is 'en' + test('translates in the current locale (default en)', async () => { + const i18n = await getRealI18n('locale'); + expect(i18n.t('update_failed')).toBe('Update failed'); i18n.setLocale('zh'); - expect(i18n.getLocale()).toBe('zh'); - + expect(i18n.t('update_failed')).toBe('更新失败'); i18n.setLocale('en'); - expect(i18n.getLocale()).toBe('en'); + expect(i18n.t('update_failed')).toBe('Update failed'); }); - test('basic translations and fallback', async () => { - const { I18n } = await getRealI18n(); - const i18n = new I18n(); - - // English translation - i18n.setLocale('en'); - expect(i18n.t('checking_update')).toBe('Checking for updates...'); + test('an unknown key falls back to the key itself', async () => { + const i18n = await getRealI18n('missing'); - // Chinese translation - i18n.setLocale('zh'); - expect(i18n.t('checking_update')).toBe('正在检查更新...'); - - // Fallback logic - // Add translation only to 'zh', check if 'en' falls back to 'zh' - i18n.addTranslations('zh', { only_in_zh: '中文专属' }); - i18n.setLocale('en'); - expect(i18n.t('only_in_zh' as any)).toBe('中文专属'); - - // Add translation only to 'en', check if 'zh' falls back to 'en' - i18n.addTranslations('en', { only_in_en: 'English Only' }); - i18n.setLocale('zh'); - expect(i18n.t('only_in_en' as any)).toBe('English Only'); - - // Key missing in both locales expect(i18n.t('missing_key' as any)).toBe('missing_key'); }); test('string interpolation', async () => { - const { I18n } = await getRealI18n(); - const i18n = new I18n(); + const i18n = await getRealI18n('interpolation'); - i18n.setLocale('en'); - - // Normal interpolation - expect(i18n.t('download_progress', { progress: 50 })).toBe( - 'Download progress: 50%' - ); - - // Multiple interpolations expect( i18n.t('error_http_status', { status: 502, statusText: 'Bad' }) ).toBe('502 Bad'); - - // Interpolation with missing values (should leave placeholder as is) - expect(i18n.t('download_progress')).toBe( - 'Download progress: {{progress}}%' - ); - - // Invalid interpolation syntax (spaces) are ignored - i18n.addTranslations('en', { invalid_syntax: 'Test {{ key }} here' }); - expect(i18n.t('invalid_syntax' as any, { key: 'value' })).toBe( - 'Test {{ key }} here' + // Missing values leave their placeholder as is. + expect(i18n.t('error_http_status', { status: 502 })).toBe( + '502 {{statusText}}' ); - }); - - test('addTranslations', async () => { - const { I18n } = await getRealI18n(); - const i18n = new I18n(); - - i18n.addTranslations('en', { new_key: 'New Value' }); - i18n.setLocale('en'); - - expect(i18n.t('new_key' as any)).toBe('New Value'); - }); - - test('singleton export', async () => { - const { i18nSingleton } = await getRealI18n(); - - // Verify it works - expect(i18nSingleton.getLocale()).toBeDefined(); - expect(i18nSingleton.t('checking_update')).toBeDefined(); + expect(i18n.t('error_http_status')).toBe('{{status}} {{statusText}}'); }); }); diff --git a/src/__tests__/jniRegistration.test.ts b/src/__tests__/jniRegistration.test.ts index 7835eaab..a2e39084 100644 --- a/src/__tests__/jniRegistration.test.ts +++ b/src/__tests__/jniRegistration.test.ts @@ -4,7 +4,7 @@ import { URL } from 'node:url'; import { decodeNativeText } from '../../scripts/encode-native-text'; // JNI_OnLoad binds Android native methods from an encoded name/signature table -// (cpp/patch_core/jni_registration.cpp). A drifted entry would silently leave +// (cpp/android_jni/jni_registration.cpp). A drifted entry would silently leave // a method unbound until its first call, so the table must match every Java // `native` declaration exactly. const root = new URL('../../', import.meta.url); @@ -16,6 +16,7 @@ const descriptor = (type: string): string => { const base = array ? type.slice(0, -2) : type; const primitives: Record = { boolean: 'Z', + byte: 'B', int: 'I', long: 'J', double: 'D', @@ -36,7 +37,9 @@ const javaNatives = () => .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/\/\/.*$/gm, ''); return [ - ...source.matchAll(/\bnative\s+([\w[\]]+)\s+(\w+)\s*\(([^)]*)\)\s*;/g), + ...source.matchAll( + /\bnative\s+([\w[\]]+)\s+(\w+)\s*\(([^)]*)\)\s*(?:throws\s+[\w.,\s]+)?;/g + ), ].map((match) => { const params = match[3] .split(',') @@ -50,7 +53,7 @@ const javaNatives = () => const registered = () => { const source = readFileSync( - new URL('cpp/patch_core/jni_registration.cpp', root), + new URL('cpp/android_jni/jni_registration.cpp', root), 'utf8' ); const entries: string[] = []; @@ -72,13 +75,13 @@ const registered = () => { describe('JNI registration table', () => { test('matches every Java native declaration', () => { const natives = javaNatives(); - expect(natives.length).toBe(11); + expect(natives.length).toBe(18); expect(registered()).toEqual(natives); }); test('stores no plain class or method names', () => { const source = readFileSync( - new URL('cpp/patch_core/jni_registration.cpp', root), + new URL('cpp/android_jni/jni_registration.cpp', root), 'utf8' ).replace(/\/\/.*$/gm, ''); expect(source).not.toContain('cn/reactnative'); diff --git a/src/__tests__/metadata.test.ts b/src/__tests__/metadata.test.ts index bc0b45ca..1bc08314 100644 --- a/src/__tests__/metadata.test.ts +++ b/src/__tests__/metadata.test.ts @@ -1,10 +1,12 @@ import { describe, expect, mock, test } from 'bun:test'; +import { realProjectModules } from './setup'; const importFreshMetadata = (cacheKey: string) => import(`../metadata?${cacheKey}`); const mockCore = (overrides: Record = {}) => { mock.module('../core', () => ({ + ...realProjectModules['../core'], buildTime: '1719999999', currentBundleSha256: 'c'.repeat(64), cInfo: { rnu: '10.53.1', rn: '0.85.2', os: 'ios 17.5', uuid: 'u-1' }, diff --git a/src/__tests__/nativeConfiguration.test.ts b/src/__tests__/nativeConfiguration.test.ts index 1c548cd3..b8c252e7 100644 --- a/src/__tests__/nativeConfiguration.test.ts +++ b/src/__tests__/nativeConfiguration.test.ts @@ -1,22 +1,9 @@ import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import { URL } from 'node:url'; -import { runInNewContext } from 'node:vm'; -import type { PushyConfiguration } from '../../harmony/pushy/src/main/ets/PushyConfiguration'; -import { normalizePushyConfiguration } from '../../harmony/pushy/src/main/ets/PushyConfiguration'; +import { evalHarmonyModule, harmonyConfigBindings } from './nativeSources'; -function runtimeSource(relativePath: string): string { - const source = readFileSync(new URL(relativePath, import.meta.url), 'utf8') - .replace(/^import[\s\S]*?;\r?\n/gm, '') - .replace(/^export\s*\{[^}]*\}(?:\s*from\s*[^;]*)?;\r?\n/gm, '') - .replace(/^export /gm, ''); - return new Bun.Transpiler({ loader: 'ts' }).transformSync(source); -} - -const storeSource = runtimeSource( - '../../harmony/pushy/src/main/ets/UpdateContext.ts' -); -const clientSource = runtimeSource('../client.ts'); +// Option validation (normalizePushyConfiguration) is the shared core's +// NormalizeConfiguration, covered by cpp/update_flow_core/tests/ +// native-config-vectors.json; this file exercises the Harmony store around it. interface ConfigStore { setNativeConfig: (config: string) => Promise; @@ -44,8 +31,9 @@ function storeHarness() { if (shouldFail) throw new Error('storage unavailable'); }, }; - const store = runInNewContext( - `${storeSource}\nconst store = Object.create(UpdateContext.prototype); store.preferences = preferences; store.flushBatchDepth = 0; store;`, + const store = evalHarmonyModule( + 'UpdateContext.ts', + 'const store = Object.create(UpdateContext.prototype); store.preferences = preferences; store.flushBatchDepth = 0; store;', { preferences, util: { generateRandomUUID: () => 'native-installation-id' }, @@ -54,6 +42,7 @@ function storeHarness() { recordJsRound: (config: string) => clearedSignals.push(config), logger: { error() {} }, getErrorMessage: (error: unknown) => String(error), + NativePatchCore: harmonyConfigBindings, } ) as ConfigStore; return { @@ -67,110 +56,6 @@ function storeHarness() { }; } -function clientHarness(native: boolean) { - const writes: string[] = []; - const client = runInNewContext( - `${clientSource}\nnew Pushy({appKey: 'test-app', nativeConfigSource: source, disableTelemetry: true});`, - { - source: native ? 'native' : 'javascript', - __DEV__: false, - assertWeb() {}, - noop() {}, - log() {}, - setDebugLogging() {}, - cInfo: { rnu: 'test-sdk', rn: 'test-rn' }, - packageVersion: '1.0', - isRolledBack: false, - Platform: { OS: 'android' }, - i18n: { setLocale() {} }, - dedupeEndpoints: (urls: string[]) => [...new Set(urls)], - PushyModule: { - syncNativeConfig: async (config: string) => { - writes.push(config); - }, - }, - } - ) as { setOptions: (options: Record) => void }; - return { client, writes }; -} - -describe('native configuration normalization', () => { - test('appKey alone supplies Pushy endpoints without automatically activating', () => { - const config = JSON.parse( - normalizePushyConfiguration({ appKey: 'test-app' }) - ); - expect(config.endpoints).toEqual([ - 'https://update.react-native.cn/api', - 'https://update.reactnative.cn/api', - ]); - expect(config.queryUrls).toHaveLength(2); - expect(config.afterDownload).toBe('none'); - expect(config.disabled).toBe(false); - expect(config.packageVersion).toBeUndefined(); - }); - - test('custom endpoints do not inherit public discovery, and are deduplicated', () => { - const options: PushyConfiguration = { - appKey: 'test-app', - endpoints: [ - 'https://updates.example/api/', - 'https://updates.example/api', - ], - afterDownload: 'setNeedUpdate', - }; - const original = JSON.stringify(options); - const config = JSON.parse(normalizePushyConfiguration(options)); - expect(config.endpoints).toEqual(['https://updates.example/api']); - expect(config.queryUrls).toEqual([]); - expect(config.afterDownload).toBe('setNeedUpdate'); - expect(JSON.stringify(options)).toBe(original); - }); - - test('allows explicit discovery URLs and version identity overrides', () => { - const config = JSON.parse( - normalizePushyConfiguration({ - appKey: 'test-app', - endpoints: ['http://localhost:8080/api'], - queryUrls: ['https://updates.example/endpoints.json?v=1'], - packageVersion: '2.0', - rn: '0.77.3', - rnu: 'test-sdk', - disabled: true, - }) - ); - expect(config.packageVersion).toBe('2.0'); - expect(config.disabled).toBe(true); - expect(config.queryUrls[0]).toContain('?v=1'); - }); - - for (const [name, options] of [ - ['missing key', {}], - ['blank key', { appKey: ' ' }], - ['wrong key type', { appKey: 12 }], - ['empty endpoints', { appKey: 'a', endpoints: [] }], - ['null endpoints', { appKey: 'a', endpoints: null }], - ['non-array endpoints', { appKey: 'a', endpoints: 'https://example.com' }], - ['unsafe scheme', { appKey: 'a', endpoints: ['file:///tmp/update'] }], - [ - 'credentials', - { appKey: 'a', endpoints: ['https://user:pass@example.com'] }, - ], - ['relative URL', { appKey: 'a', endpoints: ['/api'] }], - ['base query', { appKey: 'a', endpoints: ['https://example.com/api?x=1'] }], - ['wrong discovery type', { appKey: 'a', queryUrls: [42] }], - ['wrong activation', { appKey: 'a', afterDownload: 'immediate' }], - ['wrong disabled type', { appKey: 'a', disabled: 'false' }], - ['blank package version', { appKey: 'a', packageVersion: '' }], - ['unknown option', { appKey: 'a', endponts: ['https://example.com'] }], - ] as const) { - test(`rejects ${name} before storage is touched`, () => { - expect(() => - normalizePushyConfiguration(options as unknown as PushyConfiguration) - ).toThrow(); - }); - } -}); - describe('actual native configuration store', () => { test('configuration invalidates cache and old in-flight commits, even across A-B-A', async () => { const h = storeHarness(); @@ -209,20 +94,3 @@ describe('actual native configuration store', () => { expect(h.flushes()).toBe(2); }); }); - -describe('JS native configuration ownership', () => { - test('native ownership suppresses constructor and setOptions writes', () => { - const h = clientHarness(true); - expect(h.writes).toEqual([]); - h.client.setOptions({ updateStrategy: 'silentAndLater' }); - expect(h.writes).toEqual([]); - }); - - test('JS ownership retains existing synchronization, with explicit handover', () => { - const js = clientHarness(false); - expect(js.writes).toHaveLength(1); - const native = clientHarness(true); - native.client.setOptions({ nativeConfigSource: 'javascript' }); - expect(native.writes).toHaveLength(1); - }); -}); diff --git a/src/__tests__/nativeHostApi.test.ts b/src/__tests__/nativeHostApi.test.ts index 5c6f3b34..3fdfa6a5 100644 --- a/src/__tests__/nativeHostApi.test.ts +++ b/src/__tests__/nativeHostApi.test.ts @@ -1,27 +1,16 @@ import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import { URL } from 'node:url'; -import { runInNewContext } from 'node:vm'; +import vectorsFile from '../../cpp/update_flow_core/tests/native-config-vectors.json'; import type { BundlePreparationResult } from '../../harmony/pushy/src/main/ets/BundlePreparationResult'; import { BundlePreparationRound, bundlePreparationResult, } from '../../harmony/pushy/src/main/ets/BundlePreparationResult'; import { STATUS_NONE } from '../../harmony/pushy/src/main/ets/Texts'; +import { evalHarmonyModule, harmonyConfigBindings } from './nativeSources'; // Evaluate the actual Harmony orchestrator in an isolated VM per test. Only // platform imports, HTTP and download IO are substituted; entry points, // scheduling, configuration gates, result mapping and reset checks are real. -const source = readFileSync( - new URL( - '../../harmony/pushy/src/main/ets/SyncCoordinator.ts', - import.meta.url - ), - 'utf8' -) - .replace(/^import[\s\S]*?;\r?\n/gm, '') - .replace(/^export /gm, ''); -const javascript = new Bun.Transpiler({ loader: 'ts' }).transformSync(source); interface Decision { action: string; @@ -69,8 +58,9 @@ function harness() { return generation === state.generation && state.commitOK; }, }; - const runtime = runInNewContext( - `${javascript}\nrunQueryRequest = mockCheck;\nperformAttempts = mockDownload;\n({ check: prepareBundleNative, schedule: scheduleNativeSync });`, + const runtime = evalHarmonyModule( + 'SyncCoordinator.ts', + 'runQueryRequest = mockCheck;\nperformAttempts = mockDownload;\n({ check: prepareBundleNative, schedule: scheduleNativeSync });', { BundlePreparationRound, bundlePreparationResult, @@ -81,8 +71,8 @@ function harness() { getErrorMessage: (error: unknown) => String(error), STATUS_NONE, NativePatchCore: { - getSupportedDiffVersion: () => 2, - buildRequestBody: (input: string) => input, + ...harmonyConfigBindings, + prepareRound: () => JSON.stringify({ body: '{}', query: {} }), handleResponse: (response: string) => response, }, mockCheck: async () => { @@ -109,6 +99,17 @@ function harness() { } describe('native host API orchestration', () => { + test('the config gate stand-in matches the shared core vectors', () => { + for (const vector of vectorsFile.runnable) { + expect({ + name: vector.name, + verdict: JSON.parse( + harmonyConfigBindings.runnableConfig(vector.config) + ), + }).toEqual({ name: vector.name, verdict: vector.expected }); + } + }); + test('requires real launch initialization, without resolving the bundle', async () => { const h = harness(); expect((await h.check()).reason).toBe('not_initialized'); diff --git a/src/__tests__/nativeHostApiNaming.test.ts b/src/__tests__/nativeHostApiNaming.test.ts index 0439a351..5c47241d 100644 --- a/src/__tests__/nativeHostApiNaming.test.ts +++ b/src/__tests__/nativeHostApiNaming.test.ts @@ -1,11 +1,15 @@ import { describe, expect, test } from 'bun:test'; -import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import { URL } from 'node:url'; +import { + root, + shippedNativeSources, + source, + stripComments, +} from './nativeSources'; -const root = new URL('../../', import.meta.url); const android = 'android/src/main/java/cn/reactnative/modules/update/'; const harmony = 'harmony/pushy/src/main/ets/'; -const source = (path: string) => readFileSync(new URL(path, root), 'utf8'); // These are forbidden host API identifiers, not backend paths or JS bridge names. const oldNames = @@ -18,22 +22,6 @@ const roundNames = /\b(?:NativeCheckOrchestrator|CrashRescue|updateflow|scheduleNativeCheck|isJsCheckCompleted|commitNativeCheckResult\w*|runCheckRequest|runRescue\w*|softReload|ReloadEventEmitter|\w*PatchInputs|FlowCheckInput|\w*(?:Check|Rescue)(?:Request|Response|Result|Budget|Deadline|Active|Attempted)\w*|pushyNativeCheckReady|nativeCheckRolledBackVersion)\b/; const roundWording = /native check|crash rescue|host-check|native-check|crash-rescue/i; -const stripComments = (text: string) => - text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:"'\\])\/\/.*$/gm, '$1'); - -const shippedNativeSources = (dir: string): string[] => - readdirSync(new URL(dir, root), { recursive: true, withFileTypes: true }) - .filter( - (entry) => - entry.isFile() && /\.(?:h|m|mm|java|kt|ts|ets|cpp)$/.test(entry.name) - ) - .map((entry) => - `${entry.parentPath}/${entry.name}`.slice( - new URL(dir, root).pathname.length - dir.length - ) - ) - .filter((path) => !/\/tests?\//.test(path)); - describe('native host API naming', () => { test('Android exposes prepareBundle through PushyRuntime', () => { const entry = source(`${android}PushyRuntime.java`); @@ -44,9 +32,8 @@ describe('native host API naming', () => { expect(source(`${android}BundlePreparationResult.java`)).toContain( 'class BundlePreparationResult' ); - expect(source(`${android}PushyConfiguration.java`)).toContain( - 'class PushyConfiguration' - ); + // Options are validated by the shared core (native_config.h). + expect(entry).toContain('FlowBridge.normalizeConfiguration('); for (const name of [ 'PushyNativeUpdate', 'NativeUpdateResult', @@ -103,8 +90,7 @@ describe('native host API naming', () => { 'ios/', 'android/src/main/', 'harmony/pushy/src/main/', - 'cpp/patch_core/', - 'cpp/update_flow_core/', + 'cpp/', ].flatMap(shippedNativeSources); expect(paths).toContain(`${android}PushyRuntime.java`); const offenders = paths.filter((path) => { @@ -119,8 +105,7 @@ describe('native host API naming', () => { 'ios/', 'android/src/main/', 'harmony/pushy/src/main/', - 'cpp/patch_core/', - 'cpp/update_flow_core/', + 'cpp/', ].flatMap(shippedNativeSources); const offenders = paths.flatMap((path) => stripComments(source(path)) diff --git a/src/__tests__/nativeSources.ts b/src/__tests__/nativeSources.ts new file mode 100644 index 00000000..35a8fb0a --- /dev/null +++ b/src/__tests__/nativeSources.ts @@ -0,0 +1,92 @@ +// Shared access to the repository's native sources for the tests that check +// or evaluate them directly. +import { readdirSync, readFileSync } from 'node:fs'; +import { URL } from 'node:url'; +import { runInNewContext } from 'node:vm'; +import { nativeConfigsEquivalent } from '../updateFlowCore'; + +/** The repository root. */ +export const root = new URL('../../', import.meta.url); + +/** A repository file's text, by its path from the root. */ +export const source = (path: string) => + readFileSync(new URL(path, root), 'utf8'); + +/** Drops block and line comments (not `//` inside URLs or strings). */ +export const stripComments = (text: string) => + text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:"'\\])\/\/.*$/gm, '$1'); + +/** Root-relative paths of the native source files shipped from `dir`. */ +export const shippedNativeSources = (dir: string): string[] => + readdirSync(new URL(dir, root), { recursive: true, withFileTypes: true }) + .filter( + (entry) => + entry.isFile() && /\.(?:h|m|mm|java|kt|ts|ets|cpp)$/.test(entry.name) + ) + .map((entry) => + `${entry.parentPath}/${entry.name}`.slice( + new URL(dir, root).pathname.length - dir.length + ) + ) + .filter((path) => !/\/tests?\//.test(path)); + +const harmonyScripts = new Map(); + +/** + * Evaluates a Harmony module (harmony/pushy/src/main/ets/) in an + * isolated VM context and returns the value of `expression` evaluated after + * it. Imports are stripped, so everything the code under test imports must + * be supplied through `context`. + */ +export const evalHarmonyModule = ( + file: string, + expression: string, + context: Record = {} +) => { + let js = harmonyScripts.get(file); + if (js === undefined) { + const ts = source(`harmony/pushy/src/main/ets/${file}`) + .replace(/^import[\s\S]*?;\r?\n/gm, '') + .replace(/^export\s*\{[^}]*\}(?:\s*from\s*[^;]*)?;\r?\n/gm, '') + .replace(/^export /gm, ''); + js = new Bun.Transpiler({ loader: 'ts' }).transformSync(ts); + harmonyScripts.set(file, js); + } + return runInNewContext(`${js}\n${expression}`, context); +}; + +/** + * Stand-in for the NAPI persisted-config bindings (cpp/update_flow_core + * native_config.h) when a Harmony module is evaluated under bun. The gate is + * a JS restatement of ParseRunnableConfig, held to the shared + * native-config-vectors.json by nativeHostApi.test.ts; equivalence reuses the + * JS client's C1 implementation, which replays the same vectors as the core. + */ +export const harmonyConfigBindings = { + configsEquivalent: (a?: string, b?: string): boolean => + a !== undefined && b !== undefined && nativeConfigsEquivalent(a, b), + runnableConfig: (json?: string): string => { + if (!json) { + return JSON.stringify({ status: 'skipped', reason: 'not_configured' }); + } + const invalid = JSON.stringify({ + status: 'failed', + reason: 'invalid_config', + }); + let config: unknown; + try { + config = JSON.parse(json); + } catch { + return invalid; + } + if (!config || typeof config !== 'object' || Array.isArray(config)) { + return invalid; + } + const { appKey, disabled } = config as Record; + if (disabled) { + return JSON.stringify({ status: 'skipped', reason: 'disabled' }); + } + const trimmed = typeof appKey === 'string' ? appKey.trim() : ''; + return trimmed ? JSON.stringify({ appKey: trimmed }) : invalid; + }, +}; diff --git a/src/__tests__/nativeTextEncoding.test.ts b/src/__tests__/nativeTextEncoding.test.ts index ebc04ba4..6fbed836 100644 --- a/src/__tests__/nativeTextEncoding.test.ts +++ b/src/__tests__/nativeTextEncoding.test.ts @@ -1,15 +1,10 @@ import { describe, expect, test } from 'bun:test'; -import { readdirSync, readFileSync } from 'node:fs'; -import { URL } from 'node:url'; -import { normalizePushyConfiguration } from '../../harmony/pushy/src/main/ets/PushyConfiguration'; -import { QUERY_PATH, revealText } from '../../harmony/pushy/src/main/ets/Texts'; +import { revealText } from '../../harmony/pushy/src/main/ets/Texts'; import { decodeNativeText, encodeNativeText, } from '../../scripts/encode-native-text'; - -const root = new URL('../../', import.meta.url); -const source = (path: string) => readFileSync(new URL(path, root), 'utf8'); +import { shippedNativeSources, source, stripComments } from './nativeSources'; // Service addresses and the request path must only appear encoded in shipped // native code (see scripts/encode-native-text.ts). @@ -21,30 +16,13 @@ const plainTexts = [ 'https://cdn.jsdelivr.net/gh/reactnativecn/react-native-update@master/endpoints.json', ]; -const shippedNativeSources = (dir: string): string[] => - readdirSync(new URL(dir, root), { recursive: true, withFileTypes: true }) - .filter( - (entry) => - entry.isFile() && /\.(?:h|m|mm|java|kt|ts|ets|cpp)$/.test(entry.name) - ) - .map((entry) => - `${entry.parentPath}/${entry.name}`.slice( - new URL(dir, root).pathname.length - dir.length - ) - ) - .filter((path) => !/\/tests?\//.test(path)); - const paths = [ 'ios/', 'android/src/main/', 'harmony/pushy/src/main/', - 'cpp/patch_core/', - 'cpp/update_flow_core/', + 'cpp/', ].flatMap(shippedNativeSources); -const stripComments = (text: string) => - text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:"'\\])\/\/.*$/gm, '$1'); - const encodedIn = (text: string) => [ ...text.matchAll( @@ -60,24 +38,19 @@ describe('native text encoding', () => { } }); - test('each platform encodes the service addresses and path', () => { - const platforms = { - android: paths.filter((path) => path.startsWith('android/')), - ios: paths.filter((path) => path.startsWith('ios/')), - harmony: paths.filter((path) => path.startsWith('harmony/')), - }; - for (const [platform, files] of Object.entries(platforms)) { - const decoded = files - .flatMap((path) => encodedIn(source(path))) - .map(decodeNativeText) - .sort(); - for (const text of plainTexts) { - expect({ platform, text, found: decoded.includes(text) }).toEqual({ - platform, - text, - found: true, - }); - } + // The request path is appended by the shared round core + // (cpp/update_flow_core/native_round.cpp); the default service addresses + // only live in the shared normalizer (cpp/update_flow_core/native_config.cpp). + test('the request path and the addresses are encoded in cpp/', () => { + const shared = paths + .filter((path) => path.startsWith('cpp/')) + .flatMap((path) => encodedIn(source(path))) + .map(decodeNativeText); + for (const text of plainTexts) { + expect({ text, found: shared.includes(text) }).toEqual({ + text, + found: true, + }); } }); @@ -91,13 +64,6 @@ describe('native text encoding', () => { expect(offenders).toEqual([]); }); - test('Harmony resolves the path and default addresses at runtime', () => { - expect(QUERY_PATH).toBe('/checkUpdate/'); - const config = JSON.parse(normalizePushyConfiguration({ appKey: 'k' })); - expect(config.endpoints).toEqual(plainTexts.slice(1, 3)); - expect(config.queryUrls).toEqual(plainTexts.slice(3)); - }); - // Every string literal in shipped native code, comments excluded: none may // carry update/patch/rescue/reload wording except the entries below, which // are fixed by a contract that cannot change without breaking callers. @@ -107,12 +73,6 @@ describe('native text encoding', () => { 'android/src/main/java/cn/reactnative/modules/update/BundlePreparationResult.java: noUpdate', // Public ArkTS option type (compile-time only). 'harmony/pushy/src/main/ets/PushyConfiguration.ts: setNeedUpdate', - // JS bridge method names registered with RNOH. - 'harmony/pushy/src/main/cpp/PushyTurboModule.cpp: reloadUpdate', - 'harmony/pushy/src/main/cpp/PushyTurboModule.cpp: setNeedUpdate', - 'harmony/pushy/src/main/cpp/PushyTurboModule.cpp: downloadPatchFromPpk', - 'harmony/pushy/src/main/cpp/PushyTurboModule.cpp: downloadPatchFromPackage', - 'harmony/pushy/src/main/cpp/PushyTurboModule.cpp: downloadFullUpdate', ]; const wording = /update|patch|rescue|reload|hotfix/i; const literal = diff --git a/src/__tests__/provider.contract.test.tsx b/src/__tests__/provider.contract.test.tsx index 63f9f73f..0556713a 100644 --- a/src/__tests__/provider.contract.test.tsx +++ b/src/__tests__/provider.contract.test.tsx @@ -1,50 +1,40 @@ /** * Provider <-> client contract, exercised with the REAL Pushy client (only - * the native module / core constants are mocked, as in client.test.ts). The - * render tests next door use a hand-written fake client; this file is what - * keeps that fake honest about the members the provider actually relies on. + * the native module / core constants are mocked, as in client.test.ts): the + * provider's lifecycle (mount claim, AppState, timers, option changes) + * against the client's own option and singleton machinery. */ -import { - afterAll, - beforeEach, - describe, - expect, - mock, - spyOn, - test, -} from 'bun:test'; +import { beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'; import type React from 'react'; import TestRenderer from 'react-test-renderer'; +import { + createClient, + createJsonResponse, + flush, + mockCore, + mockI18n, + setDevForFile, +} from './helpers'; import { emitAppStateChange, mockAlert } from './setup'; // Release code paths (assertDebug allows checks, default strategies apply). -const _origDEV = (globalThis as any).__DEV__; -(globalThis as any).__DEV__ = false; +setDevForFile(false); const { useUpdate } = await import('../context'); -const createJsonResponse = (payload: unknown) => - ({ - ok: true, - status: 200, - text: async () => JSON.stringify(payload), - json: async () => payload, - }) as Response; - -const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); let importSeq = 0; -// A fresh client module per test: the client is a process-level singleton -// (module state), so every test gets its own module instance instead of -// inheriting the previous test's options, listeners and dedup window. +// A fresh client (and provider) module per test: the client is a +// process-level singleton (module state), so every test gets its own module +// instance instead of inheriting the previous test's options, listeners and +// dedup window. const setupRealClient = async ( options: Record = {}, native: { markSuccess?: ReturnType; isFirstTime?: boolean } = {} ) => { - const seq = ++importSeq; - mock.module('../core', () => ({ + mockCore({ PushyModule: { markSuccess: native.markSuccess ?? mock(() => Promise.resolve()), reloadUpdate: mock(() => Promise.resolve()), @@ -56,32 +46,13 @@ const setupRealClient = async ( restartApp: mock(() => Promise.resolve()), resetToPackagedBundle: mock(() => Promise.resolve()), }, - buildTime: '2023-01-01', - cInfo: { rnu: '10.0.0', rn: '0.73.0', os: 'ios', uuid: 'uuid' }, - currentVersion: 'hash', - currentVersionInfo: {}, isFirstTime: native.isFirstTime ?? false, - isRolledBack: false, - packageVersion: '1.0.0', - pushyNativeEventEmitter: { - addListener: mock(() => ({ remove: mock(() => {}) })), - }, - rolledBackVersion: '', - setLocalHashInfo: mock(() => {}), - supportedDiffVersion: 2, - getBundleHash: mock(() => ''), - getCurrentVersionInfo: async () => ({}), - })); - mock.module('../i18n', () => ({ - default: { - t: (key: string, params?: Record) => - params ? `${key}${JSON.stringify(params)}` : key, - setLocale: mock(() => {}), - }, - })); - const { Pushy } = await import(`../client?contract-${seq}`); - const { UpdateProvider } = await import(`../provider?contract-${seq}`); - const client = new Pushy({ appKey: 'contract-app', ...options }); + }); + mockI18n({ withParams: true }); + const { client } = await createClient({ appKey: 'contract-app', ...options }); + const { UpdateProvider } = await import( + `../provider?contract-${++importSeq}` + ); // Count the provider's calls into the client without changing what they do. const realCheckUpdate = client.checkUpdate; const checkUpdate = mock((extra?: Record) => @@ -114,10 +85,6 @@ describe('UpdateProvider <-> Pushy contract', () => { ); }); - afterAll(() => { - (globalThis as any).__DEV__ = _origDEV; - }); - test('a second concurrently mounted provider throws SINGLETON_VIOLATION', async () => { const { client, UpdateProvider } = await setupRealClient({ checkStrategy: null, diff --git a/src/__tests__/provider.render.test.tsx b/src/__tests__/provider.render.test.tsx index eea8efcb..54c7563a 100644 --- a/src/__tests__/provider.render.test.tsx +++ b/src/__tests__/provider.render.test.tsx @@ -1,20 +1,24 @@ -import { afterAll, beforeEach, describe, expect, test } from 'bun:test'; +import { beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'; import type React from 'react'; import { useContext } from 'react'; import TestRenderer from 'react-test-renderer'; +import type { CheckResult, ProgressData } from '../type'; +import { + createClient, + flush, + mockCore, + mockI18n, + setDevForFile, +} from './helpers'; import { emitAppStateChange, mockAlert } from './setup'; // Render tests exercise the release code paths (assertDebug allows checks). -const _origDEV = (globalThis as any).__DEV__; -(globalThis as any).__DEV__ = false; +setDevForFile(false); const { UpdateProvider } = await import('../provider'); const { UpdateContext, useUpdate, useUpdateProgress } = await import( '../context' ); -const { mock } = await import('bun:test'); - -import type { CheckResult, ProgressData } from '../type'; const updateResult: CheckResult = { update: true, @@ -25,99 +29,44 @@ const updateResult: CheckResult = { paths: ['https://cdn.example.com'], }; -const createClient = (options: Record = {}) => { +// The real client (a fresh module per call) with the calls the provider makes +// into the update flow spied and stubbed: checks answer `updateResult`, +// downloads deliver 'next-hash' and keep the progress callback for +// emitProgress. Options, onError/wasEmitted, the mount claim and the +// remaining methods are the client's own. +const createSpiedClient = async (options: Record = {}) => { + mockCore({ rolledBackVersion: 'rolled-back-hash' }); + mockI18n({ withParams: true }); + const { client } = await createClient({ + updateStrategy: 'alwaysAlert', + checkStrategy: 'onAppStart', + autoMarkSuccess: false, + ...options, + }); let progressCallback: ((data: ProgressData) => void) | undefined; - const errorListeners = new Set<(e: Error, eventType?: string) => void>(); - const emittedErrors = new WeakSet(); - const optionsListeners = new Set<() => void>(); - let providerMounted = false; - const client = { - options: { - updateStrategy: 'alwaysAlert', - checkStrategy: 'onAppStart', - autoMarkSuccess: false, - ...options, - }, - // The provider's option-change contract (see Pushy.setOptions): options - // are mutated in place, a version is bumped and subscribers notified. - optionsVersion: 0, - onOptionsChange: (listener: () => void) => { - optionsListeners.add(listener); - return () => { - optionsListeners.delete(listener); - }; - }, - setOptions: (next: Record) => { - Object.assign(client.options, next); - client.optionsVersion++; - optionsListeners.forEach((listener) => { - listener(); - }); - }, - claimProviderMount: () => { - if (providerMounted) { - throw new Error('error_provider_singleton'); - } - providerMounted = true; - return () => { - providerMounted = false; - }; - }, - getEffectivePackageVersion: () => options.overridePackageVersion || '1.0.0', - assertDebug: () => true, - checkUpdate: mock( - async (): Promise => ({ ...updateResult }) - ), - notifyAfterCheckUpdate: mock(() => {}), - report: mock(() => {}), - reportInvalidUpdateOnce: mock(() => {}), - markSuccess: mock(() => {}), - switchVersion: mock(async () => {}), - switchVersionLater: mock(async () => {}), - downloadUpdate: mock( - async (_info: CheckResult, onProgress?: (data: ProgressData) => void) => { - progressCallback = onProgress; - return 'next-hash'; - } - ), - downloadAndInstallApk: mock(async () => {}), - restartApp: mock(async () => {}), - t: (key: string, params?: Record) => - params ? `${key}${JSON.stringify(params)}` : key, - onError: mock((listener: (e: Error, eventType?: string) => void) => { - errorListeners.add(listener); - return () => { - errorListeners.delete(listener); - }; - }), - // Simulates the real client contract: errors are emitted to onError - // listeners (report + lastError/Alert path) regardless of throwError, - // and wasEmitted() answers whether an error object went through the - // pipeline (the provider uses it to avoid double-surfacing). - emitError: (e: Error, eventType = 'errorChecking') => { - emittedErrors.add(e); - errorListeners.forEach((listener) => { - listener(e, eventType); - }); - }, - wasEmitted: (e: unknown) => e instanceof Error && emittedErrors.has(e), + spyOn(client, 'checkUpdate').mockImplementation( + async (): Promise => ({ ...updateResult }) + ); + spyOn(client, 'downloadUpdate').mockImplementation( + async (_info: CheckResult, onProgress?: (data: ProgressData) => void) => { + progressCallback = onProgress; + return 'next-hash'; + } + ); + spyOn(client, 'switchVersion').mockImplementation(async () => true); + spyOn(client, 'switchVersionLater').mockImplementation(async () => {}); + spyOn(client, 'markSuccess').mockImplementation(async () => {}); + spyOn(client, 'reportInvalidUpdateOnce'); + return Object.assign(client, { emitProgress: (data: ProgressData) => progressCallback?.(data), - }; - return client; + }); }; -const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); - -const renderProvider = async ( - client: ReturnType, - children?: React.ReactElement -) => { +const renderProvider = async (client: any, children?: React.ReactElement) => { let renderer: TestRenderer.ReactTestRenderer; await TestRenderer.act(async () => { renderer = TestRenderer.create( - - {children ?? <>} - + {children ?? <>} ); await flush(); }); @@ -129,12 +78,8 @@ describe('UpdateProvider rendering', () => { mockAlert.mockClear(); }); - afterAll(() => { - (globalThis as any).__DEV__ = _origDEV; - }); - test('alwaysAlert strategy alerts when an update is found and downloads on confirm', async () => { - const client = createClient({ updateStrategy: 'alwaysAlert' }); + const client = await createSpiedClient({ updateStrategy: 'alwaysAlert' }); await renderProvider(client); expect(client.checkUpdate).toHaveBeenCalledTimes(1); @@ -161,7 +106,7 @@ describe('UpdateProvider rendering', () => { }); test('a hash-less update is reported but never shown as an actionable alert', async () => { - const client = createClient({ updateStrategy: 'alwaysAlert' }); + const client = await createSpiedClient({ updateStrategy: 'alwaysAlert' }); client.checkUpdate.mockImplementation(async () => ({ update: true, name: 'broken rollout entry', @@ -175,7 +120,7 @@ describe('UpdateProvider rendering', () => { }); test('an expired app package without a bundle hash keeps its download action', async () => { - const client = createClient({ updateStrategy: 'alwaysAlert' }); + const client = await createSpiedClient({ updateStrategy: 'alwaysAlert' }); client.checkUpdate.mockImplementation(async () => ({ expired: true, update: true, @@ -192,7 +137,7 @@ describe('UpdateProvider rendering', () => { }); test('an update without a downloadable artifact is never shown as actionable', async () => { - const client = createClient({ updateStrategy: 'alwaysAlert' }); + const client = await createSpiedClient({ updateStrategy: 'alwaysAlert' }); client.checkUpdate.mockImplementation(async () => ({ update: true, hash: 'broken-artifact-hash', @@ -211,7 +156,7 @@ describe('UpdateProvider rendering', () => { }); test('a rolled-back update is never shown as actionable', async () => { - const client = createClient({ updateStrategy: 'alwaysAlert' }); + const client = await createSpiedClient({ updateStrategy: 'alwaysAlert' }); client.checkUpdate.mockImplementation(async () => ({ update: true, hash: 'rolled-back-hash', @@ -228,7 +173,7 @@ describe('UpdateProvider rendering', () => { }); test('silentAndNow strategy downloads and switches without alerts', async () => { - const client = createClient({ updateStrategy: 'silentAndNow' }); + const client = await createSpiedClient({ updateStrategy: 'silentAndNow' }); await renderProvider(client); expect(client.downloadUpdate).toHaveBeenCalledTimes(1); @@ -237,7 +182,9 @@ describe('UpdateProvider rendering', () => { }); test('silentAndLater strategy downloads and defers the switch', async () => { - const client = createClient({ updateStrategy: 'silentAndLater' }); + const client = await createSpiedClient({ + updateStrategy: 'silentAndLater', + }); await renderProvider(client); expect(client.downloadUpdate).toHaveBeenCalledTimes(1); @@ -247,7 +194,7 @@ describe('UpdateProvider rendering', () => { }); test('check failure sets lastError and alerts under alwaysAlert', async () => { - const client = createClient({ updateStrategy: 'alwaysAlert' }); + const client = await createSpiedClient({ updateStrategy: 'alwaysAlert' }); const checkError = new Error('offline'); // Real client contract under the default throwError:false — the error is // emitted to onError listeners and checkUpdate resolves undefined. @@ -271,7 +218,7 @@ describe('UpdateProvider rendering', () => { }); test('a user-hook error carrying a foreign code is still surfaced (JS2-4)', async () => { - const client = createClient({ updateStrategy: 'alwaysAlert' }); + const client = await createSpiedClient({ updateStrategy: 'alwaysAlert' }); const hookError: any = new Error('axios network error'); // axios-style code on an error that never entered the client pipeline; // the old `!e?.code` heuristic wrongly treated it as already surfaced. @@ -292,7 +239,7 @@ describe('UpdateProvider rendering', () => { }); test('alertUpdateAndIgnoreError suppresses the error alert but keeps lastError', async () => { - const client = createClient({ + const client = await createSpiedClient({ updateStrategy: 'alertUpdateAndIgnoreError', }); const checkError = new Error('offline'); @@ -313,7 +260,7 @@ describe('UpdateProvider rendering', () => { }); test('dismissErrorAfter clears lastError automatically (JS-4 regression)', async () => { - const client = createClient({ + const client = await createSpiedClient({ updateStrategy: 'alertUpdateAndIgnoreError', dismissErrorAfter: 20, }); @@ -337,7 +284,7 @@ describe('UpdateProvider rendering', () => { }); test('autoMarkSuccessDelayMs delays the automatic markSuccess', async () => { - const client = createClient({ + const client = await createSpiedClient({ updateStrategy: 'silentAndLater', autoMarkSuccess: true, autoMarkSuccessDelayMs: 40, @@ -352,7 +299,7 @@ describe('UpdateProvider rendering', () => { }); test('healthCheck returning false or throwing skips the automatic markSuccess', async () => { - const unhealthy = createClient({ + const unhealthy = await createSpiedClient({ updateStrategy: 'silentAndLater', autoMarkSuccess: true, autoMarkSuccessDelayMs: 0, @@ -364,7 +311,7 @@ describe('UpdateProvider rendering', () => { }); expect(unhealthy.markSuccess).not.toHaveBeenCalled(); - const throwing = createClient({ + const throwing = await createSpiedClient({ updateStrategy: 'silentAndLater', autoMarkSuccess: true, autoMarkSuccessDelayMs: 0, @@ -378,7 +325,7 @@ describe('UpdateProvider rendering', () => { }); expect(throwing.markSuccess).not.toHaveBeenCalled(); - const healthy = createClient({ + const healthy = await createSpiedClient({ updateStrategy: 'silentAndLater', autoMarkSuccess: true, autoMarkSuccessDelayMs: 0, @@ -392,7 +339,7 @@ describe('UpdateProvider rendering', () => { }); test('test-channel payloads are refused when testChannel is false', async () => { - const client = createClient({ + const client = await createSpiedClient({ updateStrategy: 'silentAndLater', checkStrategy: null, testChannel: false, @@ -417,7 +364,7 @@ describe('UpdateProvider rendering', () => { test('a test-channel check passes toHash without swapping the logger', async () => { const logger = mock(() => {}); - const client = createClient({ + const client = await createSpiedClient({ updateStrategy: 'silentAndLater', checkStrategy: null, logger, @@ -443,7 +390,7 @@ describe('UpdateProvider rendering', () => { }); test('onAppResume strategy checks when the app becomes active', async () => { - const client = createClient({ + const client = await createSpiedClient({ updateStrategy: 'silentAndLater', checkStrategy: 'onAppResume', }); @@ -458,7 +405,9 @@ describe('UpdateProvider rendering', () => { }); test('progress ticks re-render progress consumers but not static context consumers (JS-7)', async () => { - const client = createClient({ updateStrategy: 'silentAndLater' }); + const client = await createSpiedClient({ + updateStrategy: 'silentAndLater', + }); let staticRenders = 0; const StaticProbe = () => { @@ -500,7 +449,7 @@ describe('UpdateProvider rendering', () => { }); test('setOptions on a mounted provider re-runs neither the start check nor the auto-mark timer (1.2)', async () => { - const client = createClient({ + const client = await createSpiedClient({ updateStrategy: 'silentAndLater', autoMarkSuccess: true, autoMarkSuccessDelayMs: 40, @@ -523,7 +472,7 @@ describe('UpdateProvider rendering', () => { }); test('enabling automatic checks after mount runs the start check once', async () => { - const client = createClient({ + const client = await createSpiedClient({ updateStrategy: 'silentAndLater', checkStrategy: null, }); @@ -544,7 +493,7 @@ describe('UpdateProvider rendering', () => { }); test('a strategy change re-subscribes AppState without another start check', async () => { - const client = createClient({ + const client = await createSpiedClient({ updateStrategy: 'silentAndLater', checkStrategy: 'onAppStart', }); @@ -571,7 +520,7 @@ describe('UpdateProvider rendering', () => { }); test('an expired response with a stray hash-less update is presented as expired-only', async () => { - const client = createClient({ updateStrategy: 'alwaysAlert' }); + const client = await createSpiedClient({ updateStrategy: 'alwaysAlert' }); client.checkUpdate.mockImplementation(async () => ({ expired: true, update: true, @@ -594,7 +543,9 @@ describe('UpdateProvider rendering', () => { }); test('a silent strategy hands an artifact-less release to the client, which reports it (once)', async () => { - const client = createClient({ updateStrategy: 'silentAndLater' }); + const client = await createSpiedClient({ + updateStrategy: 'silentAndLater', + }); client.checkUpdate.mockImplementation(async () => ({ update: true, hash: 'broken-artifact-hash', @@ -622,7 +573,7 @@ describe('UpdateProvider rendering', () => { }); test('a release without a name leaves no {{name}} placeholder in the alert', async () => { - const client = createClient({ updateStrategy: 'alwaysAlert' }); + const client = await createSpiedClient({ updateStrategy: 'alwaysAlert' }); client.checkUpdate.mockImplementation(async () => ({ update: true, hash: 'next-hash', @@ -638,7 +589,7 @@ describe('UpdateProvider rendering', () => { }); test('an expired download url the system cannot open becomes lastError, not an unhandled rejection (1.8)', async () => { - const client = createClient({ updateStrategy: 'silentAndNow' }); + const client = await createSpiedClient({ updateStrategy: 'silentAndNow' }); client.checkUpdate.mockImplementation(async () => ({ expired: true, downloadUrl: 'market://details?id=app', @@ -666,7 +617,7 @@ describe('UpdateProvider rendering', () => { Promise.resolve('app://open?type=__rnPushyVersionHash&data=test-hash') ); (Linking as any).getInitialURL = getInitialURL; - const client = createClient({ + const client = await createSpiedClient({ updateStrategy: 'alwaysAlert', checkStrategy: null, }); diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 4602ccd8..86297607 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -19,62 +19,66 @@ export const emitAppStateChange = (state: string) => { }); }; -const installBaseMocks = () => { - mock.module('react-native', () => { - return { - Platform: { - OS: 'ios', - Version: 13, - }, - Alert: { - alert: mockAlert, - }, - AppState: { - currentState: 'active', - addEventListener: (_type: string, handler: (state: string) => void) => { - appStateListeners.add(handler); - return { - remove: () => { - appStateListeners.delete(handler); - }, - }; - }, - }, - Linking: { - openURL: mock(() => Promise.resolve()), - getInitialURL: () => Promise.resolve(null), - addEventListener: () => ({ remove: () => {} }), - }, - DeviceEventEmitter: { - addListener: () => ({ remove: () => {} }), - }, - NativeModules: { - Pushy: { - currentVersionInfo: '{}', - downloadRootDir: '/tmp', - packageVersion: '1.0.0', - currentVersion: 'hash', - isFirstTime: false, - rolledBackVersion: 'rolled-back-hash', - buildTime: '2023-01-01', - uuid: 'uuid', - setLocalHashInfo: () => {}, - getLocalHashInfo: () => Promise.resolve('{}'), - setUuid: () => {}, +// The react-native stand-in every test starts from (the real package cannot +// load outside an app). Tests that need another platform spread it and +// override, so a leaked mock still has every export. +export const baseReactNative = () => ({ + Platform: { + OS: 'ios', + Version: 13, + }, + Alert: { + alert: mockAlert, + }, + AppState: { + currentState: 'active', + addEventListener: (_type: string, handler: (state: string) => void) => { + appStateListeners.add(handler); + return { + remove: () => { + appStateListeners.delete(handler); }, - }, - NativeEventEmitter: class { - addListener = () => ({ remove: () => {} }); - removeAllListeners = () => {}; - }, - }; - }); + }; + }, + }, + Linking: { + openURL: mock(() => Promise.resolve()), + getInitialURL: () => Promise.resolve(null), + addEventListener: () => ({ remove: () => {} }), + }, + DeviceEventEmitter: { + addListener: () => ({ remove: () => {} }), + }, + NativeModules: { + Pushy: { + currentVersionInfo: '{}', + downloadRootDir: '/tmp', + packageVersion: '1.0.0', + currentVersion: 'hash', + isFirstTime: false, + rolledBackVersion: 'rolled-back-hash', + buildTime: '2023-01-01', + uuid: 'uuid', + setLocalHashInfo: () => {}, + getLocalHashInfo: () => Promise.resolve('{}'), + setUuid: () => {}, + }, + }, + NativeEventEmitter: class { + addListener = () => ({ remove: () => {} }); + removeAllListeners = () => {}; + }, +}); + +const installBaseMocks = () => { + mock.module('react-native', baseReactNative); mock.module('../i18n', () => { return { default: { t: (key: string, params?: any) => `${key}${params ? JSON.stringify(params) : ''}`, + setLocale: () => {}, }, }; }); @@ -92,7 +96,7 @@ installBaseMocks(); // doesn't have to know to add it. (The modules under test — client, provider, // context — are absent on purpose: tests import those through cache-busting // specifiers, which mock.module() does not touch.) -const realProjectModules: Record> = { +export const realProjectModules: Record> = { '../core': { ...(await import('../core')) }, '../endpoint': { ...(await import('../endpoint')) }, '../error': { ...(await import('../error')) }, diff --git a/src/__tests__/updateFlowCore.test.ts b/src/__tests__/updateFlowCore.test.ts index 331d5c93..17f1f03e 100644 --- a/src/__tests__/updateFlowCore.test.ts +++ b/src/__tests__/updateFlowCore.test.ts @@ -7,6 +7,7 @@ import { decideDownload, isMirrorRetryableCode, isValidCheckResult, + jsonDeepEqual, nativeConfigsEquivalent, orderEndpointCandidates, } from '../updateFlowCore'; @@ -420,4 +421,33 @@ describe('native config canonical equality (C1)', () => { expect(nativeConfigsEquivalent('[]', '[]')).toBe(false); expect(nativeConfigsEquivalent(json(base), '')).toBe(false); }); + + test('does not depend on Object.hasOwn (missing before iOS 15.4)', () => { + const hasOwn = Object.hasOwn; + Reflect.deleteProperty(Object, 'hasOwn'); + try { + expect(nativeConfigsEquivalent(json(base), json(base))).toBe(true); + } finally { + Object.hasOwn = hasOwn; + } + }); +}); + +describe('jsonDeepEqual', () => { + test('ignores key order and undefined-valued members, like the wire', () => { + expect( + jsonDeepEqual( + { a: 1, b: { c: [1, { d: 'x' }] }, skip: undefined }, + { b: { c: [1, { d: 'x', gone: undefined }] }, a: 1 } + ) + ).toBe(true); + }); + + test('detects real differences', () => { + expect(jsonDeepEqual({ a: 1 }, { a: 2 })).toBe(false); + expect(jsonDeepEqual({ a: 1 }, { a: 1, b: null })).toBe(false); + expect(jsonDeepEqual({ a: null }, { a: undefined })).toBe(false); + expect(jsonDeepEqual([1, 2], [2, 1])).toBe(false); + expect(jsonDeepEqual([], {})).toBe(false); + }); }); diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index 542ee78b..855967bc 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -1,21 +1,5 @@ import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test'; - -mock.module('react-native', () => { - return { - Platform: { - OS: 'ios', - }, - }; -}); - -mock.module('../i18n', () => { - return { - default: { - t: (key: string) => key, - }, - }; -}); - +import { joinUrls } from '../updateFlowCore'; import { computeProgress, createNonSecureId, @@ -23,7 +7,6 @@ import { fetchWithTimeout, info, isProtocolDowngrade, - joinUrls, log, parseQueryParams, promiseAny, @@ -31,6 +14,7 @@ import { testUrls, warn, } from '../utils'; +import { restoreDevAfterEach } from './helpers'; const originalFetch = globalThis.fetch; @@ -397,9 +381,8 @@ describe('parseQueryParams', () => { }); describe('log gating (1.11)', () => { - const origDev = (globalThis as any).__DEV__; + restoreDevAfterEach(); afterEach(() => { - (globalThis as any).__DEV__ = origDev; setDebugLogging(false); }); @@ -454,26 +437,3 @@ describe('createNonSecureId', () => { expect(createNonSecureId(8)).toHaveLength(8); }); }); - -describe('testUrls on web', () => { - test('resolves the first url without probing or throwing', async () => { - mock.module('react-native', () => ({ Platform: { OS: 'web' } })); - const fetchMock = mock(async () => { - throw new Error('must not probe on web'); - }); - (globalThis as any).fetch = fetchMock; - const cacheKey = 'web-ping'; - const webUtils: typeof import('../utils') = await import( - `../utils?${cacheKey}` - ); - - expect(webUtils.isWeb).toBe(true); - expect( - await webUtils.testUrls([ - 'https://a.example.com', - 'https://b.example.com', - ]) - ).toBe('https://a.example.com'); - expect(fetchMock).not.toHaveBeenCalled(); - }); -}); diff --git a/src/client.ts b/src/client.ts index e0cd24d5..f378af4b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,8 +1,4 @@ -import { - DeviceEventEmitter, - type EmitterSubscription, - Platform, -} from 'react-native'; +import { DeviceEventEmitter, Platform } from 'react-native'; import { buildTime, cInfo, @@ -11,6 +7,7 @@ import { getBundleHash, isFirstTime, isRolledBack, + nativeMethod, PushyModule, packageVersion, pushyNativeEventEmitter, @@ -18,7 +15,7 @@ import { setLocalHashInfo, supportedDiffVersion, } from './core'; -import { dedupeEndpoints, executeEndpointFallback } from './endpoint'; +import { executeEndpointFallback } from './endpoint'; import { readErrorCode, toUpdateError, @@ -53,8 +50,10 @@ import { type DownloadPlan, type DownloadStrategyType, decideDownload, + dedupeEndpoints, isMirrorRetryableCode, isValidCheckResult, + jsonDeepEqual, nativeConfigsEquivalent, } from './updateFlowCore'; import { @@ -71,6 +70,14 @@ import { warn, } from './utils'; +/** One report() event: the logger payload and the source of server telemetry. */ +interface ReportEvent { + type: EventType; + message?: string; + code?: UpdateErrorCode; + data?: Record; +} + /** * Receives every error the client reports, alongside the report event type. * The UpdateProvider subscribes to surface errors as lastError/Alert; user @@ -115,14 +122,6 @@ const RELOAD_WATCHDOG_MS = 5000; // for the error message (it can end up in an alert) and the logger. const MAX_HTTP_ERROR_TEXT = 200; -const excludeConfiguredEndpoints = ( - endpoints: string[], - configuredEndpoints: string[] -) => { - const configured = new Set(configuredEndpoints); - return endpoints.filter((endpoint) => !configured.has(endpoint)); -}; - assertWeb(); const createDefaultClientOptions = (): ClientOptions => ({ @@ -137,7 +136,6 @@ const createDefaultClientOptions = (): ClientOptions => ({ }); export const sharedState: { - progressHandlers: Record; downloadingTasks: Record>; // Progress callbacks per hash: concurrent downloadUpdate callers of the // same hash each register theirs here instead of the second one being @@ -149,7 +147,6 @@ export const sharedState: { marked: boolean; applyingUpdate: boolean; } = { - progressHandlers: {}, downloadingTasks: {}, progressCallbacks: {}, downloadedHash: undefined, @@ -378,12 +375,8 @@ export class Pushy { /** Build the subset of options used by the native cold-start check. */ private getNativeConfig = (): Record | undefined => { - if ( - Platform.OS === 'web' || - typeof PushyModule.syncNativeConfig !== 'function' - ) { - // Older natives lack the method; on web PushyModule is a noop Proxy - // and the feature-detect would false-positive. + if (!nativeMethod('syncNativeConfig')) { + // Web, or an older native without the native check. return undefined; } const { @@ -441,7 +434,8 @@ export class Pushy { this.syncedNativeConfigJson = undefined; return; } - if (this.nativeConfigSyncInFlight) { + const sync = nativeMethod('syncNativeConfig'); + if (!sync || this.nativeConfigSyncInFlight) { return; } const configJson = this.pendingNativeConfigJson; @@ -450,15 +444,7 @@ export class Pushy { return; } this.nativeConfigSyncInFlight = true; - let syncResult: Promise; - try { - syncResult = Promise.resolve(PushyModule.syncNativeConfig(configJson)); - } catch (e: any) { - this.nativeConfigSyncInFlight = false; - log('syncNativeConfig failed:', e?.message || e); - return; - } - syncResult + sync(configJson) .then(() => { // A switch to 'native' while this write was in flight hands storage // to the host; remembering the write would skip it after switching @@ -484,28 +470,22 @@ export class Pushy { * rescue must keep running when JS could not reach the server. */ private markJsCheckCompleted = () => { - if ( - Platform.OS === 'web' || - typeof PushyModule.markJsCheckCompleted !== 'function' - ) { + const markCompleted = nativeMethod('markJsCheckCompleted'); + if (!markCompleted) { return; } const configJson = this.getNativeConfigJson(); if (!configJson || configJson === this.jsCheckCompletedConfigJson) { return; } - try { - Promise.resolve(PushyModule.markJsCheckCompleted(configJson)).then( - () => { - this.jsCheckCompletedConfigJson = configJson; - }, - (e: any) => { - log('markJsCheckCompleted failed:', e?.message || e); - } - ); - } catch (e: any) { - log('markJsCheckCompleted failed:', e?.message || e); - } + markCompleted(configJson).then( + () => { + this.jsCheckCompletedConfigJson = configJson; + }, + (e: any) => { + log('markJsCheckCompleted failed:', e?.message || e); + } + ); }; private syncNativeConfig = () => { @@ -517,10 +497,7 @@ export class Pushy { this.syncedNativeConfigJson = undefined; return; } - if ( - Platform.OS === 'web' || - typeof PushyModule.syncNativeConfig !== 'function' - ) { + if (!nativeMethod('syncNativeConfig')) { return; } const configJson = this.getNativeConfigJson(); @@ -547,50 +524,6 @@ export class Pushy { getEffectivePackageVersion = () => this.options.overridePackageVersion || packageVersion; - private jsonValuesEqual = (left: unknown, right: unknown): boolean => { - const compare = (a: unknown, b: unknown): boolean => { - if (a === b) { - return true; - } - if (Array.isArray(a) || Array.isArray(b)) { - return ( - Array.isArray(a) && - Array.isArray(b) && - a.length === b.length && - a.every((value, index) => compare(value, b[index])) - ); - } - if ( - a === null || - b === null || - typeof a !== 'object' || - typeof b !== 'object' - ) { - return false; - } - const leftObject = a as Record; - const rightObject = b as Record; - // Match JSON.stringify semantics for request extras: object properties - // whose value is undefined are omitted from the wire fingerprint. - const leftKeys = Object.keys(leftObject) - .filter((key) => leftObject[key] !== undefined) - .sort(); - const rightKeys = Object.keys(rightObject) - .filter((key) => rightObject[key] !== undefined) - .sort(); - return ( - leftKeys.length === rightKeys.length && - leftKeys.every( - (key, index) => - key === rightKeys[index] && - compare(leftObject[key], rightObject[key]) - ) - ); - }; - - return compare(left, right); - }; - private providerMounted = false; /** * Called by UpdateProvider on mount. A second concurrently mounted @@ -653,7 +586,8 @@ export class Pushy { if ( __DEV__ || this.options.disableTelemetry || - this.options.disableErrorReporting + this.options.disableErrorReporting || + !currentVersion ) { return; } @@ -667,7 +601,11 @@ export class Pushy { } this.reportedExceptions.add(object); } - this.reportExceptionToServer(serializeException(error, context)); + this.postReport({ + type: 'js_exception', + hash: currentVersion, + error: serializeException(error, context), + }); } catch (e: any) { log('exception telemetry error:', e?.message || e); } @@ -711,55 +649,7 @@ export class Pushy { return cleanup; }; - private reportExceptionToServer = (error: SerializedException): void => { - try { - if ( - __DEV__ || - this.options.disableTelemetry || - this.options.disableErrorReporting || - !currentVersion - ) { - return; - } - const { appKey } = this.options; - const endpoint = - this.lastWorkingEndpoint || this.options.server?.main?.[0]; - if (!appKey || !endpoint) { - return; - } - fetchWithTimeout( - `${endpoint}/report/${appKey}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - type: 'js_exception', - hash: currentVersion, - packageVersion: this.getEffectivePackageVersion(), - cInfo, - error, - }), - }, - DEFAULT_FETCH_TIMEOUT_MS - ).catch((e: any) => { - log('exception telemetry report failed:', e?.message || e); - }); - } catch (e: any) { - log('exception telemetry error:', e?.message || e); - } - }; - - report = async ({ - type, - message = '', - code, - data = {}, - }: { - type: EventType; - message?: string; - code?: UpdateErrorCode; - data?: Record; - }) => { + report = async ({ type, message = '', code, data = {} }: ReportEvent) => { log(`${type} ${code ? `[${code}] ` : ''}${message}`); // Fire-and-forget server telemetry; must not wait for the logger below. this.reportToServer({ type, message, code, data }); @@ -811,15 +701,10 @@ export class Pushy { */ private reportToServer = ({ type, - message = '', + message, code, data = {}, - }: { - type: EventType; - message?: string; - code?: UpdateErrorCode; - data?: Record; - }) => { + }: ReportEvent) => { try { if (__DEV__ || this.options.disableTelemetry) { return; @@ -828,43 +713,56 @@ export class Pushy { if (!serverType) { return; } - const { appKey } = this.options; - const endpoint = - this.lastWorkingEndpoint || this.options.server?.main?.[0]; - if (!appKey || !endpoint) { - return; - } const hash = resolveServerEventHash({ serverType, data, currentVersion }); if (!hash) { return; } - const send = (payloadType: typeof serverType, detail?: string) => - fetchWithTimeout( - `${endpoint}/report/${appKey}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - type: payloadType, - hash, - packageVersion: this.getEffectivePackageVersion(), - cInfo, - detail: truncateDetail(detail), - }), - }, - DEFAULT_FETCH_TIMEOUT_MS - ).catch((e: any) => { - log('telemetry report failed:', e?.message || e); - }); // A download rescued by a later strategy reports its patch-health // signal through downloadFallback, which only counts patch-apply // failures; downloadSuccess's data.error (any earlier failure, network // errors included) stays local. - send(serverType, message || undefined); + this.postReport({ + type: serverType, + hash, + detail: truncateDetail(message), + }); } catch (e: any) { log('telemetry error:', e?.message || e); } }; + /** + * The single telemetry POST ({endpoint}/report/{appKey}) to the last known + * working endpoint; no retry, failures only logged. + */ + private postReport = ({ + type, + hash, + ...payload + }: { + type: string; + hash: string; + detail?: string; + error?: SerializedException; + }) => { + const { appKey } = this.options; + const endpoint = this.lastWorkingEndpoint || this.options.server?.main?.[0]; + if (!appKey || !endpoint) { + return; + } + fetchWithTimeout(`${endpoint}/report/${appKey}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type, + hash, + packageVersion: this.getEffectivePackageVersion(), + cInfo, + ...payload, + }), + }).catch((e: any) => { + log('telemetry report failed:', e?.message || e); + }); + }; throwIfEnabled = (e: Error) => { if (this.options.throwError) { throw e; @@ -921,6 +819,32 @@ export class Pushy { } } }; + /** + * toUpdateError + emitError: how a caught failure leaves the client. + * Returns the error for the caller to throw or pass to throwIfEnabled. + * `fallbackMessage` is reported when the error has no message of its own. + */ + private fail = ( + e: unknown, + code: UpdateErrorCode, + type: EventType, + { + message, + fallbackMessage, + data, + }: { + message?: string; + fallbackMessage?: string; + data?: Record; + } = {} + ): UpdateError => { + const err = toUpdateError(e, code); + this.emitError(err, type, { + message: message ?? (err.message || fallbackMessage), + data, + }); + return err; + }; notifyAfterCheckUpdate = (state: UpdateCheckState) => { const { afterCheckUpdate } = this.options; if (!afterCheckUpdate) { @@ -943,16 +867,9 @@ export class Pushy { } return true; }; - getCheckUrl = (endpoint: string) => { - return `${endpoint}/checkUpdate/${this.options.appKey}`; - }; - getConfiguredCheckEndpoints = () => { - const { server } = this.options; - if (!server) { - return []; - } - return dedupeEndpoints(server.main); - }; + // server.main is deduplicated on assignment (cloneServerConfig), and the + // endpoint plan dedupes again anyway. + getConfiguredCheckEndpoints = () => this.options.server?.main ?? []; getRemoteEndpoints = async () => { const { server } = this.options; if (!server?.queryUrls?.length) { @@ -965,11 +882,7 @@ export class Pushy { // with an error page must not beat the healthy one. const remoteEndpoints = await promiseAny( server.queryUrls.map(async (queryUrl) => { - const resp = await fetchWithTimeout( - queryUrl, - {}, - DEFAULT_FETCH_TIMEOUT_MS - ); + const resp = await fetchWithTimeout(queryUrl, {}); if (!resp.ok) { throw Error(`${queryUrl}: ${resp.status}`); } @@ -981,16 +894,15 @@ export class Pushy { }) ); log('fetch endpoints:', remoteEndpoints); - return excludeConfiguredEndpoints( - dedupeEndpoints( - remoteEndpoints.filter( - // The list is fetched from a public mirror; it may add - // endpoints but never move the check outside TLS. - (endpoint): endpoint is string => - typeof endpoint === 'string' && /^https:\/\//i.test(endpoint) - ) - ), - this.getConfiguredCheckEndpoints() + // Configured and already-failed endpoints are dropped by the endpoint + // fallback itself. + return dedupeEndpoints( + remoteEndpoints.filter( + // The list is fetched from a public mirror; it may add endpoints + // but never move the check outside TLS. + (endpoint): endpoint is string => + typeof endpoint === 'string' && /^https:\/\//i.test(endpoint) + ) ); } catch (e) { log('failed to fetch endpoints from: ', server.queryUrls, e); @@ -1001,54 +913,50 @@ export class Pushy { endpoint: string, fetchPayload: Parameters[1], signal?: AbortSignal - ) => { - const resp = await fetchWithTimeout( - this.getCheckUrl(endpoint), - fetchPayload, - DEFAULT_FETCH_TIMEOUT_MS, - signal - ); - - if (!resp.ok) { - const respText = (await resp.text()).slice(0, MAX_HTTP_ERROR_TEXT); - throw new UpdateError( - this.t('error_http_status', { - status: resp.status, - statusText: respText, - }), - 'HTTP_STATUS', - { extra: { status: resp.status } } + ): Promise => { + try { + const resp = await fetchWithTimeout( + `${endpoint}/checkUpdate/${this.options.appKey}`, + fetchPayload, + DEFAULT_FETCH_TIMEOUT_MS, + signal ); - } - const result: unknown = await resp.json(); - if (!isValidCheckResult(result)) { - // A 2xx that is not a verdict (`{"error": ...}`, a captive-portal page - // that parsed, ...) is a failed endpoint: throwing keeps the fallback - // moving to the next one instead of treating it as "no update". - throw new UpdateError( - this.t('error_invalid_check_response'), - 'INVALID_RESPONSE' - ); + if (!resp.ok) { + const respText = (await resp.text()).slice(0, MAX_HTTP_ERROR_TEXT); + throw new UpdateError( + this.t('error_http_status', { + status: resp.status, + statusText: respText, + }), + 'HTTP_STATUS', + { extra: { status: resp.status } } + ); + } + + const result: unknown = await resp.json(); + if (!isValidCheckResult(result)) { + // A 2xx that is not a verdict (`{"error": ...}`, a captive-portal + // page that parsed, ...) is a failed endpoint: throwing keeps the + // fallback moving to the next one instead of treating it as "no + // update". + throw new UpdateError( + this.t('error_invalid_check_response'), + 'INVALID_RESPONSE' + ); + } + return result; + } catch (e) { + log('check endpoint failed', endpoint, e); + throw e; } - return result; }; fetchCheckResult = async (fetchPayload: Parameters[1]) => { const { endpoint, value } = await executeEndpointFallback({ configuredEndpoints: this.getConfiguredCheckEndpoints(), getRemoteEndpoints: this.getRemoteEndpoints, - tryEndpoint: async (currentEndpoint, signal) => { - try { - return await this.requestCheckResult( - currentEndpoint, - fetchPayload, - signal - ); - } catch (e) { - log('check endpoint failed', currentEndpoint, e); - throw e; - } - }, + tryEndpoint: (currentEndpoint, signal) => + this.requestCheckResult(currentEndpoint, fetchPayload, signal), onFirstFailure: ({ error }) => { this.report({ type: 'errorChecking', @@ -1073,14 +981,11 @@ export class Pushy { requestBody: Record ): Promise => { try { - if ( - __DEV__ || - nativeCheckCacheExhausted || - typeof PushyModule.getNativeCheckCache !== 'function' - ) { + const getCache = nativeMethod('getNativeCheckCache'); + if (__DEV__ || nativeCheckCacheExhausted || !getCache) { return undefined; } - const raw = await Promise.resolve(PushyModule.getNativeCheckCache()); + const raw = await getCache(); if (!raw || typeof raw !== 'string') { nativeCheckCacheExhausted = true; return undefined; @@ -1100,7 +1005,7 @@ export class Pushy { typeof entry.config !== 'string' || !configJson || !nativeConfigsEquivalent(entry.config, configJson) || - !this.jsonValuesEqual(JSON.parse(entry.request), requestBody) + !jsonDeepEqual(JSON.parse(entry.request), requestBody) ) { return undefined; } @@ -1144,9 +1049,7 @@ export class Pushy { try { await Promise.resolve(PushyModule.markSuccess()); } catch (e) { - const err = toUpdateError(e, 'MARK_SUCCESS_FAILED'); - this.emitError(err, 'errorMarkSuccess'); - throw err; + throw this.fail(e, 'MARK_SUCCESS_FAILED', 'errorMarkSuccess'); } sharedState.marked = true; this.report({ type: 'markSuccess' }); @@ -1183,15 +1086,14 @@ export class Pushy { return false; } } catch (e) { + // Release before emitting: an onError listener may retry right away. finishApplyingUpdate(applyingGeneration); // A throw from the user's beforeReload hook is business-code failure, // not an update-pipeline one: give it a distinct code so telemetry // excludes it from the server-side patch-health stats. - const err = toUpdateError(e, 'USER_HOOK_ERROR'); - this.emitError(err, 'errorSwitchVersion', { + throw this.fail(e, 'USER_HOOK_ERROR', 'errorSwitchVersion', { data: { newVersion: hash }, }); - throw err; } // resetToPackagedBundle may have completed while beforeReload awaited. if (!ownsApplyingUpdate(applyingGeneration)) { @@ -1202,11 +1104,9 @@ export class Pushy { } catch (e) { // Do not let a superseded rejection clear a newer switch. finishApplyingUpdate(applyingGeneration); - const err = toUpdateError(e, 'SWITCH_VERSION_FAILED'); - this.emitError(err, 'errorSwitchVersion', { + throw this.fail(e, 'SWITCH_VERSION_FAILED', 'errorSwitchVersion', { data: { newVersion: hash }, }); - throw err; } // A resolved reloadUpdate normally tears this JS context down right away. // iOS has been seen resolving without restarting (89c638e); a stuck @@ -1233,11 +1133,9 @@ export class Pushy { try { return await PushyModule.setNeedUpdate({ hash }); } catch (e) { - const err = toUpdateError(e, 'SWITCH_VERSION_FAILED'); - this.emitError(err, 'errorSwitchVersion', { + throw this.fail(e, 'SWITCH_VERSION_FAILED', 'errorSwitchVersion', { data: { newVersion: hash }, }); - throw err; } } }; @@ -1282,7 +1180,6 @@ export class Pushy { appKey: this.options.appKey, endpoints: this.options.server?.main, queryUrls: this.options.server?.queryUrls, - // The client uuid travels inside cInfo, i.e. inside `body`. body: stringifyBody, }); const now = Date.now(); @@ -1366,9 +1263,8 @@ export class Pushy { this.lastRespJson = undefined; this.lastCheckFingerprint = undefined; } - const err = toUpdateError(e, 'CHECK_FAILED'); - this.emitError(err, 'errorChecking', { - message: err.message || this.t('error_cannot_connect_server'), + const err = this.fail(e, 'CHECK_FAILED', 'errorChecking', { + fallbackMessage: this.t('error_cannot_connect_server'), }); this.notifyAfterCheckUpdate({ status: 'error', error: err }); this.throwIfEnabled(err); @@ -1482,15 +1378,14 @@ export class Pushy { } sharedState.progressCallbacks[hash] = progressCallbacks; const dispatchProgress = (data: ProgressData) => { - const callbacks = sharedState.progressCallbacks[hash]; - if (!callbacks || callbacks.size === 0) { + if (progressCallbacks.size === 0) { return; } const payload = { ...data, progress: computeProgress(data.received, data.total), }; - callbacks.forEach((callback) => { + progressCallbacks.forEach((callback) => { // One subscriber throwing must not starve the others or abort the // download: business callbacks are isolated, the error is logged. try { @@ -1510,17 +1405,10 @@ export class Pushy { } }; // @ts-expect-error harmony not in existing platforms - if (Platform.OS === 'harmony') { - sharedState.progressHandlers[hash] = DeviceEventEmitter.addListener( - 'RCTPushyDownloadProgress', - onNativeProgress - ); - } else { - sharedState.progressHandlers[hash] = pushyNativeEventEmitter.addListener( - 'RCTPushyDownloadProgress', - onNativeProgress - ); - } + const isHarmony = Platform.OS === 'harmony'; + const progressSubscription = ( + isHarmony ? DeviceEventEmitter : pushyNativeEventEmitter + ).addListener('RCTPushyDownloadProgress', onNativeProgress); const maxRetries = Math.max(0, Math.floor(this.options.maxRetries ?? 3)); let succeeded = ''; let lastError: UpdateError | undefined; @@ -1530,13 +1418,6 @@ export class Pushy { // message, so the final report still names it — and skip it on later // attempts instead of downloading and patching it again. const exhaustedStrategies = new Map(); - // Whether any strategy, in any attempt, failed at the patch stage. The - // downloadFallback report carries that as its code even when a later, - // unrelated transport failure happened to be the last error seen. - let patchFailed = false; - // The errors of the strategies that failed at the patch stage. A strategy - // that failed to apply is never retried, so these do not repeat. - const patchErrorMessages: string[] = []; // The ordered attempts come from decideDownload (the pure decision layer); // this side only executes them: probe candidate URLs, run the matching @@ -1573,100 +1454,101 @@ export class Pushy { full: 'error_full_patch_failed', } as const; - for (let attempt = 0; attempt <= maxRetries; attempt++) { - if (attempt > 0) { - // Exponential backoff with jitter: a bad CDN edge otherwise gets - // every client back at the same 1/2/4s marks. - const backoffMs = Math.round( - Math.min(1000 * 2 ** (attempt - 1), 10000) * - (0.75 + Math.random() * 0.5) - ); - log(`retry attempt ${attempt}/${maxRetries}, waiting ${backoffMs}ms`); - await new Promise((r) => setTimeout(() => r(), backoffMs)); - errorMessages.length = 0; - errorMessages.push(...exhaustedStrategies.values()); - lastError = undefined; - succeeded = ''; - } - this.report({ - type: 'downloading', - data: { - newVersion: hash, - attempt, - }, - }); - for (const { type, urls } of attempts) { - if (succeeded) { - break; - } - if (exhaustedStrategies.has(type)) { - continue; - } - // The HEAD race only ranks mirrors; it is not proof that a mirror can - // serve the whole artifact (CDNs answer HEAD and GET differently, and - // a transfer can die mid-stream). Every mirror of the same artifact - // gets a real download attempt before the strategy is abandoned — - // the native cold-start path already behaves this way. - let orderedUrls = rankedUrls.get(type); - if (!orderedUrls) { - orderedUrls = await this.rankDownloadUrls(urls); - rankedUrls.set(type, orderedUrls); + try { + for (let attempt = 0; attempt <= maxRetries; attempt++) { + if (attempt > 0) { + // Exponential backoff with jitter: a bad CDN edge otherwise gets + // every client back at the same 1/2/4s marks. + const backoffMs = Math.round( + Math.min(1000 * 2 ** (attempt - 1), 10000) * + (0.75 + Math.random() * 0.5) + ); + log(`retry attempt ${attempt}/${maxRetries}, waiting ${backoffMs}ms`); + await new Promise((r) => setTimeout(() => r(), backoffMs)); + errorMessages.length = 0; + errorMessages.push(...exhaustedStrategies.values()); + lastError = undefined; + succeeded = ''; } - for (const url of orderedUrls) { - log(`downloading ${type} from ${url}`); - try { - await runners[type](url); - succeeded = type; + this.report({ + type: 'downloading', + data: { + newVersion: hash, + attempt, + }, + }); + for (const { type, urls } of attempts) { + if (succeeded) { break; - } catch (e: unknown) { - // The native rejection's stable code (e.g. PATCH_FAILED vs - // DOWNLOAD_FAILED — telemetry classifies on it) may sit on the - // `code` property or, from bridges that cannot set it, as a - // `[CODE] ` message prefix. Either way it survives into the - // i18n-wrapped error together with the original as cause. - const { code, message } = readErrorCode(e); - if (url === orderedUrls[0] && orderedUrls.length > 1) { - rankedUrls.set(type, [...orderedUrls.slice(1), url]); - } - const errorMessage = this.t(errorKeys[type], { - message: message || String(e ?? ''), - }); - errorMessages.push(errorMessage); - lastError = new UpdateError( - errorMessage, - code ?? 'DOWNLOAD_FAILED', - { - cause: e, - } - ); - log(errorMessage); - if (lastError.code === 'PATCH_FAILED') { - patchFailed = true; - patchErrorMessages.push(errorMessage); - } - if (!isMirrorRetryableCode(lastError.code)) { - // The bytes arrived but could not be applied (patch/manifest - // mismatch): every mirror serves the same artifact, so move - // on to the next strategy instead of re-downloading it. - exhaustedStrategies.set(type, errorMessage); + } + if (exhaustedStrategies.has(type)) { + continue; + } + // The HEAD race only ranks mirrors; it is not proof that a mirror can + // serve the whole artifact (CDNs answer HEAD and GET differently, and + // a transfer can die mid-stream). Every mirror of the same artifact + // gets a real download attempt before the strategy is abandoned — + // the native cold-start path already behaves this way. + let orderedUrls = rankedUrls.get(type); + if (!orderedUrls) { + orderedUrls = await this.rankDownloadUrls(urls); + rankedUrls.set(type, orderedUrls); + } + for (const url of orderedUrls) { + log(`downloading ${type} from ${url}`); + try { + await runners[type](url); + succeeded = type; break; + } catch (e: unknown) { + // The native rejection's stable code (e.g. PATCH_FAILED vs + // DOWNLOAD_FAILED — telemetry classifies on it) may sit on the + // `code` property or, from bridges that cannot set it, as a + // `[CODE] ` message prefix. Either way it survives into the + // i18n-wrapped error together with the original as cause. + const { code, message } = readErrorCode(e); + if (url === orderedUrls[0] && orderedUrls.length > 1) { + rankedUrls.set(type, [...orderedUrls.slice(1), url]); + } + const errorMessage = this.t(errorKeys[type], { + message: message || String(e ?? ''), + }); + errorMessages.push(errorMessage); + lastError = new UpdateError( + errorMessage, + code ?? 'DOWNLOAD_FAILED', + { + cause: e, + } + ); + log(errorMessage); + if (!isMirrorRetryableCode(lastError.code)) { + // The bytes arrived but could not be applied (patch/manifest + // mismatch): every mirror serves the same artifact, so move + // on to the next strategy instead of re-downloading it. + exhaustedStrategies.set(type, errorMessage); + break; + } } } } + if ( + succeeded || + attempts.every(({ type }) => exhaustedStrategies.has(type)) + ) { + // Nothing left that a retry could change. + break; + } } - if ( - succeeded || - attempts.every(({ type }) => exhaustedStrategies.has(type)) - ) { - // Nothing left that a retry could change. - break; - } - } - if (sharedState.progressHandlers[hash]) { - sharedState.progressHandlers[hash].remove(); - delete sharedState.progressHandlers[hash]; - } - delete sharedState.progressCallbacks[hash]; + } finally { + progressSubscription.remove(); + delete sharedState.progressCallbacks[hash]; + } + // Only a patch-stage failure exhausts a strategy (isMirrorRetryableCode), + // and an exhausted strategy is never retried: these are exactly the patch + // failures, each once. + const patchErrorMessages = [...exhaustedStrategies.values()]; + const patchFailed = patchErrorMessages.length > 0; if (succeeded && errorMessages.length > 0) { // An earlier strategy failed and a later one rescued the download // (e.g. pdiff copiesCrc mismatch on a rebuilt binary → full). Surface @@ -1674,9 +1556,10 @@ export class Pushy { // platform that incremental delivery is failing for this binary. this.report({ type: 'downloadFallback', - // The patch-health signal wins over whatever transport failure - // happened to come last (diff PATCH_FAILED, then pdiff network - // error, then full succeeded is still a patch_fail server-side). + // The patch-health signal (any strategy, in any attempt, failing at + // the patch stage) wins over whatever transport failure happened to + // come last (diff PATCH_FAILED, then pdiff network error, then full + // succeeded is still a patch_fail server-side). code: patchFailed ? 'PATCH_FAILED' : lastError?.code, data: { newVersion: hash, @@ -1688,38 +1571,25 @@ export class Pushy { }); } if (!succeeded) { - const message = errorMessages.join(';'); - if (lastError) { - const err = toUpdateError(lastError, 'DOWNLOAD_FAILED'); - this.emitError(err, 'errorUpdate', { - message, - data: { newVersion: hash }, - }); - throw err; - } - // No download URL was even attempted (e.g. dev without a full URL): - // report for diagnostics but there is no error object to surface. - this.report({ - type: 'errorUpdate', + // Every attempt runs at least one download (a plan always has an + // artifact URL, devNoop returned above), so a failure left lastError. + throw this.fail(lastError, 'DOWNLOAD_FAILED', 'errorUpdate', { + message: errorMessages.join(';'), data: { newVersion: hash }, - message, - }); - return; - } else { - const duration = Date.now() - patchStartTime; - const data: Record = { - newVersion: hash, - diff: succeeded, - duration, - }; - if (errorMessages.length > 0) { - data.error = errorMessages.join(';'); - } - this.report({ - type: 'downloadSuccess', - data, }); } + const data: Record = { + newVersion: hash, + diff: succeeded, + duration: Date.now() - patchStartTime, + }; + if (errorMessages.length > 0) { + data.error = errorMessages.join(';'); + } + this.report({ + type: 'downloadSuccess', + data, + }); log(`downloaded ${succeeded} hash:`, hash); const hashInfo: Record = { name, @@ -1735,12 +1605,9 @@ export class Pushy { try { await setLocalHashInfo(hash, hashInfo); } catch (e: any) { - const err = toUpdateError(e, 'FILE_OPERATION_FAILED'); - this.emitError(err, 'errorUpdate', { - message: err.message, + throw this.fail(e, 'FILE_OPERATION_FAILED', 'errorUpdate', { data: { newVersion: hash }, }); - throw err; } sharedState.downloadedHash = hash; return hash; @@ -1767,12 +1634,8 @@ export class Pushy { sharedState.apkStatus = 'downloading'; this.report({ type: 'downloadingApk' }); const progressKey = 'downloadingApk'; - if (onDownloadProgress) { - if (sharedState.progressHandlers[progressKey]) { - sharedState.progressHandlers[progressKey].remove(); - } - sharedState.progressHandlers[progressKey] = - pushyNativeEventEmitter.addListener( + const progressSubscription = onDownloadProgress + ? pushyNativeEventEmitter.addListener( 'RCTPushyDownloadProgress', (event: unknown) => { const progressData = event as ProgressData; @@ -1780,8 +1643,8 @@ export class Pushy { onDownloadProgress(progressData); } } - ); - } + ) + : undefined; try { await PushyModule.downloadAndInstallApk({ url, @@ -1792,16 +1655,13 @@ export class Pushy { } catch (e) { sharedState.apkStatus = null; // Keep the native error (message/stack) instead of discarding it. - const err = toUpdateError(e, 'APK_DOWNLOAD_FAILED'); - this.emitError(err, 'errorDownloadAndInstallApk', { - message: err.message || this.t('error_apk_download_failed'), - }); - this.throwIfEnabled(err); + this.throwIfEnabled( + this.fail(e, 'APK_DOWNLOAD_FAILED', 'errorDownloadAndInstallApk', { + fallbackMessage: this.t('error_apk_download_failed'), + }) + ); } finally { - if (sharedState.progressHandlers[progressKey]) { - sharedState.progressHandlers[progressKey].remove(); - delete sharedState.progressHandlers[progressKey]; - } + progressSubscription?.remove(); } }; restartApp = async () => { @@ -1810,16 +1670,12 @@ export class Pushy { return; } } catch (e) { - const err = toUpdateError(e, 'USER_HOOK_ERROR'); - this.emitError(err, 'errorRestart'); - throw err; + throw this.fail(e, 'USER_HOOK_ERROR', 'errorRestart'); } try { return await PushyModule.restartApp(); } catch (e) { - const err = toUpdateError(e, 'RESTART_FAILED'); - this.emitError(err, 'errorRestart'); - throw err; + throw this.fail(e, 'RESTART_FAILED', 'errorRestart'); } }; /** @@ -1837,13 +1693,10 @@ export class Pushy { restart?: boolean; }): Promise => { if (!assertWeb()) { - // On web PushyModule is a Proxy of noops, so the feature-detect below - // would report a false success. return false; } - if (typeof PushyModule.resetToPackagedBundle !== 'function') { - // The JS layer can arrive via hot update onto an older binary whose - // native module predates this method. + const reset = nativeMethod('resetToPackagedBundle'); + if (!reset) { const err = new UpdateError( this.t('error_reset_not_supported'), 'RESET_FAILED' @@ -1853,11 +1706,9 @@ export class Pushy { return false; } try { - await PushyModule.resetToPackagedBundle(); + await reset(); } catch (e) { - const err = toUpdateError(e, 'RESET_FAILED'); - this.emitError(err, 'errorReset'); - this.throwIfEnabled(err); + this.throwIfEnabled(this.fail(e, 'RESET_FAILED', 'errorReset')); return false; } // The downloaded versions are gone; drop JS bookkeeping referring to them diff --git a/src/context.ts b/src/context.ts index 7f08b4c0..1d1b2efc 100644 --- a/src/context.ts +++ b/src/context.ts @@ -46,7 +46,7 @@ export interface UpdateContextValue { lastError?: Error; } -export const defaultContext: UpdateContextValue = { +const defaultContext: UpdateContextValue = { checkUpdate: asyncNoop, switchVersion: asyncNoop, switchVersionLater: asyncNoop, diff --git a/src/core.ts b/src/core.ts index 89ab5ed0..2540ab4f 100644 --- a/src/core.ts +++ b/src/core.ts @@ -35,6 +35,34 @@ if (!PushyModule) { ); } +/** + * `module[name]` as an always-async function (a synchronous bridge throw + * becomes a rejection), or undefined when the module lacks it: the JS layer + * can arrive via hot update onto an older binary whose native module predates + * the method. Always undefined on web, where the module is a noop Proxy that + * would pass any typeof check. + */ +export const bindNativeMethod = ( + module: Record, + name: string +): ((...args: any[]) => Promise) | undefined => { + const method = module === emptyModule ? undefined : module[name]; + if (typeof method !== 'function') { + return undefined; + } + return (...args) => { + try { + return Promise.resolve(method.apply(module, args)); + } catch (e) { + return Promise.reject(e); + } + }; +}; + +/** An optional PushyModule method, see bindNativeMethod. */ +export const nativeMethod = (name: string) => + bindNativeMethod(PushyModule, name); + // On web PushyModule is a Proxy whose every property is a noop function, so // reading the constants off it would make each of them a function: // `isRolledBack` true (a fake rollback report on every page load), a uuid of @@ -99,7 +127,6 @@ export const currentBundleSha256: string = : ''; export const isFirstTime: boolean = PushyConstants.isFirstTime; -export const isFirstTimeDebug: boolean = isFirstTime && isDebugChannel; export const rolledBackVersion: string = PushyConstants.rolledBackVersion; export const isRolledBack: boolean = !!rolledBackVersion; @@ -121,9 +148,10 @@ let uuid = PushyConstants.uuid; // between its dedup window and the fetch, and a hash is never worth delaying // a check for. let bundleHash = ''; -// The JS layer can arrive via hot update onto an older binary whose native -// module predates the method, hence the feature detect (on web the noop Proxy -// would pass it, so web is excluded explicitly). +// Feature-detected like nativeMethod (older natives lack it, web has no native +// module), but called directly rather than through it: a synchronous throw +// means a broken native integration and must propagate at module load, not be +// turned into a logged rejection. Only an async failure is tolerated. if (!isWebPlatform && typeof PushyModule.getBundleHash === 'function') { Promise.resolve(PushyModule.getBundleHash()) .then((hash: unknown) => { @@ -171,7 +199,7 @@ export const cInfo = { rnu: require('../package.json').version, rn: RNVersion, // tvOS reports Platform.OS 'ios'; label it apart so the server can bucket - // tvOS devices separately. Must match PushyOsName in RCTPushy.mm. + // tvOS devices separately. Must match PushyOsName in RCTPushyOrchestrator.mm. os: `${Platform.OS === 'ios' && Platform.isTV ? 'tvos' : Platform.OS} ${Platform.Version}`, uuid, }; @@ -181,7 +209,6 @@ log('bootup status', { currentVersion, currentVersionInfo, isFirstTime, - isFirstTimeDebug, isDebugChannel, cInfo, }); diff --git a/src/endpoint.ts b/src/endpoint.ts index 4c0dc4e6..5ed24bc1 100644 --- a/src/endpoint.ts +++ b/src/endpoint.ts @@ -2,8 +2,6 @@ import { UpdateError } from './error'; import i18n from './i18n'; import { dedupeEndpoints, orderEndpointCandidates } from './updateFlowCore'; -export { dedupeEndpoints }; - export interface EndpointAttemptSuccess { endpoint: string; value: T; diff --git a/src/error.ts b/src/error.ts index df9b6e9f..04cf0082 100644 --- a/src/error.ts +++ b/src/error.ts @@ -7,42 +7,13 @@ * Android/iOS/Harmony modules — and flow through promise rejections onto the * `code` property, which toUpdateError() preserves. */ -export type UpdateErrorCode = +const UPDATE_ERROR_CODES = [ // JS-layer codes - | 'MODULE_NOT_LOADED' - | 'APPKEY_REQUIRED' - | 'NO_ENDPOINTS' - | 'HTTP_STATUS' - // A 2xx check response that is not a check verdict (schema gate). - | 'INVALID_RESPONSE' - | 'CHECK_FAILED' - | 'DOWNLOAD_FAILED' - | 'SWITCH_VERSION_FAILED' - | 'MARK_SUCCESS_FAILED' - | 'APK_INSTALL_PENDING' - | 'APK_DOWNLOAD_FAILED' - // A throw from a user-provided hook (e.g. beforeReload) — not an update - // pipeline failure, and excluded from server-side patch-health telemetry. - | 'USER_HOOK_ERROR' - // A second client/provider in the same process — a hard integration error; - // the SDK is a process-level singleton. - | 'SINGLETON_VIOLATION' - // Native codes (see cpp/patch_core/error_codes.h) - | 'INVALID_OPTIONS' - | 'PATCH_FAILED' - | 'FILE_OPERATION_FAILED' - | 'RESTART_FAILED' - | 'RESET_FAILED' - | 'INVALID_HASH_INFO' - | 'UNSUPPORTED_PLATFORM' - | 'APK_INSTALL_PERMISSION_REQUIRED' - | 'APK_INSTALL_FAILED'; - -const KNOWN_CODES = new Set([ 'MODULE_NOT_LOADED', 'APPKEY_REQUIRED', 'NO_ENDPOINTS', 'HTTP_STATUS', + // A 2xx check response that is not a check verdict (schema gate). 'INVALID_RESPONSE', 'CHECK_FAILED', 'DOWNLOAD_FAILED', @@ -50,8 +21,13 @@ const KNOWN_CODES = new Set([ 'MARK_SUCCESS_FAILED', 'APK_INSTALL_PENDING', 'APK_DOWNLOAD_FAILED', + // A throw from a user-provided hook (e.g. beforeReload) — not an update + // pipeline failure, and excluded from server-side patch-health telemetry. 'USER_HOOK_ERROR', + // A second client/provider in the same process — a hard integration error; + // the SDK is a process-level singleton. 'SINGLETON_VIOLATION', + // Native codes (see cpp/patch_core/error_codes.h) 'INVALID_OPTIONS', 'PATCH_FAILED', 'FILE_OPERATION_FAILED', @@ -61,7 +37,12 @@ const KNOWN_CODES = new Set([ 'UNSUPPORTED_PLATFORM', 'APK_INSTALL_PERMISSION_REQUIRED', 'APK_INSTALL_FAILED', -]); +] as const; + +/** A stable error code (see UPDATE_ERROR_CODES). */ +export type UpdateErrorCode = (typeof UPDATE_ERROR_CODES)[number]; + +const KNOWN_CODES = new Set(UPDATE_ERROR_CODES); /** * Narrow an arbitrary `code` property (axios' ERR_NETWORK, Node's diff --git a/src/i18n.ts b/src/i18n.ts index 90d11d97..a3c1355c 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -1,108 +1,43 @@ import enTranslations from './locales/en'; import zhTranslations from './locales/zh'; +type Locale = 'zh' | 'en'; type TranslationKey = keyof typeof zhTranslations | keyof typeof enTranslations; type TranslationValues = Record; +const translations: Record> = { + zh: zhTranslations, + en: enTranslations, +}; + class I18n { - private currentLocale: 'zh' | 'en' = 'en'; - private translations = { - zh: zhTranslations, - en: enTranslations, - }; + private currentLocale: Locale = 'en'; - /** - * Set locale directly - * @param locale - 'zh' or 'en' - */ - setLocale(locale: 'zh' | 'en') { + setLocale(locale: Locale) { this.currentLocale = locale; } /** - * Get current locale - */ - getLocale(): 'zh' | 'en' { - return this.currentLocale; - } - - /** - * Translate a key with optional interpolation - * @param key - Translation key - * @param values - Values for interpolation (optional) - * @returns Translated string with interpolated values + * Translate a key, filling `{{name}}` placeholders from `values` (unknown + * placeholders are kept). A key missing from the current locale falls back + * to the other one, then to the key itself. */ t(key: TranslationKey, values?: TranslationValues): string { - const translation = - this.translations[this.currentLocale][ - key as keyof (typeof this.translations)[typeof this.currentLocale] - ]; - - if (!translation) { - // Fallback to the other locale if key not found - const fallbackLocale = this.currentLocale === 'zh' ? 'en' : 'zh'; - const fallbackTranslation = - this.translations[fallbackLocale][ - key as keyof (typeof this.translations)[typeof fallbackLocale] - ]; - - if (!fallbackTranslation) { - // If still not found, return the key itself - return String(key); - } - - return this.interpolate(fallbackTranslation, values); - } - - return this.interpolate(translation, values); - } - - /** - * Interpolate values into a string template - * Supports {{key}} syntax - * @param template - String template with {{key}} placeholders - * @param values - Values to interpolate - * @returns Interpolated string - */ - private interpolate(template: string, values?: TranslationValues): string { + const fallbackLocale = this.currentLocale === 'zh' ? 'en' : 'zh'; + const template = + translations[this.currentLocale][key] || + translations[fallbackLocale][key] || + String(key); if (!values) { return template; } - - return template.replace(/\{\{(\w+)\}\}/g, (match, key) => { - const value = values[key]; + return template.replace(/\{\{(\w+)\}\}/g, (match, name) => { + const value = values[name]; return value !== undefined ? String(value) : match; }); } - - /** - * Add or update translations for a specific locale - * @param locale - Target locale - * @param translations - Translation object to merge - */ - addTranslations(locale: 'zh' | 'en', translations: Record) { - this.translations[locale] = { - ...this.translations[locale], - ...translations, - }; - } } -// Create singleton instance const i18n = new I18n(); -// Export both the instance and the class for flexibility -export { I18n, i18n }; export default i18n; - -/** - * Usage examples: - * - * // Direct locale setting (new preferred method) - * i18n.setLocale('zh'); // Chinese - * i18n.setLocale('en'); // English - * - * // Get translations - * i18n.t('checking_update'); // Based on current locale - * i18n.t('download_progress', { progress: 50 }); // With interpolation - */ diff --git a/src/locales/en.ts b/src/locales/en.ts index 502b50a7..78dc3663 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -1,11 +1,7 @@ export default { // Common messages - checking_update: 'Checking for updates...', update_failed: 'Update failed', - // Progress messages with interpolation - download_progress: 'Download progress: {{progress}}%', - // Alert messages alert_title: 'Notice', alert_update_ready: 'Download completed. Update now?', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index c11f6211..de24b441 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -1,11 +1,7 @@ export default { // Common messages - checking_update: '正在检查更新...', update_failed: '更新失败', - // Progress messages with interpolation - download_progress: '下载进度: {{progress}}%', - // Alert messages alert_title: '提示', alert_update_ready: '下载完毕,是否立即更新?', diff --git a/src/provider.tsx b/src/provider.tsx index b8163a79..6c8b7d49 100644 --- a/src/provider.tsx +++ b/src/provider.tsx @@ -22,6 +22,16 @@ import type { CheckResult, ProgressData, UpdateTestPayload } from './type'; import { decideDownload, resolveCheckResult } from './updateFlowCore'; import { assertWeb, isWeb, log, noop, parseQueryParams } from './utils'; +// A context action applied to the hash of the given (default: the last +// checked) update info; a no-op without one. +const forUpdateHash = + ( + updateInfoRef: { current?: CheckResult }, + apply: (hash: string) => Promise + ) => + async (info: CheckResult | undefined = updateInfoRef.current) => + info?.hash ? apply(info.hash) : undefined; + export const UpdateProvider = ({ client, children, @@ -100,22 +110,32 @@ export const UpdateProvider = ({ [client, alertError] ); - const switchVersion = useCallback( - async (info: CheckResult | undefined = updateInfoRef.current) => { - if (info?.hash) { - return client.switchVersion(info.hash); - } - }, + const switchVersion = useMemo( + () => forUpdateHash(updateInfoRef, client.switchVersion), [client] ); - const switchVersionLater = useCallback( - async (info: CheckResult | undefined = updateInfoRef.current) => { - if (info?.hash) { - return client.switchVersionLater(info.hash); + const switchVersionLater = useMemo( + () => forUpdateHash(updateInfoRef, client.switchVersionLater), + [client] + ); + + // Client pipeline errors were already surfaced via the onError + // subscription; errors thrown by user hooks (beforeCheckUpdate, + // afterDownloadUpdate) bypass the pipeline and are surfaced here. Asking + // the client instead of checking `e.code` matters: axios/system errors + // carry their own code without ever entering the pipeline. + const surfaceCaughtError = useCallback( + (e: any, title: string) => { + if (!client.wasEmitted(e)) { + setLastError(e); + alertError(title, e.message); + } + if (options.throwError) { + throw e; } }, - [client] + [client, options, alertError] ); const downloadUpdate = useCallback( @@ -162,27 +182,17 @@ export const UpdateProvider = ({ ]); return true; } catch (e: any) { - // Client pipeline errors were already surfaced via the onError - // subscription; errors thrown by user hooks (afterDownloadUpdate) - // bypass the pipeline and are surfaced here. Asking the client - // instead of checking `e.code` matters: axios/system errors carry - // their own code without ever entering the pipeline. - if (!client.wasEmitted(e)) { - setLastError(e); - alertError(client.t('update_failed'), e.message); - } - if (options.throwError) { - throw e; - } + surfaceCaughtError(e, client.t('update_failed')); return false; } }, - [client, options, alertUpdate, alertError] + [client, options, alertUpdate, surfaceCaughtError] ); const downloadAndInstallApk = useCallback( async (downloadUrl: string) => { - if (Platform.OS === 'android' && downloadUrl) { + // The client itself is a no-op off Android. + if (downloadUrl) { await client.downloadAndInstallApk(downloadUrl, setProgress); } }, @@ -219,16 +229,7 @@ export const UpdateProvider = ({ try { rootInfo = await client.checkUpdate(extra); } catch (e: any) { - // Client pipeline errors were already surfaced via the onError - // subscription; errors thrown by user hooks (beforeCheckUpdate) - // bypass the pipeline and are surfaced here (see wasEmitted). - if (!client.wasEmitted(e)) { - setLastError(e); - alertError(client.t('error_update_check_failed'), e.message); - } - if (options.throwError) { - throw e; - } + surfaceCaughtError(e, client.t('error_update_check_failed')); return; } if (!rootInfo) { @@ -354,13 +355,14 @@ export const UpdateProvider = ({ client, options, alertUpdate, - alertError, + surfaceCaughtError, openExpiredDownload, downloadUpdate, ] ); - const markSuccess = client.markSuccess; + // Stable arrow-function properties of the (pinned) client. + const { markSuccess, restartApp, resetToPackagedBundle } = client; // The lifecycle effects below key on the primitive option values they read // (re-read on every render, i.e. after every setOptions), so a setOptions @@ -528,17 +530,6 @@ export const UpdateProvider = ({ [parseTestPayload] ); - const restartApp = useCallback(async () => { - return client.restartApp(); - }, [client]); - - const resetToPackagedBundle = useCallback( - async (resetOptions?: { restart?: boolean }) => { - return client.resetToPackagedBundle(resetOptions); - }, - [client] - ); - // parseTestPayload's identity follows checkUpdate (and through it the // update strategy callbacks); the Linking subscription reads it through a // ref so it is set up once instead of re-running — and replaying the @@ -579,10 +570,11 @@ export const UpdateProvider = ({ }; const linkingListener = Linking.addEventListener('url', linkingHandler); return () => { - if ('removeEventListener' in Linking) { - (Linking as any).removeEventListener('url', linkingHandler); - } else { + // RN < 0.65 returns no subscription, only removeEventListener. + if (typeof linkingListener?.remove === 'function') { linkingListener.remove(); + } else { + (Linking as any).removeEventListener('url', linkingHandler); } }; }, []); diff --git a/src/telemetry.ts b/src/telemetry.ts index 5b910777..a8a2d887 100644 --- a/src/telemetry.ts +++ b/src/telemetry.ts @@ -14,19 +14,6 @@ export type ServerEventType = // Failure detail is stored server-side in a VARCHAR(512); stay under it. export const MAX_DETAIL_LENGTH = 500; -export interface ServerReportPayload { - type: ServerEventType; - hash: string; - packageVersion?: string; - cInfo?: { - rnu?: string; - rn?: string; - os?: string; - uuid?: string; - }; - detail?: string; -} - /** * Maps a local report event to the server-side aggregate type, or undefined * for events the server does not collect. A DOWNLOAD_FAILED whose underlying diff --git a/src/type.ts b/src/type.ts index 6931c273..d1e9238e 100644 --- a/src/type.ts +++ b/src/type.ts @@ -41,21 +41,6 @@ interface RootResult { bundleStatus?: 'matched' | 'rebuiltSameJs' | 'unknownBundle'; } -export type UpToDateCheckResult = RootResult & { - upToDate: true; -}; - -export type UpdateAvailableCheckResult = RootResult & - Partial & { - update: true; - expVersion?: VersionInfo; - }; - -export type ExpiredCheckResult = RootResult & { - expired: true; - downloadUrl: string; -}; - export type CheckResult = RootResult & Partial & { expVersion?: VersionInfo; diff --git a/src/updateFlowCore.ts b/src/updateFlowCore.ts index 05b645e9..bcf17592 100644 --- a/src/updateFlowCore.ts +++ b/src/updateFlowCore.ts @@ -127,7 +127,12 @@ const NATIVE_CONFIG_DEFAULTS: Record = { afterDownload: 'none', }; -const jsonDeepEqual = (a: unknown, b: unknown): boolean => { +/** + * Deep equality of JSON values, key order ignored. Object members whose value + * is undefined count as absent, matching JSON.stringify (they do not exist on + * the wire). + */ +export const jsonDeepEqual = (a: unknown, b: unknown): boolean => { if (a === b) { return true; } @@ -144,11 +149,16 @@ const jsonDeepEqual = (a: unknown, b: unknown): boolean => { } const left = a as Record; const right = b as Record; - const leftKeys = Object.keys(left); + const definedKeys = (object: Record) => + Object.keys(object).filter((key) => object[key] !== undefined); + const leftKeys = definedKeys(left); return ( - leftKeys.length === Object.keys(right).length && + leftKeys.length === definedKeys(right).length && leftKeys.every( - (key) => Object.hasOwn(right, key) && jsonDeepEqual(left[key], right[key]) + (key) => + // biome-ignore lint/suspicious/noPrototypeBuiltins: Object.hasOwn is missing from JSC before iOS 15.4 + Object.prototype.hasOwnProperty.call(right, key) && + jsonDeepEqual(left[key], right[key]) ) ); }; @@ -324,8 +334,10 @@ export interface CheckFingerprintInput { appKey?: string; endpoints?: string[]; queryUrls?: string[]; - uuid?: string; - /** The exact serialized request body (identity fields + extra). */ + /** + * The exact serialized request body (identity fields + extra; the client + * uuid travels inside its cInfo). + */ body: string; } @@ -337,16 +349,9 @@ export function buildCheckFingerprint({ appKey, endpoints, queryUrls, - uuid, body, }: CheckFingerprintInput): string { - return JSON.stringify([ - appKey ?? '', - endpoints ?? [], - queryUrls ?? [], - uuid ?? '', - body, - ]); + return JSON.stringify([appKey ?? '', endpoints ?? [], queryUrls ?? [], body]); } /** diff --git a/src/utils.ts b/src/utils.ts index 2a8302b3..2b32a1dd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -82,43 +82,32 @@ export const emptyModule = new Proxy(emptyModuleTarget, { }, }); -// Web never probes (assertWeb gates every flow); the identity keeps the -// signature. Not the bare `Promise.resolve`: called unbound through map() it -// throws. -const ping = isWeb - ? (url: string) => Promise.resolve(url) - : async (url: string) => { - try { - const { - status, - statusText, - url: finalUrl, - } = await fetchWithTimeout( - url, - { - method: 'HEAD', - }, - DEFAULT_FETCH_TIMEOUT_MS - ); - if (status === 200) { - if (isProtocolDowngrade(url, finalUrl)) { - // The probe was redirected from https to plaintext http. Never - // let a downgraded final URL win the race: the winner is what - // the real artifact download uses. - log('ping rejected: https redirected to http', url, finalUrl); - throw Error(i18n.t('error_ping_failed')); - } - return finalUrl; - } - log('ping failed', url, status, statusText); +const ping = async (url: string) => { + try { + const { + status, + statusText, + url: finalUrl, + } = await fetchWithTimeout(url, { + method: 'HEAD', + }); + if (status === 200) { + if (isProtocolDowngrade(url, finalUrl)) { + // The probe was redirected from https to plaintext http. Never let a + // downgraded final URL win the race: the winner is what the real + // artifact download uses. + log('ping rejected: https redirected to http', url, finalUrl); throw Error(i18n.t('error_ping_failed')); - } catch (e) { - log('ping error', url, e); - throw e; } - }; - -export { joinUrls } from './updateFlowCore'; + return finalUrl; + } + log('ping failed', url, status, statusText); + throw Error(i18n.t('error_ping_failed')); + } catch (e) { + log('ping error', url, e); + throw e; + } +}; export const testUrls = async (urls?: string[]): Promise => { if (!urls?.length) { @@ -182,16 +171,14 @@ export const fetchWithTimeout = ( // Promise.race the losing fetch kept running (and kept the connection busy) // long after the caller had already moved on. const controller = new AbortController(); - // The timeout controller replaces params.signal on the fetch call, so a - // caller-provided signal (e.g. the hedged endpoint race cancelling losers) - // must be chained onto it manually. - const externalSignal = - signal ?? ((params as any)?.signal as AbortSignal | undefined); + // The timeout controller is the fetch call's signal, so a caller-provided + // signal (e.g. the hedged endpoint race cancelling losers) must be chained + // onto it manually. const onExternalAbort = () => controller.abort(); - if (externalSignal?.aborted) { + if (signal?.aborted) { controller.abort(); - } else if (externalSignal) { - externalSignal.addEventListener('abort', onExternalAbort); + } else if (signal) { + signal.addEventListener('abort', onExternalAbort); } const timeoutId = setTimeout(() => { log('fetch timeout', url); @@ -200,7 +187,7 @@ export const fetchWithTimeout = ( return enhancedFetch(url, { ...params, signal: controller.signal as any }) .catch((e: any) => { - if (externalSignal?.aborted) { + if (signal?.aborted) { // Cancelled by the caller, not timed out; keep the abort semantics. throw e; } @@ -213,7 +200,7 @@ export const fetchWithTimeout = ( clearTimeout(timeoutId); // A long-lived caller signal (one per endpoint race) would otherwise // keep every finished request's listener — and its controller — alive. - externalSignal?.removeEventListener('abort', onExternalAbort); + signal?.removeEventListener('abort', onExternalAbort); }); };