diff --git a/README.md b/README.md index c887a663e..efabaed79 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Supports React Native 0.77 ~ 0.86. - **React Native**: 0.77 or higher - **iOS**: 15.5 or higher - **Android**: API level 16 or higher +- **Android NDK and CMake**: the Android library compiles the applier that installs binary patch updates from C sources, so building an app that depends on it needs both. A React Native project already builds native code, and the Android Gradle Plugin installs the NDK and CMake versions it is missing, so this is usually nothing to set up. ## ๐Ÿš— Migration Guide diff --git a/android/app/build.gradle b/android/app/build.gradle index a04ddb52b..efb68e82f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -32,8 +32,27 @@ android { defaultConfig { consumerProguardFiles 'proguard-rules.pro' } + + // Builds the binary patch applier. Its sources are the shared ones at the root of the + // repository, which the CMake file points at; nothing is compiled twice. + externalNativeBuild { + cmake { + path "src/main/cpp/CMakeLists.txt" + } + } + + testOptions { + // The unit tests exercise plain logic, so the android framework classes they touch + // in passing (logging above all) are allowed to do nothing instead of throwing. + unitTests.returnDefaultValues = true + } } dependencies { implementation "com.facebook.react:react-native:+" + + testImplementation "junit:junit:4.13.2" + // org.json ships as stubs in the android framework jar, and the unit tests parse real + // manifests, so they need an implementation that actually parses. + testImplementation "org.json:json:20231013" } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 09d2c37af..04da50562 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -37,3 +37,8 @@ # Can't find referenced class org.bouncycastle.** -dontwarn com.nimbusds.jose.** + +# The binary patch applier is found by name from the native library. +-keepclasseswithmembernames class com.microsoft.codepush.react.HDiffPatchNative { + native ; +} diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 000000000..eedf2efeb --- /dev/null +++ b/android/app/src/main/cpp/CMakeLists.txt @@ -0,0 +1,38 @@ +cmake_minimum_required(VERSION 3.13) + +project(codepush-binarypatch C) + +# The applier sources live outside the platform directories because the host build and +# the other platform compile the very same files. Referencing them where they are is +# what keeps the appliers of the platforms from drifting apart, so they are deliberately +# not copied into this directory. +set(BINARY_PATCH_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../../../../cpp/binarypatch) + +add_library(codepush-binarypatch SHARED + ${CMAKE_CURRENT_SOURCE_DIR}/binarypatch_jni.c + ${BINARY_PATCH_DIR}/binarypatch_zstd_decompressor.c + ${BINARY_PATCH_DIR}/vendor/HDiffPatch/libHDiffPatch/HPatch/patch.c + ${BINARY_PATCH_DIR}/vendor/zstd/common/debug.c + ${BINARY_PATCH_DIR}/vendor/zstd/common/entropy_common.c + ${BINARY_PATCH_DIR}/vendor/zstd/common/error_private.c + ${BINARY_PATCH_DIR}/vendor/zstd/common/fse_decompress.c + ${BINARY_PATCH_DIR}/vendor/zstd/common/xxhash.c + ${BINARY_PATCH_DIR}/vendor/zstd/common/zstd_common.c + ${BINARY_PATCH_DIR}/vendor/zstd/decompress/huf_decompress.c + ${BINARY_PATCH_DIR}/vendor/zstd/decompress/zstd_ddict.c + ${BINARY_PATCH_DIR}/vendor/zstd/decompress/zstd_decompress.c + ${BINARY_PATCH_DIR}/vendor/zstd/decompress/zstd_decompress_block.c) + +target_include_directories(codepush-binarypatch PRIVATE + ${BINARY_PATCH_DIR} + ${BINARY_PATCH_DIR}/vendor/HDiffPatch + ${BINARY_PATCH_DIR}/vendor/zstd) + +# ZSTD_DISABLE_ASM: the assembly fast path is intentionally not vendored. +# _IS_USED_MULTITHREAD=0: patches are applied on the thread that downloads them. +target_compile_definitions(codepush-binarypatch PRIVATE + ZSTD_DISABLE_ASM=1 + _IS_USED_MULTITHREAD=0) + +find_library(log-lib log) +target_link_libraries(codepush-binarypatch ${log-lib}) diff --git a/android/app/src/main/cpp/binarypatch_jni.c b/android/app/src/main/cpp/binarypatch_jni.c new file mode 100644 index 000000000..ee8e20b42 --- /dev/null +++ b/android/app/src/main/cpp/binarypatch_jni.c @@ -0,0 +1,181 @@ +/* + * JNI entry point of the CodePush binary patch applier. + * + * The applier itself is the shared C code one directory tree up, which the host build and + * the other platform compile as well; this file only moves data across the JNI boundary + * and turns a failure into the result code `HDiffPatchNative` hands back to Java. + * + * Memory contract, the same one the host build documents: + * - the base bundle is held whole, because the patches are produced with `hdiffz -m`, + * which patches with random access to the base data + * - the patch is held whole, being far smaller than either bundle + * - the restored bundle is written sequentially to a file, so a patch session never + * holds two bundles at once + * + * A patch carries no checksum of the base data and its zstd streams carry no content + * checksum, so a successful apply here is not proof of a correct result. The caller + * verifies the base and target hashes; this file cannot. + */ + +#include +#include +#include +#include + +#include + +#include "binarypatch_zstd_decompressor.h" +#include "libHDiffPatch/HPatch/patch.h" + +#define LOG_TAG "ReactNative" +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +/* Mirrored by CodePushBinaryPatch.PatchApplier; the two lists have to stay in step. */ +#define RESULT_OK 0 +#define RESULT_INVALID_ARGUMENT 1 +#define RESULT_IO_ERROR 2 +#define RESULT_INVALID_HEADER 3 +#define RESULT_UNSUPPORTED_COMPRESSION 4 +#define RESULT_SIZE_MISMATCH 5 +#define RESULT_APPLY_FAILED 6 + +/* Scratch buffer handed to patch_decompress_with_cache() to reduce stream reads. */ +#define APPLY_CACHE_SIZE (1024 * 1024) + +typedef struct { + FILE* file; + hpatch_StreamPos_t writtenSize; +} TSequentialFileWriter; + +static hpatch_BOOL _write_sequential(const hpatch_TStreamOutput* stream, + hpatch_StreamPos_t writeToPos, + const unsigned char* data, + const unsigned char* data_end) { + TSequentialFileWriter* self = (TSequentialFileWriter*)stream->streamImport; + const size_t length = (size_t)(data_end - data); + /* patch_decompress_with_cache() only ever appends; anything else is a bug. */ + if (writeToPos != self->writtenSize) { + return hpatch_FALSE; + } + if (fwrite(data, 1, length, self->file) != length) { + return hpatch_FALSE; + } + self->writtenSize += length; + return hpatch_TRUE; +} + +JNIEXPORT jint JNICALL +Java_com_microsoft_codepush_react_HDiffPatchNative_applyPatch(JNIEnv* env, + jclass clazz, + jbyteArray base, + jbyteArray patch, + jstring outputPath, + jlong expectedTargetSize) { + jbyte* baseData = NULL; + jbyte* patchData = NULL; + const char* outputPathChars = NULL; + unsigned char* cache = NULL; + jsize baseSize = 0; + jsize patchSize = 0; + hpatch_compressedDiffInfo diffInfo; + hpatch_TDecompress decompressor; + hpatch_TStreamInput baseStream; + hpatch_TStreamInput patchStream; + hpatch_TStreamOutput targetStream; + TSequentialFileWriter writer; + hpatch_BOOL applied; + int result = RESULT_OK; + + (void)clazz; + + if ((base == NULL) || (patch == NULL) || (outputPath == NULL) || (expectedTargetSize <= 0)) { + return RESULT_INVALID_ARGUMENT; + } + + baseSize = (*env)->GetArrayLength(env, base); + patchSize = (*env)->GetArrayLength(env, patch); + baseData = (*env)->GetByteArrayElements(env, base, NULL); + patchData = (*env)->GetByteArrayElements(env, patch, NULL); + outputPathChars = (*env)->GetStringUTFChars(env, outputPath, NULL); + if ((baseData == NULL) || (patchData == NULL) || (outputPathChars == NULL)) { + LOGE("[CodePush] out of memory while reading the binary patch inputs"); + result = RESULT_IO_ERROR; + goto cleanup; + } + + binarypatch_zstd_decompressor_init(&decompressor); + + if (!getCompressedDiffInfo_mem(&diffInfo, (const unsigned char*)patchData, + (const unsigned char*)patchData + patchSize)) { + LOGE("[CodePush] the binary patch header could not be read"); + result = RESULT_INVALID_HEADER; + goto cleanup; + } + if ((strlen(diffInfo.compressType) > 0) && !decompressor.is_can_open(diffInfo.compressType)) { + LOGE("[CodePush] the binary patch uses an unsupported codec: %s", diffInfo.compressType); + result = RESULT_UNSUPPORTED_COMPRESSION; + goto cleanup; + } + if (diffInfo.oldDataSize != (hpatch_StreamPos_t)baseSize) { + LOGE("[CodePush] the binary patch expects a %llu byte base bundle, this one is %llu bytes", + (unsigned long long)diffInfo.oldDataSize, (unsigned long long)baseSize); + result = RESULT_SIZE_MISMATCH; + goto cleanup; + } + if (diffInfo.newDataSize != (hpatch_StreamPos_t)expectedTargetSize) { + LOGE("[CodePush] the binary patch produces %llu bytes, the manifest promises %llu", + (unsigned long long)diffInfo.newDataSize, (unsigned long long)expectedTargetSize); + result = RESULT_SIZE_MISMATCH; + goto cleanup; + } + + cache = (unsigned char*)malloc(APPLY_CACHE_SIZE); + if (cache == NULL) { + LOGE("[CodePush] out of memory while allocating the binary patch cache"); + result = RESULT_IO_ERROR; + goto cleanup; + } + + writer.file = fopen(outputPathChars, "wb"); + writer.writtenSize = 0; + if (writer.file == NULL) { + LOGE("[CodePush] the restored bundle could not be opened for writing"); + result = RESULT_IO_ERROR; + goto cleanup; + } + + mem_as_hStreamInput(&baseStream, (const unsigned char*)baseData, (const unsigned char*)baseData + baseSize); + mem_as_hStreamInput(&patchStream, (const unsigned char*)patchData, (const unsigned char*)patchData + patchSize); + memset(&targetStream, 0, sizeof(targetStream)); + targetStream.streamImport = &writer; + targetStream.streamSize = diffInfo.newDataSize; + targetStream.write = _write_sequential; + + applied = patch_decompress_with_cache(&targetStream, &baseStream, &patchStream, &decompressor, + cache, cache + APPLY_CACHE_SIZE); + if (fclose(writer.file) != 0) { + LOGE("[CodePush] the restored bundle could not be flushed to disk"); + result = RESULT_IO_ERROR; + goto cleanup; + } + if (!applied) { + LOGE("[CodePush] applying the binary patch failed (decError=%d)", (int)decompressor.decError); + result = RESULT_APPLY_FAILED; + goto cleanup; + } + +cleanup: + free(cache); + if (outputPathChars != NULL) { + (*env)->ReleaseStringUTFChars(env, outputPath, outputPathChars); + } + /* JNI_ABORT: neither array is written to, so nothing has to be copied back. */ + if (patchData != NULL) { + (*env)->ReleaseByteArrayElements(env, patch, patchData, JNI_ABORT); + } + if (baseData != NULL) { + (*env)->ReleaseByteArrayElements(env, base, baseData, JNI_ABORT); + } + + return (jint)result; +} diff --git a/android/app/src/main/java/com/microsoft/codepush/react/BinaryPatchResult.java b/android/app/src/main/java/com/microsoft/codepush/react/BinaryPatchResult.java new file mode 100644 index 000000000..763ef0a31 --- /dev/null +++ b/android/app/src/main/java/com/microsoft/codepush/react/BinaryPatchResult.java @@ -0,0 +1,55 @@ +package com.microsoft.codepush.react; + +/** + * Outcome of restoring an update from its binary patch archive. + * + * A failed result is not an error the user hears about: it is the signal to download the + * update's full archive instead. The reason strings are the vocabulary the appliers of + * every platform report, and the logs a rollout is judged from are read for exactly these + * words, so they must not be reworded. + */ +public class BinaryPatchResult { + + /** The bundle inside the app binary could not be opened or read. */ + public static final String REASON_BASE_BUNDLE_UNAVAILABLE = "base_bundle_unavailable"; + + /** The bundle inside the app binary is not the one the patch was computed against. */ + public static final String REASON_BASE_HASH_MISMATCH = "base_hash_mismatch"; + + /** The manifest is missing, malformed, points outside the archive, or asks for too much. */ + public static final String REASON_INVALID_MANIFEST = "invalid_manifest"; + + /** The patch was produced by a format or a codec this client cannot apply. */ + public static final String REASON_UNSUPPORTED_FORMAT = "unsupported_format"; + + /** The applier refused the patch, or the restored bundle could not be written. */ + public static final String REASON_PATCH_APPLY_FAILED = "patch_apply_failed"; + + /** The restored bundle is not the one the manifest promised. */ + public static final String REASON_TARGET_VERIFICATION_FAILED = "target_verification_failed"; + + private final boolean mSucceeded; + private final String mFailureReason; + + private BinaryPatchResult(boolean succeeded, String failureReason) { + mSucceeded = succeeded; + mFailureReason = failureReason; + } + + public static BinaryPatchResult success() { + return new BinaryPatchResult(true, null); + } + + public static BinaryPatchResult failure(String failureReason) { + return new BinaryPatchResult(false, failureReason); + } + + public boolean succeeded() { + return mSucceeded; + } + + /** Why the full archive has to be downloaded instead, or null when the patch was applied. */ + public String getFailureReason() { + return mFailureReason; + } +} diff --git a/android/app/src/main/java/com/microsoft/codepush/react/CodePush.java b/android/app/src/main/java/com/microsoft/codepush/react/CodePush.java index 2e91f464b..8f3f87a97 100644 --- a/android/app/src/main/java/com/microsoft/codepush/react/CodePush.java +++ b/android/app/src/main/java/com/microsoft/codepush/react/CodePush.java @@ -61,7 +61,7 @@ public static synchronized CodePush getInstance(Context context, boolean isDebug private CodePush(Context context, boolean isDebugMode) { mContext = context.getApplicationContext(); - mUpdateManager = new CodePushUpdateManager(context.getFilesDir().getAbsolutePath()); + mUpdateManager = new CodePushUpdateManager(context.getFilesDir().getAbsolutePath(), mContext); mTelemetryManager = new CodePushTelemetryManager(mContext); mDeploymentKey = "deprecated_deployment_key"; mIsDebugMode = isDebugMode; diff --git a/android/app/src/main/java/com/microsoft/codepush/react/CodePushBinaryPatch.java b/android/app/src/main/java/com/microsoft/codepush/react/CodePushBinaryPatch.java new file mode 100644 index 000000000..83beb7e5e --- /dev/null +++ b/android/app/src/main/java/com/microsoft/codepush/react/CodePushBinaryPatch.java @@ -0,0 +1,293 @@ +package com.microsoft.codepush.react; + +import org.json.JSONObject; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * Rebuilds the JS bundle of an update that was downloaded as a binary patch archive. + * + * A patch archive holds everything the full archive holds except the JS bundle, which it + * carries as a patch against the bundle that shipped inside the app binary, plus a + * manifest describing how to rebuild it. Restoring means applying that patch, verifying + * the result, moving it to where the bundle belongs and deleting the two patch-only + * files. What is left is byte for byte the contents of the full archive, so the folder + * hash check that follows the install is unchanged and stays the last line of defence. + * + * Nothing here trusts the patch. Neither the diff format nor the zstd streams inside it + * carry a checksum of the data they produce, so an apply that reports success is not + * proof of a correct result: a base bundle of the right size but the wrong content, or a + * corrupted patch body, both produce wrong bytes without any error. The base bundle is + * hashed before the patch is applied and the restored bundle is hashed afterwards, and + * the restored bytes only reach the update contents once both checks have passed. + * + * Every failure is reported as a {@link BinaryPatchResult}, never as an exception: the + * caller answers all of them the same way, by downloading the full archive instead. + */ +public class CodePushBinaryPatch { + + /** Reads the JS bundle that shipped inside the app binary. */ + public interface BaseBundleProvider { + byte[] readBaseBundle(String bundleFileName) throws IOException; + } + + /** + * Applies a patch to a base bundle and writes the restored bundle to a file. + * + * The result codes are the ones the native applier returns, so they have to stay in + * step with the codes in the JNI wrapper. + */ + public interface PatchApplier { + int RESULT_OK = 0; + int RESULT_INVALID_ARGUMENT = 1; + int RESULT_IO_ERROR = 2; + int RESULT_INVALID_HEADER = 3; + int RESULT_UNSUPPORTED_COMPRESSION = 4; + int RESULT_SIZE_MISMATCH = 5; + int RESULT_APPLY_FAILED = 6; + /** Reported by the wrapper itself, when the native library could not be loaded. */ + int RESULT_LIBRARY_UNAVAILABLE = 7; + + int apply(byte[] base, byte[] patch, String outputPath, long expectedTargetSize); + } + + private static final int COPY_BUFFER_SIZE = 1024 * 8; + + private final BaseBundleProvider mBaseBundleProvider; + private final PatchApplier mPatchApplier; + + public CodePushBinaryPatch(BaseBundleProvider baseBundleProvider, PatchApplier patchApplier) { + mBaseBundleProvider = baseBundleProvider; + mPatchApplier = patchApplier; + } + + /** + * Turns the contents of a downloaded patch archive into the contents of the full one. + * + * @param unzippedFolderPath the unzipped archive, which is modified in place + * @param workingFolderPath scratch directory for the restored bundle, emptied before + * and after the attempt so an interrupted run leaves nothing + * @param baseBundleFileName name of the JS bundle inside the app binary + */ + public BinaryPatchResult restoreBundle(String unzippedFolderPath, String workingFolderPath, String baseBundleFileName) { + File contentsFolder = resolveContentsFolder(new File(unzippedFolderPath)); + File manifestFile = new File(contentsFolder, CodePushConstants.BINARY_PATCH_MANIFEST_FILE_NAME); + if (!manifestFile.isFile()) { + return BinaryPatchResult.failure(BinaryPatchResult.REASON_INVALID_MANIFEST); + } + + JSONObject manifest; + try { + manifest = CodePushUtils.getJsonObjectFromFile(manifestFile.getAbsolutePath()); + } catch (IOException | CodePushMalformedDataException e) { + CodePushUtils.log(e); + return BinaryPatchResult.failure(BinaryPatchResult.REASON_INVALID_MANIFEST); + } + + if (manifest.optInt(CodePushConstants.BINARY_PATCH_FORMAT_VERSION_KEY, -1) != CodePushConstants.BINARY_PATCH_FORMAT_VERSION + || !CodePushConstants.BINARY_PATCH_ALGORITHM.equals(manifest.optString(CodePushConstants.BINARY_PATCH_ALGORITHM_KEY, null))) { + return BinaryPatchResult.failure(BinaryPatchResult.REASON_UNSUPPORTED_FORMAT); + } + + File targetBundleFile = resolveInsideFolder(contentsFolder, manifest.optString(CodePushConstants.BINARY_PATCH_BUNDLE_PATH_KEY, null)); + File patchFile = resolveInsideFolder(contentsFolder, manifest.optString(CodePushConstants.BINARY_PATCH_FILE_KEY, null)); + String baseBundleHash = manifest.optString(CodePushConstants.BINARY_PATCH_BASE_BUNDLE_HASH_KEY, null); + String targetBundleHash = manifest.optString(CodePushConstants.BINARY_PATCH_TARGET_BUNDLE_HASH_KEY, null); + long targetBundleSize = manifest.optLong(CodePushConstants.BINARY_PATCH_TARGET_BUNDLE_SIZE_KEY, -1); + if (targetBundleFile == null || patchFile == null || !patchFile.isFile() + || isNullOrEmpty(baseBundleHash) || isNullOrEmpty(targetBundleHash) + || targetBundleSize <= 0 || targetBundleSize > CodePushConstants.BINARY_PATCH_MAX_TARGET_BUNDLE_SIZE) { + return BinaryPatchResult.failure(BinaryPatchResult.REASON_INVALID_MANIFEST); + } + + // An earlier attempt that was killed while patching leaves its restored bundle behind. + FileUtils.deleteDirectoryAtPath(workingFolderPath); + File workingFolder = new File(workingFolderPath); + if (!workingFolder.mkdirs()) { + CodePushUtils.log("Unable to create the binary patch working directory at " + workingFolderPath); + return BinaryPatchResult.failure(BinaryPatchResult.REASON_PATCH_APPLY_FAILED); + } + + try { + if (workingFolder.getUsableSpace() < targetBundleSize) { + CodePushUtils.log("Not enough free space to restore a " + targetBundleSize + " byte bundle."); + return BinaryPatchResult.failure(BinaryPatchResult.REASON_PATCH_APPLY_FAILED); + } + + byte[] baseBundle; + try { + baseBundle = mBaseBundleProvider.readBaseBundle(baseBundleFileName); + } catch (Exception e) { + CodePushUtils.log(e); + return BinaryPatchResult.failure(BinaryPatchResult.REASON_BASE_BUNDLE_UNAVAILABLE); + } + if (baseBundle == null) { + return BinaryPatchResult.failure(BinaryPatchResult.REASON_BASE_BUNDLE_UNAVAILABLE); + } + if (!baseBundleHash.equals(CodePushUpdateUtils.computeHashForBytes(baseBundle))) { + return BinaryPatchResult.failure(BinaryPatchResult.REASON_BASE_HASH_MISMATCH); + } + + byte[] patch; + try { + patch = readFile(patchFile); + } catch (IOException e) { + CodePushUtils.log(e); + return BinaryPatchResult.failure(BinaryPatchResult.REASON_PATCH_APPLY_FAILED); + } + + File restoredBundleFile = new File(workingFolder, CodePushConstants.BINARY_PATCH_TARGET_FILE_NAME); + int resultCode = mPatchApplier.apply(baseBundle, patch, restoredBundleFile.getAbsolutePath(), targetBundleSize); + if (resultCode != PatchApplier.RESULT_OK) { + CodePushUtils.log("The binary patch applier returned " + resultCode + "."); + return BinaryPatchResult.failure(reasonForResultCode(resultCode)); + } + + if (restoredBundleFile.length() != targetBundleSize) { + return BinaryPatchResult.failure(BinaryPatchResult.REASON_TARGET_VERIFICATION_FAILED); + } + String restoredBundleHash; + try { + restoredBundleHash = CodePushUpdateUtils.computeHashForFile(restoredBundleFile); + } catch (IOException e) { + CodePushUtils.log(e); + return BinaryPatchResult.failure(BinaryPatchResult.REASON_TARGET_VERIFICATION_FAILED); + } + if (!targetBundleHash.equals(restoredBundleHash)) { + return BinaryPatchResult.failure(BinaryPatchResult.REASON_TARGET_VERIFICATION_FAILED); + } + + if (!moveFile(restoredBundleFile, targetBundleFile) || !patchFile.delete() || !manifestFile.delete()) { + // The contents are half restored, so they must not be installed. The full + // archive that follows unzips over them, which is what clears them. + CodePushUtils.log("Unable to put the restored bundle in place of the patch."); + return BinaryPatchResult.failure(BinaryPatchResult.REASON_PATCH_APPLY_FAILED); + } + + return BinaryPatchResult.success(); + } finally { + FileUtils.deleteDirectoryAtPath(workingFolderPath); + } + } + + /** + * Finds the contents root inside an unzipped archive. + * + * An archive wraps its files in a single directory, and the manifest's paths are + * relative to that directory rather than to the archive. An archive whose files are at + * the top level is its own contents root, which is how the tooling that unpacks one + * reads it too. + */ + private static File resolveContentsFolder(File unzippedFolder) { + File[] entries = unzippedFolder.listFiles(); + if (entries != null && entries.length == 1 && entries[0].isDirectory()) { + return entries[0]; + } + + return unzippedFolder; + } + + /** + * Resolves a path the manifest points at, refusing anything that would reach outside + * the archive - an archive is untrusted input, and its manifest is no more trusted + * than its entries. + * + * @return the resolved file, or null when the path is unusable + */ + private static File resolveInsideFolder(File folder, String relativePath) { + if (isNullOrEmpty(relativePath) || new File(relativePath).isAbsolute()) { + return null; + } + + try { + String folderPath = folder.getCanonicalPath() + File.separator; + String resolvedPath = new File(folder, relativePath).getCanonicalPath(); + if (!resolvedPath.startsWith(folderPath)) { + return null; + } + + return new File(resolvedPath); + } catch (IOException e) { + CodePushUtils.log(e); + return null; + } + } + + private static String reasonForResultCode(int resultCode) { + return resultCode == PatchApplier.RESULT_UNSUPPORTED_COMPRESSION + ? BinaryPatchResult.REASON_UNSUPPORTED_FORMAT + : BinaryPatchResult.REASON_PATCH_APPLY_FAILED; + } + + /** Rename, falling back to a copy for the case where the two paths are on different volumes. */ + private static boolean moveFile(File sourceFile, File destinationFile) { + File destinationFolder = destinationFile.getParentFile(); + if (destinationFolder != null && !destinationFolder.exists() && !destinationFolder.mkdirs()) { + return false; + } + + if (sourceFile.renameTo(destinationFile)) { + return true; + } + + try { + copyFile(sourceFile, destinationFile); + } catch (IOException e) { + CodePushUtils.log(e); + return false; + } + + return sourceFile.delete(); + } + + private static void copyFile(File sourceFile, File destinationFile) throws IOException { + InputStream input = new FileInputStream(sourceFile); + try { + OutputStream output = new FileOutputStream(destinationFile); + try { + byte[] buffer = new byte[COPY_BUFFER_SIZE]; + int bytesRead; + while ((bytesRead = input.read(buffer)) > 0) { + output.write(buffer, 0, bytesRead); + } + } finally { + output.close(); + } + } finally { + input.close(); + } + } + + private static byte[] readFile(File file) throws IOException { + long fileSize = file.length(); + if (fileSize <= 0 || fileSize > Integer.MAX_VALUE) { + throw new IOException("Cannot read " + file.getAbsolutePath() + ", it is " + fileSize + " bytes long."); + } + + byte[] contents = new byte[(int) fileSize]; + InputStream input = new FileInputStream(file); + try { + int offset = 0; + while (offset < contents.length) { + int bytesRead = input.read(contents, offset, contents.length - offset); + if (bytesRead < 0) { + throw new IOException("Unexpected end of " + file.getAbsolutePath() + "."); + } + offset += bytesRead; + } + } finally { + input.close(); + } + + return contents; + } + + private static boolean isNullOrEmpty(String value) { + return value == null || value.isEmpty(); + } +} 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 8ac3cdfbe..6bd22333b 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 @@ -2,6 +2,25 @@ public class CodePushConstants { public static final String ASSETS_BUNDLE_PREFIX = "assets://"; + // The binary patch values below are the client half of the contract the CLI writes + // into a patch archive. The CLI is TypeScript, so its constants cannot be imported + // here: any change to the manifest it produces has to be repeated in this file. + public static final String BINARY_PATCH_ALGORITHM = "hdiffpatch-m-zstd"; + public static final String BINARY_PATCH_ALGORITHM_KEY = "algorithm"; + public static final String BINARY_PATCH_BASE_BUNDLE_HASH_KEY = "baseBundleHash"; + public static final String BINARY_PATCH_BUNDLE_PATH_KEY = "bundlePath"; + public static final String BINARY_PATCH_DOWNLOAD_URL_KEY = "binaryPatchDownloadUrl"; + public static final String BINARY_PATCH_FILE_KEY = "patchFile"; + public static final String BINARY_PATCH_FOLDER_NAME = "binary-patch"; + public static final int BINARY_PATCH_FORMAT_VERSION = 1; + public static final String BINARY_PATCH_FORMAT_VERSION_KEY = "formatVersion"; + public static final String BINARY_PATCH_MANIFEST_FILE_NAME = "codepush-binary-patch.json"; + // A manifest asking for a bundle larger than this is treated as malformed rather than + // as a reason to reserve that much memory and disk. + public static final long BINARY_PATCH_MAX_TARGET_BUNDLE_SIZE = 512L * 1024 * 1024; + public static final String BINARY_PATCH_TARGET_BUNDLE_HASH_KEY = "targetBundleHash"; + public static final String BINARY_PATCH_TARGET_BUNDLE_SIZE_KEY = "targetBundleSize"; + public static final String BINARY_PATCH_TARGET_FILE_NAME = "target.bundle"; public static final String CODE_PUSH_FOLDER_PREFIX = "CodePush"; public static final String CODE_PUSH_HASH_FILE_NAME = "CodePushHash"; public static final String CODE_PUSH_OLD_HASH_FILE_NAME = "CodePushHash.json"; 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 c5c8f5df6..02511ae7a 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 @@ -1,14 +1,17 @@ package com.microsoft.codepush.react; +import android.content.Context; import android.os.Build; import org.json.JSONObject; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; +import java.io.InputStream; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URL; @@ -19,9 +22,15 @@ public class CodePushUpdateManager { private String mDocumentsDirectory; + private final CodePushBinaryPatch mBinaryPatch; - public CodePushUpdateManager(String documentsDirectory) { + public CodePushUpdateManager(String documentsDirectory, Context context) { + this(documentsDirectory, new CodePushBinaryPatch(new AssetsBaseBundleProvider(context), new HDiffPatchNative())); + } + + CodePushUpdateManager(String documentsDirectory, CodePushBinaryPatch binaryPatch) { mDocumentsDirectory = documentsDirectory; + mBinaryPatch = binaryPatch; } private String getDownloadFilePath() { @@ -32,6 +41,10 @@ private String getUnzippedFolderPath() { return CodePushUtils.appendPathComponent(getCodePushPath(), CodePushConstants.UNZIPPED_FOLDER_NAME); } + private String getBinaryPatchFolderPath() { + return CodePushUtils.appendPathComponent(getCodePushPath(), CodePushConstants.BINARY_PATCH_FOLDER_NAME); + } + private String getDocumentsDirectory() { return mDocumentsDirectory; } @@ -145,6 +158,72 @@ public JSONObject getPackage(String packageHash) { public void downloadPackage(JSONObject updatePackage, String expectedBundleFileName, DownloadProgressCallback progressCallback) throws IOException { + // A release that was published with a binary patch offers two archives of the same + // update. The patch is worth trying because it is a fraction of the size, and the + // full archive is always there when it does not work out. + String binaryPatchDownloadUrl = updatePackage.optString(CodePushConstants.BINARY_PATCH_DOWNLOAD_URL_KEY, null); + if (binaryPatchDownloadUrl != null + && tryDownloadBinaryPatchPackage(updatePackage, expectedBundleFileName, progressCallback, binaryPatchDownloadUrl)) { + return; + } + + downloadAndInstallPackage(updatePackage, expectedBundleFileName, progressCallback, + updatePackage.optString(CodePushConstants.DOWNLOAD_URL_KEY, null), false); + } + + /** + * Installs the update from its binary patch archive. + * + * Every way this can fail ends the same way, with the update being downloaded in full + * instead, so none of them is reported to the caller as an error. The fallback happens + * exactly once without anything having to count it: the full archive is downloaded by + * a call that is not allowed to take the patch path, so it has no failure of its own to + * fall back from. + * + * @return true when the update was installed, false when the caller has to download the + * full archive instead + */ + private boolean tryDownloadBinaryPatchPackage(JSONObject updatePackage, String expectedBundleFileName, + DownloadProgressCallback progressCallback, + String binaryPatchDownloadUrl) { + try { + BinaryPatchResult patchResult = downloadAndInstallPackage(updatePackage, expectedBundleFileName, + progressCallback, binaryPatchDownloadUrl, true); + if (patchResult.succeeded()) { + return true; + } + + CodePushUtils.log("Binary patch update failed (" + patchResult.getFailureReason() + + "). Downloading the full update instead."); + } catch (Exception | OutOfMemoryError e) { + // Applying a patch is the one path that holds a whole bundle in memory, so + // running out of it is a failure this has to absorb like any other: by the time + // it lands here the arrays are unreachable, and the full archive is downloaded + // to disk in chunks rather than held. + CodePushUtils.log(e); + CodePushUtils.log("The binary patch update could not be completed. Downloading the full update instead."); + } finally { + FileUtils.deleteDirectoryAtPath(getBinaryPatchFolderPath()); + } + + return false; + } + + /** + * Downloads an update from one of its archives and installs it. + * + * @param isBinaryPatchUpdate whether the archive holds a binary patch of the JS bundle, + * which has to be applied before the contents are the update. + * Only an archive downloaded from the binary patch URL is + * treated that way, so an update being downloaded in full can + * never end up on the patch path. + * @return the outcome of the patch: a failed result means the update was not installed + * and the caller has to fall back to the full archive. Downloading the full + * archive always succeeds or throws. + */ + BinaryPatchResult downloadAndInstallPackage(JSONObject updatePackage, String expectedBundleFileName, + DownloadProgressCallback progressCallback, + String downloadUrlString, boolean isBinaryPatchUpdate) throws IOException { String newUpdateHash = updatePackage.optString(CodePushConstants.PACKAGE_HASH_KEY, null); String newUpdateFolderPath = getPackageFolderPath(newUpdateHash); String newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME); @@ -154,7 +233,6 @@ public void downloadPackage(JSONObject updatePackage, String expectedBundleFileN FileUtils.deleteDirectoryAtPath(newUpdateFolderPath); } - String downloadUrlString = updatePackage.optString(CodePushConstants.DOWNLOAD_URL_KEY, null); HttpURLConnection connection = null; BufferedInputStream bin = null; FileOutputStream fos = null; @@ -232,6 +310,20 @@ public void downloadPackage(JSONObject updatePackage, String expectedBundleFileN FileUtils.unzipFile(downloadFile, unzippedFolderPath); FileUtils.deleteFileOrFolderSilently(downloadFile); + // Rebuild the JS bundle the archive only carries a patch of, which leaves the + // contents identical to the ones the full archive would have delivered. + if (isBinaryPatchUpdate) { + long patchStartTime = System.currentTimeMillis(); + BinaryPatchResult patchResult = mBinaryPatch.restoreBundle(unzippedFolderPath, + getBinaryPatchFolderPath(), expectedBundleFileName); + if (!patchResult.succeeded()) { + return patchResult; + } + + CodePushUtils.log("Restored the update from its binary patch in " + + (System.currentTimeMillis() - patchStartTime) + " ms."); + } + // Merge contents with current update based on the manifest String diffManifestFilePath = CodePushUtils.appendPathComponent(unzippedFolderPath, CodePushConstants.DIFF_MANIFEST_FILE_NAME); @@ -268,12 +360,22 @@ public void downloadPackage(JSONObject updatePackage, String expectedBundleFileN CodePushUtils.setJSONValueForKey(updatePackage, CodePushConstants.RELATIVE_BUNDLE_PATH_KEY, relativeBundlePath); } } else { + if (isBinaryPatchUpdate) { + // Whatever the patch URL served, it is not a patch archive - an error page + // answered with a 200 looks like this too. Moving it into place would + // install bytes no hash has ever been checked against, so the full archive + // is downloaded instead. + return BinaryPatchResult.failure(BinaryPatchResult.REASON_INVALID_MANIFEST); + } + // File is a jsbundle, move it to a folder with the packageHash as its name FileUtils.moveFile(downloadFile, newUpdateFolderPath, expectedBundleFileName); } // Save metadata to the folder. CodePushUtils.writeJsonToFile(updatePackage, newUpdateMetadataPath); + + return BinaryPatchResult.success(); } public void installPackage(JSONObject updatePackage, boolean removePendingUpdate) { @@ -349,4 +451,38 @@ public void downloadAndReplaceCurrentBundle(String remoteBundleUrl, String bundl public void clearUpdates() { FileUtils.deleteDirectoryAtPath(getCodePushPath()); } + + /** Reads the JS bundle that shipped inside the app binary out of the APK's assets. */ + private static class AssetsBaseBundleProvider implements CodePushBinaryPatch.BaseBundleProvider { + + private final Context mContext; + + AssetsBaseBundleProvider(Context context) { + mContext = context; + } + + @Override + public byte[] readBaseBundle(String bundleFileName) throws IOException { + // The bundle is stored uncompressed in the APK, so it is read straight into + // memory: a copy on disk would buy nothing and cost the space the restored + // bundle needs. + InputStream assetStream = mContext.getAssets().open(bundleFileName); + try { + // An uncompressed asset knows its whole length up front, so the buffer is + // sized for it: growing one would repeatedly hold two copies of a bundle + // that is already the largest allocation on this path. + ByteArrayOutputStream bundleBytes = new ByteArrayOutputStream( + Math.max(assetStream.available(), CodePushConstants.DOWNLOAD_BUFFER_SIZE)); + byte[] buffer = new byte[CodePushConstants.DOWNLOAD_BUFFER_SIZE]; + int bytesRead; + while ((bytesRead = assetStream.read(buffer)) > 0) { + bundleBytes.write(buffer, 0, bytesRead); + } + + return bundleBytes.toByteArray(); + } finally { + assetStream.close(); + } + } + } } 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 d9ec5d5d3..ee08111a2 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 @@ -86,6 +86,19 @@ private static String computeHash(InputStream dataStream) { return String.format("%064x", new java.math.BigInteger(1, hash)); } + /** + * SHA-256 of a single file's bytes, as lowercase hex - the way a binary patch manifest + * records the bundle it was computed against and the bundle it produces. + */ + public static String computeHashForFile(File file) throws IOException { + return computeHash(new FileInputStream(file)); + } + + /** SHA-256 of the bytes in memory, in the same form as {@link #computeHashForFile}. */ + public static String computeHashForBytes(byte[] data) { + return computeHash(new ByteArrayInputStream(data)); + } + public static void copyNecessaryFilesFromCurrentPackage(String diffManifestFilePath, 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."); diff --git a/android/app/src/main/java/com/microsoft/codepush/react/HDiffPatchNative.java b/android/app/src/main/java/com/microsoft/codepush/react/HDiffPatchNative.java new file mode 100644 index 000000000..0ca5635bf --- /dev/null +++ b/android/app/src/main/java/com/microsoft/codepush/react/HDiffPatchNative.java @@ -0,0 +1,52 @@ +package com.microsoft.codepush.react; + +/** + * The native HDiffPatch applier, which is what actually turns the bundle inside the app + * binary into the bundle an update wants to run. + * + * The library is loaded on first use rather than when the class is loaded, and a load + * failure is reported as a result code instead of an error: an app whose build did not + * produce the library still has to install its updates, it just has to download them in + * full. + */ +public class HDiffPatchNative implements CodePushBinaryPatch.PatchApplier { + + private static final String LIBRARY_NAME = "codepush-binarypatch"; + + private static boolean sLibraryLoadAttempted = false; + private static boolean sLibraryLoaded = false; + + @Override + public int apply(byte[] base, byte[] patch, String outputPath, long expectedTargetSize) { + if (!loadLibrary()) { + return RESULT_LIBRARY_UNAVAILABLE; + } + + return applyPatch(base, patch, outputPath, expectedTargetSize); + } + + private static synchronized boolean loadLibrary() { + if (!sLibraryLoadAttempted) { + sLibraryLoadAttempted = true; + try { + System.loadLibrary(LIBRARY_NAME); + sLibraryLoaded = true; + } catch (UnsatisfiedLinkError e) { + CodePushUtils.log("Unable to load the binary patch library: " + e.getMessage()); + } + } + + return sLibraryLoaded; + } + + /** + * Writes the bundle that `patch` produces from `base` to `outputPath`. + * + * The base bundle and the patch are passed as arrays because the applier needs random + * access to both, while the restored bundle is written straight to a file, so the two + * bundles are never in memory at the same time. + * + * @return one of the RESULT_* codes of {@link CodePushBinaryPatch.PatchApplier} + */ + private static native int applyPatch(byte[] base, byte[] patch, String outputPath, long expectedTargetSize); +} diff --git a/android/app/src/test/java/com/microsoft/codepush/react/CodePushBinaryPatchTest.java b/android/app/src/test/java/com/microsoft/codepush/react/CodePushBinaryPatchTest.java new file mode 100644 index 000000000..84c49bf49 --- /dev/null +++ b/android/app/src/test/java/com/microsoft/codepush/react/CodePushBinaryPatchTest.java @@ -0,0 +1,393 @@ +package com.microsoft.codepush.react; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.json.JSONObject; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.Charset; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public class CodePushBinaryPatchTest { + + private static final String BUNDLE_FILE_NAME = "index.android.bundle"; + private static final String PATCH_FILE_NAME = BUNDLE_FILE_NAME + ".patch"; + + private static final byte[] BASE_BUNDLE = bytes("the bundle inside the app binary"); + private static final byte[] TARGET_BUNDLE = bytes("the bundle the update wants to run"); + private static final byte[] PATCH = bytes("the difference between the two"); + + @Rule + public TemporaryFolder mTemporaryFolder = new TemporaryFolder(); + + private File mContentsFolder; + private File mWorkingFolder; + private File mManifestFile; + private File mPatchFile; + private File mBundleFile; + + @Before + public void setUp() throws IOException { + mContentsFolder = mTemporaryFolder.newFolder("unzipped"); + mWorkingFolder = new File(mTemporaryFolder.getRoot(), CodePushConstants.BINARY_PATCH_FOLDER_NAME); + mManifestFile = new File(mContentsFolder, CodePushConstants.BINARY_PATCH_MANIFEST_FILE_NAME); + mPatchFile = new File(mContentsFolder, PATCH_FILE_NAME); + mBundleFile = new File(mContentsFolder, BUNDLE_FILE_NAME); + + writeFile(mPatchFile, PATCH); + writeManifest(validManifest()); + } + + @Test + public void appliesAPatchAndLeavesTheContentsOfAFullArchiveBehind() throws IOException { + FakePatchApplier applier = new FakePatchApplier(CodePushBinaryPatch.PatchApplier.RESULT_OK, TARGET_BUNDLE); + + BinaryPatchResult result = restoreBundle(BASE_BUNDLE, applier); + + assertTrue(result.succeeded()); + assertArrayEquals(TARGET_BUNDLE, readFile(mBundleFile)); + assertFalse("the patch file is not part of the update", mPatchFile.exists()); + assertFalse("the manifest is not part of the update", mManifestFile.exists()); + assertArrayEquals(BASE_BUNDLE, applier.base); + assertArrayEquals(PATCH, applier.patch); + assertEquals(TARGET_BUNDLE.length, applier.expectedTargetSize); + assertNoTemporaryFilesLeft(); + } + + @Test + public void appliesAPatchThatSitsInTheArchivesOwnRootDirectory() throws IOException { + // An archive wraps its files in one directory, and the manifest's paths are relative + // to that directory rather than to the unzipped archive. + File unzippedFolder = mTemporaryFolder.newFolder("archive"); + File archiveRoot = new File(unzippedFolder, "CodePush"); + assertTrue(archiveRoot.mkdirs()); + assertTrue(mManifestFile.renameTo(new File(archiveRoot, CodePushConstants.BINARY_PATCH_MANIFEST_FILE_NAME))); + assertTrue(mPatchFile.renameTo(new File(archiveRoot, PATCH_FILE_NAME))); + + BinaryPatchResult result = new CodePushBinaryPatch(providerOf(BASE_BUNDLE), succeedingApplier()) + .restoreBundle(unzippedFolder.getAbsolutePath(), mWorkingFolder.getAbsolutePath(), BUNDLE_FILE_NAME); + + assertTrue(result.succeeded()); + assertArrayEquals(TARGET_BUNDLE, readFile(new File(archiveRoot, BUNDLE_FILE_NAME))); + assertFalse(new File(archiveRoot, PATCH_FILE_NAME).exists()); + assertFalse(new File(archiveRoot, CodePushConstants.BINARY_PATCH_MANIFEST_FILE_NAME).exists()); + assertNoTemporaryFilesLeft(); + } + + @Test + public void removesWhatAnInterruptedAttemptLeftInTheWorkingDirectory() throws IOException { + assertTrue(mWorkingFolder.mkdirs()); + writeFile(new File(mWorkingFolder, CodePushConstants.BINARY_PATCH_TARGET_FILE_NAME), bytes("half a bundle")); + + BinaryPatchResult result = restoreBundle(BASE_BUNDLE, + new FakePatchApplier(CodePushBinaryPatch.PatchApplier.RESULT_OK, TARGET_BUNDLE)); + + assertTrue(result.succeeded()); + assertArrayEquals(TARGET_BUNDLE, readFile(mBundleFile)); + assertNoTemporaryFilesLeft(); + } + + @Test + public void reportsAnInvalidManifestWhenThereIsNone() { + assertTrue(mManifestFile.delete()); + + assertFailure(BinaryPatchResult.REASON_INVALID_MANIFEST, restoreBundle(BASE_BUNDLE, succeedingApplier())); + } + + @Test + public void reportsAnInvalidManifestWhenItIsNotJson() throws IOException { + writeFile(mManifestFile, bytes("not json at all")); + + assertFailure(BinaryPatchResult.REASON_INVALID_MANIFEST, restoreBundle(BASE_BUNDLE, succeedingApplier())); + } + + @Test + public void reportsAnUnsupportedFormatForAnotherFormatVersion() throws IOException { + writeManifest(putValue(validManifest(), CodePushConstants.BINARY_PATCH_FORMAT_VERSION_KEY, + CodePushConstants.BINARY_PATCH_FORMAT_VERSION + 1)); + + assertFailure(BinaryPatchResult.REASON_UNSUPPORTED_FORMAT, restoreBundle(BASE_BUNDLE, succeedingApplier())); + } + + @Test + public void reportsAnUnsupportedFormatForAnotherAlgorithm() throws IOException { + writeManifest(putValue(validManifest(), CodePushConstants.BINARY_PATCH_ALGORITHM_KEY, "bsdiff-bz2")); + + assertFailure(BinaryPatchResult.REASON_UNSUPPORTED_FORMAT, restoreBundle(BASE_BUNDLE, succeedingApplier())); + } + + @Test + public void reportsAnInvalidManifestWhenTheBundlePathLeavesTheArchive() throws IOException { + writeManifest(putValue(validManifest(), CodePushConstants.BINARY_PATCH_BUNDLE_PATH_KEY, + "../" + BUNDLE_FILE_NAME)); + + assertFailure(BinaryPatchResult.REASON_INVALID_MANIFEST, restoreBundle(BASE_BUNDLE, succeedingApplier())); + assertFalse(new File(mTemporaryFolder.getRoot(), BUNDLE_FILE_NAME).exists()); + } + + @Test + public void reportsAnInvalidManifestWhenThePatchPathLeavesTheArchive() throws IOException { + writeFile(new File(mTemporaryFolder.getRoot(), PATCH_FILE_NAME), PATCH); + writeManifest(putValue(validManifest(), CodePushConstants.BINARY_PATCH_FILE_KEY, "../" + PATCH_FILE_NAME)); + + assertFailure(BinaryPatchResult.REASON_INVALID_MANIFEST, restoreBundle(BASE_BUNDLE, succeedingApplier())); + } + + @Test + public void reportsAnInvalidManifestWhenTheBundlePathIsAbsolute() throws IOException { + writeManifest(putValue(validManifest(), CodePushConstants.BINARY_PATCH_BUNDLE_PATH_KEY, + new File(mTemporaryFolder.getRoot(), BUNDLE_FILE_NAME).getAbsolutePath())); + + assertFailure(BinaryPatchResult.REASON_INVALID_MANIFEST, restoreBundle(BASE_BUNDLE, succeedingApplier())); + } + + @Test + public void reportsAnInvalidManifestWhenTheArchiveHasNoPatchFile() { + assertTrue(mPatchFile.delete()); + + assertFailure(BinaryPatchResult.REASON_INVALID_MANIFEST, restoreBundle(BASE_BUNDLE, succeedingApplier())); + } + + @Test + public void reportsAnInvalidManifestWhenTheTargetSizeIsEmpty() throws IOException { + writeManifest(putValue(validManifest(), CodePushConstants.BINARY_PATCH_TARGET_BUNDLE_SIZE_KEY, 0)); + + assertFailure(BinaryPatchResult.REASON_INVALID_MANIFEST, restoreBundle(BASE_BUNDLE, succeedingApplier())); + } + + @Test + public void reportsAnInvalidManifestWhenTheTargetSizeIsBeyondTheLimit() throws IOException { + writeManifest(putValue(validManifest(), CodePushConstants.BINARY_PATCH_TARGET_BUNDLE_SIZE_KEY, + CodePushConstants.BINARY_PATCH_MAX_TARGET_BUNDLE_SIZE + 1)); + + assertFailure(BinaryPatchResult.REASON_INVALID_MANIFEST, restoreBundle(BASE_BUNDLE, succeedingApplier())); + } + + @Test + public void reportsAnUnavailableBaseBundleWhenTheBinaryBundleCannotBeRead() { + CodePushBinaryPatch binaryPatch = new CodePushBinaryPatch(new CodePushBinaryPatch.BaseBundleProvider() { + @Override + public byte[] readBaseBundle(String bundleFileName) throws IOException { + throw new IOException("no such asset: " + bundleFileName); + } + }, succeedingApplier()); + + assertFailure(BinaryPatchResult.REASON_BASE_BUNDLE_UNAVAILABLE, restoreBundle(binaryPatch)); + } + + @Test + public void reportsABaseHashMismatchWhenTheBinaryHoldsAnotherBundle() { + FakePatchApplier applier = new FakePatchApplier(CodePushBinaryPatch.PatchApplier.RESULT_OK, TARGET_BUNDLE); + + BinaryPatchResult result = restoreBundle(bytes("a bundle from another app build"), applier); + + assertFailure(BinaryPatchResult.REASON_BASE_HASH_MISMATCH, result); + assertEquals("a patch is not applied to a base it was not computed against", 0, applier.invocationCount); + } + + @Test + public void reportsAnUnsupportedFormatWhenTheApplierRejectsTheCodec() { + BinaryPatchResult result = restoreBundle(BASE_BUNDLE, + new FakePatchApplier(CodePushBinaryPatch.PatchApplier.RESULT_UNSUPPORTED_COMPRESSION, null)); + + assertFailure(BinaryPatchResult.REASON_UNSUPPORTED_FORMAT, result); + } + + @Test + public void reportsAFailedApplyWhenThePatchHeaderIsCorrupt() { + BinaryPatchResult result = restoreBundle(BASE_BUNDLE, + new FakePatchApplier(CodePushBinaryPatch.PatchApplier.RESULT_INVALID_HEADER, null)); + + assertFailure(BinaryPatchResult.REASON_PATCH_APPLY_FAILED, result); + assertNoRestoredBundleInTheContents(); + } + + @Test + public void reportsAFailedApplyWhenTheNativeLibraryIsMissing() { + BinaryPatchResult result = restoreBundle(BASE_BUNDLE, + new FakePatchApplier(CodePushBinaryPatch.PatchApplier.RESULT_LIBRARY_UNAVAILABLE, null)); + + assertFailure(BinaryPatchResult.REASON_PATCH_APPLY_FAILED, result); + } + + @Test + public void reportsAFailedVerificationWhenTheRestoredBundleHasAnotherSize() { + BinaryPatchResult result = restoreBundle(BASE_BUNDLE, + new FakePatchApplier(CodePushBinaryPatch.PatchApplier.RESULT_OK, bytes("too short"))); + + assertFailure(BinaryPatchResult.REASON_TARGET_VERIFICATION_FAILED, result); + assertNoRestoredBundleInTheContents(); + } + + @Test + public void reportsAFailedVerificationWhenTheRestoredBundleHasAnotherContent() { + // A corrupted patch body applies without any error and produces wrong bytes, which + // only the target hash catches. + byte[] wrongBundle = TARGET_BUNDLE.clone(); + wrongBundle[0] = (byte) (wrongBundle[0] ^ 0xFF); + + BinaryPatchResult result = restoreBundle(BASE_BUNDLE, + new FakePatchApplier(CodePushBinaryPatch.PatchApplier.RESULT_OK, wrongBundle)); + + assertFailure(BinaryPatchResult.REASON_TARGET_VERIFICATION_FAILED, result); + assertNoRestoredBundleInTheContents(); + } + + private BinaryPatchResult restoreBundle(byte[] baseBundle, CodePushBinaryPatch.PatchApplier applier) { + return restoreBundle(new CodePushBinaryPatch(providerOf(baseBundle), applier)); + } + + private BinaryPatchResult restoreBundle(CodePushBinaryPatch binaryPatch) { + return binaryPatch.restoreBundle(mContentsFolder.getAbsolutePath(), mWorkingFolder.getAbsolutePath(), + BUNDLE_FILE_NAME); + } + + private void assertFailure(String expectedReason, BinaryPatchResult result) { + assertFalse(result.succeeded()); + assertEquals(expectedReason, result.getFailureReason()); + assertNoTemporaryFilesLeft(); + } + + private void assertNoTemporaryFilesLeft() { + assertFalse("the working directory outlived the patch attempt", mWorkingFolder.exists()); + } + + /** The update must never be installed from bytes that did not pass verification. */ + private void assertNoRestoredBundleInTheContents() { + assertFalse(mBundleFile.exists()); + assertTrue(mPatchFile.exists()); + assertTrue(mManifestFile.exists()); + } + + private JSONObject validManifest() { + JSONObject manifest = new JSONObject(); + putValue(manifest, CodePushConstants.BINARY_PATCH_FORMAT_VERSION_KEY, CodePushConstants.BINARY_PATCH_FORMAT_VERSION); + putValue(manifest, CodePushConstants.BINARY_PATCH_ALGORITHM_KEY, CodePushConstants.BINARY_PATCH_ALGORITHM); + putValue(manifest, CodePushConstants.BINARY_PATCH_BUNDLE_PATH_KEY, BUNDLE_FILE_NAME); + putValue(manifest, CodePushConstants.BINARY_PATCH_FILE_KEY, PATCH_FILE_NAME); + putValue(manifest, CodePushConstants.BINARY_PATCH_BASE_BUNDLE_HASH_KEY, sha256(BASE_BUNDLE)); + putValue(manifest, CodePushConstants.BINARY_PATCH_TARGET_BUNDLE_HASH_KEY, sha256(TARGET_BUNDLE)); + putValue(manifest, CodePushConstants.BINARY_PATCH_TARGET_BUNDLE_SIZE_KEY, TARGET_BUNDLE.length); + return manifest; + } + + private static JSONObject putValue(JSONObject manifest, String key, Object value) { + CodePushUtils.setJSONValueForKey(manifest, key, value); + return manifest; + } + + private void writeManifest(JSONObject manifest) throws IOException { + writeFile(mManifestFile, bytes(manifest.toString())); + } + + private static CodePushBinaryPatch.BaseBundleProvider providerOf(final byte[] baseBundle) { + return new CodePushBinaryPatch.BaseBundleProvider() { + @Override + public byte[] readBaseBundle(String bundleFileName) { + assertEquals(BUNDLE_FILE_NAME, bundleFileName); + return baseBundle; + } + }; + } + + private static CodePushBinaryPatch.PatchApplier succeedingApplier() { + return new FakePatchApplier(CodePushBinaryPatch.PatchApplier.RESULT_OK, TARGET_BUNDLE); + } + + /** Stands in for the native applier, which is exercised on a device instead. */ + private static class FakePatchApplier implements CodePushBinaryPatch.PatchApplier { + + private final int mResultCode; + private final byte[] mRestoredBundle; + + int invocationCount; + byte[] base; + byte[] patch; + long expectedTargetSize; + + FakePatchApplier(int resultCode, byte[] restoredBundle) { + mResultCode = resultCode; + mRestoredBundle = restoredBundle; + } + + @Override + public int apply(byte[] base, byte[] patch, String outputPath, long expectedTargetSize) { + this.invocationCount++; + this.base = base; + this.patch = patch; + this.expectedTargetSize = expectedTargetSize; + + if (mRestoredBundle != null) { + try { + writeFile(new File(outputPath), mRestoredBundle); + } catch (IOException e) { + return RESULT_IO_ERROR; + } + } + + return mResultCode; + } + } + + private static void writeFile(File file, byte[] contents) throws IOException { + OutputStream output = new FileOutputStream(file); + try { + output.write(contents); + } finally { + output.close(); + } + } + + private static byte[] readFile(File file) throws IOException { + byte[] contents = new byte[(int) file.length()]; + InputStream input = new FileInputStream(file); + try { + int offset = 0; + while (offset < contents.length) { + int bytesRead = input.read(contents, offset, contents.length - offset); + if (bytesRead < 0) { + throw new IOException("Unexpected end of " + file); + } + offset += bytesRead; + } + } finally { + input.close(); + } + + return contents; + } + + private static byte[] bytes(String text) { + return text.getBytes(Charset.forName("UTF-8")); + } + + /** Hashes the way a manifest does, without going through the code under test. */ + private static String sha256(byte[] data) { + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + + StringBuilder hex = new StringBuilder(); + for (byte hashByte : digest.digest(data)) { + hex.append(String.format("%02x", hashByte)); + } + + return hex.toString(); + } +} diff --git a/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerBinaryPatchTest.java b/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerBinaryPatchTest.java new file mode 100644 index 000000000..62a46fc2a --- /dev/null +++ b/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerBinaryPatchTest.java @@ -0,0 +1,159 @@ +package com.microsoft.codepush.react; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.json.JSONObject; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Which archive an update is downloaded from, and how the one download that is allowed to + * fail falls back to the other one. + */ +public class CodePushUpdateManagerBinaryPatchTest { + + private static final String BUNDLE_FILE_NAME = "index.android.bundle"; + private static final String FULL_ARCHIVE_URL = "https://example.test/updates/full.zip"; + private static final String PATCH_ARCHIVE_URL = "https://example.test/updates/full.zip-patch.zip"; + + @Rule + public TemporaryFolder mTemporaryFolder = new TemporaryFolder(); + + private String mDocumentsDirectory; + private File mBinaryPatchFolder; + + @Before + public void setUp() { + mDocumentsDirectory = mTemporaryFolder.getRoot().getAbsolutePath(); + mBinaryPatchFolder = new File( + new File(mDocumentsDirectory, CodePushConstants.CODE_PUSH_FOLDER_PREFIX), + CodePushConstants.BINARY_PATCH_FOLDER_NAME); + } + + @Test + public void downloadsTheFullArchiveWhenTheUpdateHasNoBinaryPatch() throws IOException { + RecordingUpdateManager updateManager = new RecordingUpdateManager(mDocumentsDirectory, + BinaryPatchResult.success()); + + updateManager.downloadPackage(updatePackage(null), BUNDLE_FILE_NAME, ignoreProgress()); + + assertEquals(Arrays.asList(FULL_ARCHIVE_URL), updateManager.downloadedUrls); + assertEquals(Arrays.asList(false), updateManager.patchAttempts); + } + + @Test + public void downloadsOnlyTheBinaryPatchArchiveWhenThePatchApplies() throws IOException { + RecordingUpdateManager updateManager = new RecordingUpdateManager(mDocumentsDirectory, + BinaryPatchResult.success()); + + updateManager.downloadPackage(updatePackage(PATCH_ARCHIVE_URL), BUNDLE_FILE_NAME, ignoreProgress()); + + assertEquals(Arrays.asList(PATCH_ARCHIVE_URL), updateManager.downloadedUrls); + assertEquals(Arrays.asList(true), updateManager.patchAttempts); + } + + @Test + public void fallsBackToTheFullArchiveOnceWhenThePatchCannotBeApplied() throws IOException { + RecordingUpdateManager updateManager = new RecordingUpdateManager(mDocumentsDirectory, + BinaryPatchResult.failure(BinaryPatchResult.REASON_BASE_HASH_MISMATCH)); + + updateManager.downloadPackage(updatePackage(PATCH_ARCHIVE_URL), BUNDLE_FILE_NAME, ignoreProgress()); + + assertEquals(Arrays.asList(PATCH_ARCHIVE_URL, FULL_ARCHIVE_URL), updateManager.downloadedUrls); + // The second download is not a patch download, so it has no patch of its own to fail + // at: the fallback can only happen the once. + assertEquals(Arrays.asList(true, false), updateManager.patchAttempts); + } + + @Test + public void fallsBackToTheFullArchiveWhenTheBinaryPatchArchiveCannotBeDownloaded() throws IOException { + RecordingUpdateManager updateManager = new RecordingUpdateManager(mDocumentsDirectory, null); + + updateManager.downloadPackage(updatePackage(PATCH_ARCHIVE_URL), BUNDLE_FILE_NAME, ignoreProgress()); + + assertEquals(Arrays.asList(PATCH_ARCHIVE_URL, FULL_ARCHIVE_URL), updateManager.downloadedUrls); + assertEquals(Arrays.asList(true, false), updateManager.patchAttempts); + } + + @Test + public void removesTheBinaryPatchWorkingDirectoryWhateverTheOutcomeIs() throws IOException { + List outcomes = Arrays.asList(BinaryPatchResult.success(), + BinaryPatchResult.failure(BinaryPatchResult.REASON_TARGET_VERIFICATION_FAILED), null); + + for (BinaryPatchResult outcome : outcomes) { + assertTrue(mBinaryPatchFolder.mkdirs()); + assertTrue(new File(mBinaryPatchFolder, CodePushConstants.BINARY_PATCH_TARGET_FILE_NAME).createNewFile()); + + new RecordingUpdateManager(mDocumentsDirectory, outcome) + .downloadPackage(updatePackage(PATCH_ARCHIVE_URL), BUNDLE_FILE_NAME, ignoreProgress()); + + assertFalse(mBinaryPatchFolder.exists()); + } + } + + private static JSONObject updatePackage(String binaryPatchDownloadUrl) { + JSONObject updatePackage = new JSONObject(); + CodePushUtils.setJSONValueForKey(updatePackage, CodePushConstants.PACKAGE_HASH_KEY, "package-hash"); + CodePushUtils.setJSONValueForKey(updatePackage, CodePushConstants.DOWNLOAD_URL_KEY, FULL_ARCHIVE_URL); + if (binaryPatchDownloadUrl != null) { + CodePushUtils.setJSONValueForKey(updatePackage, CodePushConstants.BINARY_PATCH_DOWNLOAD_URL_KEY, + binaryPatchDownloadUrl); + } + + return updatePackage; + } + + private static DownloadProgressCallback ignoreProgress() { + return new DownloadProgressCallback() { + @Override + public void call(DownloadProgress downloadProgress) { + } + }; + } + + /** + * Records which archive each download went to instead of downloading and installing it. + * Downloading is what these cover, so the manager is built without the collaborator that + * applies a patch - nothing here reaches it. + */ + private static class RecordingUpdateManager extends CodePushUpdateManager { + + private final BinaryPatchResult mPatchOutcome; + + final List downloadedUrls = new ArrayList<>(); + final List patchAttempts = new ArrayList<>(); + + /** @param patchOutcome what the patch download ends in, or null when it cannot be downloaded */ + RecordingUpdateManager(String documentsDirectory, BinaryPatchResult patchOutcome) { + super(documentsDirectory, (CodePushBinaryPatch) null); + mPatchOutcome = patchOutcome; + } + + @Override + BinaryPatchResult downloadAndInstallPackage(JSONObject updatePackage, String expectedBundleFileName, + DownloadProgressCallback progressCallback, + String downloadUrlString, boolean isBinaryPatchUpdate) throws IOException { + downloadedUrls.add(downloadUrlString); + patchAttempts.add(isBinaryPatchUpdate); + + if (!isBinaryPatchUpdate) { + return BinaryPatchResult.success(); + } + if (mPatchOutcome == null) { + throw new IOException("the binary patch archive is not there"); + } + + return mPatchOutcome; + } + } +} diff --git a/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerDownloadTest.java b/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerDownloadTest.java new file mode 100644 index 000000000..2b7af6676 --- /dev/null +++ b/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerDownloadTest.java @@ -0,0 +1,414 @@ +package com.microsoft.codepush.react; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.json.JSONObject; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.Charset; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * Downloading a real archive over HTTP and installing what comes out of it. + * + * These exercise the update manager end to end - download, unzip, restore, folder hash, + * metadata - with only the two seams of the applier stubbed, because the wiring between + * those steps is where an archive that is not what it claims has to be caught. + */ +public class CodePushUpdateManagerDownloadTest { + + private static final String BUNDLE_FILE_NAME = "index.android.bundle"; + /** Every archive wraps its files in one directory, which the manifest paths are relative to. */ + private static final String CONTENTS_DIR_NAME = "CodePush"; + private static final String ASSET_PATH = "assets/logo.png"; + + private static final byte[] BASE_BUNDLE = bytes("the bundle inside the app binary"); + private static final byte[] TARGET_BUNDLE = bytes("the bundle the update wants to run"); + private static final byte[] PATCH = bytes("the difference between the two"); + private static final byte[] ASSET = bytes("an image the update ships with"); + private static final byte[] ERROR_PAGE = bytes("404 Not Found"); + + @Rule + public TemporaryFolder mTemporaryFolder = new TemporaryFolder(); + + private TestArchiveServer mServer; + + private String mDocumentsDirectory; + private String mPackageHash; + private File mPackageFolder; + + @Before + public void setUp() throws IOException { + mDocumentsDirectory = mTemporaryFolder.getRoot().getAbsolutePath(); + mPackageHash = packageHashOf(fullArchiveContents()); + mPackageFolder = new File(new File(mDocumentsDirectory, CodePushConstants.CODE_PUSH_FOLDER_PREFIX), mPackageHash); + + mServer = new TestArchiveServer(); + } + + @After + public void tearDown() throws IOException { + mServer.close(); + } + + @Test + public void installsAnUpdateFromItsBinaryPatchArchive() throws IOException { + String patchUrl = serve("/patch.zip", zipOf(patchArchiveContents())); + String fullUrl = serve("/full.zip", zipOf(fullArchiveContents())); + + updateManager(applierWriting(TARGET_BUNDLE)) + .downloadPackage(updatePackage(fullUrl, patchUrl), BUNDLE_FILE_NAME, ignoreProgress()); + + assertEquals("the full archive is not downloaded when the patch installs", + Arrays.asList("/patch.zip"), mServer.requestedPaths()); + assertInstalledContents(); + } + + @Test + public void fallsBackToTheFullArchiveWhenThePatchUrlDoesNotServeAnArchive() throws IOException { + // A CDN that answers an error page with a 200 is the realistic way this happens. + String patchUrl = serve("/patch.zip", ERROR_PAGE); + String fullUrl = serve("/full.zip", zipOf(fullArchiveContents())); + + updateManager(applierWriting(TARGET_BUNDLE)) + .downloadPackage(updatePackage(fullUrl, patchUrl), BUNDLE_FILE_NAME, ignoreProgress()); + + assertEquals(Arrays.asList("/patch.zip", "/full.zip"), mServer.requestedPaths()); + assertInstalledContents(); + } + + @Test + public void reportsAnInvalidManifestWhenThePatchUrlDoesNotServeAnArchive() throws IOException { + String patchUrl = serve("/patch.zip", ERROR_PAGE); + + BinaryPatchResult result = updateManager(applierWriting(TARGET_BUNDLE)).downloadAndInstallPackage( + updatePackage("https://example.test/unused.zip", patchUrl), BUNDLE_FILE_NAME, ignoreProgress(), + patchUrl, true); + + assertFalse(result.succeeded()); + assertEquals(BinaryPatchResult.REASON_INVALID_MANIFEST, result.getFailureReason()); + assertFalse("bytes that are not an update must not reach the package folder", mPackageFolder.exists()); + } + + @Test + public void fallsBackToTheFullArchiveWhenApplyingThePatchRunsOutOfMemory() throws IOException { + String patchUrl = serve("/patch.zip", zipOf(patchArchiveContents())); + String fullUrl = serve("/full.zip", zipOf(fullArchiveContents())); + CodePushBinaryPatch.PatchApplier outOfMemoryApplier = new CodePushBinaryPatch.PatchApplier() { + @Override + public int apply(byte[] base, byte[] patch, String outputPath, long expectedTargetSize) { + throw new OutOfMemoryError("Failed to allocate the restored bundle"); + } + }; + + updateManager(outOfMemoryApplier) + .downloadPackage(updatePackage(fullUrl, patchUrl), BUNDLE_FILE_NAME, ignoreProgress()); + + assertEquals(Arrays.asList("/patch.zip", "/full.zip"), mServer.requestedPaths()); + assertInstalledContents(); + } + + /** The installed update is the full archive's contents, whichever archive it came from. */ + private void assertInstalledContents() throws IOException { + File contentsFolder = new File(mPackageFolder, CONTENTS_DIR_NAME); + assertArrayEquals(TARGET_BUNDLE, readFile(new File(contentsFolder, BUNDLE_FILE_NAME))); + assertArrayEquals(ASSET, readFile(new File(contentsFolder, ASSET_PATH))); + assertFalse(new File(contentsFolder, CodePushConstants.BINARY_PATCH_MANIFEST_FILE_NAME).exists()); + assertFalse(new File(contentsFolder, BUNDLE_FILE_NAME + ".patch").exists()); + + // Written last, so its presence also says the folder hash check passed. + JSONObject metadata = CodePushUtils.getJsonObjectFromFile( + new File(mPackageFolder, CodePushConstants.PACKAGE_FILE_NAME).getAbsolutePath()); + String bundlePath = metadata.optString(CodePushConstants.RELATIVE_BUNDLE_PATH_KEY, null); + assertTrue("the metadata points at the restored bundle, but says " + bundlePath, + bundlePath != null && bundlePath.endsWith(CONTENTS_DIR_NAME + "/" + BUNDLE_FILE_NAME)); + + File binaryPatchFolder = new File( + new File(mDocumentsDirectory, CodePushConstants.CODE_PUSH_FOLDER_PREFIX), + CodePushConstants.BINARY_PATCH_FOLDER_NAME); + assertFalse(binaryPatchFolder.exists()); + } + + private CodePushUpdateManager updateManager(CodePushBinaryPatch.PatchApplier applier) { + CodePushBinaryPatch binaryPatch = new CodePushBinaryPatch(new CodePushBinaryPatch.BaseBundleProvider() { + @Override + public byte[] readBaseBundle(String bundleFileName) { + return BASE_BUNDLE; + } + }, applier); + + return new CodePushUpdateManager(mDocumentsDirectory, binaryPatch); + } + + private static CodePushBinaryPatch.PatchApplier applierWriting(final byte[] restoredBundle) { + return new CodePushBinaryPatch.PatchApplier() { + @Override + public int apply(byte[] base, byte[] patch, String outputPath, long expectedTargetSize) { + assertArrayEquals(BASE_BUNDLE, base); + assertArrayEquals(PATCH, patch); + try { + writeFile(new File(outputPath), restoredBundle); + } catch (IOException e) { + return RESULT_IO_ERROR; + } + + return RESULT_OK; + } + }; + } + + private Map fullArchiveContents() { + Map contents = new LinkedHashMap<>(); + contents.put(CONTENTS_DIR_NAME + "/" + BUNDLE_FILE_NAME, TARGET_BUNDLE); + contents.put(CONTENTS_DIR_NAME + "/" + ASSET_PATH, ASSET); + return contents; + } + + private Map patchArchiveContents() { + JSONObject manifest = new JSONObject(); + CodePushUtils.setJSONValueForKey(manifest, CodePushConstants.BINARY_PATCH_FORMAT_VERSION_KEY, + CodePushConstants.BINARY_PATCH_FORMAT_VERSION); + CodePushUtils.setJSONValueForKey(manifest, CodePushConstants.BINARY_PATCH_ALGORITHM_KEY, + CodePushConstants.BINARY_PATCH_ALGORITHM); + CodePushUtils.setJSONValueForKey(manifest, CodePushConstants.BINARY_PATCH_BUNDLE_PATH_KEY, BUNDLE_FILE_NAME); + CodePushUtils.setJSONValueForKey(manifest, CodePushConstants.BINARY_PATCH_FILE_KEY, BUNDLE_FILE_NAME + ".patch"); + CodePushUtils.setJSONValueForKey(manifest, CodePushConstants.BINARY_PATCH_BASE_BUNDLE_HASH_KEY, sha256(BASE_BUNDLE)); + CodePushUtils.setJSONValueForKey(manifest, CodePushConstants.BINARY_PATCH_TARGET_BUNDLE_HASH_KEY, sha256(TARGET_BUNDLE)); + CodePushUtils.setJSONValueForKey(manifest, CodePushConstants.BINARY_PATCH_TARGET_BUNDLE_SIZE_KEY, TARGET_BUNDLE.length); + + Map contents = new LinkedHashMap<>(); + contents.put(CONTENTS_DIR_NAME + "/" + CodePushConstants.BINARY_PATCH_MANIFEST_FILE_NAME, bytes(manifest.toString())); + contents.put(CONTENTS_DIR_NAME + "/" + BUNDLE_FILE_NAME + ".patch", PATCH); + contents.put(CONTENTS_DIR_NAME + "/" + ASSET_PATH, ASSET); + return contents; + } + + private JSONObject updatePackage(String downloadUrl, String binaryPatchDownloadUrl) { + JSONObject updatePackage = new JSONObject(); + CodePushUtils.setJSONValueForKey(updatePackage, CodePushConstants.PACKAGE_HASH_KEY, mPackageHash); + CodePushUtils.setJSONValueForKey(updatePackage, CodePushConstants.DOWNLOAD_URL_KEY, downloadUrl); + CodePushUtils.setJSONValueForKey(updatePackage, CodePushConstants.BINARY_PATCH_DOWNLOAD_URL_KEY, binaryPatchDownloadUrl); + return updatePackage; + } + + private String serve(String path, byte[] body) { + return mServer.serve(path, body); + } + + /** + * Serves the archives over a loopback socket, so the download really goes through + * `HttpURLConnection` the way it does on a device. Written on a plain socket rather than + * against an HTTP library because a unit test here has neither the JDK's server nor a + * dependency that could stand in for it. + */ + private static class TestArchiveServer { + + private final ServerSocket mSocket; + private final Map mBodies = new HashMap<>(); + private final List mRequestedPaths = Collections.synchronizedList(new ArrayList()); + + TestArchiveServer() throws IOException { + mSocket = new ServerSocket(0, 0, InetAddress.getByName("127.0.0.1")); + Thread serverThread = new Thread(new Runnable() { + @Override + public void run() { + serveUntilClosed(); + } + }); + serverThread.setDaemon(true); + serverThread.start(); + } + + synchronized String serve(String path, byte[] body) { + mBodies.put(path, body); + return "http://127.0.0.1:" + mSocket.getLocalPort() + path; + } + + List requestedPaths() { + return new ArrayList<>(mRequestedPaths); + } + + void close() throws IOException { + mSocket.close(); + } + + private void serveUntilClosed() { + while (!mSocket.isClosed()) { + Socket connection; + try { + connection = mSocket.accept(); + } catch (IOException e) { + // The socket was closed while waiting, which is how the test ends. + return; + } + + try { + try { + respond(connection); + } finally { + connection.close(); + } + } catch (IOException e) { + // A broken connection is the client's business, not the server's. + } + } + } + + private void respond(Socket connection) throws IOException { + BufferedReader request = new BufferedReader( + new InputStreamReader(connection.getInputStream(), Charset.forName("UTF-8"))); + String requestLine = request.readLine(); + for (String header = request.readLine(); header != null && !header.isEmpty(); header = request.readLine()) { + // The headers are read to the blank line so the request is fully consumed. + } + + String path = requestLine == null ? "" : requestLine.split(" ")[1]; + mRequestedPaths.add(path); + byte[] body = bodyFor(path); + + OutputStream response = connection.getOutputStream(); + if (body == null) { + response.write(bytes("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")); + } else { + response.write(bytes("HTTP/1.1 200 OK\r\nContent-Length: " + body.length + + "\r\nConnection: close\r\n\r\n")); + response.write(body); + } + response.flush(); + // Half-close and wait for the client to hang up, so the response is not cut off + // by closing the socket underneath it. + connection.shutdownOutput(); + while (connection.getInputStream().read() >= 0) { + // Drains whatever the client sends before it closes. + } + } + + private synchronized byte[] bodyFor(String path) { + return mBodies.get(path); + } + } + + private static byte[] zipOf(Map contents) throws IOException { + ByteArrayOutputStream archive = new ByteArrayOutputStream(); + ZipOutputStream zipStream = new ZipOutputStream(archive); + try { + for (Map.Entry entry : contents.entrySet()) { + zipStream.putNextEntry(new ZipEntry(entry.getKey())); + zipStream.write(entry.getValue()); + zipStream.closeEntry(); + } + } finally { + zipStream.close(); + } + + return archive.toByteArray(); + } + + /** + * The package hash of update contents, computed the way the CLI computes it: the sorted + * `:` entries, stringified as a JSON array, hashed. + */ + private static String packageHashOf(Map contents) { + List manifest = new ArrayList<>(); + for (Map.Entry entry : contents.entrySet()) { + manifest.add(entry.getKey() + ":" + sha256(entry.getValue())); + } + Collections.sort(manifest); + + StringBuilder entries = new StringBuilder("["); + for (int i = 0; i < manifest.size(); i++) { + if (i > 0) { + entries.append(","); + } + entries.append('"').append(manifest.get(i)).append('"'); + } + entries.append("]"); + + return sha256(bytes(entries.toString())); + } + + private static DownloadProgressCallback ignoreProgress() { + return new DownloadProgressCallback() { + @Override + public void call(DownloadProgress downloadProgress) { + } + }; + } + + private static void writeFile(File file, byte[] contents) throws IOException { + OutputStream output = new FileOutputStream(file); + try { + output.write(contents); + } finally { + output.close(); + } + } + + private static byte[] readFile(File file) throws IOException { + byte[] contents = new byte[(int) file.length()]; + InputStream input = new FileInputStream(file); + try { + int offset = 0; + while (offset < contents.length) { + int bytesRead = input.read(contents, offset, contents.length - offset); + if (bytesRead < 0) { + throw new IOException("Unexpected end of " + file); + } + offset += bytesRead; + } + } finally { + input.close(); + } + + return contents; + } + + private static byte[] bytes(String text) { + return text.getBytes(Charset.forName("UTF-8")); + } + + private static String sha256(byte[] data) { + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + + StringBuilder hex = new StringBuilder(); + for (byte hashByte : digest.digest(data)) { + hex.append(String.format("%02x", hashByte)); + } + + return hex.toString(); + } +} diff --git a/cli/README.ko.md b/cli/README.ko.md index ce6f50aa3..da9c5407a 100644 --- a/cli/README.ko.md +++ b/cli/README.ko.md @@ -117,6 +117,12 @@ npx code-push release [options] ์ ˆ๊ฐ๋Ÿ‰์€ ์—…๋กœ๋“œ ์ „์— ์ถœ๋ ฅ๋ฉ๋‹ˆ๋‹ค. ๋ฆด๋ฆฌ์Šค ํžˆ์Šคํ† ๋ฆฌ ํ•ญ๋ชฉ์—๋Š” full ๋ฒˆ๋“ค URL๊ณผ ํ•จ๊ป˜ patch ๋ฒˆ๋“ค์„ ๋‚ด๋ ค๋ฐ›์„ ์ˆ˜ ์žˆ๋Š” URL์ด ๊ธฐ๋ก๋ฉ๋‹ˆ๋‹ค. +Android ํด๋ผ์ด์–ธํŠธ๋Š” patch ๋ฒˆ๋“ค์ด ์žˆ๋Š” ๋ฆด๋ฆฌ์Šค๋ผ๋ฉด patch๋กœ ์—…๋ฐ์ดํŠธ๋ฅผ ์„ค์น˜ํ•˜๊ณ , patch๋ฅผ +์ ์šฉํ•  ์ˆ˜ ์—†์œผ๋ฉด full ๋ฒˆ๋“ค์„ ๋Œ€์‹  ๋‚ด๋ ค๋ฐ›์œผ๋ฏ€๋กœ patch ๋•Œ๋ฌธ์— ์„ค์น˜๊ฐ€ ์‹คํŒจํ•˜์ง€๋Š” ์•Š์Šต๋‹ˆ๋‹ค. +patch ์ ์šฉ์€ ๋„ค์ดํ‹ฐ๋ธŒ ์ฝ”๋“œ์ด๋ฉฐ Android ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๊ฐ€ ์†Œ์Šค์—์„œ ์ง์ ‘ ๋นŒ๋“œํ•˜๋ฏ€๋กœ, ์ด ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ +์‚ฌ์šฉํ•˜๋Š” ์•ฑ์„ ๋นŒ๋“œํ•˜๋ ค๋ฉด NDK์™€ CMake๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. React Native ํ”„๋กœ์ ํŠธ๋ผ๋ฉด ๋Œ€๊ฐœ ์ด๋ฏธ +๊ฐ–์ถ”๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. + patch๋Š” ๋Œ€์ฒดํ•˜๋ ค๋Š” archive๋ณด๋‹ค ์ž‘์„ ๋•Œ๋งŒ ๋ฐฐํฌํ•  ๊ฐ€์น˜๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. CLI๋Š” ์‚ฌ์šฉ์ž์—๊ฒŒ ๋ฌป์ง€ ์•Š์œผ๋ฏ€๋กœ, patch ํฌ๊ธฐ๊ฐ€ full ์ด์ƒ์ผ ๋•Œ์˜ ๋™์ž‘์„ `--on-oversized-patch`๋กœ ๋ฏธ๋ฆฌ ์ •ํ•ฉ๋‹ˆ๋‹ค. ๊ธฐ๋ณธ๊ฐ’ `skip`์€ ๊ฒฝ๊ณ ๋ฅผ ๋‚จ๊ธฐ๊ณ  ์š”์•ฝ์— skip ์‚ฌ์‹ค์„ ๋ช…์‹œํ•œ ๋’ค full ๋ฒˆ๋“ค๋งŒ ๋ฐฐํฌํ•˜๋ฉฐ, `fail`์€ diff --git a/cli/README.md b/cli/README.md index b063889fb..5deba4434 100644 --- a/cli/README.md +++ b/cli/README.md @@ -116,6 +116,12 @@ applying it yields the same `packageHash` as the full bundle. Both sizes and the are printed before either artifact is uploaded. The release history entry records where the patch bundle can be downloaded, next to the full bundle URL. +An Android client installs the update from the patch bundle when the release has one, and +downloads the full bundle instead whenever the patch cannot be applied, so a release is +never left uninstallable by a patch. Applying a patch is native code, which the Android +library builds from source: an app that depends on it needs the NDK and CMake, both of +which a React Native project normally already has. + A patch is only worth publishing when it is smaller than the archive it replaces. The CLI never prompts, so `--on-oversized-patch` decides in advance what happens when the patch comes out the same size or larger: `skip` (the default) logs a warning, notes the skip in diff --git a/src/CodePush.js b/src/CodePush.js index 1f90131c4..85a878dbd 100644 --- a/src/CodePush.js +++ b/src/CodePush.js @@ -239,8 +239,9 @@ async function checkForUpdate(handleBinaryVersionMismatchCallback = null) { * 4) The server said there is an update, but the update's hash is the same as that * of the binary's currently running version. This should only happen in Android - * unlike iOS, we don't attach the binary's hash to the updateCheck request - * because we want to avoid having to install diff updates against the binary's - * version, which we can't do yet on Android. + * because an update built against the binary's version does not need one: a + * release published with a binary patch carries the patch archive next to the + * full one, and the client decides for itself which of the two to install. */ if (!update || update.updateAppVersion || localPackage && (update.packageHash === localPackage.packageHash) ||