Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,24 @@
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;

import java.security.interfaces.*;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
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;
Expand Down Expand Up @@ -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);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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<String>,
// Map key: file's relative path in the package being installed.
val patchedFiles: Map<String, PatchedFileEntry>,
)

@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)
}
Original file line number Diff line number Diff line change
@@ -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 {

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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()))
}
Loading