diff --git a/CodePush.podspec b/CodePush.podspec index fc1cf8f95..c2d03f4ee 100644 --- a/CodePush.podspec +++ b/CodePush.podspec @@ -12,11 +12,40 @@ Pod::Spec.new do |s| s.source = { :git => 'https://github.com/Soomgo-Mobile/react-native-code-push.git', :tag => "v#{s.version}"} s.ios.deployment_target = '15.5' s.tvos.deployment_target = '15.5' - s.preserve_paths = '*.js' + s.preserve_paths = ['*.js', 'cpp/binarypatch/**/*.h'] s.library = 'z' - s.source_files = 'ios/CodePush/*.{h,m,mm}' + # The applier that installs a binary patch update is compiled from the C sources the + # other platform and the host build compile as well, referenced where they live rather + # than copied here, which is what keeps the appliers of the platforms from drifting + # apart. Only the decompressing half of zstd is needed, and its assembly fast path is + # deliberately not vendored, so this list is the same one the other platform builds. + s.source_files = [ + 'ios/CodePush/*.{h,m,mm}', + 'cpp/binarypatch/binarypatch_zstd_decompressor.c', + 'cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/patch.c', + 'cpp/binarypatch/vendor/zstd/common/debug.c', + 'cpp/binarypatch/vendor/zstd/common/entropy_common.c', + 'cpp/binarypatch/vendor/zstd/common/error_private.c', + 'cpp/binarypatch/vendor/zstd/common/fse_decompress.c', + 'cpp/binarypatch/vendor/zstd/common/xxhash.c', + 'cpp/binarypatch/vendor/zstd/common/zstd_common.c', + 'cpp/binarypatch/vendor/zstd/decompress/huf_decompress.c', + 'cpp/binarypatch/vendor/zstd/decompress/zstd_ddict.c', + 'cpp/binarypatch/vendor/zstd/decompress/zstd_decompress.c', + 'cpp/binarypatch/vendor/zstd/decompress/zstd_decompress_block.c' + ] s.public_header_files = ['ios/CodePush/CodePush.h'] + binary_patch_header_search_paths = '"$(PODS_TARGET_SRCROOT)/cpp/binarypatch" "$(PODS_TARGET_SRCROOT)/cpp/binarypatch/vendor/HDiffPatch" "$(PODS_TARGET_SRCROOT)/cpp/binarypatch/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. + binary_patch_preprocessor_definitions = '$(inherited) ZSTD_DISABLE_ASM=1 _IS_USED_MULTITHREAD=0' + + s.pod_target_xcconfig = { + 'HEADER_SEARCH_PATHS' => binary_patch_header_search_paths, + 'GCC_PREPROCESSOR_DEFINITIONS' => binary_patch_preprocessor_definitions + } + # Note: Even though there are copy/pasted versions of some of these dependencies in the repo, # we explicitly let CocoaPods pull in the versions below so all dependencies are resolved and # linked properly at a parent workspace level. @@ -25,9 +54,12 @@ Pod::Spec.new do |s| if ENV['RCT_NEW_ARCH_ENABLED'] == '1' s.compiler_flags = '-DRCT_NEW_ARCH_ENABLED=1' + # This replaces pod_target_xcconfig rather than adding to it, so what the applier + # needs to build has to be carried into it as well. s.pod_target_xcconfig = { 'CLANG_CXX_LANGUAGE_STANDARD' => 'c++17', - 'HEADER_SEARCH_PATHS' => "\"$(PODS_ROOT)/Headers/Public/ReactCodegen\" \"${PODS_CONFIGURATION_BUILD_DIR}/ReactCodegen/ReactCodegen.framework/Headers\"" + 'HEADER_SEARCH_PATHS' => "\"$(PODS_ROOT)/Headers/Public/ReactCodegen\" \"${PODS_CONFIGURATION_BUILD_DIR}/ReactCodegen/ReactCodegen.framework/Headers\" #{binary_patch_header_search_paths}", + 'GCC_PREPROCESSOR_DEFINITIONS' => binary_patch_preprocessor_definitions } s.dependency 'ReactCodegen' diff --git a/README.md b/README.md index efabaed79..814284bb3 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,10 @@ Run `cd ios && pod install && cd ..` (`npx pod-install`, `bundle exec pod install --project-directory=./ios`, ..) +The pod compiles the applier that installs binary patch updates from the C sources it +carries, so there is nothing else to install: CocoaPods builds them along with the rest +of the pod. + #### (2) Edit `AppDelegate` Code diff --git a/cli/README.ko.md b/cli/README.ko.md index da9c5407a..a9028c55d 100644 --- a/cli/README.ko.md +++ b/cli/README.ko.md @@ -117,11 +117,11 @@ npx code-push release [options] 절감량은 업로드 전에 출력됩니다. 릴리스 히스토리 항목에는 full 번들 URL과 함께 patch 번들을 내려받을 수 있는 URL이 기록됩니다. -Android 클라이언트는 patch 번들이 있는 릴리스라면 patch로 업데이트를 설치하고, patch를 -적용할 수 없으면 full 번들을 대신 내려받으므로 patch 때문에 설치가 실패하지는 않습니다. -patch 적용은 네이티브 코드이며 Android 라이브러리가 소스에서 직접 빌드하므로, 이 라이브러리를 -사용하는 앱을 빌드하려면 NDK와 CMake가 필요합니다. React Native 프로젝트라면 대개 이미 -갖추고 있습니다. +클라이언트는 patch 번들이 있는 릴리스라면 patch로 업데이트를 설치하고, patch를 적용할 수 +없으면 full 번들을 대신 내려받으므로 patch 때문에 설치가 실패하지는 않습니다. patch 적용은 +네이티브 코드이며 두 라이브러리가 같은 소스에서 직접 빌드합니다. Android는 그래서 NDK와 +CMake가 필요하지만 React Native 프로젝트라면 대개 이미 갖추고 있고, iOS는 pod가 소스를 +함께 가지고 있어 CocoaPods가 빌드하므로 따로 준비할 것이 없습니다. patch는 대체하려는 archive보다 작을 때만 배포할 가치가 있습니다. CLI는 사용자에게 묻지 않으므로, patch 크기가 full 이상일 때의 동작을 `--on-oversized-patch`로 미리 정합니다. diff --git a/cli/README.md b/cli/README.md index 5deba4434..272c9faff 100644 --- a/cli/README.md +++ b/cli/README.md @@ -116,11 +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 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 both libraries build from +the same sources: on Android that needs the NDK and CMake, both of which a React Native +project normally already has, and on iOS the pod carries the sources, so CocoaPods builds +them with nothing to add. 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 diff --git a/ios/CodePush/CodePush.h b/ios/CodePush/CodePush.h index b5acade4a..b96565acc 100644 --- a/ios/CodePush/CodePush.h +++ b/ios/CodePush/CodePush.h @@ -206,6 +206,18 @@ failCallback:(void (^)(NSError *err))failCallback; @interface CodePushUpdateUtils : NSObject +/* + * The SHA-256 of some bytes, written the way every hash of an update's + * contents is: lower case hex. + */ ++ (NSString *)computeHashForData:(NSData *)inputData; + +/* + * The same hash, of the contents of a file. Returns nil when the file + * cannot be read. + */ ++ (NSString *)computeHashForFileAtPath:(NSString *)filePath; + + (BOOL)copyEntriesInFolder:(NSString *)sourceFolder destFolder:(NSString *)destFolder error:(NSError **)error; diff --git a/ios/CodePush/CodePushBinaryPatch.h b/ios/CodePush/CodePushBinaryPatch.h new file mode 100644 index 000000000..8b6fea391 --- /dev/null +++ b/ios/CodePush/CodePushBinaryPatch.h @@ -0,0 +1,71 @@ +#import + +/* + * Why an update that was downloaded as a binary patch has to be downloaded in full + * instead. + * + * A failed restore is not an error the user ever hears about: it is the signal to + * download the update's full archive. These reasons 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. + */ + +/** The bundle inside the app binary could not be opened or read. */ +extern NSString *const CodePushBinaryPatchReasonBaseBundleUnavailable; + +/** The bundle inside the app binary is not the one the patch was computed against. */ +extern NSString *const CodePushBinaryPatchReasonBaseHashMismatch; + +/** The manifest is missing, malformed, points outside the archive, or asks for too much. */ +extern NSString *const CodePushBinaryPatchReasonInvalidManifest; + +/** The patch was produced by a format or a codec this client cannot apply. */ +extern NSString *const CodePushBinaryPatchReasonUnsupportedFormat; + +/** The applier refused the patch, or the restored bundle could not be written. */ +extern NSString *const CodePushBinaryPatchReasonPatchApplyFailed; + +/** The restored bundle is not the one the manifest promised. */ +extern NSString *const CodePushBinaryPatchReasonTargetVerificationFailed; + +/** + * 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 reason, never as an exception: the caller answers all + * of them the same way, by downloading the full archive instead. + */ +@interface CodePushBinaryPatch : NSObject + +/** + * Turns the contents of a downloaded patch archive into the contents of the full one. + * + * This reads and hashes whole bundles, so it belongs on the queue the download runs on + * and never on the main queue. + * + * @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 baseBundleURL the JS bundle that shipped inside the app binary + * @param failureReason set to the reason the full archive has to be downloaded + * instead, whenever this returns NO + */ ++ (BOOL)restoreBundleInUnzippedFolder:(NSString *)unzippedFolderPath + workingFolder:(NSString *)workingFolderPath + baseBundleURL:(NSURL *)baseBundleURL + failureReason:(NSString **)failureReason; + +@end diff --git a/ios/CodePush/CodePushBinaryPatch.m b/ios/CodePush/CodePushBinaryPatch.m new file mode 100644 index 000000000..ad65e3d4f --- /dev/null +++ b/ios/CodePush/CodePushBinaryPatch.m @@ -0,0 +1,465 @@ +#import "CodePushBinaryPatch.h" +#import "CodePush.h" + +#include +#include +#include + +/* + * The applier itself is the shared C code the other platform and the host build compile + * as well, referenced where it lives rather than copied here, which is what keeps the + * appliers of the platforms from drifting apart. It is included by this file alone, so + * no C type reaches a header of this pod. + */ +#include "binarypatch_zstd_decompressor.h" +#include "libHDiffPatch/HPatch/patch.h" + +NSString *const CodePushBinaryPatchReasonBaseBundleUnavailable = @"base_bundle_unavailable"; +NSString *const CodePushBinaryPatchReasonBaseHashMismatch = @"base_hash_mismatch"; +NSString *const CodePushBinaryPatchReasonInvalidManifest = @"invalid_manifest"; +NSString *const CodePushBinaryPatchReasonUnsupportedFormat = @"unsupported_format"; +NSString *const CodePushBinaryPatchReasonPatchApplyFailed = @"patch_apply_failed"; +NSString *const CodePushBinaryPatchReasonTargetVerificationFailed = @"target_verification_failed"; + +#pragma mark - Private constants + +/* + * The manifest a patch archive carries. These values are the format contract itself: the + * CLI that writes a manifest and the applier of the other platform spell them exactly + * this way, and an archive is unreadable to a client that spells them differently. + */ +static NSString *const BinaryPatchManifestFileName = @"codepush-binary-patch.json"; +static NSString *const BinaryPatchAlgorithm = @"hdiffpatch-m-zstd"; +static NSString *const BinaryPatchAlgorithmKey = @"algorithm"; +static NSString *const BinaryPatchBaseBundleHashKey = @"baseBundleHash"; +static NSString *const BinaryPatchBundlePathKey = @"bundlePath"; +static NSString *const BinaryPatchFileKey = @"patchFile"; +static NSString *const BinaryPatchFormatVersionKey = @"formatVersion"; +static NSString *const BinaryPatchTargetBundleHashKey = @"targetBundleHash"; +static NSString *const BinaryPatchTargetBundleSizeKey = @"targetBundleSize"; +static const NSInteger BinaryPatchFormatVersion = 1; + +/* + * The largest bundle a patch is allowed to promise. A manifest is untrusted input, and + * the size in it decides how much disk the restore asks for before a single byte of the + * patch has been read. + */ +static const long long BinaryPatchMaxTargetBundleSize = 512LL * 1024 * 1024; + +/** Name the restored bundle is written under, inside the working directory. */ +static NSString *const BinaryPatchTargetFileName = @"target.bundle"; + +#pragma mark - Native applier + +/* Scratch buffer handed to patch_decompress_with_cache() to reduce stream reads. */ +#define APPLY_CACHE_SIZE (1024 * 1024) + +/* + * What the applier below reports. + * + * These are the result codes the other platform's wrapper hands back across its own + * boundary, kept in step so both wrappers answer the same failure with the same reason. + * The code that platform reserves for a native library that could not be loaded has no + * counterpart here: the applier is linked into the app binary. + */ +typedef NS_ENUM(NSInteger, CodePushBinaryPatchApplyResult) { + CodePushBinaryPatchApplyResultOK = 0, + CodePushBinaryPatchApplyResultInvalidArgument = 1, + CodePushBinaryPatchApplyResultIOError = 2, + CodePushBinaryPatchApplyResultInvalidHeader = 3, + CodePushBinaryPatchApplyResultUnsupportedCompression = 4, + CodePushBinaryPatchApplyResultSizeMismatch = 5, + CodePushBinaryPatchApplyResultApplyFailed = 6 +}; + +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; +} + +/* + * Applies a patch to a base bundle and writes the restored bundle to a file. + * + * 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 function cannot. + */ +static CodePushBinaryPatchApplyResult CodePushApplyBinaryPatch(const unsigned char* baseData, + size_t baseSize, + const unsigned char* patchData, + size_t patchSize, + const char* outputPath, + long long expectedTargetSize) { + unsigned char* cache = NULL; + hpatch_compressedDiffInfo diffInfo; + hpatch_TDecompress decompressor; + hpatch_TStreamInput baseStream; + hpatch_TStreamInput patchStream; + hpatch_TStreamOutput targetStream; + TSequentialFileWriter writer; + hpatch_BOOL applied; + CodePushBinaryPatchApplyResult result = CodePushBinaryPatchApplyResultOK; + + if ((baseData == NULL) || (patchData == NULL) || (outputPath == NULL) || (expectedTargetSize <= 0)) { + return CodePushBinaryPatchApplyResultInvalidArgument; + } + + binarypatch_zstd_decompressor_init(&decompressor); + + if (!getCompressedDiffInfo_mem(&diffInfo, patchData, patchData + patchSize)) { + CPLog(@"The binary patch header could not be read."); + return CodePushBinaryPatchApplyResultInvalidHeader; + } + if ((strlen(diffInfo.compressType) > 0) && !decompressor.is_can_open(diffInfo.compressType)) { + CPLog(@"The binary patch uses an unsupported codec: %s", diffInfo.compressType); + return CodePushBinaryPatchApplyResultUnsupportedCompression; + } + if (diffInfo.oldDataSize != (hpatch_StreamPos_t)baseSize) { + CPLog(@"The binary patch expects a %llu byte base bundle, this one is %llu bytes.", + (unsigned long long)diffInfo.oldDataSize, (unsigned long long)baseSize); + return CodePushBinaryPatchApplyResultSizeMismatch; + } + if (diffInfo.newDataSize != (hpatch_StreamPos_t)expectedTargetSize) { + CPLog(@"The binary patch produces %llu bytes, the manifest promises %llu.", + (unsigned long long)diffInfo.newDataSize, (unsigned long long)expectedTargetSize); + return CodePushBinaryPatchApplyResultSizeMismatch; + } + + cache = (unsigned char*)malloc(APPLY_CACHE_SIZE); + if (cache == NULL) { + CPLog(@"Out of memory while allocating the binary patch cache."); + return CodePushBinaryPatchApplyResultIOError; + } + + writer.file = fopen(outputPath, "wb"); + writer.writtenSize = 0; + if (writer.file == NULL) { + CPLog(@"The restored bundle could not be opened for writing."); + free(cache); + return CodePushBinaryPatchApplyResultIOError; + } + + mem_as_hStreamInput(&baseStream, baseData, baseData + baseSize); + mem_as_hStreamInput(&patchStream, patchData, 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) { + CPLog(@"The restored bundle could not be flushed to disk."); + result = CodePushBinaryPatchApplyResultIOError; + } else if (!applied) { + CPLog(@"Applying the binary patch failed (decError=%d).", (int)decompressor.decError); + result = CodePushBinaryPatchApplyResultApplyFailed; + } + + free(cache); + return result; +} + +@implementation CodePushBinaryPatch + +#pragma mark - Public methods + ++ (BOOL)restoreBundleInUnzippedFolder:(NSString *)unzippedFolderPath + workingFolder:(NSString *)workingFolderPath + baseBundleURL:(NSURL *)baseBundleURL + failureReason:(NSString **)failureReason +{ + NSFileManager *fileManager = [NSFileManager defaultManager]; + NSString *contentsFolderPath = [self contentsFolderInUnzippedFolder:unzippedFolderPath]; + NSString *manifestFilePath = [contentsFolderPath stringByAppendingPathComponent:BinaryPatchManifestFileName]; + if (![self isFileAtPath:manifestFilePath]) { + return [self failWithReason:CodePushBinaryPatchReasonInvalidManifest + outFailureReason:failureReason]; + } + + NSError *error = nil; + NSData *manifestData = [NSData dataWithContentsOfFile:manifestFilePath + options:0 + error:&error]; + id manifest = manifestData ? [NSJSONSerialization JSONObjectWithData:manifestData + options:kNilOptions + error:&error] + : nil; + if (![manifest isKindOfClass:[NSDictionary class]]) { + CPLog(@"The binary patch manifest could not be read: %@", error); + return [self failWithReason:CodePushBinaryPatchReasonInvalidManifest + outFailureReason:failureReason]; + } + + if ([[self numberInManifest:manifest forKey:BinaryPatchFormatVersionKey] integerValue] != BinaryPatchFormatVersion + || ![BinaryPatchAlgorithm isEqualToString:[self stringInManifest:manifest forKey:BinaryPatchAlgorithmKey]]) { + return [self failWithReason:CodePushBinaryPatchReasonUnsupportedFormat + outFailureReason:failureReason]; + } + + NSString *targetBundleFilePath = [self pathInsideFolder:contentsFolderPath + relativePath:[self stringInManifest:manifest forKey:BinaryPatchBundlePathKey]]; + NSString *patchFilePath = [self pathInsideFolder:contentsFolderPath + relativePath:[self stringInManifest:manifest forKey:BinaryPatchFileKey]]; + NSString *baseBundleHash = [self stringInManifest:manifest forKey:BinaryPatchBaseBundleHashKey]; + NSString *targetBundleHash = [self stringInManifest:manifest forKey:BinaryPatchTargetBundleHashKey]; + long long targetBundleSize = [[self numberInManifest:manifest forKey:BinaryPatchTargetBundleSizeKey] longLongValue]; + if (targetBundleFilePath == nil || patchFilePath == nil || ![self isFileAtPath:patchFilePath] + || baseBundleHash.length == 0 || targetBundleHash.length == 0 + || targetBundleSize <= 0 || targetBundleSize > BinaryPatchMaxTargetBundleSize) { + return [self failWithReason:CodePushBinaryPatchReasonInvalidManifest + outFailureReason:failureReason]; + } + + // An earlier attempt that was killed while patching leaves its restored bundle behind. + [fileManager removeItemAtPath:workingFolderPath error:nil]; + if (![fileManager createDirectoryAtPath:workingFolderPath + withIntermediateDirectories:YES + attributes:nil + error:&error]) { + CPLog(@"Unable to create the binary patch working directory at %@: %@", workingFolderPath, error); + return [self failWithReason:CodePushBinaryPatchReasonPatchApplyFailed + outFailureReason:failureReason]; + } + + NSString *reason = [self restoreBundleFromPatchAtPath:patchFilePath + baseBundleURL:baseBundleURL + baseBundleHash:baseBundleHash + targetBundleFilePath:targetBundleFilePath + targetBundleHash:targetBundleHash + targetBundleSize:targetBundleSize + manifestFilePath:manifestFilePath + workingFolder:workingFolderPath]; + // Whatever the attempt did, it leaves nothing of its own behind. + [fileManager removeItemAtPath:workingFolderPath error:nil]; + if (reason != nil) { + return [self failWithReason:reason outFailureReason:failureReason]; + } + + return YES; +} + +#pragma mark - Private methods + +/** + * Everything the restore does once the manifest has been read and the working directory + * exists, so that the one caller can empty that directory whichever way this ends. + * + * @return the reason the full archive has to be downloaded instead, or nil when the + * update contents are now the ones the full archive would have delivered + */ ++ (NSString *)restoreBundleFromPatchAtPath:(NSString *)patchFilePath + baseBundleURL:(NSURL *)baseBundleURL + baseBundleHash:(NSString *)baseBundleHash + targetBundleFilePath:(NSString *)targetBundleFilePath + targetBundleHash:(NSString *)targetBundleHash + targetBundleSize:(long long)targetBundleSize + manifestFilePath:(NSString *)manifestFilePath + workingFolder:(NSString *)workingFolderPath +{ + NSFileManager *fileManager = [NSFileManager defaultManager]; + // A volume that will not say how much room is left is not taken to have none: a + // restore that runs out of space still fails, just later and by writing a short file. + NSNumber *freeSize = [fileManager attributesOfFileSystemForPath:workingFolderPath + error:nil][NSFileSystemFreeSize]; + if (freeSize != nil && freeSize.longLongValue < targetBundleSize) { + CPLog(@"Not enough free space to restore a %lld byte bundle.", targetBundleSize); + return CodePushBinaryPatchReasonPatchApplyFailed; + } + + NSString *baseBundlePath = [baseBundleURL path]; + if (baseBundlePath == nil) { + CPLog(@"The app binary carries no JS bundle to patch against."); + return CodePushBinaryPatchReasonBaseBundleUnavailable; + } + + NSError *error = nil; + // Applying a patch is the one step that needs a whole bundle at once, and the base + // bundle is a read-only file inside the app binary: mapping it hands the applier the + // random access it needs without the update ever holding a copy of it. + NSData *baseBundle = [NSData dataWithContentsOfFile:baseBundlePath + options:NSDataReadingMappedIfSafe + error:&error]; + if (baseBundle == nil) { + CPLog(@"Unable to read the JS bundle inside the app binary: %@", error); + return CodePushBinaryPatchReasonBaseBundleUnavailable; + } + if (![baseBundleHash isEqualToString:[CodePushUpdateUtils computeHashForData:baseBundle]]) { + return CodePushBinaryPatchReasonBaseHashMismatch; + } + + NSData *patch = [NSData dataWithContentsOfFile:patchFilePath + options:0 + error:&error]; + if (patch == nil) { + CPLog(@"Unable to read the binary patch: %@", error); + return CodePushBinaryPatchReasonPatchApplyFailed; + } + + NSString *restoredBundleFilePath = [workingFolderPath stringByAppendingPathComponent:BinaryPatchTargetFileName]; + CodePushBinaryPatchApplyResult applyResult = CodePushApplyBinaryPatch(baseBundle.bytes, + baseBundle.length, + patch.bytes, + patch.length, + [restoredBundleFilePath fileSystemRepresentation], + targetBundleSize); + if (applyResult != CodePushBinaryPatchApplyResultOK) { + CPLog(@"The binary patch applier returned %ld.", (long)applyResult); + return applyResult == CodePushBinaryPatchApplyResultUnsupportedCompression + ? CodePushBinaryPatchReasonUnsupportedFormat + : CodePushBinaryPatchReasonPatchApplyFailed; + } + + NSDictionary *restoredBundleAttributes = [fileManager attributesOfItemAtPath:restoredBundleFilePath + error:&error]; + if (restoredBundleAttributes == nil || (long long)[restoredBundleAttributes fileSize] != targetBundleSize) { + CPLog(@"The restored bundle is not %lld bytes long: %@", targetBundleSize, error); + return CodePushBinaryPatchReasonTargetVerificationFailed; + } + + NSString *restoredBundleHash = [CodePushUpdateUtils computeHashForFileAtPath:restoredBundleFilePath]; + if (restoredBundleHash == nil || ![targetBundleHash isEqualToString:restoredBundleHash]) { + return CodePushBinaryPatchReasonTargetVerificationFailed; + } + + if (![self moveItemAtPath:restoredBundleFilePath toPath:targetBundleFilePath] + || ![fileManager removeItemAtPath:patchFilePath error:&error] + || ![fileManager removeItemAtPath:manifestFilePath error:&error]) { + // The contents are half restored, so they must not be installed. The download + // that follows empties the unzipped archive before it replaces it, which is what + // clears them. + CPLog(@"Unable to put the restored bundle in place of the patch: %@", error); + return CodePushBinaryPatchReasonPatchApplyFailed; + } + + return nil; +} + +/** + * 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. + */ ++ (NSString *)contentsFolderInUnzippedFolder:(NSString *)unzippedFolderPath +{ + NSArray *entries = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:unzippedFolderPath + error:nil]; + if (entries.count == 1) { + NSString *entryPath = [unzippedFolderPath stringByAppendingPathComponent:entries.firstObject]; + BOOL isDirectory = NO; + if ([[NSFileManager defaultManager] fileExistsAtPath:entryPath isDirectory:&isDirectory] && isDirectory) { + return entryPath; + } + } + + return unzippedFolderPath; +} + +/** + * 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 path, or nil when the path is unusable + */ ++ (NSString *)pathInsideFolder:(NSString *)folderPath + relativePath:(NSString *)relativePath +{ + if (relativePath.length == 0 || [relativePath isAbsolutePath]) { + return nil; + } + + NSString *resolvedFolderPath = [[folderPath stringByStandardizingPath] stringByAppendingString:@"/"]; + NSString *resolvedPath = [[folderPath stringByAppendingPathComponent:relativePath] stringByStandardizingPath]; + if (![resolvedPath hasPrefix:resolvedFolderPath]) { + return nil; + } + + return resolvedPath; +} + +/** Move, replacing whatever the archive may have carried at the destination. */ ++ (BOOL)moveItemAtPath:(NSString *)sourcePath + toPath:(NSString *)destinationPath +{ + NSFileManager *fileManager = [NSFileManager defaultManager]; + NSString *destinationFolderPath = [destinationPath stringByDeletingLastPathComponent]; + NSError *error = nil; + if (![fileManager fileExistsAtPath:destinationFolderPath] + && ![fileManager createDirectoryAtPath:destinationFolderPath + withIntermediateDirectories:YES + attributes:nil + error:&error]) { + CPLog(@"Unable to create %@: %@", destinationFolderPath, error); + return NO; + } + + [fileManager removeItemAtPath:destinationPath error:nil]; + if (![fileManager moveItemAtPath:sourcePath toPath:destinationPath error:&error]) { + CPLog(@"Unable to move %@ to %@: %@", sourcePath, destinationPath, error); + return NO; + } + + return YES; +} + ++ (BOOL)isFileAtPath:(NSString *)path +{ + BOOL isDirectory = NO; + return [[NSFileManager defaultManager] fileExistsAtPath:path isDirectory:&isDirectory] && !isDirectory; +} + +/** The value of a manifest key, or nil when it is absent or not a string. */ ++ (NSString *)stringInManifest:(NSDictionary *)manifest + forKey:(NSString *)key +{ + id value = manifest[key]; + return [value isKindOfClass:[NSString class]] ? value : nil; +} + +/** The value of a manifest key, or nil when it is absent or not a number. */ ++ (NSNumber *)numberInManifest:(NSDictionary *)manifest + forKey:(NSString *)key +{ + id value = manifest[key]; + return [value isKindOfClass:[NSNumber class]] ? value : nil; +} + ++ (BOOL)failWithReason:(NSString *)reason + outFailureReason:(NSString **)failureReason +{ + if (failureReason != NULL) { + *failureReason = reason; + } + + return NO; +} + +@end diff --git a/ios/CodePush/CodePushPackage.m b/ios/CodePush/CodePushPackage.m index 0d345a9c1..d242b414b 100644 --- a/ios/CodePush/CodePushPackage.m +++ b/ios/CodePush/CodePushPackage.m @@ -1,4 +1,5 @@ #import "CodePush.h" +#import "CodePushBinaryPatch.h" #if __has_include() #import #else @@ -9,8 +10,11 @@ @implementation CodePushPackage #pragma mark - Private constants +static NSString *const BinaryPatchDownloadUrlKey = @"binaryPatchDownloadUrl"; +static NSString *const BinaryPatchFolderName = @"binary-patch"; static NSString *const DiffManifestFileName = @"hotcodepush.json"; static NSString *const DownloadFileName = @"download.zip"; +static NSString *const DownloadUrlKey = @"downloadUrl"; static NSString *const RelativeBundlePathKey = @"bundlePath"; static NSString *const StatusFile = @"codepush.json"; static NSString *const UpdateBundleFileName = @"app.jsbundle"; @@ -49,6 +53,75 @@ + (void)downloadPackage:(NSDictionary *)updatePackage progressCallback:(void (^)(long long, long long))progressCallback doneCallback:(void (^)())doneCallback failCallback:(void (^)(NSError *err))failCallback +{ + void (^downloadFullPackage)(void) = ^{ + [self downloadAndInstallPackage:updatePackage + expectedBundleFileName:expectedBundleFileName + operationQueue:operationQueue + downloadUrl:updatePackage[DownloadUrlKey] + isBinaryPatchUpdate:NO + progressCallback:progressCallback + doneCallback:doneCallback + failCallback:failCallback + patchFallbackCallback:nil]; + }; + + // 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. + NSString *binaryPatchDownloadUrl = updatePackage[BinaryPatchDownloadUrlKey]; + if (![binaryPatchDownloadUrl isKindOfClass:[NSString class]] || [binaryPatchDownloadUrl length] == 0) { + return downloadFullPackage(); + } + + // Every way the patch can fail ends the same way, with the update being downloaded in + // full instead, so none of them reaches 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. + [self downloadAndInstallPackage:updatePackage + expectedBundleFileName:expectedBundleFileName + operationQueue:operationQueue + downloadUrl:binaryPatchDownloadUrl + isBinaryPatchUpdate:YES + progressCallback:progressCallback + doneCallback:^{ + [self deleteBinaryPatchFolder]; + doneCallback(); + } + failCallback:^(NSError *err) { + [self deleteBinaryPatchFolder]; + CPLog(@"The binary patch update could not be completed (%@). Downloading the full update instead.", err.localizedDescription); + downloadFullPackage(); + } + patchFallbackCallback:^(NSString *failureReason) { + [self deleteBinaryPatchFolder]; + CPLog(@"Binary patch update failed (%@). Downloading the full update instead.", failureReason); + downloadFullPackage(); + }]; +} + +/* + * Downloads an update from one of its archives and installs it. + * + * isBinaryPatchUpdate says 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. + * + * patchFallbackCallback is called instead of doneCallback when the patch cannot be + * applied: the update was not installed, and the caller has to download the full archive. + * It is nil for a full download, which has nothing to fall back to. + */ ++ (void)downloadAndInstallPackage:(NSDictionary *)updatePackage + expectedBundleFileName:(NSString *)expectedBundleFileName + operationQueue:(dispatch_queue_t)operationQueue + downloadUrl:(NSString *)downloadUrl + isBinaryPatchUpdate:(BOOL)isBinaryPatchUpdate + progressCallback:(void (^)(long long, long long))progressCallback + doneCallback:(void (^)(void))doneCallback + failCallback:(void (^)(NSError *err))failCallback + patchFallbackCallback:(void (^)(NSString *failureReason))patchFallbackCallback { NSString *newUpdateHash = updatePackage[@"packageHash"]; NSString *newUpdateFolderPath = [self getPackageFolderPath:newUpdateHash]; @@ -109,6 +182,23 @@ + (void)downloadPackage:(NSDictionary *)updatePackage nonFailingError = nil; } + // 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) { + NSDate *patchStartTime = [NSDate date]; + NSString *patchFailureReason = nil; + if (![CodePushBinaryPatch restoreBundleInUnzippedFolder:unzippedFolderPath + workingFolder:[self getBinaryPatchFolderPath] + baseBundleURL:[CodePush binaryBundleURL] + failureReason:&patchFailureReason]) { + patchFallbackCallback(patchFailureReason); + return; + } + + CPLog(@"Restored the update from its binary patch in %.0f ms.", + [[NSDate date] timeIntervalSinceDate:patchStartTime] * 1000); + } + NSString *diffManifestFilePath = [unzippedFolderPath stringByAppendingPathComponent:DiffManifestFileName]; BOOL isDiffUpdate = [[NSFileManager defaultManager] fileExistsAtPath:diffManifestFilePath]; @@ -252,6 +342,15 @@ + (void)downloadPackage:(NSDictionary *)updatePackage } } 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. + patchFallbackCallback(CodePushBinaryPatchReasonInvalidManifest); + return; + } + [[NSFileManager defaultManager] createDirectoryAtPath:newUpdateFolderPath withIntermediateDirectories:YES attributes:nil @@ -284,7 +383,22 @@ + (void)downloadPackage:(NSDictionary *)updatePackage failCallback:failCallback]; - [downloadHandler download:updatePackage[@"downloadUrl"]]; + [downloadHandler download:downloadUrl]; +} + +/* + * Removes what a patch attempt works in, whichever way the attempt ended - and whatever + * an attempt that was killed halfway through left there. + */ ++ (void)deleteBinaryPatchFolder +{ + [[NSFileManager defaultManager] removeItemAtPath:[self getBinaryPatchFolderPath] + error:nil]; +} + ++ (NSString *)getBinaryPatchFolderPath +{ + return [[self getCodePushPath] stringByAppendingPathComponent:BinaryPatchFolderName]; } + (NSString *)getCodePushPath diff --git a/ios/CodePush/CodePushUpdateUtils.m b/ios/CodePush/CodePushUpdateUtils.m index 97be11419..5a76d878f 100644 --- a/ios/CodePush/CodePushUpdateUtils.m +++ b/ios/CodePush/CodePushUpdateUtils.m @@ -7,6 +7,9 @@ @implementation CodePushUpdateUtils NSString * const BinaryHashKey = @"CodePushBinaryHash"; NSString * const ManifestFolderPrefix = @"CodePush"; +/* How much of a file is read at a time while hashing it. */ +static const NSUInteger HashChunkSize = 1024 * 8; + /* Ignore list for hashing */ @@ -95,11 +98,56 @@ + (NSString *)computeHashForData:(NSData *)inputData { uint8_t digest[CC_SHA256_DIGEST_LENGTH]; CC_SHA256(inputData.bytes, (CC_LONG)inputData.length, digest); + return [self hashStringForDigest:digest]; +} + +/* + A file is hashed a chunk at a time, so that hashing one costs no more memory than the + chunk however large the file is. + */ ++ (NSString *)computeHashForFileAtPath:(NSString *)filePath +{ + NSFileHandle *fileHandle = [NSFileHandle fileHandleForReadingAtPath:filePath]; + if (!fileHandle) { + CPLog(@"Unable to open %@ to hash it.", filePath); + return nil; + } + + CC_SHA256_CTX context; + CC_SHA256_Init(&context); + + while (YES) { + @autoreleasepool { + NSError *error = nil; + NSData *chunk = [fileHandle readDataUpToLength:HashChunkSize error:&error]; + if (!chunk) { + CPLog(@"Unable to read %@ to hash it: %@", filePath, error); + [fileHandle closeAndReturnError:nil]; + return nil; + } + + if (chunk.length == 0) { + break; + } + + CC_SHA256_Update(&context, chunk.bytes, (CC_LONG)chunk.length); + } + } + + [fileHandle closeAndReturnError:nil]; + + uint8_t digest[CC_SHA256_DIGEST_LENGTH]; + CC_SHA256_Final(digest, &context); + return [self hashStringForDigest:digest]; +} + ++ (NSString *)hashStringForDigest:(const uint8_t *)digest +{ NSMutableString* inputHash = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2]; for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) { [inputHash appendFormat:@"%02x", digest[i]]; } - + return inputHash; }