Skip to content

Commit 847a59d

Browse files
committed
Android: apply bsdiff patches during package install
1 parent 4e73f5a commit 847a59d

14 files changed

Lines changed: 768 additions & 39 deletions

File tree

android/app/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ dependencies {
9696
implementation 'com.nimbusds:nimbus-jose-jwt:9.37.3'
9797

9898
testImplementation 'junit:junit:4.13.2'
99+
testImplementation 'org.json:json:20231013'
99100

100101
androidTestImplementation 'junit:junit:4.13.2'
101102
androidTestImplementation 'androidx.test.ext:junit:1.2.1'

android/app/src/main/java/com/microsoft/codepush/react/CodePushConstants.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ public class CodePushConstants {
1010
public static final String CURRENT_PACKAGE_KEY = "currentPackage";
1111
public static final String DEFAULT_JS_BUNDLE_NAME = "index.android.bundle";
1212
public static final String DIFF_MANIFEST_FILE_NAME = "hotcodepush.json";
13+
// Folder within the update ZIP that contains the diff patches. Must be in sync with server-side impl.
14+
public static final String DIFF_PATCHES_FOLDER_NAME = "__hcp_patches";
1315
public static final int DOWNLOAD_BUFFER_SIZE = 1024 * 256;
1416
public static final String DOWNLOAD_FILE_NAME = "download.zip";
1517
public static final String DOWNLOAD_PROGRESS_EVENT_NAME = "CodePushDownloadProgress";

android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
import android.os.Build;
44

5+
import com.microsoft.codepush.react.diffpatch.BinaryDiffPatcher;
6+
import com.microsoft.codepush.react.diffpatch.DiffManifest;
7+
import com.microsoft.codepush.react.diffpatch.DiffManifestKt;
8+
9+
import org.json.JSONException;
510
import org.json.JSONObject;
611

712
import java.io.BufferedInputStream;
@@ -237,14 +242,36 @@ public void downloadPackage(JSONObject updatePackage, String expectedBundleFileN
237242
String diffManifestFilePath = CodePushUtils.appendPathComponent(unzippedFolderPath,
238243
CodePushConstants.DIFF_MANIFEST_FILE_NAME);
239244
boolean isDiffUpdate = FileUtils.fileAtPathExists(diffManifestFilePath);
245+
DiffManifest diffManifest = null;
240246
if (isDiffUpdate) {
247+
try {
248+
diffManifest = DiffManifestKt.parseDiffManifest(CodePushUtils.getJsonObjectFromFile(diffManifestFilePath));
249+
} catch (JSONException e) {
250+
throw new CodePushMalformedDataException(diffManifestFilePath, e);
251+
}
241252
String currentPackageFolderPath = getCurrentPackageFolderPath();
242-
CodePushUpdateUtils.copyNecessaryFilesFromCurrentPackage(diffManifestFilePath, currentPackageFolderPath, newUpdateFolderPath);
253+
CodePushUpdateUtils.copyNecessaryFilesFromCurrentPackage(diffManifest, currentPackageFolderPath, newUpdateFolderPath);
243254
File diffManifestFile = new File(diffManifestFilePath);
244255
diffManifestFile.delete();
245256
}
246257

247258
FileUtils.copyDirectoryContents(unzippedFolderPath, newUpdateFolderPath);
259+
260+
if (isDiffUpdate) {
261+
// Run patching after copyNecessaryFilesFromCurrentPackage() so patched output overwrites
262+
// bytes copied in from the old package at the same paths.
263+
if (diffManifest.getVersion() == 2) {
264+
String currentPackageFolderPath = getCurrentPackageFolderPath();
265+
if (currentPackageFolderPath == null) {
266+
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.");
267+
}
268+
BinaryDiffPatcher.applyBinaryDiffPatches(diffManifest, new File(currentPackageFolderPath), new File(unzippedFolderPath), new File(newUpdateFolderPath));
269+
FileUtils.deleteDirectoryAtPath(new File(newUpdateFolderPath, CodePushConstants.DIFF_PATCHES_FOLDER_NAME).getPath());
270+
} else if (diffManifest.getVersion() > 2) {
271+
throw new IOException("Diff manifest version " + diffManifest.getVersion() + " is not supported by this SDK version.");
272+
}
273+
}
274+
248275
FileUtils.deleteFileAtPathSilently(unzippedFolderPath);
249276

250277
// For zip updates, we need to find the relative path to the jsBundle and save it in the

android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateUtils.java

Lines changed: 10 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,24 @@
33
import android.content.Context;
44
import android.util.Base64;
55

6+
import com.microsoft.codepush.react.diffpatch.DiffManifest;
7+
import com.microsoft.codepush.react.diffpatch.Sha256;
8+
69
import com.nimbusds.jose.JWSVerifier;
710
import com.nimbusds.jose.crypto.RSASSAVerifier;
811
import com.nimbusds.jwt.SignedJWT;
912

1013
import java.security.interfaces.*;
1114

1215
import org.json.JSONArray;
13-
import org.json.JSONException;
14-
import org.json.JSONObject;
1516

1617
import java.io.ByteArrayInputStream;
1718
import java.io.File;
1819
import java.io.FileInputStream;
1920
import java.io.FileNotFoundException;
2021
import java.io.IOException;
2122
import java.io.InputStream;
22-
import java.security.DigestInputStream;
2323
import java.security.KeyFactory;
24-
import java.security.MessageDigest;
25-
import java.security.NoSuchAlgorithmException;
2624
import java.security.PublicKey;
2725
import java.security.spec.X509EncodedKeySpec;
2826
import java.util.ArrayList;
@@ -73,51 +71,25 @@ private static void addContentsOfFolderToManifest(String folderPath, String path
7371
}
7472

7573
private static String computeHash(InputStream dataStream) {
76-
MessageDigest messageDigest = null;
77-
DigestInputStream digestInputStream = null;
7874
try {
79-
messageDigest = MessageDigest.getInstance("SHA-256");
80-
digestInputStream = new DigestInputStream(dataStream, messageDigest);
81-
byte[] byteBuffer = new byte[1024 * 8];
82-
while (digestInputStream.read(byteBuffer) != -1) ;
83-
} catch (NoSuchAlgorithmException | IOException e) {
75+
return Sha256.sha256Hex(dataStream);
76+
} catch (Exception e) {
8477
// Should not happen.
8578
throw new CodePushUnknownException("Unable to compute hash of update contents.", e);
86-
} finally {
87-
try {
88-
if (digestInputStream != null) {
89-
digestInputStream.close();
90-
}
91-
if (dataStream != null) {
92-
dataStream.close();
93-
}
94-
} catch (IOException e) {
95-
e.printStackTrace();
96-
}
9779
}
98-
99-
byte[] hash = messageDigest.digest();
100-
return String.format("%064x", new java.math.BigInteger(1, hash));
10180
}
10281

103-
public static void copyNecessaryFilesFromCurrentPackage(String diffManifestFilePath, String currentPackageFolderPath, String newPackageFolderPath) throws IOException {
82+
public static void copyNecessaryFilesFromCurrentPackage(DiffManifest diffManifest, String currentPackageFolderPath, String newPackageFolderPath) throws IOException {
10483
if (currentPackageFolderPath == null || !new File(currentPackageFolderPath).exists()) {
10584
CodePushUtils.log("Unable to copy files from current package during diff update, because currentPackageFolderPath is invalid.");
10685
return;
10786
}
10887
FileUtils.copyDirectoryContents(currentPackageFolderPath, newPackageFolderPath);
109-
JSONObject diffManifest = CodePushUtils.getJsonObjectFromFile(diffManifestFilePath);
110-
try {
111-
JSONArray deletedFiles = diffManifest.getJSONArray("deletedFiles");
112-
for (int i = 0; i < deletedFiles.length(); i++) {
113-
String fileNameToDelete = deletedFiles.getString(i);
114-
File fileToDelete = new File(newPackageFolderPath, fileNameToDelete);
115-
if (fileToDelete.exists()) {
116-
fileToDelete.delete();
117-
}
88+
for (String fileNameToDelete : diffManifest.getDeletedFiles()) {
89+
File fileToDelete = new File(newPackageFolderPath, fileNameToDelete);
90+
if (fileToDelete.exists()) {
91+
fileToDelete.delete();
11892
}
119-
} catch (JSONException e) {
120-
throw new CodePushUnknownException("Unable to copy files from current package during diff update", e);
12193
}
12294
}
12395

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
@file:JvmName("BinaryDiffPatcher")
2+
package com.microsoft.codepush.react.diffpatch
3+
4+
import java.io.File
5+
import java.io.IOException
6+
7+
class BinaryDiffApplyException(val relativePath: String, reason: String) :
8+
IOException("Failed to apply binary diff patch for \"$relativePath\": $reason")
9+
10+
@JvmOverloads
11+
fun applyBinaryDiffPatches(
12+
manifest: DiffManifest,
13+
currentPackageFolder: File,
14+
unzippedFolder: File,
15+
newUpdateFolder: File,
16+
patchApplier: PatchApplier = NativeBsdiffPatchApplier,
17+
) {
18+
for ((relativePath, entry) in manifest.patchedFiles) {
19+
if (entry.algo != "bsdiff") {
20+
throw BinaryDiffApplyException(relativePath, "unsupported patch algorithm: ${entry.algo}")
21+
}
22+
}
23+
24+
for ((relativePath, entry) in manifest.patchedFiles) {
25+
val oldFile = resolveWithin(currentPackageFolder, relativePath)
26+
if (sha256Hex(oldFile) != entry.baseHash) {
27+
throw BinaryDiffApplyException(relativePath, "baseHash mismatch")
28+
}
29+
30+
val diffFile = resolveWithin(unzippedFolder, entry.patch)
31+
val newFile = resolveWithin(newUpdateFolder, relativePath).apply { parentFile?.mkdirs() }
32+
33+
val result = patchApplier.apply(oldFile, diffFile, newFile)
34+
if (result != DiffPatch.PatchResult.OK) {
35+
throw BinaryDiffApplyException(relativePath, "patch failed: $result")
36+
}
37+
38+
if (sha256Hex(newFile) != entry.targetHash) {
39+
throw BinaryDiffApplyException(relativePath, "targetHash mismatch")
40+
}
41+
}
42+
}
43+
44+
// Manifest-supplied paths come from the update's JSON, so we treat them as untrusted.
45+
// Resolve them strictly under `base` and reject anything ("../../etc", an absolute path) that would otherwise
46+
// let a manifest entry read or write outside the package/patch folders.
47+
private fun resolveWithin(base: File, relativePath: String): File {
48+
val baseCanonical = base.canonicalFile
49+
val resolved = File(base, relativePath).canonicalFile
50+
if (resolved != baseCanonical && !resolved.path.startsWith(baseCanonical.path + File.separator)) {
51+
throw BinaryDiffApplyException(relativePath, "path escapes expected directory")
52+
}
53+
return resolved
54+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package com.microsoft.codepush.react.diffpatch
2+
3+
import org.json.JSONException
4+
import org.json.JSONObject
5+
6+
data class PatchedFileEntry(
7+
// The only value this client understands at the moment is "bsdiff".
8+
val algo: String,
9+
// SHA-256 hex of the file's content in the currently installed package
10+
// Should be checked before patching.
11+
val baseHash: String,
12+
// SHA-256 hex the patched output must match, should be checked after patching.
13+
val targetHash: String,
14+
// Zip-relative path to the patch file, under the reserved prefix (CodePushConstants.DIFF_PATCHES_FOLDER_NAME).
15+
val patch: String,
16+
)
17+
18+
data class DiffManifest(
19+
// No version field, or version 1: original format, file-by-file patching only.
20+
// Version 2: adds support for binary diff patching.
21+
val version: Int,
22+
// Relative paths, from the old package, to delete rather than carry over into the new one.
23+
val deletedFiles: List<String>,
24+
// Map key: file's relative path in the package being installed.
25+
val patchedFiles: Map<String, PatchedFileEntry>,
26+
)
27+
28+
@Throws(JSONException::class)
29+
fun parseDiffManifest(json: JSONObject): DiffManifest {
30+
val version = if (json.has("version")) json.getInt("version") else 1
31+
32+
val deletedFilesJson = json.optJSONArray("deletedFiles")
33+
val deletedFiles = if (deletedFilesJson != null) {
34+
(0 until deletedFilesJson.length()).map { deletedFilesJson.getString(it) }
35+
} else {
36+
emptyList()
37+
}
38+
39+
val patchedFilesJson = json.optJSONObject("patchedFiles")
40+
val patchedFiles = if (patchedFilesJson != null) {
41+
patchedFilesJson.keys().asSequence().associateWith { relativePath ->
42+
val entry = patchedFilesJson.getJSONObject(relativePath)
43+
PatchedFileEntry(
44+
algo = entry.getString("algo"),
45+
baseHash = entry.getString("baseHash"),
46+
targetHash = entry.getString("targetHash"),
47+
patch = entry.getString("patch"),
48+
)
49+
}
50+
} else {
51+
emptyMap()
52+
}
53+
54+
return DiffManifest(version = version, deletedFiles = deletedFiles, patchedFiles = patchedFiles)
55+
}

android/app/src/main/java/com/microsoft/codepush/react/diffpatch/DiffPatch.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
package com.microsoft.codepush.react.diffpatch
22

3+
import java.io.File
4+
5+
// Purposes of this interface:
6+
// 1. Allows unit testing the business logic by substituting a fake PatchApplier.
7+
// 2. Allows the SDK to support multiple patching algorithms in the future, if we ever need to.
8+
interface PatchApplier {
9+
fun apply(oldFile: File, diffFile: File, newFile: File): DiffPatch.PatchResult
10+
}
11+
12+
object NativeBsdiffPatchApplier : PatchApplier {
13+
override fun apply(oldFile: File, diffFile: File, newFile: File) =
14+
DiffPatch.applyPatch(oldFile.path, diffFile.path, newFile.path)
15+
}
16+
317
object DiffPatch {
418

519
/**
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
@file:JvmName("Sha256")
2+
package com.microsoft.codepush.react.diffpatch
3+
4+
import java.io.File
5+
import java.io.InputStream
6+
import java.math.BigInteger
7+
import java.security.DigestInputStream
8+
import java.security.MessageDigest
9+
10+
fun sha256Hex(file: File): String = file.inputStream().use { sha256Hex(it) }
11+
12+
fun sha256Hex(inputStream: InputStream): String {
13+
val messageDigest = MessageDigest.getInstance("SHA-256")
14+
DigestInputStream(inputStream, messageDigest).use { digestInputStream ->
15+
val buffer = ByteArray(1024 * 8)
16+
while (digestInputStream.read(buffer) != -1) {
17+
// Drain the stream; DigestInputStream updates the digest as a side effect.
18+
}
19+
}
20+
return String.format("%064x", BigInteger(1, messageDigest.digest()))
21+
}

0 commit comments

Comments
 (0)