From be03110ff702b6459c8d0ce5944c6454d790c12c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliv=C3=A9r=20Falvai?= Date: Fri, 21 Aug 2026 15:19:59 +0200 Subject: [PATCH] Android: apply bsdiff patches during package install --- android/app/build.gradle | 1 + .../codepush/react/CodePushConstants.java | 2 + .../codepush/react/CodePushUpdateManager.java | 29 +- .../codepush/react/CodePushUpdateUtils.java | 55 +-- .../react/diffpatch/BinaryDiffPatcher.kt | 54 +++ .../codepush/react/diffpatch/DiffManifest.kt | 59 +++ .../codepush/react/diffpatch/DiffPatch.kt | 14 + .../codepush/react/diffpatch/Sha256.kt | 21 ++ .../react/diffpatch/BinaryDiffPatcherTest.kt | 350 ++++++++++++++++++ .../react/diffpatch/DiffManifestTest.kt | 147 ++++++++ .../codepush/react/diffpatch/Sha256Test.kt | 65 ++++ .../test/resources/binarydiff/basic/new.dat | 25 ++ .../test/resources/binarydiff/basic/old.dat | 19 + .../resources/binarydiff/basic/patch.bsdiff | Bin 0 -> 286 bytes 14 files changed, 802 insertions(+), 39 deletions(-) create mode 100644 android/app/src/main/java/com/microsoft/codepush/react/diffpatch/BinaryDiffPatcher.kt create mode 100644 android/app/src/main/java/com/microsoft/codepush/react/diffpatch/DiffManifest.kt create mode 100644 android/app/src/main/java/com/microsoft/codepush/react/diffpatch/Sha256.kt create mode 100644 android/app/src/test/java/com/microsoft/codepush/react/diffpatch/BinaryDiffPatcherTest.kt create mode 100644 android/app/src/test/java/com/microsoft/codepush/react/diffpatch/DiffManifestTest.kt create mode 100644 android/app/src/test/java/com/microsoft/codepush/react/diffpatch/Sha256Test.kt create mode 100644 android/app/src/test/resources/binarydiff/basic/new.dat create mode 100644 android/app/src/test/resources/binarydiff/basic/old.dat create mode 100644 android/app/src/test/resources/binarydiff/basic/patch.bsdiff diff --git a/android/app/build.gradle b/android/app/build.gradle index a1879ecc..3b152015 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -96,6 +96,7 @@ dependencies { implementation 'com.nimbusds:nimbus-jose-jwt:9.37.3' testImplementation 'junit:junit:4.13.2' + testImplementation 'org.json:json:20231013' androidTestImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.2.1' diff --git a/android/app/src/main/java/com/microsoft/codepush/react/CodePushConstants.java b/android/app/src/main/java/com/microsoft/codepush/react/CodePushConstants.java index 90d43263..cd621f4a 100644 --- a/android/app/src/main/java/com/microsoft/codepush/react/CodePushConstants.java +++ b/android/app/src/main/java/com/microsoft/codepush/react/CodePushConstants.java @@ -10,6 +10,8 @@ public class CodePushConstants { public static final String CURRENT_PACKAGE_KEY = "currentPackage"; public static final String DEFAULT_JS_BUNDLE_NAME = "index.android.bundle"; public static final String DIFF_MANIFEST_FILE_NAME = "hotcodepush.json"; + // Folder within the update ZIP that contains the diff patches. Must be in sync with server-side impl. + public static final String DIFF_PATCHES_FOLDER_NAME = "__hcp_patches"; public static final int DOWNLOAD_BUFFER_SIZE = 1024 * 256; public static final String DOWNLOAD_FILE_NAME = "download.zip"; public static final String DOWNLOAD_PROGRESS_EVENT_NAME = "CodePushDownloadProgress"; 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 0bbe38cb..2a99893c 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 @@ -2,6 +2,11 @@ import android.os.Build; +import com.microsoft.codepush.react.diffpatch.BinaryDiffPatcher; +import com.microsoft.codepush.react.diffpatch.DiffManifest; +import com.microsoft.codepush.react.diffpatch.DiffManifestKt; + +import org.json.JSONException; import org.json.JSONObject; import java.io.BufferedInputStream; @@ -237,14 +242,36 @@ public void downloadPackage(JSONObject updatePackage, String expectedBundleFileN String diffManifestFilePath = CodePushUtils.appendPathComponent(unzippedFolderPath, CodePushConstants.DIFF_MANIFEST_FILE_NAME); boolean isDiffUpdate = FileUtils.fileAtPathExists(diffManifestFilePath); + DiffManifest diffManifest = null; if (isDiffUpdate) { + try { + diffManifest = DiffManifestKt.parseDiffManifest(CodePushUtils.getJsonObjectFromFile(diffManifestFilePath)); + } catch (JSONException e) { + throw new CodePushMalformedDataException(diffManifestFilePath, e); + } String currentPackageFolderPath = getCurrentPackageFolderPath(); - CodePushUpdateUtils.copyNecessaryFilesFromCurrentPackage(diffManifestFilePath, currentPackageFolderPath, newUpdateFolderPath); + CodePushUpdateUtils.copyNecessaryFilesFromCurrentPackage(diffManifest, currentPackageFolderPath, newUpdateFolderPath); File diffManifestFile = new File(diffManifestFilePath); diffManifestFile.delete(); } FileUtils.copyDirectoryContents(unzippedFolderPath, newUpdateFolderPath); + + if (isDiffUpdate) { + // Run patching after copyNecessaryFilesFromCurrentPackage() so patched output overwrites + // bytes copied in from the old package at the same paths. + if (diffManifest.getVersion() == 2) { + String currentPackageFolderPath = getCurrentPackageFolderPath(); + if (currentPackageFolderPath == null) { + throw new CodePushInvalidUpdateException("Received a binary diff update, but no currently installed package exists to diff against (this is likely the first CodePush update for this app install). Diffing against the embedded app binary is not yet supported."); + } + BinaryDiffPatcher.applyBinaryDiffPatches(diffManifest, new File(currentPackageFolderPath), new File(unzippedFolderPath), new File(newUpdateFolderPath)); + FileUtils.deleteDirectoryAtPath(new File(newUpdateFolderPath, CodePushConstants.DIFF_PATCHES_FOLDER_NAME).getPath()); + } else if (diffManifest.getVersion() > 2) { + throw new IOException("Diff manifest version " + diffManifest.getVersion() + " is not supported by this SDK version."); + } + } + FileUtils.deleteFileAtPathSilently(unzippedFolderPath); // For zip updates, we need to find the relative path to the jsBundle and save it in the diff --git a/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateUtils.java b/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateUtils.java index 2c90b851..4aaac8b4 100644 --- a/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateUtils.java +++ b/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateUtils.java @@ -3,6 +3,9 @@ import android.content.Context; import android.util.Base64; +import com.microsoft.codepush.react.diffpatch.DiffManifest; +import com.microsoft.codepush.react.diffpatch.Sha256; + import com.nimbusds.jose.JWSVerifier; import com.nimbusds.jose.crypto.RSASSAVerifier; import com.nimbusds.jwt.SignedJWT; @@ -10,8 +13,6 @@ import java.security.interfaces.*; import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; import java.io.ByteArrayInputStream; import java.io.File; @@ -19,10 +20,7 @@ import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; -import java.security.DigestInputStream; import java.security.KeyFactory; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; import java.security.PublicKey; import java.security.spec.X509EncodedKeySpec; import java.util.ArrayList; @@ -73,51 +71,32 @@ private static void addContentsOfFolderToManifest(String folderPath, String path } private static String computeHash(InputStream dataStream) { - MessageDigest messageDigest = null; - DigestInputStream digestInputStream = null; try { - messageDigest = MessageDigest.getInstance("SHA-256"); - digestInputStream = new DigestInputStream(dataStream, messageDigest); - byte[] byteBuffer = new byte[1024 * 8]; - while (digestInputStream.read(byteBuffer) != -1) ; - } catch (NoSuchAlgorithmException | IOException e) { + return Sha256.sha256Hex(dataStream); + } catch (Exception e) { // Should not happen. throw new CodePushUnknownException("Unable to compute hash of update contents.", e); - } finally { - try { - if (digestInputStream != null) { - digestInputStream.close(); - } - if (dataStream != null) { - dataStream.close(); - } - } catch (IOException e) { - e.printStackTrace(); - } } - - byte[] hash = messageDigest.digest(); - return String.format("%064x", new java.math.BigInteger(1, hash)); } - public static void copyNecessaryFilesFromCurrentPackage(String diffManifestFilePath, String currentPackageFolderPath, String newPackageFolderPath) throws IOException { + public static void copyNecessaryFilesFromCurrentPackage(DiffManifest diffManifest, String currentPackageFolderPath, String newPackageFolderPath) throws IOException { if (currentPackageFolderPath == null || !new File(currentPackageFolderPath).exists()) { CodePushUtils.log("Unable to copy files from current package during diff update, because currentPackageFolderPath is invalid."); return; } FileUtils.copyDirectoryContents(currentPackageFolderPath, newPackageFolderPath); - JSONObject diffManifest = CodePushUtils.getJsonObjectFromFile(diffManifestFilePath); - try { - JSONArray deletedFiles = diffManifest.getJSONArray("deletedFiles"); - for (int i = 0; i < deletedFiles.length(); i++) { - String fileNameToDelete = deletedFiles.getString(i); - File fileToDelete = new File(newPackageFolderPath, fileNameToDelete); - if (fileToDelete.exists()) { - fileToDelete.delete(); - } + File newPackageFolderCanonical = new File(newPackageFolderPath).getCanonicalFile(); + for (String fileNameToDelete : diffManifest.getDeletedFiles()) { + // deletedFiles comes from the update's diff manifest, so treat it as untrusted: reject any + // entry (e.g. "../../etc/passwd") that would resolve outside newPackageFolderPath. + File fileToDelete = new File(newPackageFolderPath, fileNameToDelete).getCanonicalFile(); + if (!fileToDelete.equals(newPackageFolderCanonical) + && !fileToDelete.getPath().startsWith(newPackageFolderCanonical.getPath() + File.separator)) { + throw new CodePushInvalidUpdateException("Diff manifest deletedFiles entry \"" + fileNameToDelete + "\" escapes the update package directory."); + } + if (fileToDelete.exists()) { + fileToDelete.delete(); } - } catch (JSONException e) { - throw new CodePushUnknownException("Unable to copy files from current package during diff update", e); } } diff --git a/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/BinaryDiffPatcher.kt b/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/BinaryDiffPatcher.kt new file mode 100644 index 00000000..756a4079 --- /dev/null +++ b/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/BinaryDiffPatcher.kt @@ -0,0 +1,54 @@ +@file:JvmName("BinaryDiffPatcher") +package com.microsoft.codepush.react.diffpatch + +import java.io.File +import java.io.IOException + +class BinaryDiffApplyException(val relativePath: String, reason: String) : + IOException("Failed to apply binary diff patch for \"$relativePath\": $reason") + +@JvmOverloads +fun applyBinaryDiffPatches( + manifest: DiffManifest, + currentPackageFolder: File, + unzippedFolder: File, + newUpdateFolder: File, + patchApplier: PatchApplier = NativeBsdiffPatchApplier, +) { + for ((relativePath, entry) in manifest.patchedFiles) { + if (entry.algo != "bsdiff") { + throw BinaryDiffApplyException(relativePath, "unsupported patch algorithm: ${entry.algo}") + } + } + + for ((relativePath, entry) in manifest.patchedFiles) { + val oldFile = resolveWithin(currentPackageFolder, relativePath) + if (sha256Hex(oldFile) != entry.baseHash) { + throw BinaryDiffApplyException(relativePath, "baseHash mismatch") + } + + val diffFile = resolveWithin(unzippedFolder, entry.patch) + val newFile = resolveWithin(newUpdateFolder, relativePath).apply { parentFile?.mkdirs() } + + val result = patchApplier.apply(oldFile, diffFile, newFile) + if (result != DiffPatch.PatchResult.OK) { + throw BinaryDiffApplyException(relativePath, "patch failed: $result") + } + + if (sha256Hex(newFile) != entry.targetHash) { + throw BinaryDiffApplyException(relativePath, "targetHash mismatch") + } + } +} + +// Manifest-supplied paths come from the update's JSON, so we treat them as untrusted. +// Resolve them strictly under `base` and reject anything ("../../etc", an absolute path) that would otherwise +// let a manifest entry read or write outside the package/patch folders. +private fun resolveWithin(base: File, relativePath: String): File { + val baseCanonical = base.canonicalFile + val resolved = File(base, relativePath).canonicalFile + if (resolved != baseCanonical && !resolved.path.startsWith(baseCanonical.path + File.separator)) { + throw BinaryDiffApplyException(relativePath, "path escapes expected directory") + } + return resolved +} diff --git a/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/DiffManifest.kt b/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/DiffManifest.kt new file mode 100644 index 00000000..b45f9a3d --- /dev/null +++ b/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/DiffManifest.kt @@ -0,0 +1,59 @@ +package com.microsoft.codepush.react.diffpatch + +import org.json.JSONException +import org.json.JSONObject + +data class PatchedFileEntry( + // The only value this client understands at the moment is "bsdiff". + val algo: String, + // SHA-256 hex of the file's content in the currently installed package + // Should be checked before patching. + val baseHash: String, + // SHA-256 hex the patched output must match, should be checked after patching. + val targetHash: String, + // Zip-relative path to the patch file, under the reserved prefix (CodePushConstants.DIFF_PATCHES_FOLDER_NAME). + val patch: String, +) + +data class DiffManifest( + // No version field, or version 1: original format, file-by-file patching only. + // Version 2: adds support for binary diff patching. + val version: Int, + // Relative paths, from the old package, to delete rather than carry over into the new one. + val deletedFiles: List, + // Map key: file's relative path in the package being installed. + val patchedFiles: Map, +) + +@Throws(JSONException::class) +fun parseDiffManifest(json: JSONObject): DiffManifest { + val version = if (json.has("version")) json.getInt("version") else 1 + + val deletedFilesJson = json.optJSONArray("deletedFiles") + val deletedFiles = if (deletedFilesJson != null) { + (0 until deletedFilesJson.length()).map { deletedFilesJson.getString(it) } + } else { + emptyList() + } + + val patchedFilesJson = json.optJSONObject("patchedFiles") + val patchedFiles = if (patchedFilesJson != null) { + patchedFilesJson.keys().asSequence().associateWith { relativePath -> + val entry = patchedFilesJson.getJSONObject(relativePath) + PatchedFileEntry( + algo = entry.getString("algo"), + baseHash = entry.getString("baseHash"), + targetHash = entry.getString("targetHash"), + patch = entry.getString("patch"), + ) + } + } else { + emptyMap() + } + + if (version != 2 && patchedFiles.isNotEmpty()) { + throw JSONException("Diff manifest declares version $version but contains patchedFiles, which requires version 2.") + } + + return DiffManifest(version = version, deletedFiles = deletedFiles, patchedFiles = patchedFiles) +} diff --git a/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/DiffPatch.kt b/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/DiffPatch.kt index 651a8c3e..61b8243b 100644 --- a/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/DiffPatch.kt +++ b/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/DiffPatch.kt @@ -1,5 +1,19 @@ package com.microsoft.codepush.react.diffpatch +import java.io.File + +// Purposes of this interface: +// 1. Allows unit testing the business logic by substituting a fake PatchApplier. +// 2. Allows the SDK to support multiple patching algorithms in the future, if we ever need to. +interface PatchApplier { + fun apply(oldFile: File, diffFile: File, newFile: File): DiffPatch.PatchResult +} + +object NativeBsdiffPatchApplier : PatchApplier { + override fun apply(oldFile: File, diffFile: File, newFile: File) = + DiffPatch.applyPatch(oldFile.path, diffFile.path, newFile.path) +} + object DiffPatch { /** diff --git a/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/Sha256.kt b/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/Sha256.kt new file mode 100644 index 00000000..8acaea09 --- /dev/null +++ b/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/Sha256.kt @@ -0,0 +1,21 @@ +@file:JvmName("Sha256") +package com.microsoft.codepush.react.diffpatch + +import java.io.File +import java.io.InputStream +import java.math.BigInteger +import java.security.DigestInputStream +import java.security.MessageDigest + +fun sha256Hex(file: File): String = file.inputStream().use { sha256Hex(it) } + +fun sha256Hex(inputStream: InputStream): String { + val messageDigest = MessageDigest.getInstance("SHA-256") + DigestInputStream(inputStream, messageDigest).use { digestInputStream -> + val buffer = ByteArray(1024 * 8) + while (digestInputStream.read(buffer) != -1) { + // Drain the stream; DigestInputStream updates the digest as a side effect. + } + } + return String.format("%064x", BigInteger(1, messageDigest.digest())) +} diff --git a/android/app/src/test/java/com/microsoft/codepush/react/diffpatch/BinaryDiffPatcherTest.kt b/android/app/src/test/java/com/microsoft/codepush/react/diffpatch/BinaryDiffPatcherTest.kt new file mode 100644 index 00000000..d094f293 --- /dev/null +++ b/android/app/src/test/java/com/microsoft/codepush/react/diffpatch/BinaryDiffPatcherTest.kt @@ -0,0 +1,350 @@ +package com.microsoft.codepush.react.diffpatch + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +private class FakePatchApplier(private val apply: (File, File, File) -> DiffPatch.PatchResult) : PatchApplier { + var invocationCount = 0 + private set + + override fun apply(oldFile: File, diffFile: File, newFile: File): DiffPatch.PatchResult { + invocationCount++ + return apply.invoke(oldFile, diffFile, newFile) + } +} + +class BinaryDiffPatcherTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private fun manifestOf(patchedFiles: Map) = + DiffManifest(version = 2, deletedFiles = emptyList(), patchedFiles = patchedFiles) + + @Test + fun applyBinaryDiffPatches_happyPath_writesPatchedFileAtRightPath() { + // Given + val currentPackageFolder = tempFolder.newFolder("current") + val oldFile = File(currentPackageFolder, "index.android.bundle").apply { + parentFile?.mkdirs() + writeText("old hermes bytecode contents") + } + val unzippedFolder = tempFolder.newFolder("unzipped") + val diffFile = File(unzippedFolder, "__hcp_patches/index.android.bundle").apply { + parentFile?.mkdirs() + writeText("fake diff bytes") + } + val newUpdateFolder = tempFolder.newFolder("newUpdate") + val patchedBytes = "new hermes bytecode contents".toByteArray() + + val manifest = manifestOf( + mapOf( + "index.android.bundle" to PatchedFileEntry( + algo = "bsdiff", + baseHash = sha256Hex(oldFile), + targetHash = sha256Hex(patchedBytes.inputStream()), + patch = "__hcp_patches/index.android.bundle", + ) + ) + ) + val applier = FakePatchApplier { _, _, newFile -> newFile.writeBytes(patchedBytes); DiffPatch.PatchResult.OK } + + // When + applyBinaryDiffPatches(manifest, currentPackageFolder, unzippedFolder, newUpdateFolder, applier) + + // Then + val newFile = File(newUpdateFolder, "index.android.bundle") + assertTrue(newFile.exists()) + assertEquals("new hermes bytecode contents", newFile.readText()) + assertEquals(1, applier.invocationCount) + } + + @Test + fun applyBinaryDiffPatches_baseHashMismatch_throwsWithoutInvokingApplier() { + // Given + val currentPackageFolder = tempFolder.newFolder("current") + File(currentPackageFolder, "index.android.bundle").apply { + parentFile?.mkdirs() + writeText("old hermes bytecode contents") + } + val unzippedFolder = tempFolder.newFolder("unzipped") + val newUpdateFolder = tempFolder.newFolder("newUpdate") + + val manifest = manifestOf( + mapOf( + "index.android.bundle" to PatchedFileEntry( + algo = "bsdiff", + baseHash = "wrong-hash", + targetHash = "irrelevant", + patch = "__hcp_patches/index.android.bundle", + ) + ) + ) + val applier = FakePatchApplier { _, _, _ -> DiffPatch.PatchResult.OK } + + // When / Then + try { + applyBinaryDiffPatches(manifest, currentPackageFolder, unzippedFolder, newUpdateFolder, applier) + fail("expected BinaryDiffApplyException") + } catch (e: BinaryDiffApplyException) { + assertEquals("index.android.bundle", e.relativePath) + } + assertEquals(0, applier.invocationCount) + } + + @Test + fun applyBinaryDiffPatches_applierReturnsNonOk_throws() { + // Given + val currentPackageFolder = tempFolder.newFolder("current") + val oldFile = File(currentPackageFolder, "index.android.bundle").apply { + parentFile?.mkdirs() + writeText("old hermes bytecode contents") + } + val unzippedFolder = tempFolder.newFolder("unzipped") + val newUpdateFolder = tempFolder.newFolder("newUpdate") + + val manifest = manifestOf( + mapOf( + "index.android.bundle" to PatchedFileEntry( + algo = "bsdiff", + baseHash = sha256Hex(oldFile), + targetHash = "irrelevant", + patch = "__hcp_patches/index.android.bundle", + ) + ) + ) + val applier = FakePatchApplier { _, _, _ -> DiffPatch.PatchResult.PATCH_FAILED } + + // When / Then + try { + applyBinaryDiffPatches(manifest, currentPackageFolder, unzippedFolder, newUpdateFolder, applier) + fail("expected BinaryDiffApplyException") + } catch (e: BinaryDiffApplyException) { + assertEquals("index.android.bundle", e.relativePath) + } + assertEquals(1, applier.invocationCount) + } + + @Test + fun applyBinaryDiffPatches_targetHashMismatchAfterSuccessfulApply_throws() { + // Given + val currentPackageFolder = tempFolder.newFolder("current") + val oldFile = File(currentPackageFolder, "index.android.bundle").apply { + parentFile?.mkdirs() + writeText("old hermes bytecode contents") + } + val unzippedFolder = tempFolder.newFolder("unzipped") + val newUpdateFolder = tempFolder.newFolder("newUpdate") + + val manifest = manifestOf( + mapOf( + "index.android.bundle" to PatchedFileEntry( + algo = "bsdiff", + baseHash = sha256Hex(oldFile), + targetHash = "wrong-target-hash", + patch = "__hcp_patches/index.android.bundle", + ) + ) + ) + val applier = FakePatchApplier { _, _, newFile -> newFile.writeText("actual output"); DiffPatch.PatchResult.OK } + + // When / Then + try { + applyBinaryDiffPatches(manifest, currentPackageFolder, unzippedFolder, newUpdateFolder, applier) + fail("expected BinaryDiffApplyException") + } catch (e: BinaryDiffApplyException) { + assertEquals("index.android.bundle", e.relativePath) + } + } + + @Test + fun applyBinaryDiffPatches_unknownAlgo_throwsWithoutInvokingApplier() { + // Given + val currentPackageFolder = tempFolder.newFolder("current") + val unzippedFolder = tempFolder.newFolder("unzipped") + val newUpdateFolder = tempFolder.newFolder("newUpdate") + + val manifest = manifestOf( + mapOf( + "index.android.bundle" to PatchedFileEntry( + algo = "some-other-algo", + baseHash = "irrelevant", + targetHash = "irrelevant", + patch = "__hcp_patches/index.android.bundle", + ) + ) + ) + val applier = FakePatchApplier { _, _, _ -> DiffPatch.PatchResult.OK } + + // When / Then + try { + applyBinaryDiffPatches(manifest, currentPackageFolder, unzippedFolder, newUpdateFolder, applier) + fail("expected BinaryDiffApplyException") + } catch (e: BinaryDiffApplyException) { + assertEquals("index.android.bundle", e.relativePath) + } + assertEquals(0, applier.invocationCount) + } + + @Test + fun applyBinaryDiffPatches_oneOfMultipleEntriesFails_wholeInstallAborts() { + // Given + val currentPackageFolder = tempFolder.newFolder("current") + val goodOldFile = File(currentPackageFolder, "index.android.bundle").apply { writeText("good old hermes bytecode") } + val badOldFile = File(currentPackageFolder, "assets/drawable-mdpi/ic_launcher.png").apply { + parentFile?.mkdirs() + writeText("bad old") + } + val unzippedFolder = tempFolder.newFolder("unzipped") + val newUpdateFolder = tempFolder.newFolder("newUpdate") + + val manifest = manifestOf( + mapOf( + "index.android.bundle" to PatchedFileEntry( + algo = "bsdiff", + baseHash = sha256Hex(goodOldFile), + targetHash = sha256Hex("good new hermes bytecode".toByteArray().inputStream()), + patch = "__hcp_patches/index.android.bundle", + ), + "assets/drawable-mdpi/ic_launcher.png" to PatchedFileEntry( + algo = "bsdiff", + baseHash = sha256Hex(badOldFile), + targetHash = "wrong-target-hash", + patch = "__hcp_patches/assets/drawable-mdpi/ic_launcher.png", + ), + ) + ) + val applier = FakePatchApplier { _, _, newFile -> newFile.writeText("good new hermes bytecode"); DiffPatch.PatchResult.OK } + + // When / Then + try { + applyBinaryDiffPatches(manifest, currentPackageFolder, unzippedFolder, newUpdateFolder, applier) + fail("expected BinaryDiffApplyException") + } catch (e: BinaryDiffApplyException) { + // one of the two entries is expected to fail; which one depends on map iteration order + assertTrue(e.relativePath == "index.android.bundle" || e.relativePath == "assets/drawable-mdpi/ic_launcher.png") + } + } + + @Test + fun applyBinaryDiffPatches_relativePathEscapesCurrentPackageFolder_throwsWithoutInvokingApplier() { + // Given + val currentPackageFolder = tempFolder.newFolder("current") + val unzippedFolder = tempFolder.newFolder("unzipped") + val newUpdateFolder = tempFolder.newFolder("newUpdate") + val secret = File(tempFolder.root, "secret.bundle").apply { writeText("outside the package folder") } + + val manifest = manifestOf( + mapOf( + "../secret.bundle" to PatchedFileEntry( + algo = "bsdiff", + baseHash = sha256Hex(secret), + targetHash = "irrelevant", + patch = "__hcp_patches/secret.bundle", + ) + ) + ) + val applier = FakePatchApplier { _, _, _ -> DiffPatch.PatchResult.OK } + + // When / Then + try { + applyBinaryDiffPatches(manifest, currentPackageFolder, unzippedFolder, newUpdateFolder, applier) + fail("expected BinaryDiffApplyException") + } catch (e: BinaryDiffApplyException) { + assertEquals("../secret.bundle", e.relativePath) + } + assertEquals(0, applier.invocationCount) + } + + @Test + fun applyBinaryDiffPatches_patchFieldEscapesUnzippedFolder_throwsWithoutInvokingApplier() { + // Given + val currentPackageFolder = tempFolder.newFolder("current") + val oldFile = File(currentPackageFolder, "index.android.bundle").apply { + parentFile?.mkdirs() + writeText("old hermes bytecode contents") + } + val unzippedFolder = tempFolder.newFolder("unzipped") + val newUpdateFolder = tempFolder.newFolder("newUpdate") + File(tempFolder.root, "outside.bsdiff").writeText("fake diff bytes") + + val manifest = manifestOf( + mapOf( + "index.android.bundle" to PatchedFileEntry( + algo = "bsdiff", + baseHash = sha256Hex(oldFile), + targetHash = "irrelevant", + patch = "../outside.bsdiff", + ) + ) + ) + val applier = FakePatchApplier { _, _, _ -> DiffPatch.PatchResult.OK } + + // When / Then + try { + applyBinaryDiffPatches(manifest, currentPackageFolder, unzippedFolder, newUpdateFolder, applier) + fail("expected BinaryDiffApplyException") + } catch (e: BinaryDiffApplyException) { + assertEquals("../outside.bsdiff", e.relativePath) + } + assertEquals(0, applier.invocationCount) + } + + @Test + fun applyBinaryDiffPatches_realBsdiffFixtureShape_appliesSuccessfully() { + fun fixture(name: String) = + checkNotNull(javaClass.getResourceAsStream("/binarydiff/basic/$name")) { "missing fixture $name" } + + // Given + val currentPackageFolder = tempFolder.newFolder("current") + val oldFile = File(currentPackageFolder, "index.android.bundle").apply { + parentFile?.mkdirs() + fixture("old.dat").use { input -> outputStream().use { input.copyTo(it) } } + } + val unzippedFolder = tempFolder.newFolder("unzipped") + File(unzippedFolder, "__hcp_patches/index.android.bundle").apply { + parentFile?.mkdirs() + fixture("patch.bsdiff").use { input -> outputStream().use { input.copyTo(it) } } + } + val expectedNewBytes = fixture("new.dat").use { it.readBytes() } + val newUpdateFolder = tempFolder.newFolder("newUpdate") + + val manifestJson = JSONObject( + """ + { + "version": 2, + "deletedFiles": [], + "patchedFiles": { + "index.android.bundle": { + "algo": "bsdiff", + "baseHash": "${sha256Hex(oldFile)}", + "targetHash": "${sha256Hex(expectedNewBytes.inputStream())}", + "patch": "__hcp_patches/index.android.bundle" + } + } + } + """.trimIndent() + ) + val manifest = parseDiffManifest(manifestJson) + + val applier = FakePatchApplier { _, diffFile, newFile -> + assertTrue("diff file should exist at the manifest-resolved path", diffFile.exists()) + newFile.writeBytes(expectedNewBytes) + DiffPatch.PatchResult.OK + } + + // When + applyBinaryDiffPatches(manifest, currentPackageFolder, unzippedFolder, newUpdateFolder, applier) + + // Then + val newFile = File(newUpdateFolder, "index.android.bundle") + assertTrue(newFile.exists()) + assertTrue(expectedNewBytes.contentEquals(newFile.readBytes())) + } +} diff --git a/android/app/src/test/java/com/microsoft/codepush/react/diffpatch/DiffManifestTest.kt b/android/app/src/test/java/com/microsoft/codepush/react/diffpatch/DiffManifestTest.kt new file mode 100644 index 00000000..f363c4be --- /dev/null +++ b/android/app/src/test/java/com/microsoft/codepush/react/diffpatch/DiffManifestTest.kt @@ -0,0 +1,147 @@ +package com.microsoft.codepush.react.diffpatch + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DiffManifestTest { + + @Test + fun parseDiffManifest_v1Shape_defaultsVersionToOneAndPatchedFilesToEmpty() { + // Given + val json = JSONObject().put("deletedFiles", org.json.JSONArray(listOf("stale.js", "old/asset.png"))) + + // When + val manifest = parseDiffManifest(json) + + // Then + assertEquals(1, manifest.version) + assertEquals(listOf("stale.js", "old/asset.png"), manifest.deletedFiles) + assertTrue(manifest.patchedFiles.isEmpty()) + } + + @Test + fun parseDiffManifest_missingDeletedFiles_defaultsToEmptyList() { + // Given + val json = JSONObject() + + // When + val manifest = parseDiffManifest(json) + + // Then + assertEquals(1, manifest.version) + assertTrue(manifest.deletedFiles.isEmpty()) + assertTrue(manifest.patchedFiles.isEmpty()) + } + + @Test + fun parseDiffManifest_v2Shape_parsesMultiplePatchedFilesEntries() { + // Given + val json = JSONObject( + """ + { + "version": 2, + "deletedFiles": ["removed.js"], + "patchedFiles": { + "relative/path.js": { + "algo": "bsdiff", + "baseHash": "base-hash-1", + "targetHash": "target-hash-1", + "patch": "__hcp_patches/relative/path.js" + }, + "another/file.js": { + "algo": "bsdiff", + "baseHash": "base-hash-2", + "targetHash": "target-hash-2", + "patch": "__hcp_patches/another/file.js" + } + } + } + """.trimIndent() + ) + + // When + val manifest = parseDiffManifest(json) + + // Then + assertEquals(2, manifest.version) + assertEquals(listOf("removed.js"), manifest.deletedFiles) + assertEquals(2, manifest.patchedFiles.size) + assertEquals( + PatchedFileEntry( + algo = "bsdiff", + baseHash = "base-hash-1", + targetHash = "target-hash-1", + patch = "__hcp_patches/relative/path.js", + ), + manifest.patchedFiles["relative/path.js"], + ) + assertEquals( + PatchedFileEntry( + algo = "bsdiff", + baseHash = "base-hash-2", + targetHash = "target-hash-2", + patch = "__hcp_patches/another/file.js", + ), + manifest.patchedFiles["another/file.js"], + ) + } + + @Test + fun parseDiffManifest_missingPatchedFiles_defaultsToEmptyMap() { + // Given + val json = JSONObject().put("version", 2).put("deletedFiles", org.json.JSONArray()) + + // When + val manifest = parseDiffManifest(json) + + // Then + assertEquals(2, manifest.version) + assertTrue(manifest.patchedFiles.isEmpty()) + } + + @Test(expected = org.json.JSONException::class) + fun parseDiffManifest_v1ShapeWithPatchedFiles_throws() { + // Given + val json = JSONObject( + """ + { + "version": 1, + "patchedFiles": { + "relative/path.js": { + "algo": "bsdiff", + "baseHash": "base-hash-1", + "targetHash": "target-hash-1", + "patch": "__hcp_patches/relative/path.js" + } + } + } + """.trimIndent() + ) + + // When / Then (parseDiffManifest is expected to throw) + parseDiffManifest(json) + } + + @Test(expected = org.json.JSONException::class) + fun parseDiffManifest_patchedFileEntryMissingRequiredField_throws() { + // Given + val json = JSONObject( + """ + { + "version": 2, + "patchedFiles": { + "relative/path.js": { + "algo": "bsdiff", + "baseHash": "base-hash-1" + } + } + } + """.trimIndent() + ) + + // When / Then (parseDiffManifest is expected to throw) + parseDiffManifest(json) + } +} diff --git a/android/app/src/test/java/com/microsoft/codepush/react/diffpatch/Sha256Test.kt b/android/app/src/test/java/com/microsoft/codepush/react/diffpatch/Sha256Test.kt new file mode 100644 index 00000000..e73e2cc5 --- /dev/null +++ b/android/app/src/test/java/com/microsoft/codepush/react/diffpatch/Sha256Test.kt @@ -0,0 +1,65 @@ +package com.microsoft.codepush.react.diffpatch + +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class Sha256Test { + + @get:Rule + val tempFolder = TemporaryFolder() + + @Test + fun sha256Hex_emptyFile_matchesKnownHash() { + // Given + val file = tempFolder.newFile("empty.dat") + + // When + val hash = sha256Hex(file) + + // Then + // SHA-256 of the empty byte sequence, a widely published constant. + assertEquals("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", hash) + } + + @Test + fun sha256Hex_knownBytes_matchesKnownHash() { + // Given + val file = tempFolder.newFile("abc.dat").apply { writeBytes("abc".toByteArray()) } + + // When + val hash = sha256Hex(file) + + // Then + // SHA-256("abc"), a widely published constant. + assertEquals("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", hash) + } + + @Test + fun sha256Hex_isZeroPaddedToSixtyFourLowercaseHexChars() { + // Given + val file = tempFolder.newFile("small.dat").apply { writeBytes(byteArrayOf(0)) } + + // When + val hash = sha256Hex(file) + + // Then + assertEquals(64, hash.length) + assertEquals(hash.lowercase(), hash) + } + + @Test + fun sha256Hex_fileAndInputStreamOverloads_agree() { + // Given + val file = tempFolder.newFile("agree.dat").apply { writeBytes("some content".toByteArray()) } + + // When + val fromFile = sha256Hex(file) + val fromStream = file.inputStream().use { sha256Hex(it) } + + // Then + assertEquals(fromFile, fromStream) + } +} diff --git a/android/app/src/test/resources/binarydiff/basic/new.dat b/android/app/src/test/resources/binarydiff/basic/new.dat new file mode 100644 index 00000000..54241f83 --- /dev/null +++ b/android/app/src/test/resources/binarydiff/basic/new.dat @@ -0,0 +1,25 @@ +function greet(name) { + console.log("Hello there, " + name + "!"); + return "Hello there, " + name + "!"; +} + +function farewell(name) { + console.log("Goodbye, " + name + "."); + return "Goodbye, " + name + "."; +} + +function shout(name) { + console.log("HEY, " + name.toUpperCase() + "!!!"); + return "HEY, " + name.toUpperCase() + "!!!"; +} + +var VERSION = "1.1.0"; +var BUILD_NUMBER = 43; + +module.exports = { + greet: greet, + farewell: farewell, + shout: shout, + VERSION: VERSION, + BUILD_NUMBER: BUILD_NUMBER, +}; diff --git a/android/app/src/test/resources/binarydiff/basic/old.dat b/android/app/src/test/resources/binarydiff/basic/old.dat new file mode 100644 index 00000000..b4667705 --- /dev/null +++ b/android/app/src/test/resources/binarydiff/basic/old.dat @@ -0,0 +1,19 @@ +function greet(name) { + console.log("Hello, " + name + "!"); + return "Hello, " + name + "!"; +} + +function farewell(name) { + console.log("Goodbye, " + name + "."); + return "Goodbye, " + name + "."; +} + +var VERSION = "1.0.0"; +var BUILD_NUMBER = 42; + +module.exports = { + greet: greet, + farewell: farewell, + VERSION: VERSION, + BUILD_NUMBER: BUILD_NUMBER, +}; diff --git a/android/app/src/test/resources/binarydiff/basic/patch.bsdiff b/android/app/src/test/resources/binarydiff/basic/patch.bsdiff new file mode 100644 index 0000000000000000000000000000000000000000..a0e9d31a9dd94dbc834b8f28e9e1ddf006caddd6 GIT binary patch literal 286 zcmZ-g0ruJgVxesq9k6~_#bfRw3WQf|(V z-gzwN5@P>tc=!Rr=pQ^$T0rYwEO;Q|#K6d;z#zcD$)LcXWWtc)aKS`ebr*wl%bOPU zqjx6yiKy5yi*+?1^x7)^^I~9NlW!=RiQ_3Eu;WEBwbdXhQmSS#2XevkS| F4FDy1TXz5e literal 0 HcmV?d00001