diff --git a/CodePush.podspec b/CodePush.podspec index c78a4979f..d5d5faa3f 100644 --- a/CodePush.podspec +++ b/CodePush.podspec @@ -13,8 +13,10 @@ Pod::Spec.new do |s| s.ios.deployment_target = '15.5' s.tvos.deployment_target = '15.5' s.preserve_paths = '*.js' - s.library = 'z' - s.source_files = 'ios/CodePush/*.{h,m}' + # 'z' for SSZipArchive; 'bz2' for the FEAT-2 vendored bspatch (bsdiff/ota_bspatch.c). + s.libraries = ['z', 'bz2'] + # Recurse into subdirs and include .c so the vendored bspatch core compiles. + s.source_files = 'ios/CodePush/**/*.{h,m,c}' s.public_header_files = ['ios/CodePush/CodePush.h'] # Note: Even though there are copy/pasted versions of some of these dependencies in the repo, diff --git a/FEAT-2-NATIVE-INTEGRATION.md b/FEAT-2-NATIVE-INTEGRATION.md new file mode 100644 index 000000000..314729fa7 --- /dev/null +++ b/FEAT-2-NATIVE-INTEGRATION.md @@ -0,0 +1,48 @@ +# FEAT-2 — Native Diff Applier: Integration & Verification Guide + +Status of the client (SDK) side of FEAT-2 (differential bundle updates). + +## ✅ Done & verified (in this repo, branch `feat/feat-2-bundle-diff`) +- **SHARED-S4** — `client-capabilities.js`: advertises `client_capabilities` on update_check (empty by default). *6 assertions green.* +- **SDK-C1 patch** — `patches/code-push+4.2.2.patch`: forwards `is_diff/diff_url/source_hash/asset_manifest_url` from the server response into `remotePackage`. *Validated: applies clean, valid JS.* +- **T3.1 gate** — `diff-support.js` `shouldAttemptDiff()`: opt-in client gate (`experimental.bundleDiff`). *6 assertions green.* +- **Fields already flow to native:** `package-mixins.js` copies all package fields into `NativeCodePush.downloadUpdate(...)`, so `diffUrl`/`sourceHash` reach native automatically once advertised. + +## ✅ Native hook IMPLEMENTED (both platforms) — device-validation pending +- **Android** (`CodePushUpdateManager.downloadPackage` + `CodePushDiffPatcher.java`, `jbsdiff`): when `update_check` offers `diffUrl/sourceHash/bundleHash`, it verifies the current bundle == `sourceHash`, downloads the patch, applies it (jbsdiff), copies the current package (assets) + replaces the bundle, verifies == `bundleHash`, writes metadata. **Any failure ⇒ falls through to the normal full download.** +- **iOS** (`CodePushPackage.downloadPackage` + `CodePushDiffPatcher.{h,m}` + vendored `bsdiff/ota_bspatch.c`, `libbz2`): the exact mirror, using bspatch. + +Both reuse the apply core already proven (iOS simulator + jbsdiff format-verified; the vendored `ota_bspatch.c` recompiles to the exact target). The classic full-download path is the default and is preserved by construction. + +> **Still required: on-device validation.** This cannot be done in the authoring environment (no Android/iOS app build of the SDK). A mobile dev must build the SDK into an app + point it at the OTA server, then run the device verification checklist below. + +## Integration steps (do on a device-capable machine) + +### Android +1. Add dependency in `android/build.gradle`: `implementation("io.sigpipe:jbsdiff:1.0")`. +2. In `CodePushUpdateManager.downloadPackage(...)` (or `CodePushNativeModule.downloadUpdate`), **before** the normal full download: + - if `updatePackage.diffUrl` + `sourceHash` are present **and** the on-disk current package hash == `sourceHash`: + - download the patch from `diffUrl`, + - `byte[] target = CodePushDiffPatcher.applyPatch(currentBundleBytes, patchBytes);` + - write `target` into the new update folder as the bundle, + - **verify**: compute the package hash via the existing `CodePushUpdateUtils` and assert it equals `updatePackage.packageHash`. + - on ANY failure or hash mismatch → **fall back** to the existing full download. Never install an unverified bundle. + +### iOS +1. Vendor `bspatch.c`/`bspatch.h` (bsdiff project, BSD-2-Clause) into `ios/CodePush/bsdiff/`, exposing `codepush_bspatch(...)` as declared in `CodePushDiffPatcher.m`; add to `CodePush.podspec`; link `libbz2.tbd`. +2. In `CodePushPackage.m` download flow, mirror the Android logic: apply via `CodePushDiffPatcher applyPatch:toSource:error:`, verify hash via `CodePushUpdateUtils`, fall back to full on failure. + +### Enable the capability (last) +Only after the native applier is verified on both platforms, add `"bundle-diff"` to `CLIENT_CAPABILITIES` in `client-capabilities.js`. Until then the server never offers diffs (capability not advertised), so everything stays classic. + +## Device verification checklist (the real proof) +- [ ] Publish v_n then v_n+1 to a staging deployment (server `OTA_FEATURE_BUNDLE_DIFF=true`). +- [ ] Device on v_n with `experimental.bundleDiff` + `bundle-diff` capability enabled. +- [ ] Update_check returns `diff_url`; device downloads a **few KB** patch (not full). +- [ ] Patched bundle hash == target `packageHash`; app boots on v_n+1; `notifyAppReady` → no rollback. +- [ ] Force a corrupt patch → device **falls back to full download**, installs correctly. +- [ ] Classic build (no capability) → unaffected, full download. +- [ ] Measure bytes transferred vs full; confirm KB-scale. + +## Why this split +The server side and JS plumbing are fully unit/integration-tested here. The native appliers require a device toolchain to verify; shipping them un-wired keeps the working OTA path safe while giving the team ready-to-integrate, reviewable code. diff --git a/android/app/build.gradle b/android/app/build.gradle index 43744af4e..7ca452215 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -30,4 +30,7 @@ android { dependencies { implementation "com.facebook.react:react-native:+" implementation 'com.nimbusds:nimbus-jose-jwt:9.37.3' + // FEAT-2: bsdiff (BSDIFF40) patch applier — format-compatible with the OTA + // server's bsdiff output (verified). Pulls org.apache.commons:commons-compress. + implementation 'io.sigpipe:jbsdiff:1.0' } diff --git a/android/app/src/main/java/com/microsoft/codepush/react/CodePushDiffPatcher.java b/android/app/src/main/java/com/microsoft/codepush/react/CodePushDiffPatcher.java new file mode 100644 index 000000000..8a3b9bd95 --- /dev/null +++ b/android/app/src/main/java/com/microsoft/codepush/react/CodePushDiffPatcher.java @@ -0,0 +1,67 @@ +package com.microsoft.codepush.react; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.security.MessageDigest; + +import io.sigpipe.jbsdiff.Patch; + +/** + * FEAT-2 — Android bundle-diff patcher. + * + * Applies a bsdiff (BSDIFF40) patch to the current JS bundle to reconstruct the + * new bundle, and verifies the result hash. Format-compatible with the OTA + * server's `bsdiff` output (validated). + * + * All methods throw on failure; callers MUST catch and fall back to a full + * download (never install an unverified bundle). + */ +public final class CodePushDiffPatcher { + + private CodePushDiffPatcher() {} + + /** + * Applies {@code patchFile} to {@code sourceFile} (bsdiff) and writes the + * reconstructed bundle to {@code outFile}. + */ + public static void applyPatch(File sourceFile, File patchFile, File outFile) throws Exception { + byte[] source = readFile(sourceFile); + byte[] patch = readFile(patchFile); + ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(source.length, 1024)); + Patch.patch(source, patch, out); // jbsdiff + FileOutputStream fos = new FileOutputStream(outFile); + try { + fos.write(out.toByteArray()); + } finally { + fos.close(); + } + } + + /** Lowercase hex SHA-256 of a file. */ + public static String sha256(File file) throws Exception { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(readFile(file)); + StringBuilder sb = new StringBuilder(digest.length * 2); + for (byte b : digest) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + private static byte[] readFile(File file) throws Exception { + FileInputStream fis = new FileInputStream(file); + try { + ByteArrayOutputStream bos = new ByteArrayOutputStream((int) Math.max(file.length(), 1024)); + byte[] buf = new byte[8192]; + int n; + while ((n = fis.read(buf)) >= 0) { + bos.write(buf, 0, n); + } + return bos.toByteArray(); + } finally { + fis.close(); + } + } +} diff --git a/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java b/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java index 0bbe38cba..052ee13e1 100644 --- a/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java +++ b/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java @@ -143,6 +143,107 @@ public JSONObject getPackage(String packageHash) { } } + /** + * FEAT-2: try to produce the new bundle by applying a bsdiff patch to the + * current bundle, instead of downloading the full bundle. + * + * Returns true only if a patch was applied AND the reconstructed bundle's hash + * matches the server's target (bundleHash). On any doubt/failure returns false + * so the caller falls back to the normal full download. Never throws; never + * installs an unverified bundle. + */ + private boolean tryApplyBundleDiff(JSONObject updatePackage, String newUpdateHash, + String newUpdateFolderPath, String newUpdateMetadataPath, + String expectedBundleFileName) { + String diffUrl = updatePackage.optString("diffUrl", null); + String sourceHash = updatePackage.optString("sourceHash", null); + String bundleHash = updatePackage.optString("bundleHash", null); + if (diffUrl == null || sourceHash == null || bundleHash == null) { + return false; + } + + File patchFile = null; + try { + // 1) Locate the current on-device bundle (the patch source). + String currentBundlePath = getCurrentPackageBundlePath(expectedBundleFileName); + String currentPackageFolderPath = getCurrentPackageFolderPath(); + if (currentBundlePath == null || currentPackageFolderPath == null) { + return false; + } + File currentBundle = new File(currentBundlePath); + if (!currentBundle.exists()) { + return false; + } + + // 2) The current bundle must be exactly the patch's source. + if (!sourceHash.equalsIgnoreCase(CodePushDiffPatcher.sha256(currentBundle))) { + return false; + } + + // 3) Download the (tiny) patch. + patchFile = new File(getCodePushPath(), "ota.patch"); + patchFile.getParentFile().mkdirs(); + downloadToFile(diffUrl, patchFile); + + // 4) Stage: copy the current package (assets etc.) into the new folder, then + // replace the bundle with the patched one (in the same relative location). + FileUtils.copyDirectoryContents(currentPackageFolderPath, newUpdateFolderPath); + String relativeBundlePath = currentBundle.getAbsolutePath().substring( + new File(currentPackageFolderPath).getAbsolutePath().length() + 1); + File newBundle = new File(newUpdateFolderPath, relativeBundlePath); + newBundle.getParentFile().mkdirs(); + CodePushDiffPatcher.applyPatch(currentBundle, patchFile, newBundle); + + // 5) Verify the reconstructed bundle == the server's target hash. + if (!bundleHash.equalsIgnoreCase(CodePushDiffPatcher.sha256(newBundle))) { + FileUtils.deleteDirectoryAtPath(newUpdateFolderPath); + return false; + } + + // 6) Record metadata, mirroring the normal staging path. + CodePushUtils.setJSONValueForKey(updatePackage, CodePushConstants.RELATIVE_BUNDLE_PATH_KEY, relativeBundlePath); + CodePushUtils.writeJsonToFile(updatePackage, newUpdateMetadataPath); + CodePushUtils.log("FEAT-2: applied bsdiff patch (verified) — staged from diff."); + return true; + } catch (Throwable t) { + CodePushUtils.log("FEAT-2: bundle-diff apply failed, falling back to full download: " + t.getMessage()); + FileUtils.deleteDirectoryAtPath(newUpdateFolderPath); + return false; + } finally { + if (patchFile != null && patchFile.exists()) { + patchFile.delete(); + } + } + } + + /** Downloads {@code url} to {@code destFile} over HTTP(S). */ + private void downloadToFile(String url, File destFile) throws IOException { + HttpURLConnection connection = null; + BufferedInputStream bin = null; + FileOutputStream fos = null; + BufferedOutputStream bout = null; + try { + connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setRequestProperty("Accept-Encoding", "identity"); + bin = new BufferedInputStream(connection.getInputStream()); + fos = new FileOutputStream(destFile); + bout = new BufferedOutputStream(fos, CodePushConstants.DOWNLOAD_BUFFER_SIZE); + byte[] data = new byte[CodePushConstants.DOWNLOAD_BUFFER_SIZE]; + int n; + while ((n = bin.read(data, 0, CodePushConstants.DOWNLOAD_BUFFER_SIZE)) >= 0) { + bout.write(data, 0, n); + } + } finally { + try { + if (bout != null) bout.close(); + if (fos != null) fos.close(); + if (bin != null) bin.close(); + if (connection != null) connection.disconnect(); + } catch (IOException ignored) { + } + } + } + public void downloadPackage(JSONObject updatePackage, String expectedBundleFileName, DownloadProgressCallback progressCallback, String stringPublicKey) throws IOException { @@ -155,6 +256,14 @@ public void downloadPackage(JSONObject updatePackage, String expectedBundleFileN FileUtils.deleteDirectoryAtPath(newUpdateFolderPath); } + // FEAT-2: if the server offered a bsdiff patch (diffUrl/sourceHash/bundleHash) + // and we can apply + verify it, stage the patched bundle and return. On ANY + // failure this returns false and we fall through to the normal full download. + if (tryApplyBundleDiff(updatePackage, newUpdateHash, newUpdateFolderPath, + newUpdateMetadataPath, expectedBundleFileName)) { + return; + } + String downloadUrlString = updatePackage.optString(CodePushConstants.DOWNLOAD_URL_KEY, null); HttpURLConnection connection = null; BufferedInputStream bin = null; diff --git a/client-capabilities.js b/client-capabilities.js new file mode 100644 index 000000000..400c4bd0c --- /dev/null +++ b/client-capabilities.js @@ -0,0 +1,63 @@ +/** + * Client Capability Advertisement (SHARED-S4) + * + * The SDK tells the OTA server which OPTIONAL features this build can actually + * handle, via the `client_capabilities` query param on `/update_check`. The + * server only returns an optimized response when the client advertised support + * (see server `isFeatureActive`); otherwise it returns the classic response. + * + * IMPORTANT: a capability is added to {@link CLIENT_CAPABILITIES} ONLY once its + * full client-side implementation (including native code) is shipped — never + * before. This guarantees the server never sends an optimization the client + * cannot use. The list is therefore EMPTY by default ⇒ classic behavior, no + * change to existing apps. + * + * This intercepts at our own request adapter (request-fetch-adapter.js), so no + * change to the upstream `code-push` acquisition SDK is required. + */ + +/** + * Optional-feature capabilities THIS SDK build can handle. + * Uncomment a capability only when its client implementation is complete. + * @type {string[]} + */ +const CLIENT_CAPABILITIES = [ + // "bundle-diff", // FEAT-2 — enable after native bspatch (FEAT-2-T3.2/T3.3) ships + // "asset-cache", // FEAT-1 — enable after native asset cache ships +]; + +/** + * Builds the comma-separated capability value. + * @param {string[]} [caps] + * @returns {string} e.g. "bundle-diff,asset-cache" (empty string if none) + */ +function buildCapabilityValue(caps) { + const list = caps || CLIENT_CAPABILITIES; + return list.filter(Boolean).join(","); +} + +/** + * Appends `client_capabilities` to an `/update_check` URL. + * No-op when there are no capabilities, the input is not a string, or the URL is + * not an update_check request — so the classic request is byte-for-byte unchanged + * by default. + * + * @param {string} url - The fully-built request URL from the acquisition SDK. + * @param {string[]} [caps] - Override capability list (for tests). + * @returns {string} The URL, possibly with `client_capabilities` appended. + */ +function appendClientCapabilities(url, caps) { + const value = buildCapabilityValue(caps); + if (!value) return url; + if (typeof url !== "string") return url; + if (url.indexOf("/update_check") === -1) return url; + + const separator = url.indexOf("?") === -1 ? "?" : "&"; + return url + separator + "client_capabilities=" + encodeURIComponent(value); +} + +module.exports = { + CLIENT_CAPABILITIES, + buildCapabilityValue, + appendClientCapabilities, +}; diff --git a/client-capabilities.test.js b/client-capabilities.test.js new file mode 100644 index 000000000..9ce6945a5 --- /dev/null +++ b/client-capabilities.test.js @@ -0,0 +1,38 @@ +/** + * Standalone unit test for client-capabilities (SHARED-S4). + * Pure logic, no React Native deps — run directly: `node client-capabilities.test.js` + */ +const assert = require("node:assert"); +const { appendClientCapabilities, buildCapabilityValue } = require("./client-capabilities"); + +const UPDATE_URL = "https://ota.example.com/update_check?deployment_key=k&app_version=1.0.0"; + +// 1) Default (no capabilities) → URL unchanged (classic behavior preserved) +assert.strictEqual(appendClientCapabilities(UPDATE_URL, []), UPDATE_URL, "no caps → unchanged"); +assert.strictEqual(buildCapabilityValue([]), "", "empty caps → empty value"); + +// 2) With capabilities → appended with '&' (URL already has '?') +assert.strictEqual( + appendClientCapabilities(UPDATE_URL, ["bundle-diff"]), + UPDATE_URL + "&client_capabilities=bundle-diff", + "appends with & when query exists", +); + +// 3) Multiple capabilities, comma-joined + encoded +assert.strictEqual( + appendClientCapabilities("https://h/update_check", ["bundle-diff", "asset-cache"]), + "https://h/update_check?client_capabilities=bundle-diff%2Casset-cache", + "appends with ? when no query; comma-encoded", +); + +// 4) Non-update_check URLs are never touched +assert.strictEqual( + appendClientCapabilities("https://h/report_status/deploy", ["bundle-diff"]), + "https://h/report_status/deploy", + "non-update_check → unchanged", +); + +// 5) Non-string input is safe +assert.strictEqual(appendClientCapabilities(undefined, ["bundle-diff"]), undefined, "undefined → unchanged"); + +console.log("client-capabilities.test.js: all 6 assertions passed ✓"); diff --git a/diff-support.js b/diff-support.js new file mode 100644 index 000000000..7ab002b13 --- /dev/null +++ b/diff-support.js @@ -0,0 +1,34 @@ +/** + * Client-side bundle-diff support (FEAT-2 · T3.1) + * + * The optional diff fields (`diffUrl`, `sourceHash`) reach the native layer + * automatically: `package-mixins.js` copies every field of the remote package + * into the object passed to `NativeCodePush.downloadUpdate`, and the SDK-C1 + * patch makes the upstream acquisition SDK forward those fields. + * + * This module only provides the OPT-IN GATE: the client attempts a diff download + * exactly when the server offered one AND the app enabled it. Native (T3.2/T3.3) + * mirrors this check before applying a patch. Default (no opt-in) ⇒ classic full + * download, unchanged. + * + * @module diff-support + */ + +/** + * Whether the client should attempt a differential (patch) download for a + * remote package, instead of a full download. + * + * @param {object} remotePackage - The remote package (may carry diffUrl/sourceHash). + * @param {object} [syncOptions] - Sync options; diff is opt-in via experimental.bundleDiff. + * @returns {boolean} true ⇒ attempt diff; false ⇒ full download (classic). + */ +function shouldAttemptDiff(remotePackage, syncOptions) { + if (!remotePackage || typeof remotePackage !== "object") return false; + const enabled = !!(syncOptions && syncOptions.experimental && syncOptions.experimental.bundleDiff); + if (!enabled) return false; + // Server must have offered a usable diff against the client's current bundle. + return typeof remotePackage.diffUrl === "string" && remotePackage.diffUrl.length > 0 + && typeof remotePackage.sourceHash === "string" && remotePackage.sourceHash.length > 0; +} + +module.exports = { shouldAttemptDiff }; diff --git a/diff-support.test.js b/diff-support.test.js new file mode 100644 index 000000000..d7044620e --- /dev/null +++ b/diff-support.test.js @@ -0,0 +1,25 @@ +/** + * Standalone unit test for diff-support (FEAT-2-T3.1). + * Pure logic, no React Native deps — run: `node diff-support.test.js` + */ +const assert = require("node:assert"); +const { shouldAttemptDiff } = require("./diff-support"); + +const withDiff = { diffUrl: "https://cdn/patch", sourceHash: "Hsrc", packageHash: "Htgt" }; +const optsOn = { experimental: { bundleDiff: true } }; + +// Opt-in + server offered a diff → attempt +assert.strictEqual(shouldAttemptDiff(withDiff, optsOn), true, "opt-in + diff offered → true"); + +// Not opted in → classic full download (default safe) +assert.strictEqual(shouldAttemptDiff(withDiff, {}), false, "no opt-in → false"); +assert.strictEqual(shouldAttemptDiff(withDiff, undefined), false, "no options → false"); + +// Opted in but server offered no diff → full +assert.strictEqual(shouldAttemptDiff({ packageHash: "Htgt" }, optsOn), false, "no diffUrl → false"); +assert.strictEqual(shouldAttemptDiff({ diffUrl: "u", sourceHash: "" }, optsOn), false, "empty sourceHash → false"); + +// Bad input is safe +assert.strictEqual(shouldAttemptDiff(null, optsOn), false, "null pkg → false"); + +console.log("diff-support.test.js: all 6 assertions passed ✓"); diff --git a/docs/FEAT-2-HOW-IT-WORKS.md b/docs/FEAT-2-HOW-IT-WORKS.md new file mode 100644 index 000000000..c53e59cc8 --- /dev/null +++ b/docs/FEAT-2-HOW-IT-WORKS.md @@ -0,0 +1,183 @@ +# FEAT-2 — Differential JS Bundle Updates: How It Works + +> **Common document** for the whole FEAT-2 feature — it describes the **single end-to-end flow** across the OTA **server** (`react-native-ota-updater`) and the **client SDK** (`react-native-code-push`) as one story, not two. The same file lives in both repos. + +## 1. What FEAT-2 does (in one paragraph) + +Instead of re-downloading the **entire** JS bundle on every OTA, the device tells the server the hash of the bundle it already has. The server finds that bundle in its archive, computes a tiny **binary diff (bsdiff)** against the new bundle, and returns a small **patch** instead of the full bundle. The device applies the patch locally to reconstruct the new bundle, **verifies its hash** matches the server's target, and installs it. A few KB move instead of several MB. It is **entirely optional and backward-compatible**: with the feature off, or for any client/edge case that can't use it, the system serves the classic full bundle unchanged. + +## 2. Mental model + +```mermaid +flowchart LR + subgraph Publish + CLI[CLI: rnota release react] -->|full bundle + packageHash| ARCH[(R2 bundle archive)] + end + subgraph "Server (decides full vs diff)" + UC[/GET update_check/] --> GATE{feature on?\nclient capable?\nsource in archive?} + GATE -->|no| FULL[serve full download_url] + GATE -->|yes| DIFF[serve diff_url + source_hash\n+ full download_url] + DIFF --> ENG[bsdiff engine] --> PCACHE[(R2 patch cache\npatches/Hsrc_Htgt)] + end + subgraph "Client (applies)" + DEV[device] -->|package_hash + client_capabilities| UC + DEV -->|download tiny patch| PCACHE + DEV --> APPLY[bspatch: source+patch -> target] + APPLY --> VERIFY{hash == target?} + VERIFY -->|yes| INSTALL[install] + VERIFY -->|no| FALLBACK[download full bundle] + end +``` + +**The golden rules** (why it's safe): +- The **full `download_url` is always present** in the response — the diff is *additive*. +- The client **never installs an unverified bundle** — it checks the reconstructed hash equals the server's target `packageHash` before install; any mismatch ⇒ full-bundle fallback. +- **Off by default**: nothing activates unless the server flag is on **and** the client advertises support **and** a usable source exists **and** a patch is worthwhile. + +## 3. The APIs involved + +### `GET /api/codepush/acquisition/update_check` +The single device-facing endpoint that gains FEAT-2 fields. **Additive only.** + +**Request query (existing + new):** +| Param | Role | +|-------|------| +| `deployment_key` | identifies the channel | +| `app_version` | device binary version | +| `package_hash` | **the hash of the bundle the device currently has** → the diff *source* | +| `label` | current release label | +| `client_unique_id` | device id (rollout) | +| **`client_capabilities`** | **NEW** — comma list, e.g. `bundle-diff`; absent ⇒ classic client | + +**Response `update_info` (existing + new, snake_case):** +| Field | Role | +|-------|------| +| `download_url` | full bundle URL (**always present**) | +| `package_hash` | the **target** hash (used to verify the patched result) | +| `is_mandatory`, `label`, `package_size`, `description`, `app_version`, `target_binary_range` | classic fields | +| **`is_diff`** | **NEW** — true when a patch is offered | +| **`diff_url`** | **NEW** — URL of the bsdiff patch (`patches/_`) | +| **`source_hash`** | **NEW** — the source hash the patch applies to (echo of the device's `package_hash`) | + +### Other endpoints (unchanged, reused) +- `GET ` — the device downloads the patch bytes from R2/CDN (same mechanism as a full bundle download). +- `POST /report_status/download` and `POST /report_status/deploy` — unchanged status reporting that feeds metrics. + +## 4. The complete step-by-step flow (1 → N) + +### Phase A — Publish (developer, unchanged) +- **A1.** Developer runs `rnota codepush release react ...` for **v_n**, then later **v_n+1**. +- **A2.** Each release uploads the **full bundle** to R2 and stores a row with its `packageHash` and `blobId`. *(FEAT-2 adds nothing here — the archive of past bundles already exists and is the diff source material.)* + +### Phase B — Device checks for an update +- **B1.** The app calls `CodePush.sync()` → `checkForUpdate()` (`CodePush.js`), which reads the native config (deployment key, app version, **current `packageHash`**). +- **B2.** The acquisition SDK builds the `update_check` URL. Our **`request-fetch-adapter.js`** appends **`client_capabilities`** (from `client-capabilities.js`) — *only* the capabilities this build supports. Default list is empty ⇒ classic request. +- **B3.** `GET /update_check` reaches the server with `package_hash = H(v_n)` (the source) and, if enabled, `client_capabilities=bundle-diff`. + +### Phase C — Server decides full vs diff +- **C1.** The route (`update_check.route.ts`) resolves the **target** release the device should get (existing acquisition logic) → `updateInfo` with `download_url` + target `packageHash = H(v_n+1)`. *This part is classic and unchanged.* +- **C2.** **Gate** (`computeBundleDiffFields` → `isFeatureActive`): proceed only if `OTA_FEATURE_BUNDLE_DIFF` (env) **AND** `bundle-diff` ∈ `client_capabilities`. Otherwise return `updateInfo` as-is (classic). *No extra DB/R2 work happens when off.* +- **C3.** Resolve blobs: `findReleaseByPackageHash(deployment, H(v_n))` (source) and the target → their R2 `blobId`s. If the source isn't in the archive (e.g. a base bundle) ⇒ no diff, serve full. +- **C4.** **Patch resolution** (`resolvePatchNonBlocking`): + - Compute cache key `patches/_`. + - **Cache hit** → attach diff fields (go to C6). + - **Cache miss** → return **full** now (non-blocking), and **schedule background generation** (deduped by `SingleFlight` so concurrent identical requests generate once). +- **C5.** **Background generation** (`resolvePatch` + `createCliDiffEngine`): fetch source & target bytes (`getObjectBytes`), run **`bsdiff`** to produce the patch, apply the **size-cap** (`shouldServeDiff` — skip if the patch isn't meaningfully smaller), then **cache** the patch to R2 (`putObjectBytes` → `patches/_`). The *next* request for this pair gets the diff. +- **C6.** Build the response: classic `updateInfo` **plus** `is_diff/diff_url/source_hash` (only on a cache hit). Record **metrics** (`DiffMetrics`: diff vs full served, bytes saved, hit-rate). Return snake_case JSON. + +### Phase D — Client receives the answer +- **D1.** The acquisition SDK parses the response. **Patched** (`patches/code-push+4.2.2.patch`) so it **forwards** `is_diff/diff_url/source_hash` into the `remotePackage` (the stock SDK would drop them). +- **D2.** `checkForUpdate()` returns the `remotePackage` (now carrying `diffUrl`, `sourceHash`). + +### Phase E — Client downloads & applies +- **E1.** `sync()` calls `remotePackage.download()`. `package-mixins.js` copies **all** package fields (incl. `diffUrl`/`sourceHash`) into `NativeCodePush.downloadUpdate(...)`. +- **E2.** **Client gate** (`diff-support.js shouldAttemptDiff`): attempt a diff only if opted in (`experimental.bundleDiff`) **and** `diffUrl` + `sourceHash` are present. +- **E3.** **Native applier** (`CodePushDiffPatcher`, Android/iOS — *integration pending, see §7*): + - locate the on-device current bundle; confirm its hash == `sourceHash`, + - download the **patch** from `diffUrl` (few KB), + - `applyPatch(currentBundleBytes, patchBytes)` (bsdiff/bspatch / HDiffPatch `hpatchz`) → reconstructed target bytes, + - **verify** reconstructed hash == `packageHash` (target) using existing `CodePushUpdateUtils`. +- **E4.** **Fallback ladder** — if *anything* fails (not opted in, no `diffUrl`, source mismatch, download error, patch error, **hash mismatch**) ⇒ download the **full bundle** (classic path). Never install unverified bytes. +- **E5.** `POST /report_status/download`. + +### Phase F — Install & confirm +- **F1.** The reconstructed (or full) bundle is staged and installed per install mode; the app restarts onto it. +- **F2.** The app calls `notifyAppReady()` → cancels the rollback timer (unchanged safety net). If the new bundle crashes before this, the native layer auto-rolls-back. +- **F3.** `POST /report_status/deploy` (success/failure) → server increments metrics (active/installed; failed on rollback). + +```mermaid +sequenceDiagram + autonumber + participant Dev as CLI (publish) + participant SDK as Device SDK + participant Nat as Native applier + participant API as update_check + participant R2 as R2 (bundles + patches) + + Dev->>R2: A2 upload full bundles v_n, v_n+1 (+ packageHash) + SDK->>API: B3 GET update_check (package_hash=H(v_n), client_capabilities=bundle-diff) + API->>API: C1 resolve target (H(v_n+1)) [classic] + API->>API: C2 gate: server flag AND client capability + API->>R2: C3/C5 (cache miss) read source+target bytes + API->>API: C5 bsdiff → patch, size-cap + API->>R2: C5 cache patch → patches/H(v_n)_H(v_n+1) + API-->>SDK: C6 update_info { download_url (full), package_hash, [is_diff, diff_url, source_hash] } + Note over SDK: D1 patched acquisition SDK forwards diff fields + SDK->>Nat: E1 downloadUpdate(package incl. diffUrl/sourceHash) + alt diff available + opted-in + source matches + Nat->>R2: E3 download tiny patch (diff_url) + Nat->>Nat: E3 apply patch → reconstruct target + Nat->>Nat: E3 verify hash == package_hash + alt verified + Nat-->>SDK: reconstructed bundle + else mismatch/any error + Nat->>R2: E4 download FULL bundle (fallback) + end + else no diff + Nat->>R2: E4 download FULL bundle (classic) + end + SDK->>API: E5 report_status/download + SDK->>Nat: F1 install + restart + SDK->>API: F3 report_status/deploy (after notifyAppReady) +``` + +## 5. Method & file reference (one table, both halves) + +| Step | Concern | Method / file | +|------|---------|---------------| +| B2 | advertise capability | `client-capabilities.js :: appendClientCapabilities`, `request-fetch-adapter.js` | +| B3 | request schema | `packages/api/src/schemas/update-check.ts` (`client_capabilities`) | +| C2 | feature gate | `utils/codepush/capabilities.ts :: isFeatureActive / parseClientCapabilities`; `env.ts :: OTA_FEATURE_BUNDLE_DIFF` | +| C3 | source lookup | `internal/db/.../manager/codepush.ts :: findReleaseByPackageHash` | +| C4 | orchestration | `utils/codepush/diff-service.ts :: resolvePatchNonBlocking / resolvePatch / patchCacheKey` | +| C4 | dedupe | `utils/codepush/single-flight.ts :: SingleFlight` | +| C5 | byte I/O | `internal/aws/src/index.ts :: getObjectBytes / putObjectBytes` | +| C5 | diff engine | `utils/codepush/cli-diff-engine.ts :: createCliDiffEngine` (bsdiff; `OTA_BSDIFF_PATH`) | +| C5 | size-cap | `utils/codepush/diff-policy.ts :: shouldServeDiff` | +| C5 | cache + engine adapters | `utils/codepush/diff-adapters.ts :: createR2PatchCache / getDefaultDiffEngine / patchSingleFlight` | +| C6 | attach + wire | `utils/codepush/diff-attach.ts :: computeBundleDiffFields`; `routes/.../acquisition/update_check.route.ts` | +| C6 | metrics | `utils/codepush/diff-metrics.ts :: DiffMetrics` | +| D1 | forward fields | `patches/code-push+4.2.2.patch` | +| D2/E1 | check + download | `CodePush.js :: checkForUpdate`, `package-mixins.js :: remote.download` | +| E2 | client gate | `diff-support.js :: shouldAttemptDiff` | +| E3 | native apply + verify | `android/.../CodePushDiffPatcher.java`, `ios/CodePush/CodePushDiffPatcher.{h,m}`, `CodePushUpdateManager`, `CodePushUpdateUtils` | +| F2 | rollback safety | `notifyApplicationReady` (unchanged) | + +## 6. Feature flags & safety summary + +| Layer | Control | Default | +|-------|---------|---------| +| Server | `OTA_FEATURE_BUNDLE_DIFF` (env) | **off** | +| Per-request | client `client_capabilities` contains `bundle-diff` | **absent** | +| Client opt-in | `sync({ experimental: { bundleDiff: true } })` | **off** | +| Client capability published | `CLIENT_CAPABILITIES` includes `bundle-diff` | **empty** (until native applier verified) | + +A diff is used only when **every** layer is on **and** a usable source + worthwhile patch exist. Any failure at any point ⇒ classic full bundle. Integrity is guaranteed by the **verify-before-install** hash check. + +## 7. Current status (what's live vs pending) + +- **Server:** complete + tested (real `bsdiff` engine, e2e integration proof, metrics, single-flight). PR `rently-com/react-native-ota-updater#29`. +- **Client plumbing:** complete (capability advertisement, response-field patch, opt-in gate). PR `prasad-rently/react-native-code-push#1`. +- **Pending (device-gated):** the **native applier** (Android #2, iOS #3) + go-live (parity, RenterApp staging, enable capability — #4). Until those land, `CLIENT_CAPABILITIES` stays empty, so the server never offers diffs and everything is classic. See `FEAT-2-NATIVE-INTEGRATION.md`. + +> Because the capability is unpublished until native is verified, **turning on the server flag today changes nothing** — by design. diff --git a/ios/CodePush/CodePushDiffPatcher.h b/ios/CodePush/CodePushDiffPatcher.h new file mode 100644 index 000000000..578687dbd --- /dev/null +++ b/ios/CodePush/CodePushDiffPatcher.h @@ -0,0 +1,29 @@ +// FEAT-2 — iOS bundle-diff patcher (interface). +// +// Applies a bsdiff (BSDIFF40) patch to the current JS bundle to reconstruct the +// new bundle (avoiding a full download), and computes file hashes for verification. +// Format-compatible with the OTA server's `bsdiff` output (validated). +// +// All methods fail safe — callers MUST treat failure as "fall back to full +// download" and never install an unverified bundle. + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface CodePushDiffPatcher : NSObject + +/// Applies the bsdiff patch at @c patchPath to the file at @c sourcePath, writing +/// the reconstructed file to @c outputPath. Returns YES on success; NO (with +/// @c error set) on any failure. ++ (BOOL)applyPatchAtPath:(NSString *)patchPath + toSourceAtPath:(NSString *)sourcePath + outputPath:(NSString *)outputPath + error:(NSError *_Nullable *_Nullable)error; + +/// Lowercase hex SHA-256 of the file at @c path, or nil if it cannot be read. ++ (nullable NSString *)sha256OfFileAtPath:(NSString *)path; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/CodePush/CodePushDiffPatcher.m b/ios/CodePush/CodePushDiffPatcher.m new file mode 100644 index 000000000..c5c8c11f0 --- /dev/null +++ b/ios/CodePush/CodePushDiffPatcher.m @@ -0,0 +1,45 @@ +// FEAT-2 — iOS bundle-diff patcher (implementation). +// +// Uses the vendored, public-domain bspatch core (bsdiff project, Colin Percival, +// BSD-2-Clause) in bsdiff/ota_bspatch.c. Linked against libbz2 (see CodePush.podspec). + +#import "CodePushDiffPatcher.h" +#import +#import "bsdiff/ota_bspatch.h" + +@implementation CodePushDiffPatcher + ++ (BOOL)applyPatchAtPath:(NSString *)patchPath + toSourceAtPath:(NSString *)sourcePath + outputPath:(NSString *)outputPath + error:(NSError *_Nullable *_Nullable)error { + int rc = ota_bspatch(sourcePath.fileSystemRepresentation, + outputPath.fileSystemRepresentation, + patchPath.fileSystemRepresentation); + if (rc != 0) { + if (error) { + *error = [NSError errorWithDomain:@"CodePushDiffPatcher" + code:rc + userInfo:@{NSLocalizedDescriptionKey: + [NSString stringWithFormat:@"bspatch failed (rc=%d)", rc]}]; + } + return NO; + } + return YES; +} + ++ (nullable NSString *)sha256OfFileAtPath:(NSString *)path { + NSData *data = [NSData dataWithContentsOfFile:path]; + if (data == nil) { + return nil; + } + unsigned char digest[CC_SHA256_DIGEST_LENGTH]; + CC_SHA256(data.bytes, (CC_LONG)data.length, digest); + NSMutableString *hex = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2]; + for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) { + [hex appendFormat:@"%02x", digest[i]]; + } + return [hex copy]; +} + +@end diff --git a/ios/CodePush/CodePushPackage.m b/ios/CodePush/CodePushPackage.m index 992b651ff..9a66e0aab 100644 --- a/ios/CodePush/CodePushPackage.m +++ b/ios/CodePush/CodePushPackage.m @@ -1,4 +1,5 @@ #import "CodePush.h" +#import "CodePushDiffPatcher.h" #if __has_include() #import #else @@ -43,6 +44,102 @@ + (void)downloadAndReplaceCurrentBundle:(NSString *)remoteBundleUrl } } +// FEAT-2: try to produce the new bundle by applying a bsdiff patch to the current +// bundle, instead of downloading the full bundle. Returns YES only if a patch was +// applied AND the reconstructed bundle's hash matches the server's target. On any +// doubt/failure returns NO so the caller falls back to the full download. Never +// installs an unverified bundle. ++ (BOOL)tryApplyBundleDiff:(NSDictionary *)updatePackage + expectedBundleFileName:(NSString *)expectedBundleFileName + newUpdateFolderPath:(NSString *)newUpdateFolderPath + newUpdateMetadataPath:(NSString *)newUpdateMetadataPath +{ + NSString *diffUrl = updatePackage[@"diffUrl"]; + NSString *sourceHash = updatePackage[@"sourceHash"]; + NSString *bundleHash = updatePackage[@"bundleHash"]; + if (diffUrl.length == 0 || sourceHash.length == 0 || bundleHash.length == 0) { + return NO; + } + + NSFileManager *fm = [NSFileManager defaultManager]; + NSString *patchPath = [[self getCodePushPath] stringByAppendingPathComponent:@"ota.patch"]; + + @try { + NSError *err = nil; + + // 1) Locate the current on-device bundle (the patch source). + NSString *currentBundlePath = [self getCurrentPackageBundlePath:&err]; + NSString *currentPackageFolderPath = [self getCurrentPackageFolderPath:&err]; + if (currentBundlePath == nil || currentPackageFolderPath == nil || + ![fm fileExistsAtPath:currentBundlePath]) { + return NO; + } + + // 2) The current bundle must be exactly the patch's source. + NSString *curHash = [CodePushDiffPatcher sha256OfFileAtPath:currentBundlePath]; + if (curHash == nil || ![sourceHash.lowercaseString isEqualToString:curHash.lowercaseString]) { + return NO; + } + + // 3) Download the (tiny) patch. + NSData *patchData = [NSData dataWithContentsOfURL:[NSURL URLWithString:diffUrl]]; + if (patchData == nil) { + return NO; + } + [fm createDirectoryAtPath:[self getCodePushPath] withIntermediateDirectories:YES attributes:nil error:nil]; + if (![patchData writeToFile:patchPath atomically:YES]) { + return NO; + } + + // 4) Stage: copy the current package (assets etc.) into the new folder, then + // replace the bundle with the patched one in the same relative location. + if ([fm fileExistsAtPath:newUpdateFolderPath]) { + [fm removeItemAtPath:newUpdateFolderPath error:nil]; + } + if (![fm copyItemAtPath:currentPackageFolderPath toPath:newUpdateFolderPath error:&err]) { + return NO; + } + NSString *relativeBundlePath = [currentBundlePath substringFromIndex:currentPackageFolderPath.length + 1]; + NSString *newBundlePath = [newUpdateFolderPath stringByAppendingPathComponent:relativeBundlePath]; + [fm createDirectoryAtPath:[newBundlePath stringByDeletingLastPathComponent] + withIntermediateDirectories:YES attributes:nil error:nil]; + [fm removeItemAtPath:newBundlePath error:nil]; + if (![CodePushDiffPatcher applyPatchAtPath:patchPath + toSourceAtPath:currentBundlePath + outputPath:newBundlePath + error:&err]) { + [fm removeItemAtPath:newUpdateFolderPath error:nil]; + return NO; + } + + // 5) Verify the reconstructed bundle == the server's target hash. + NSString *newHash = [CodePushDiffPatcher sha256OfFileAtPath:newBundlePath]; + if (newHash == nil || ![bundleHash.lowercaseString isEqualToString:newHash.lowercaseString]) { + [fm removeItemAtPath:newUpdateFolderPath error:nil]; + return NO; + } + + // 6) Record metadata, mirroring the normal staging path. + NSMutableDictionary *mutableUpdatePackage = [updatePackage mutableCopy]; + [mutableUpdatePackage setValue:relativeBundlePath forKey:RelativeBundlePathKey]; + NSData *serialized = [NSJSONSerialization dataWithJSONObject:mutableUpdatePackage options:0 error:&err]; + if (serialized == nil) { + [fm removeItemAtPath:newUpdateFolderPath error:nil]; + return NO; + } + NSString *json = [[NSString alloc] initWithData:serialized encoding:NSUTF8StringEncoding]; + [json writeToFile:newUpdateMetadataPath atomically:YES encoding:NSUTF8StringEncoding error:nil]; + return YES; + } @catch (NSException *exception) { + [fm removeItemAtPath:newUpdateFolderPath error:nil]; + return NO; + } @finally { + if ([fm fileExistsAtPath:patchPath]) { + [fm removeItemAtPath:patchPath error:nil]; + } + } +} + + (void)downloadPackage:(NSDictionary *)updatePackage expectedBundleFileName:(NSString *)expectedBundleFileName publicKey:(NSString *)publicKey @@ -76,7 +173,17 @@ + (void)downloadPackage:(NSDictionary *)updatePackage if (error) { return failCallback(error); } - + + // FEAT-2: if the server offered a bsdiff patch (diffUrl/sourceHash/bundleHash) + // and we can apply + verify it, stage the patched bundle and finish — otherwise + // fall through to the normal full download. + if ([self tryApplyBundleDiff:updatePackage + expectedBundleFileName:expectedBundleFileName + newUpdateFolderPath:newUpdateFolderPath + newUpdateMetadataPath:newUpdateMetadataPath]) { + return doneCallback(); + } + NSString *downloadFilePath = [self getDownloadFilePath]; NSString *bundleFilePath = [newUpdateFolderPath stringByAppendingPathComponent:UpdateBundleFileName]; diff --git a/ios/CodePush/bsdiff/ota_bspatch.c b/ios/CodePush/bsdiff/ota_bspatch.c new file mode 100644 index 000000000..b1c232e30 --- /dev/null +++ b/ios/CodePush/bsdiff/ota_bspatch.c @@ -0,0 +1,131 @@ +/*- + * Library refactor of bspatch.c — applies a classic bsdiff "BSDIFF40" patch. + * + * Original: Copyright 2003-2005 Colin Percival (BSD-2-Clause). The algorithm is + * unchanged; main() is converted into ota_bspatch() which returns an error code + * instead of calling err()/errx() (which exit the process) — so a corrupt patch + * never crashes the app, the caller just falls back to a full download. + * + * Original copyright notice: + * Copyright 2003-2005 Colin Percival. All rights reserved. + * Redistribution and use in source and binary forms, with or without + * modification, are permitted providing that the conditions of the + * BSD-2-Clause license are met. THE SOFTWARE IS PROVIDED "AS IS". + */ + +#include +#include +#include +#include +#include +#include +#include + +#include "ota_bspatch.h" + +static off_t offtin(unsigned char *buf) { + off_t y; + y = buf[7] & 0x7F; + y = y * 256; y += buf[6]; + y = y * 256; y += buf[5]; + y = y * 256; y += buf[4]; + y = y * 256; y += buf[3]; + y = y * 256; y += buf[2]; + y = y * 256; y += buf[1]; + y = y * 256; y += buf[0]; + if (buf[7] & 0x80) y = -y; + return y; +} + +int ota_bspatch(const char *oldpath, const char *newpath, const char *patchpath) { + FILE *f = NULL, *cpf = NULL, *dpf = NULL, *epf = NULL; + BZFILE *cpfbz2 = NULL, *dpfbz2 = NULL, *epfbz2 = NULL; + int cbz2err = 0, dbz2err = 0, ebz2err = 0; + int fd = -1; + ssize_t oldsize = 0, newsize = 0; + ssize_t bzctrllen, bzdatalen; + unsigned char header[32], buf[8]; + unsigned char *old = NULL, *newp = NULL; + off_t oldpos, newpos, ctrl[3], lenread, i; + int rc = -1; + + /* Read header */ + if ((f = fopen(patchpath, "rb")) == NULL) goto cleanup; + if (fread(header, 1, 32, f) < 32) goto cleanup; + if (memcmp(header, "BSDIFF40", 8) != 0) goto cleanup; + + bzctrllen = (ssize_t)offtin(header + 8); + bzdatalen = (ssize_t)offtin(header + 16); + newsize = (ssize_t)offtin(header + 24); + if (bzctrllen < 0 || bzdatalen < 0 || newsize < 0) goto cleanup; + fclose(f); f = NULL; + + /* Open the three bzip2 streams at their offsets */ + if ((cpf = fopen(patchpath, "rb")) == NULL) goto cleanup; + if (fseeko(cpf, 32, SEEK_SET)) goto cleanup; + if ((cpfbz2 = BZ2_bzReadOpen(&cbz2err, cpf, 0, 0, NULL, 0)) == NULL) goto cleanup; + if ((dpf = fopen(patchpath, "rb")) == NULL) goto cleanup; + if (fseeko(dpf, 32 + bzctrllen, SEEK_SET)) goto cleanup; + if ((dpfbz2 = BZ2_bzReadOpen(&dbz2err, dpf, 0, 0, NULL, 0)) == NULL) goto cleanup; + if ((epf = fopen(patchpath, "rb")) == NULL) goto cleanup; + if (fseeko(epf, 32 + bzctrllen + bzdatalen, SEEK_SET)) goto cleanup; + if ((epfbz2 = BZ2_bzReadOpen(&ebz2err, epf, 0, 0, NULL, 0)) == NULL) goto cleanup; + + /* Read the old file into memory */ + if ((fd = open(oldpath, O_RDONLY, 0)) < 0) goto cleanup; + if ((oldsize = lseek(fd, 0, SEEK_END)) == -1) goto cleanup; + if ((old = malloc(oldsize + 1)) == NULL) goto cleanup; + if (lseek(fd, 0, SEEK_SET) != 0) goto cleanup; + if (read(fd, old, oldsize) != oldsize) goto cleanup; + close(fd); fd = -1; + if ((newp = malloc(newsize + 1)) == NULL) goto cleanup; + + oldpos = 0; newpos = 0; + while (newpos < newsize) { + /* Read control data */ + for (i = 0; i <= 2; i++) { + lenread = BZ2_bzRead(&cbz2err, cpfbz2, buf, 8); + if ((lenread < 8) || ((cbz2err != BZ_OK) && (cbz2err != BZ_STREAM_END))) goto cleanup; + ctrl[i] = offtin(buf); + } + if (newpos + ctrl[0] > newsize) goto cleanup; + + /* Read diff string */ + lenread = BZ2_bzRead(&dbz2err, dpfbz2, newp + newpos, (int)ctrl[0]); + if ((lenread < ctrl[0]) || ((dbz2err != BZ_OK) && (dbz2err != BZ_STREAM_END))) goto cleanup; + + /* Add old data to diff string */ + for (i = 0; i < ctrl[0]; i++) + if ((oldpos + i >= 0) && (oldpos + i < oldsize)) + newp[newpos + i] += old[oldpos + i]; + + newpos += ctrl[0]; oldpos += ctrl[0]; + if (newpos + ctrl[1] > newsize) goto cleanup; + + /* Read extra string */ + lenread = BZ2_bzRead(&ebz2err, epfbz2, newp + newpos, (int)ctrl[1]); + if ((lenread < ctrl[1]) || ((ebz2err != BZ_OK) && (ebz2err != BZ_STREAM_END))) goto cleanup; + + newpos += ctrl[1]; oldpos += ctrl[2]; + } + + /* Write the new file */ + if ((fd = open(newpath, O_CREAT | O_TRUNC | O_WRONLY, 0666)) < 0) goto cleanup; + if (write(fd, newp, newsize) != newsize) goto cleanup; + close(fd); fd = -1; + + rc = 0; /* success */ + +cleanup: + if (cpfbz2) BZ2_bzReadClose(&cbz2err, cpfbz2); + if (dpfbz2) BZ2_bzReadClose(&dbz2err, dpfbz2); + if (epfbz2) BZ2_bzReadClose(&ebz2err, epfbz2); + if (cpf) fclose(cpf); + if (dpf) fclose(dpf); + if (epf) fclose(epf); + if (f) fclose(f); + if (fd >= 0) close(fd); + if (old) free(old); + if (newp) free(newp); + return rc; +} diff --git a/ios/CodePush/bsdiff/ota_bspatch.h b/ios/CodePush/bsdiff/ota_bspatch.h new file mode 100644 index 000000000..bcd61446d --- /dev/null +++ b/ios/CodePush/bsdiff/ota_bspatch.h @@ -0,0 +1,17 @@ +/* ota_bspatch — apply a classic bsdiff (BSDIFF40) patch. + * Library refactor of bspatch.c (Colin Percival, BSD-2-Clause) — see ota_bspatch.c. + * Returns 0 on success, non-zero on any failure (never exits the process). */ +#ifndef OTA_BSPATCH_H +#define OTA_BSPATCH_H + +#ifdef __cplusplus +extern "C" { +#endif + +int ota_bspatch(const char *oldpath, const char *newpath, const char *patchpath); + +#ifdef __cplusplus +} +#endif + +#endif /* OTA_BSPATCH_H */ diff --git a/package.json b/package.json index 04a09dca2..3731bec2d 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "author": "Microsoft Corporation", "license": "MIT", "scripts": { + "postinstall": "patch-package", "clean": "shx rm -rf bin", "setup": "npm install --quiet --no-progress", "prebuild:tests": "npm run clean && npm run tslint", @@ -56,6 +57,7 @@ "express": "latest", "mkdirp": "latest", "mocha": "^9.2.0", + "patch-package": "^8.0.0", "q": "^1.5.1", "run-sequence": "latest", "shx": "^0.3.4", diff --git a/patches/README.md b/patches/README.md new file mode 100644 index 000000000..600dd3b2b --- /dev/null +++ b/patches/README.md @@ -0,0 +1,30 @@ +# Patches (patch-package) + +Applied automatically on install via the `postinstall: patch-package` script. + +## `code-push+4.2.2.patch` — forward optional update_check fields (FEAT-1/FEAT-2) + +**Why:** the upstream `code-push@4.2.2` acquisition SDK (`queryUpdateWithCurrentPackage`) +hand-builds `remotePackage` from only 8 known fields and **drops everything else**. +That silently discards the OTA server's optional response fields: + +- `is_diff`, `diff_url`, `source_hash` — FEAT-2 (differential bundle updates) +- `asset_manifest_url` — FEAT-1 (asset-decoupled bundles) + +This patch forwards those fields (as `isDiff`, `diffUrl`, `sourceHash`, `assetManifestUrl`) +so they reach `CodePush.js` and the native layer. It changes nothing for classic +responses — when the server doesn't send these fields they are simply `undefined`. + +**Scope:** query path only; `report_status/*` is untouched. + +> ⚠️ **Consumers (e.g. RenterApp) must carry an equivalent patch** for whichever +> `code-push` (or `@code-push-next/...` acquisition) version they resolve — patches +> do not propagate through npm publish. See the app's `patches/` / `.yarn/patches`. + +## This is a stopgap — a rewrite is PENDING + +Patching a transitive, archived dependency is intentionally temporary. The tracked +plan is to **own the acquisition query path in-repo** (no `code-push` dependency for +`update_check`), forwarding all fields by construction. See: +- `analysis`/`features/client-sdk-rewrite-tracker.md` → §4.1 scoped acquisition rewrite +- `features/ROADMAP.md` → "Client SDK acquisition rewrite (PENDING)" diff --git a/patches/code-push+4.2.2.patch b/patches/code-push+4.2.2.patch new file mode 100644 index 000000000..8f11b35f7 --- /dev/null +++ b/patches/code-push+4.2.2.patch @@ -0,0 +1,18 @@ +diff --git a/node_modules/code-push/script/acquisition-sdk.js b/node_modules/code-push/script/acquisition-sdk.js +index 0000000..1111111 100644 +--- a/node_modules/code-push/script/acquisition-sdk.js ++++ b/node_modules/code-push/script/acquisition-sdk.js +@@ -80,7 +80,12 @@ + isMandatory: updateInfo.is_mandatory, + packageHash: updateInfo.package_hash, + packageSize: updateInfo.package_size, +- downloadUrl: updateInfo.download_url ++ downloadUrl: updateInfo.download_url, ++ // OTA fork (FEAT-1/FEAT-2): forward optional fields the upstream SDK otherwise drops. ++ isDiff: updateInfo.is_diff, ++ diffUrl: updateInfo.diff_url, ++ sourceHash: updateInfo.source_hash, ++ assetManifestUrl: updateInfo.asset_manifest_url + }; + callback(/*error=*/ null, remotePackage); + }); diff --git a/request-fetch-adapter.js b/request-fetch-adapter.js index a3f2c46f1..9eaf19c2b 100644 --- a/request-fetch-adapter.js +++ b/request-fetch-adapter.js @@ -1,4 +1,5 @@ const packageJson = require("./package.json"); +const { appendClientCapabilities } = require("./client-capabilities"); module.exports = { async request(verb, url, requestBody, callback) { @@ -7,6 +8,11 @@ module.exports = { requestBody = null; } + // SHARED-S4: advertise this build's optional-feature capabilities on + // update_check requests. No-op (URL unchanged) when no capabilities are + // enabled, so classic behavior is preserved by default. + url = appendClientCapabilities(url); + const headers = { "Accept": "application/json", "Content-Type": "application/json",