Skip to content

Commit aeee55a

Browse files
committed
Android: apply bsdiff patches during package install
1 parent 4e25ffb commit aeee55a

14 files changed

Lines changed: 839 additions & 40 deletions

File tree

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;
@@ -253,14 +258,36 @@ void installDownloadedUpdate(JSONObject updatePackage, String expectedBundleFile
253258
String diffManifestFilePath = CodePushUtils.appendPathComponent(unzippedFolderPath,
254259
CodePushConstants.DIFF_MANIFEST_FILE_NAME);
255260
boolean isDiffUpdate = FileUtils.fileAtPathExists(diffManifestFilePath);
261+
DiffManifest diffManifest = null;
256262
if (isDiffUpdate) {
263+
try {
264+
diffManifest = DiffManifestKt.parseDiffManifest(CodePushUtils.getJsonObjectFromFile(diffManifestFilePath));
265+
} catch (JSONException e) {
266+
throw new CodePushMalformedDataException(diffManifestFilePath, e);
267+
}
257268
String currentPackageFolderPath = getCurrentPackageFolderPath();
258-
CodePushUpdateUtils.copyNecessaryFilesFromCurrentPackage(diffManifestFilePath, currentPackageFolderPath, newUpdateFolderPath);
269+
CodePushUpdateUtils.copyNecessaryFilesFromCurrentPackage(diffManifest, currentPackageFolderPath, newUpdateFolderPath);
259270
File diffManifestFile = new File(diffManifestFilePath);
260271
diffManifestFile.delete();
261272
}
262273

263274
FileUtils.copyDirectoryContents(unzippedFolderPath, newUpdateFolderPath);
275+
276+
if (isDiffUpdate) {
277+
// Run patching after copyNecessaryFilesFromCurrentPackage() so patched output overwrites
278+
// bytes copied in from the old package at the same paths.
279+
if (diffManifest.getVersion() == 2) {
280+
String currentPackageFolderPath = getCurrentPackageFolderPath();
281+
if (currentPackageFolderPath == null) {
282+
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.");
283+
}
284+
BinaryDiffPatcher.applyBinaryDiffPatches(diffManifest, new File(currentPackageFolderPath), new File(unzippedFolderPath), new File(newUpdateFolderPath));
285+
FileUtils.deleteDirectoryAtPath(new File(newUpdateFolderPath, CodePushConstants.DIFF_PATCHES_FOLDER_NAME).getPath());
286+
} else if (diffManifest.getVersion() > 2) {
287+
throw new IOException("Diff manifest version " + diffManifest.getVersion() + " is not supported by this SDK version.");
288+
}
289+
}
290+
264291
FileUtils.deleteFileAtPathSilently(unzippedFolderPath);
265292

266293
// 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: 17 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,32 @@ 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+
File newPackageFolderCanonical = new File(newPackageFolderPath).getCanonicalFile();
89+
for (String fileNameToDelete : diffManifest.getDeletedFiles()) {
90+
// deletedFiles comes from the update's diff manifest, so treat it as untrusted: reject any
91+
// entry (e.g. "../../etc/passwd") that would resolve outside newPackageFolderPath.
92+
File fileToDelete = new File(newPackageFolderPath, fileNameToDelete).getCanonicalFile();
93+
if (!fileToDelete.equals(newPackageFolderCanonical)
94+
&& !fileToDelete.getPath().startsWith(newPackageFolderCanonical.getPath() + File.separator)) {
95+
throw new CodePushInvalidUpdateException("Diff manifest deletedFiles entry \"" + fileNameToDelete + "\" escapes the update package directory.");
96+
}
97+
if (fileToDelete.exists()) {
98+
fileToDelete.delete();
11899
}
119-
} catch (JSONException e) {
120-
throw new CodePushUnknownException("Unable to copy files from current package during diff update", e);
121100
}
122101
}
123102

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: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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+
if (version != 2 && patchedFiles.isNotEmpty()) {
55+
throw JSONException("Diff manifest declares version $version but contains patchedFiles, which requires version 2.")
56+
}
57+
58+
return DiffManifest(version = version, deletedFiles = deletedFiles, patchedFiles = patchedFiles)
59+
}

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+
}

android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerTest.kt

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ class CodePushUpdateManagerTest {
3636
logMock.close()
3737
}
3838

39-
private fun manager() = CodePushUpdateManager(tempFolder.newFolder("documents").absolutePath)
39+
private fun manager() =
40+
CodePushUpdateManager(tempFolder.newFolder("documents").absolutePath)
4041

4142
private fun updatePackage(hash: String) = JSONObject().apply {
4243
put(CodePushConstants.PACKAGE_HASH_KEY, hash)
@@ -216,6 +217,42 @@ class CodePushUpdateManagerTest {
216217
}
217218
}
218219

220+
@Test
221+
fun installDownloadedUpdate_diffManifestVersionOutOfRange_throwsIOException() {
222+
// Given
223+
val update = manager()
224+
val downloadFile = zipOf(CodePushConstants.DIFF_MANIFEST_FILE_NAME to """{"version":3,"deletedFiles":[],"patchedFiles":{}}""")
225+
val pkg = updatePackage("hash8")
226+
val newUpdateFolderPath = update.getPackageFolderPath("hash8")
227+
val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME)
228+
229+
// When / Then
230+
try {
231+
update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath)
232+
fail("expected IOException")
233+
} catch (e: java.io.IOException) {
234+
assertTrue(e.message!!.contains("Diff manifest version 3 is not supported by this SDK version"))
235+
}
236+
}
237+
238+
@Test
239+
fun installDownloadedUpdate_binaryDiffUpdateWithNoCurrentPackageInstalled_throwsInvalidUpdateException() {
240+
// Given
241+
val update = manager()
242+
val downloadFile = zipOf(CodePushConstants.DIFF_MANIFEST_FILE_NAME to """{"version":2,"deletedFiles":[],"patchedFiles":{}}""")
243+
val pkg = updatePackage("hash10")
244+
val newUpdateFolderPath = update.getPackageFolderPath("hash10")
245+
val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME)
246+
247+
// When / Then
248+
try {
249+
update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath)
250+
fail("expected CodePushInvalidUpdateException")
251+
} catch (e: CodePushInvalidUpdateException) {
252+
assertTrue(e.message!!.contains("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."))
253+
}
254+
}
255+
219256
@Test
220257
fun installDownloadedUpdate_versionOneDiffUpdate_carriesOverKeptFilesDeletesRemovedOnesAndAppliesNewOnes() {
221258
// Given

0 commit comments

Comments
 (0)