diff --git a/android/app/build.gradle b/android/app/build.gradle index a1879ec..3b15201 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 90d4326..cd621f4 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 0bbe38c..db19e09 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,33 @@ 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(); + BinaryDiffPatcher.applyBinaryDiffPatches(diffManifest, new File(currentPackageFolderPath), new File(unzippedFolderPath), new File(newUpdateFolderPath)); + FileUtils.deleteDirectoryAtPath(new File(unzippedFolderPath, 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 2c90b85..da3a835 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,25 @@ 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(); - } + for (String fileNameToDelete : diffManifest.getDeletedFiles()) { + File fileToDelete = new File(newPackageFolderPath, fileNameToDelete); + 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 0000000..68cf1df --- /dev/null +++ b/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/BinaryDiffPatcher.kt @@ -0,0 +1,57 @@ +@file:JvmName("BinaryDiffPatcher") +package com.microsoft.codepush.react.diffpatch + +import java.io.File +import java.io.IOException + +// Always has a real relativePath: every throw site is inside applyBinaryDiffPatches, where the +// failure is tied to one specific manifest entry. A manifest-level failure (e.g. an unsupported +// manifest version) isn't this exception's concern. +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 0000000..679a7f3 --- /dev/null +++ b/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/DiffManifest.kt @@ -0,0 +1,55 @@ +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() + } + + 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 651a8c3..61b8243 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 0000000..8acaea0 --- /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 0000000..a0deca6 --- /dev/null +++ b/android/app/src/test/java/com/microsoft/codepush/react/diffpatch/BinaryDiffPatcherTest.kt @@ -0,0 +1,330 @@ +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() { + 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 } + + applyBinaryDiffPatches(manifest, currentPackageFolder, unzippedFolder, newUpdateFolder, applier) + + 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() { + 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 } + + 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() { + 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 } + + 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() { + 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 } + + try { + applyBinaryDiffPatches(manifest, currentPackageFolder, unzippedFolder, newUpdateFolder, applier) + fail("expected BinaryDiffApplyException") + } catch (e: BinaryDiffApplyException) { + assertEquals("index.android.bundle", e.relativePath) + } + } + + @Test + fun applyBinaryDiffPatches_unknownAlgo_throwsWithoutInvokingApplier() { + 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 } + + 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() { + 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 } + + 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() { + 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 } + + 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() { + 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 } + + 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" } + + 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 + } + + applyBinaryDiffPatches(manifest, currentPackageFolder, unzippedFolder, newUpdateFolder, applier) + + 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 0000000..de337ce --- /dev/null +++ b/android/app/src/test/java/com/microsoft/codepush/react/diffpatch/DiffManifestTest.kt @@ -0,0 +1,110 @@ +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() { + val json = JSONObject().put("deletedFiles", org.json.JSONArray(listOf("stale.js", "old/asset.png"))) + + val manifest = parseDiffManifest(json) + + assertEquals(1, manifest.version) + assertEquals(listOf("stale.js", "old/asset.png"), manifest.deletedFiles) + assertTrue(manifest.patchedFiles.isEmpty()) + } + + @Test + fun parseDiffManifest_missingDeletedFiles_defaultsToEmptyList() { + val json = JSONObject() + + val manifest = parseDiffManifest(json) + + assertEquals(1, manifest.version) + assertTrue(manifest.deletedFiles.isEmpty()) + assertTrue(manifest.patchedFiles.isEmpty()) + } + + @Test + fun parseDiffManifest_v2Shape_parsesMultiplePatchedFilesEntries() { + 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() + ) + + val manifest = parseDiffManifest(json) + + 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() { + val json = JSONObject().put("version", 2).put("deletedFiles", org.json.JSONArray()) + + val manifest = parseDiffManifest(json) + + assertEquals(2, manifest.version) + assertTrue(manifest.patchedFiles.isEmpty()) + } + + @Test(expected = org.json.JSONException::class) + fun parseDiffManifest_patchedFileEntryMissingRequiredField_throws() { + val json = JSONObject( + """ + { + "version": 2, + "patchedFiles": { + "relative/path.js": { + "algo": "bsdiff", + "baseHash": "base-hash-1" + } + } + } + """.trimIndent() + ) + + 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 0000000..9b76e1b --- /dev/null +++ b/android/app/src/test/java/com/microsoft/codepush/react/diffpatch/Sha256Test.kt @@ -0,0 +1,53 @@ +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() { + val file = tempFolder.newFile("empty.dat") + + val hash = sha256Hex(file) + + // SHA-256 of the empty byte sequence, a widely published constant. + assertEquals("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", hash) + } + + @Test + fun sha256Hex_knownBytes_matchesKnownHash() { + val file = tempFolder.newFile("abc.dat").apply { writeBytes("abc".toByteArray()) } + + val hash = sha256Hex(file) + + // SHA-256("abc"), a widely published constant. + assertEquals("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", hash) + } + + @Test + fun sha256Hex_isZeroPaddedToSixtyFourLowercaseHexChars() { + val file = tempFolder.newFile("small.dat").apply { writeBytes(byteArrayOf(0)) } + + val hash = sha256Hex(file) + + assertEquals(64, hash.length) + assertEquals(hash.lowercase(), hash) + } + + @Test + fun sha256Hex_fileAndInputStreamOverloads_agree() { + val file = tempFolder.newFile("agree.dat").apply { writeBytes("some content".toByteArray()) } + + val fromFile = sha256Hex(file) + val fromStream = file.inputStream().use { sha256Hex(it) } + + 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 0000000..54241f8 --- /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 0000000..b466770 --- /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 0000000..a0e9d31 Binary files /dev/null and b/android/app/src/test/resources/binarydiff/basic/patch.bsdiff differ