Skip to content

Commit 3d49b44

Browse files
committed
Android: apply bsdiff patches during package install
1 parent 07ede1f commit 3d49b44

14 files changed

Lines changed: 765 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: 25 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,33 @@ 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+
BinaryDiffPatcher.applyBinaryDiffPatches(diffManifest, new File(currentPackageFolderPath), new File(unzippedFolderPath), new File(newUpdateFolderPath));
266+
FileUtils.deleteDirectoryAtPath(new File(newUpdateFolderPath, CodePushConstants.DIFF_PATCHES_FOLDER_NAME).getPath());
267+
} else if (diffManifest.getVersion() > 2) {
268+
throw new IOException("Diff manifest version " + diffManifest.getVersion() + " is not supported by this SDK version.");
269+
}
270+
}
271+
248272
FileUtils.deleteFileAtPathSilently(unzippedFolderPath);
249273

250274
// 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)