diff --git a/docs/setup-ios.md b/docs/setup-ios.md
index 3918f9c1..ace2d0b2 100644
--- a/docs/setup-ios.md
+++ b/docs/setup-ios.md
@@ -136,3 +136,19 @@ MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANkWYydPuyOumR/sn2agNBVDnzyRpM16NAUpYPGxNgjSEp0e
```
+### Enable Binary Diff Updates
+
+Switch for applying binary diff (bsdiff) patches during a diff update, off by default (at the moment). When disabled, only file-by-file diffing is applied (for example, skipping assets if only the main JS bundle changed, but that whole file is downloaded byte for byte). Add a `CodePushEnableBinaryDiffUpdates` boolean record to `Info.plist` to turn it on:
+
+```xml
+
+
+
+
+ CodePushEnableBinaryDiffUpdates
+
+
+
+
+
+```
diff --git a/ios/CodePush.xcodeproj/project.pbxproj b/ios/CodePush.xcodeproj/project.pbxproj
index 03c3887e..594a3899 100644
--- a/ios/CodePush.xcodeproj/project.pbxproj
+++ b/ios/CodePush.xcodeproj/project.pbxproj
@@ -7,6 +7,24 @@
objects = {
/* Begin PBXBuildFile section */
+ 46BD3246E1289AF018ED4FEB /* CodePushSha256Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C09E6C954EFE879F5C40F34A /* CodePushSha256Tests.swift */; };
+ BBC7F97A68E454FB38953FC4 /* CodePushDiffManifestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9645879798B46D35D8D824F8 /* CodePushDiffManifestTests.swift */; };
+ 3643F3729205426163367671 /* CodePushBinaryDiffPatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F42FA68BF21AED765F55E71A /* CodePushBinaryDiffPatcherTests.swift */; };
+ B75F8EB77C01BA01704CAD97 /* CodePushSha256.m in Sources */ = {isa = PBXBuildFile; fileRef = 62708BC475E46556136816C9 /* CodePushSha256.m */; };
+ A2206CF91ED7FE901C630AB4 /* CodePushSha256.m in Sources */ = {isa = PBXBuildFile; fileRef = 62708BC475E46556136816C9 /* CodePushSha256.m */; };
+ 6663AFF6236CD01E20CBDD80 /* CodePushSha256.m in Sources */ = {isa = PBXBuildFile; fileRef = 62708BC475E46556136816C9 /* CodePushSha256.m */; };
+ 6A57E1B401615DA251D0F91F /* CodePushSha256.h in Headers */ = {isa = PBXBuildFile; fileRef = B7CE82B73092943E0F67E825 /* CodePushSha256.h */; };
+ B0E0E947B133B742F6B41FBF /* CodePushSha256.h in Headers */ = {isa = PBXBuildFile; fileRef = B7CE82B73092943E0F67E825 /* CodePushSha256.h */; };
+ CED0A6936F6F8C354302F246 /* CodePushDiffManifest.m in Sources */ = {isa = PBXBuildFile; fileRef = 49166671F067D5F6429B262B /* CodePushDiffManifest.m */; };
+ CD32522E04D0F0E86DCC7BBE /* CodePushDiffManifest.m in Sources */ = {isa = PBXBuildFile; fileRef = 49166671F067D5F6429B262B /* CodePushDiffManifest.m */; };
+ 0E9314F338AC505D2E33C1A3 /* CodePushDiffManifest.m in Sources */ = {isa = PBXBuildFile; fileRef = 49166671F067D5F6429B262B /* CodePushDiffManifest.m */; };
+ C11A2DB29D3D814B1A7891DF /* CodePushDiffManifest.h in Headers */ = {isa = PBXBuildFile; fileRef = 2A092598DCE51279CA317823 /* CodePushDiffManifest.h */; };
+ 4693408DCB9ACB8E02BF69B1 /* CodePushDiffManifest.h in Headers */ = {isa = PBXBuildFile; fileRef = 2A092598DCE51279CA317823 /* CodePushDiffManifest.h */; };
+ 1993F82DF6330426AC5CBC2E /* CodePushBinaryDiffPatcher.m in Sources */ = {isa = PBXBuildFile; fileRef = 9414C770C70E1B1319F24B12 /* CodePushBinaryDiffPatcher.m */; };
+ F41E2EFAD0D322F7D14D7125 /* CodePushBinaryDiffPatcher.m in Sources */ = {isa = PBXBuildFile; fileRef = 9414C770C70E1B1319F24B12 /* CodePushBinaryDiffPatcher.m */; };
+ D5BC8B1BF7D2DA03BB888FCC /* CodePushBinaryDiffPatcher.m in Sources */ = {isa = PBXBuildFile; fileRef = 9414C770C70E1B1319F24B12 /* CodePushBinaryDiffPatcher.m */; };
+ E5C712D353D9164F04A2F456 /* CodePushBinaryDiffPatcher.h in Headers */ = {isa = PBXBuildFile; fileRef = 0F588BE40F9B3092085C5B63 /* CodePushBinaryDiffPatcher.h */; };
+ 469472C57F1D5FF7C0274268 /* CodePushBinaryDiffPatcher.h in Headers */ = {isa = PBXBuildFile; fileRef = 0F588BE40F9B3092085C5B63 /* CodePushBinaryDiffPatcher.h */; };
0ABCB5DEFE01A7A15552A498 /* file_for_patch.c in Sources */ = {isa = PBXBuildFile; fileRef = 70779807AB59EA4711737F4E /* file_for_patch.c */; };
08B8B3B8260E70B7ECA85451 /* bspatch_bridge.c in Sources */ = {isa = PBXBuildFile; fileRef = A430CBE260F09A3233110E28 /* bspatch_bridge.c */; };
A75C1A7A555663186E25AA3D /* libHDiffPatch/HPatch/patch.c in Sources */ = {isa = PBXBuildFile; fileRef = 827016DF6E52E356F4B89D18 /* libHDiffPatch/HPatch/patch.c */; };
@@ -181,6 +199,15 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
+ C09E6C954EFE879F5C40F34A /* CodePushSha256Tests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CodePushSha256Tests.swift; sourceTree = ""; };
+ 9645879798B46D35D8D824F8 /* CodePushDiffManifestTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CodePushDiffManifestTests.swift; sourceTree = ""; };
+ F42FA68BF21AED765F55E71A /* CodePushBinaryDiffPatcherTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CodePushBinaryDiffPatcherTests.swift; sourceTree = ""; };
+ B7CE82B73092943E0F67E825 /* CodePushSha256.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = CodePushSha256.h; path = CodePush/CodePushSha256.h; sourceTree = ""; };
+ 62708BC475E46556136816C9 /* CodePushSha256.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = CodePushSha256.m; path = CodePush/CodePushSha256.m; sourceTree = ""; };
+ 2A092598DCE51279CA317823 /* CodePushDiffManifest.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = CodePushDiffManifest.h; path = CodePush/CodePushDiffManifest.h; sourceTree = ""; };
+ 49166671F067D5F6429B262B /* CodePushDiffManifest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = CodePushDiffManifest.m; path = CodePush/CodePushDiffManifest.m; sourceTree = ""; };
+ 0F588BE40F9B3092085C5B63 /* CodePushBinaryDiffPatcher.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = CodePushBinaryDiffPatcher.h; path = CodePush/CodePushBinaryDiffPatcher.h; sourceTree = ""; };
+ 9414C770C70E1B1319F24B12 /* CodePushBinaryDiffPatcher.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = CodePushBinaryDiffPatcher.m; path = CodePush/CodePushBinaryDiffPatcher.m; sourceTree = ""; };
0BF68F85125CF81D7EB65ABB /* bspatch_bridge.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = bspatch_bridge.h; sourceTree = ""; };
0DC7989C75C72A774EF3685F /* bsdiff_wrapper/bspatch_wrapper.c */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.c; path = bsdiff_wrapper/bspatch_wrapper.c; sourceTree = ""; };
134814201AA4EA6300B7C361 /* libCodePush.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libCodePush.a; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -430,6 +457,12 @@
54FFEDDF1BF550630061DD23 /* CodePushDownloadHandler.m */,
1B762E8F1C9A5E9A006EF800 /* CodePushErrorUtils.m */,
810D4E6C1B96935000B397E9 /* CodePushPackage.m */,
+ B7CE82B73092943E0F67E825 /* CodePushSha256.h */,
+ 62708BC475E46556136816C9 /* CodePushSha256.m */,
+ 2A092598DCE51279CA317823 /* CodePushDiffManifest.h */,
+ 49166671F067D5F6429B262B /* CodePushDiffManifest.m */,
+ 0F588BE40F9B3092085C5B63 /* CodePushBinaryDiffPatcher.h */,
+ 9414C770C70E1B1319F24B12 /* CodePushBinaryDiffPatcher.m */,
5421FE301C58AD5A00986A55 /* CodePushTelemetryManager.m */,
540D20111C7684FE00D6EF41 /* CodePushUpdateUtils.m */,
1B23B9131BF9267B000BB2F0 /* RCTConvert+CodePushInstallMode.m */,
@@ -459,6 +492,9 @@
isa = PBXGroup;
children = (
95D6DD0EACAC8D095880DFD0 /* BSPatchTests.swift */,
+ C09E6C954EFE879F5C40F34A /* CodePushSha256Tests.swift */,
+ 9645879798B46D35D8D824F8 /* CodePushDiffManifestTests.swift */,
+ F42FA68BF21AED765F55E71A /* CodePushBinaryDiffPatcherTests.swift */,
E9FA144425AE78B97AD6C870 /* DiffPatchTests-Bridging-Header.h */,
E74D80ECD8DD03C4C0772B7B /* Fixtures */,
);
@@ -656,6 +692,9 @@
3221E4762C8ABE1300268379 /* mz_strm_pkcrypt.h in Headers */,
3221E4642C8ABE1300268379 /* mz_strm_split.h in Headers */,
6463C8471EBA0D290095B8CD /* CodePush.h in Headers */,
+ B0E0E947B133B742F6B41FBF /* CodePushSha256.h in Headers */,
+ 4693408DCB9ACB8E02BF69B1 /* CodePushDiffManifest.h in Headers */,
+ 469472C57F1D5FF7C0274268 /* CodePushBinaryDiffPatcher.h in Headers */,
3221E46A2C8ABE1300268379 /* mz_strm.h in Headers */,
3221E4782C8ABE1300268379 /* mz_zip_rw.h in Headers */,
3221E4802C8ABE1400268379 /* mz.h in Headers */,
@@ -716,6 +755,9 @@
3221E4632C8ABE1300268379 /* mz_strm_split.h in Headers */,
3221E4752C8ABE1300268379 /* mz_strm_pkcrypt.h in Headers */,
8482F84E1E24C66300F793DB /* CodePush.h in Headers */,
+ 6A57E1B401615DA251D0F91F /* CodePushSha256.h in Headers */,
+ C11A2DB29D3D814B1A7891DF /* CodePushDiffManifest.h in Headers */,
+ E5C712D353D9164F04A2F456 /* CodePushBinaryDiffPatcher.h in Headers */,
F88664711F4AD1EE0036D01B /* JWTCoding+VersionTwo.h in Headers */,
F886646B1F4AD1EE0036D01B /* JWTCoding+ResultTypes.h in Headers */,
3221E4652C8ABE1300268379 /* mz_strm_buf.h in Headers */,
@@ -884,6 +926,9 @@
5498D8F61D21F14100B5EB43 /* CodePushUtils.m in Sources */,
3221E4612C8ABE1300268379 /* mz_zip_rw.c in Sources */,
810D4E6D1B96935000B397E9 /* CodePushPackage.m in Sources */,
+ B75F8EB77C01BA01704CAD97 /* CodePushSha256.m in Sources */,
+ CED0A6936F6F8C354302F246 /* CodePushDiffManifest.m in Sources */,
+ 1993F82DF6330426AC5CBC2E /* CodePushBinaryDiffPatcher.m in Sources */,
3221E4552C8ABE1300268379 /* mz_strm_pkcrypt.c in Sources */,
F88664531F4AD1EE0036D01B /* JWTAlgorithmESBase.m in Sources */,
3221E4532C8ABE1300268379 /* mz_strm_os_posix.c in Sources */,
@@ -907,6 +952,9 @@
6463C8311EBA0CFB0095B8CD /* CodePushErrorUtils.m in Sources */,
3221E46E2C8ABE1300268379 /* mz_crypt.c in Sources */,
6463C8321EBA0CFB0095B8CD /* CodePushPackage.m in Sources */,
+ A2206CF91ED7FE901C630AB4 /* CodePushSha256.m in Sources */,
+ CD32522E04D0F0E86DCC7BBE /* CodePushDiffManifest.m in Sources */,
+ F41E2EFAD0D322F7D14D7125 /* CodePushBinaryDiffPatcher.m in Sources */,
6463C8331EBA0CFB0095B8CD /* CodePushTelemetryManager.m in Sources */,
6463C8341EBA0CFB0095B8CD /* CodePushUpdateUtils.m in Sources */,
3221E45C2C8ABE1300268379 /* mz_strm_zlib.c in Sources */,
@@ -929,7 +977,13 @@
buildActionMask = 2147483647;
files = (
B5B50F91FAAF80444988E284 /* BSPatchTests.swift in Sources */,
+ 46BD3246E1289AF018ED4FEB /* CodePushSha256Tests.swift in Sources */,
+ BBC7F97A68E454FB38953FC4 /* CodePushDiffManifestTests.swift in Sources */,
+ 3643F3729205426163367671 /* CodePushBinaryDiffPatcherTests.swift in Sources */,
47F66D5AF3C3185E1A3E3B15 /* bspatch_bridge.c in Sources */,
+ 6663AFF6236CD01E20CBDD80 /* CodePushSha256.m in Sources */,
+ 0E9314F338AC505D2E33C1A3 /* CodePushDiffManifest.m in Sources */,
+ D5BC8B1BF7D2DA03BB888FCC /* CodePushBinaryDiffPatcher.m in Sources */,
DC983F1C71E0E7131BB343C5 /* libHDiffPatch/HPatch/patch.c in Sources */,
A88F11124A2120A8373A8B61 /* bsdiff_wrapper/bspatch_wrapper.c in Sources */,
0ABCB5DEFE01A7A15552A498 /* file_for_patch.c in Sources */,
diff --git a/ios/CodePush/CodePush.h b/ios/CodePush/CodePush.h
index 1f762150..bdd0f3e3 100644
--- a/ios/CodePush/CodePush.h
+++ b/ios/CodePush/CodePush.h
@@ -103,6 +103,7 @@
@property (copy) NSString *deploymentKey;
@property (copy) NSString *serverURL;
@property (copy) NSString *publicKey;
+@property (readonly) BOOL enableBinaryDiffUpdates;
+ (instancetype)current;
@@ -141,6 +142,7 @@ failCallback:(void (^)(NSError *err))failCallback;
+ (void)downloadPackage:(NSDictionary *)updatePackage
expectedBundleFileName:(NSString *)expectedBundleFileName
publicKey:(NSString *)publicKey
+ enableBinaryDiffUpdates:(BOOL)enableBinaryDiffUpdates
operationQueue:(dispatch_queue_t)operationQueue
progressCallback:(void (^)(long long, long long))progressCallback
doneCallback:(void (^)())doneCallback
diff --git a/ios/CodePush/CodePush.m b/ios/CodePush/CodePush.m
index 15d3a591..89b9f17f 100644
--- a/ios/CodePush/CodePush.m
+++ b/ios/CodePush/CodePush.m
@@ -875,11 +875,13 @@ -(void)loadBundleOnTick:(NSTimer *)timer {
}
NSString * publicKey = [[CodePushConfig current] publicKey];
+ BOOL enableBinaryDiffUpdates = [[CodePushConfig current] enableBinaryDiffUpdates];
[CodePushPackage
downloadPackage:mutableUpdatePackage
expectedBundleFileName:[bundleResourceName stringByAppendingPathExtension:bundleResourceExtension]
publicKey:publicKey
+ enableBinaryDiffUpdates:enableBinaryDiffUpdates
operationQueue:_methodQueue
// The download is progressing forward
progressCallback:^(long long expectedContentLength, long long receivedContentLength) {
diff --git a/ios/CodePush/CodePushBinaryDiffPatcher.h b/ios/CodePush/CodePushBinaryDiffPatcher.h
new file mode 100644
index 00000000..19d9e3f8
--- /dev/null
+++ b/ios/CodePush/CodePushBinaryDiffPatcher.h
@@ -0,0 +1,23 @@
+#import
+#import "CodePushDiffManifest.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface CodePushBinaryDiffPatcher : NSObject
+
+// Applies every entry in manifest.patchedFiles: verifies the pre-patch file
+// against baseHash, applies the patch into newUpdateFolder, then verifies
+// the result against targetHash.
+//
+// Returns NO and sets *error on the first failure (unsupported algo, hash
+// mismatch, or patch failure).
++ (BOOL)applyBinaryDiffPatchesFromManifest:(CodePushDiffManifest *)manifest
+ currentPackageFolder:(NSString *)currentPackageFolder
+ unzippedFolder:(NSString *)unzippedFolder
+ newUpdateFolder:(NSString *)newUpdateFolder
+ error:(NSError **)error
+ NS_SWIFT_NAME(applyBinaryDiffPatches(manifest:currentPackageFolder:unzippedFolder:newUpdateFolder:));
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/CodePush/CodePushBinaryDiffPatcher.m b/ios/CodePush/CodePushBinaryDiffPatcher.m
new file mode 100644
index 00000000..3dc0d545
--- /dev/null
+++ b/ios/CodePush/CodePushBinaryDiffPatcher.m
@@ -0,0 +1,105 @@
+#import "CodePushBinaryDiffPatcher.h"
+#import "CodePushSha256.h"
+#import "bspatch_bridge.h"
+
+static NSString *const CodePushBinaryDiffPatcherErrorDomain = @"CodePushBinaryDiffPatcherError";
+
+static NSError *patchApplyError(NSString *relativePath, NSString *reason)
+{
+ return [NSError errorWithDomain:CodePushBinaryDiffPatcherErrorDomain
+ code:1
+ userInfo:@{ NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Failed to apply binary diff patch for \"%@\": %@", relativePath, reason] }];
+}
+
+// Keep in sync with shared/diffpatch/bspatch_bridge.h.
+static NSString *describeBSPatchResult(CodePushBSPatchResult result)
+{
+ switch (result) {
+ case CODEPUSH_BSPATCH_OK: return @"OK";
+ case CODEPUSH_BSPATCH_ERR_BAD_DIFF_HEADER: return @"BAD_DIFF_HEADER";
+ case CODEPUSH_BSPATCH_ERR_OPEN_OLD: return @"OPEN_OLD_FAILED";
+ case CODEPUSH_BSPATCH_ERR_OPEN_DIFF: return @"OPEN_DIFF_FAILED";
+ case CODEPUSH_BSPATCH_ERR_OPEN_OUT: return @"OPEN_OUT_FAILED";
+ case CODEPUSH_BSPATCH_ERR_OOM: return @"OUT_OF_MEMORY";
+ case CODEPUSH_BSPATCH_ERR_PATCH_FAILED: return @"PATCH_FAILED";
+ }
+ return [NSString stringWithFormat:@"UNKNOWN (%ld)", (long)result];
+}
+
+@implementation CodePushBinaryDiffPatcher
+
++ (BOOL)applyBinaryDiffPatchesFromManifest:(CodePushDiffManifest *)manifest
+ currentPackageFolder:(NSString *)currentPackageFolder
+ unzippedFolder:(NSString *)unzippedFolder
+ newUpdateFolder:(NSString *)newUpdateFolder
+ error:(NSError **)error
+{
+ NSDictionary *patchedFiles = manifest.patchedFiles;
+
+ for (NSString *relativePath in patchedFiles) {
+ CodePushPatchedFileEntry *entry = patchedFiles[relativePath];
+ if (![entry.algo isEqualToString:@"bsdiff"]) {
+ if (error) *error = patchApplyError(relativePath, [NSString stringWithFormat:@"unsupported patch algorithm: %@", entry.algo]);
+ return NO;
+ }
+ }
+
+ NSFileManager *fileManager = [NSFileManager defaultManager];
+
+ for (NSString *relativePath in patchedFiles) {
+ CodePushPatchedFileEntry *entry = patchedFiles[relativePath];
+
+ NSString *(^resolveWithin)(NSString *, NSString *) = ^NSString *(NSString *base, NSString *path) {
+ NSString *resolved = [CodePushDiffManifest resolvePath:path withinFolder:base];
+ // See +[CodePushDiffManifest resolvePath:withinFolder:] for what gets rejected.
+ if (resolved == nil) {
+ if (error) *error = patchApplyError(relativePath, @"path escapes expected directory");
+ }
+ return resolved;
+ };
+
+ NSString *oldFile = resolveWithin(currentPackageFolder, relativePath);
+ if (!oldFile) return NO;
+
+ NSError *hashError = nil;
+ NSString *oldFileHash = CodePushSha256HexForFile(oldFile, &hashError);
+ if (!oldFileHash || ![oldFileHash isEqualToString:entry.baseHash]) {
+ if (error) *error = patchApplyError(relativePath, [NSString stringWithFormat:@"baseHash mismatch: expected %@, got %@", entry.baseHash, oldFileHash]);
+ return NO;
+ }
+
+ NSString *diffFile = resolveWithin(unzippedFolder, entry.patch);
+ if (!diffFile) return NO;
+
+ NSString *newFile = resolveWithin(newUpdateFolder, relativePath);
+ if (!newFile) return NO;
+
+ NSError *createDirError = nil;
+ [fileManager createDirectoryAtPath:[newFile stringByDeletingLastPathComponent]
+ withIntermediateDirectories:YES
+ attributes:nil
+ error:&createDirError];
+ if (createDirError) {
+ if (error) *error = patchApplyError(relativePath, createDirError.localizedDescription);
+ return NO;
+ }
+
+ CodePushBSPatchResult result = codepush_bspatch_apply(oldFile.fileSystemRepresentation,
+ diffFile.fileSystemRepresentation,
+ newFile.fileSystemRepresentation);
+ if (result != CODEPUSH_BSPATCH_OK) {
+ if (error) *error = patchApplyError(relativePath, [NSString stringWithFormat:@"patch failed: %@", describeBSPatchResult(result)]);
+ return NO;
+ }
+
+ NSString *newFileHash = CodePushSha256HexForFile(newFile, &hashError);
+ if (!newFileHash || ![newFileHash isEqualToString:entry.targetHash]) {
+ if (error) *error = patchApplyError(relativePath, [NSString stringWithFormat:@"targetHash mismatch: expected %@, got %@", entry.targetHash, newFileHash]);
+ return NO;
+ }
+ }
+
+ return YES;
+}
+
+@end
diff --git a/ios/CodePush/CodePushConfig.m b/ios/CodePush/CodePushConfig.m
index 029d4180..43d83a24 100644
--- a/ios/CodePush/CodePushConfig.m
+++ b/ios/CodePush/CodePushConfig.m
@@ -3,6 +3,7 @@
@implementation CodePushConfig {
NSMutableDictionary *_configDictionary;
+ BOOL _enableBinaryDiffUpdates;
}
static CodePushConfig *_currentConfig;
@@ -36,7 +37,8 @@ - (instancetype)init
NSString *deploymentKey = [infoDictionary objectForKey:@"CodePushDeploymentKey"];
NSString *serverURL = [infoDictionary objectForKey:@"CodePushServerURL"];
NSString *publicKey = [infoDictionary objectForKey:@"CodePushPublicKey"];
-
+ _enableBinaryDiffUpdates = [[infoDictionary objectForKey:@"CodePushEnableBinaryDiffUpdates"] boolValue];
+
NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
NSString *clientUniqueId = [userDefaults stringForKey:ClientUniqueIDConfigKey];
if (clientUniqueId == nil) {
@@ -96,6 +98,11 @@ - (NSString *)publicKey
return [_configDictionary objectForKey:PublicKeyKey];
}
+- (BOOL)enableBinaryDiffUpdates
+{
+ return _enableBinaryDiffUpdates;
+}
+
- (void)setAppVersion:(NSString *)appVersion
{
[_configDictionary setValue:appVersion forKey:AppVersionConfigKey];
diff --git a/ios/CodePush/CodePushDiffManifest.h b/ios/CodePush/CodePushDiffManifest.h
new file mode 100644
index 00000000..7f22b9a0
--- /dev/null
+++ b/ios/CodePush/CodePushDiffManifest.h
@@ -0,0 +1,57 @@
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface CodePushPatchedFileEntry : NSObject
+
+// The only value this client understands at the moment is "bsdiff".
+@property (nonatomic, readonly, copy) NSString *algo;
+// SHA-256 hex of the file's content in the currently installed package. Checked before patching.
+@property (nonatomic, readonly, copy) NSString *baseHash;
+// SHA-256 hex the patched output must match. Checked after patching.
+@property (nonatomic, readonly, copy) NSString *targetHash;
+// Zip-relative path to the patch file, under the reserved patches folder prefix.
+@property (nonatomic, readonly, copy) NSString *patch;
+
+- (instancetype)initWithAlgo:(NSString *)algo
+ baseHash:(NSString *)baseHash
+ targetHash:(NSString *)targetHash
+ patch:(NSString *)patch;
+
+@end
+
+@interface CodePushDiffManifest : NSObject
+
+// No version field, or version 1: original format, file-by-file patching only.
+// Version 2: adds support for binary diff patching.
+@property (nonatomic, readonly, assign) NSInteger version;
+// Relative paths, from the old package, to delete rather than carry over into the new one.
+@property (nonatomic, readonly, copy) NSArray *deletedFiles;
+// Key: file's relative path in the package being installed.
+@property (nonatomic, readonly, copy) NSDictionary *patchedFiles;
+
+- (instancetype)initWithVersion:(NSInteger)version
+ deletedFiles:(NSArray *)deletedFiles
+ patchedFiles:(NSDictionary *)patchedFiles;
+
+// Parses a diff manifest from its already-deserialized JSON representation.
+// Returns nil and sets *error if a required field is missing or malformed.
++ (nullable instancetype)manifestFromJSON:(NSDictionary *)json error:(NSError **)error NS_SWIFT_NAME(init(json:));
+
+// Turns a relative path from a diff manifest into an absolute path under
+// `folder`. Returns nil if the path can lead out of `folder`.
+//
+// Every path in a manifest is untrusted: the manifest and the files it refers
+// to come from the downloaded update, which is unpacked before anything
+// verifies it. `folder` is inside the app's own container, where the app
+// sandbox does not apply - a relative path that climbs out of `folder`
+// ("../../Library/Preferences/x"), or a symlink in `folder` pointing
+// elsewhere in the container, can reach other app data such as the currently
+// installed package. This method rejects those, and absolute paths too.
++ (nullable NSString *)resolvePath:(NSString *)relativePath
+ withinFolder:(NSString *)folder
+ NS_SWIFT_NAME(resolvePath(_:withinFolder:));
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/CodePush/CodePushDiffManifest.m b/ios/CodePush/CodePushDiffManifest.m
new file mode 100644
index 00000000..ecbbd0da
--- /dev/null
+++ b/ios/CodePush/CodePushDiffManifest.m
@@ -0,0 +1,194 @@
+#import "CodePushDiffManifest.h"
+
+#import
+
+static NSString *const CodePushDiffManifestErrorDomain = @"CodePushDiffManifestError";
+
+static NSError *missingFieldError(NSString *fieldName, NSString *context)
+{
+ return [NSError errorWithDomain:CodePushDiffManifestErrorDomain
+ code:1
+ userInfo:@{ NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Diff manifest %@ is missing required field \"%@\"", context, fieldName] }];
+}
+
+static NSError *malformedManifestError(NSString *message)
+{
+ return [NSError errorWithDomain:CodePushDiffManifestErrorDomain
+ code:2
+ userInfo:@{ NSLocalizedDescriptionKey: message }];
+}
+
+// Resolves every symlink in `path`. Returns nil if `path` does not exist or
+// cannot be read.
+static NSString *canonicalPathOfExistingItem(NSString *path)
+{
+ char buffer[PATH_MAX];
+ if (realpath(path.fileSystemRepresentation, buffer) == NULL) {
+ return nil;
+ }
+ return [[NSFileManager defaultManager] stringWithFileSystemRepresentation:buffer length:strlen(buffer)];
+}
+
+// realpath() needs the whole path to exist, but the files a patch writes do not
+// exist yet. Canonicalize the deepest ancestor that does exist, then re-append
+// the components below it. Returns nil if one of those components is a dangling
+// symlink: it would survive canonicalization as its own path, and a write to it
+// would still follow the link out of the folder.
+static NSString *canonicalPathAllowingMissingComponents(NSString *path)
+{
+ NSMutableArray *missingComponents = [NSMutableArray array];
+ NSString *existingAncestor = path;
+ NSString *canonicalPath = nil;
+
+ while ((canonicalPath = canonicalPathOfExistingItem(existingAncestor)) == nil) {
+ NSString *parent = [existingAncestor stringByDeletingLastPathComponent];
+ if (parent.length == 0 || [parent isEqualToString:existingAncestor]) {
+ return nil;
+ }
+ [missingComponents insertObject:existingAncestor.lastPathComponent atIndex:0];
+ existingAncestor = parent;
+ }
+
+ for (NSString *component in missingComponents) {
+ canonicalPath = [canonicalPath stringByAppendingPathComponent:component];
+
+ struct stat fileInfo;
+ if (lstat(canonicalPath.fileSystemRepresentation, &fileInfo) == 0 && S_ISLNK(fileInfo.st_mode)) {
+ return nil;
+ }
+ }
+ return canonicalPath;
+}
+
+@implementation CodePushPatchedFileEntry
+
+- (instancetype)initWithAlgo:(NSString *)algo
+ baseHash:(NSString *)baseHash
+ targetHash:(NSString *)targetHash
+ patch:(NSString *)patch
+{
+ self = [super init];
+ if (self) {
+ _algo = [algo copy];
+ _baseHash = [baseHash copy];
+ _targetHash = [targetHash copy];
+ _patch = [patch copy];
+ }
+ return self;
+}
+
+@end
+
+@implementation CodePushDiffManifest
+
+- (instancetype)initWithVersion:(NSInteger)version
+ deletedFiles:(NSArray *)deletedFiles
+ patchedFiles:(NSDictionary *)patchedFiles
+{
+ self = [super init];
+ if (self) {
+ _version = version;
+ _deletedFiles = [deletedFiles copy];
+ _patchedFiles = [patchedFiles copy];
+ }
+ return self;
+}
+
++ (nullable instancetype)manifestFromJSON:(NSDictionary *)json error:(NSError **)error
+{
+ if (![json isKindOfClass:[NSDictionary class]]) {
+ if (error) *error = missingFieldError(@"version/deletedFiles/patchedFiles", @"root");
+ return nil;
+ }
+
+ // A version we cannot read is a hard failure: silently treating it as 1
+ // would skip every patch and install the old bytes under the new hash.
+ id versionValue = json[@"version"];
+ NSInteger version = 1;
+ if (versionValue != nil && ![versionValue isKindOfClass:[NSNull class]]) {
+ if (![versionValue isKindOfClass:[NSNumber class]]) {
+ if (error) *error = malformedManifestError([NSString stringWithFormat:@"Diff manifest field \"version\" must be a number, but is \"%@\"", versionValue]);
+ return nil;
+ }
+ version = [versionValue integerValue];
+ }
+
+ NSArray *deletedFilesJSON = json[@"deletedFiles"];
+ NSMutableArray *deletedFiles = [NSMutableArray array];
+ if ([deletedFilesJSON isKindOfClass:[NSArray class]]) {
+ for (id deletedFileName in deletedFilesJSON) {
+ if (![deletedFileName isKindOfClass:[NSString class]]) {
+ if (error) *error = missingFieldError(@"deletedFiles", @"entry is not a string");
+ return nil;
+ }
+ [deletedFiles addObject:deletedFileName];
+ }
+ }
+
+ NSDictionary *patchedFilesJSON = json[@"patchedFiles"];
+ NSMutableDictionary *patchedFiles = [NSMutableDictionary dictionary];
+ if ([patchedFilesJSON isKindOfClass:[NSDictionary class]]) {
+ for (NSString *relativePath in patchedFilesJSON) {
+ NSDictionary *entryJSON = patchedFilesJSON[relativePath];
+ if (![entryJSON isKindOfClass:[NSDictionary class]]) {
+ if (error) *error = missingFieldError(relativePath, @"patchedFiles entry");
+ return nil;
+ }
+
+ NSString *algo = entryJSON[@"algo"];
+ NSString *baseHash = entryJSON[@"baseHash"];
+ NSString *targetHash = entryJSON[@"targetHash"];
+ NSString *patch = entryJSON[@"patch"];
+ if (![algo isKindOfClass:[NSString class]] || ![baseHash isKindOfClass:[NSString class]] ||
+ ![targetHash isKindOfClass:[NSString class]] || ![patch isKindOfClass:[NSString class]]) {
+ if (error) *error = missingFieldError(@"algo/baseHash/targetHash/patch", [NSString stringWithFormat:@"patchedFiles[\"%@\"]", relativePath]);
+ return nil;
+ }
+
+ patchedFiles[relativePath] = [[CodePushPatchedFileEntry alloc] initWithAlgo:algo
+ baseHash:baseHash
+ targetHash:targetHash
+ patch:patch];
+ }
+ }
+
+ // Only version 2 and up carry patches. A version 1 manifest that lists them
+ // is malformed, and applying none of them would leave the old bytes behind.
+ if (version < 2 && patchedFiles.count > 0) {
+ if (error) *error = malformedManifestError([NSString stringWithFormat:@"Diff manifest version %ld does not support binary diff patches, but the manifest lists %lu of them", (long)version, (unsigned long)patchedFiles.count]);
+ return nil;
+ }
+
+ return [[CodePushDiffManifest alloc] initWithVersion:version
+ deletedFiles:deletedFiles
+ patchedFiles:patchedFiles];
+}
+
++ (nullable NSString *)resolvePath:(NSString *)relativePath withinFolder:(NSString *)folder
+{
+ if (relativePath.length == 0 || relativePath.isAbsolutePath) {
+ return nil;
+ }
+ for (NSString *component in relativePath.pathComponents) {
+ if ([component isEqualToString:@".."]) {
+ return nil;
+ }
+ }
+
+ NSString *canonicalFolder = canonicalPathOfExistingItem(folder);
+ if (canonicalFolder == nil) {
+ return nil;
+ }
+
+ NSString *resolved = canonicalPathAllowingMissingComponents([canonicalFolder stringByAppendingPathComponent:relativePath]);
+ if (resolved == nil) {
+ return nil;
+ }
+ if (![resolved isEqualToString:canonicalFolder] &&
+ ![resolved hasPrefix:[canonicalFolder stringByAppendingString:@"/"]]) {
+ return nil;
+ }
+ return resolved;
+}
+
+@end
diff --git a/ios/CodePush/CodePushPackage.m b/ios/CodePush/CodePushPackage.m
index 992b651f..8a37b669 100644
--- a/ios/CodePush/CodePushPackage.m
+++ b/ios/CodePush/CodePushPackage.m
@@ -1,15 +1,30 @@
#import "CodePush.h"
+#import "CodePushDiffManifest.h"
+#import "CodePushBinaryDiffPatcher.h"
#if __has_include()
#import
#else
#import "SSZipArchive.h"
#endif
+@interface CodePushPackage ()
+
++ (BOOL)validateAndApplyDiffManifest:(CodePushDiffManifest *)diffManifest
+ currentPackageFolder:(NSString *)currentPackageFolderPath
+ unzippedFolder:(NSString *)unzippedFolderPath
+ newUpdateFolder:(NSString *)newUpdateFolderPath
+ enableBinaryDiffUpdates:(BOOL)enableBinaryDiffUpdates
+ error:(NSError **)error;
+
+@end
+
@implementation CodePushPackage
#pragma mark - Private constants
static NSString *const DiffManifestFileName = @"hotcodepush.json";
+// Folder within the update ZIP that contains the diff patches.
+static NSString *const DiffPatchesFolderName = @"__hcp_patches";
static NSString *const DownloadFileName = @"download.zip";
static NSString *const RelativeBundlePathKey = @"bundlePath";
static NSString *const StatusFile = @"codepush.json";
@@ -17,6 +32,58 @@ @implementation CodePushPackage
static NSString *const UpdateMetadataFileName = @"app.json";
static NSString *const UnzippedFolderName = @"unzipped";
+#pragma mark - Private methods
+
++ (BOOL)validateAndApplyDiffManifest:(CodePushDiffManifest *)diffManifest
+ currentPackageFolder:(NSString *)currentPackageFolderPath
+ unzippedFolder:(NSString *)unzippedFolderPath
+ newUpdateFolder:(NSString *)newUpdateFolderPath
+ enableBinaryDiffUpdates:(BOOL)enableBinaryDiffUpdates
+ error:(NSError **)error
+{
+ if (diffManifest.version > 2 || diffManifest.version < 1) {
+ *error = [CodePushErrorUtils errorWithMessage:
+ [NSString stringWithFormat:@"Diff manifest version %ld is not supported by this SDK version.", (long)diffManifest.version]];
+ return NO;
+ } else if (diffManifest.version == 2 && !enableBinaryDiffUpdates) {
+ *error = [CodePushErrorUtils errorWithMessage:
+ @"Received a binary diff update, but binary diff updates are not enabled on this client. Set CodePushEnableBinaryDiffUpdates to true in Info.plist to enable them."];
+ return NO;
+ } else if (diffManifest.version == 2) {
+ if (currentPackageFolderPath == nil) {
+ *error = [CodePushErrorUtils errorWithMessage:
+ @"Received a binary diff update, but this device has no previously installed CodePush package to diff against."];
+ return NO;
+ }
+
+ BOOL patchesApplied = [CodePushBinaryDiffPatcher applyBinaryDiffPatchesFromManifest:diffManifest
+ currentPackageFolder:currentPackageFolderPath
+ unzippedFolder:unzippedFolderPath
+ newUpdateFolder:newUpdateFolderPath
+ error:error];
+ if (!patchesApplied) {
+ if (!*error) {
+ *error = [CodePushErrorUtils errorWithMessage:@"Failed to apply the binary diff patches of this update."];
+ }
+ return NO;
+ }
+
+ // The patches folder must not stay in the installed package: it is
+ // not part of the released contents, so it changes the folder hash
+ // and surfaces later as a misleading integrity-check failure.
+ NSString *patchesFolderPath = [newUpdateFolderPath stringByAppendingPathComponent:DiffPatchesFolderName];
+ if ([[NSFileManager defaultManager] fileExistsAtPath:patchesFolderPath]) {
+ [[NSFileManager defaultManager] removeItemAtPath:patchesFolderPath
+ error:error];
+ if (*error) {
+ return NO;
+ }
+ }
+ }
+
+ return YES;
+}
+
#pragma mark - Public methods
+ (void)clearUpdates
@@ -46,6 +113,7 @@ + (void)downloadAndReplaceCurrentBundle:(NSString *)remoteBundleUrl
+ (void)downloadPackage:(NSDictionary *)updatePackage
expectedBundleFileName:(NSString *)expectedBundleFileName
publicKey:(NSString *)publicKey
+ enableBinaryDiffUpdates:(BOOL)enableBinaryDiffUpdates
operationQueue:(dispatch_queue_t)operationQueue
progressCallback:(void (^)(long long, long long))progressCallback
doneCallback:(void (^)())doneCallback
@@ -112,10 +180,12 @@ + (void)downloadPackage:(NSDictionary *)updatePackage
NSString *diffManifestFilePath = [unzippedFolderPath stringByAppendingPathComponent:DiffManifestFileName];
BOOL isDiffUpdate = [[NSFileManager defaultManager] fileExistsAtPath:diffManifestFilePath];
-
+ CodePushDiffManifest *diffManifest = nil;
+ NSString *currentPackageFolderPath = nil;
+
if (isDiffUpdate) {
// Copy the current package to the new package.
- NSString *currentPackageFolderPath = [self getCurrentPackageFolderPath:&error];
+ currentPackageFolderPath = [self getCurrentPackageFolderPath:&error];
if (error) {
failCallback(error);
return;
@@ -158,7 +228,6 @@ + (void)downloadPackage:(NSDictionary *)updatePackage
}
}
- // Delete files mentioned in the manifest.
NSString *manifestContent = [NSString stringWithContentsOfFile:diffManifestFilePath
encoding:NSUTF8StringEncoding
error:&error];
@@ -171,9 +240,26 @@ + (void)downloadPackage:(NSDictionary *)updatePackage
NSDictionary *manifestJSON = [NSJSONSerialization JSONObjectWithData:data
options:kNilOptions
error:&error];
- NSArray *deletedFiles = manifestJSON[@"deletedFiles"];
- for (NSString *deletedFileName in deletedFiles) {
- NSString *absoluteDeletedFilePath = [newUpdateFolderPath stringByAppendingPathComponent:deletedFileName];
+ if (error) {
+ failCallback(error);
+ return;
+ }
+
+ diffManifest = [CodePushDiffManifest manifestFromJSON:manifestJSON error:&error];
+ if (error) {
+ failCallback(error);
+ return;
+ }
+
+ for (NSString *deletedFileName in diffManifest.deletedFiles) {
+ NSString *absoluteDeletedFilePath = [CodePushDiffManifest resolvePath:deletedFileName
+ withinFolder:newUpdateFolderPath];
+ if (absoluteDeletedFilePath == nil) {
+ error = [CodePushErrorUtils errorWithMessage:
+ [NSString stringWithFormat:@"Diff manifest lists a deleted file (\"%@\") outside the update folder.", deletedFileName]];
+ failCallback(error);
+ return;
+ }
if ([[NSFileManager defaultManager] fileExistsAtPath:absoluteDeletedFilePath]) {
[[NSFileManager defaultManager] removeItemAtPath:absoluteDeletedFilePath
error:&error];
@@ -199,7 +285,21 @@ + (void)downloadPackage:(NSDictionary *)updatePackage
failCallback(error);
return;
}
-
+
+ if (isDiffUpdate) {
+ // Run patching after copyEntriesInFolder: so patched output overwrites
+ // bytes copied in from the old package at the same paths.
+ if (![CodePushPackage validateAndApplyDiffManifest:diffManifest
+ currentPackageFolder:currentPackageFolderPath
+ unzippedFolder:unzippedFolderPath
+ newUpdateFolder:newUpdateFolderPath
+ enableBinaryDiffUpdates:enableBinaryDiffUpdates
+ error:&error]) {
+ failCallback(error);
+ return;
+ }
+ }
+
[[NSFileManager defaultManager] removeItemAtPath:unzippedFolderPath
error:&nonFailingError];
if (nonFailingError) {
diff --git a/ios/CodePush/CodePushSha256.h b/ios/CodePush/CodePushSha256.h
new file mode 100644
index 00000000..dab213b9
--- /dev/null
+++ b/ios/CodePush/CodePushSha256.h
@@ -0,0 +1,12 @@
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+// SHA-256 hex digest of a file's contents.
+// Returns nil and sets *error if the file can't be opened/read.
+NSString * _Nullable CodePushSha256HexForFile(NSString *filePath, NSError **error);
+
+// SHA-256 hex digest of an in-memory buffer.
+NSString *CodePushSha256HexForData(NSData *data);
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/CodePush/CodePushSha256.m b/ios/CodePush/CodePushSha256.m
new file mode 100644
index 00000000..d88c42cf
--- /dev/null
+++ b/ios/CodePush/CodePushSha256.m
@@ -0,0 +1,53 @@
+#import "CodePushSha256.h"
+#include
+
+static NSString *const CodePushSha256ErrorDomain = @"CodePushSha256Error";
+
+static NSString *hexStringForDigest(unsigned char digest[CC_SHA256_DIGEST_LENGTH])
+{
+ NSMutableString *hex = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2];
+ for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) {
+ [hex appendFormat:@"%02x", digest[i]];
+ }
+ return hex;
+}
+
+NSString *CodePushSha256HexForData(NSData *data)
+{
+ unsigned char digest[CC_SHA256_DIGEST_LENGTH];
+ CC_SHA256(data.bytes, (CC_LONG)data.length, digest);
+ return hexStringForDigest(digest);
+}
+
+NSString *CodePushSha256HexForFile(NSString *filePath, NSError **error)
+{
+ NSFileHandle *fileHandle = [NSFileHandle fileHandleForReadingAtPath:filePath];
+ if (!fileHandle) {
+ if (error) {
+ *error = [NSError errorWithDomain:CodePushSha256ErrorDomain
+ code:1
+ userInfo:@{ NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Could not open file for reading: %@", filePath] }];
+ }
+ return nil;
+ }
+
+ CC_SHA256_CTX context;
+ CC_SHA256_Init(&context);
+
+ static const NSUInteger kChunkSize = 1024 * 8;
+ @try {
+ while (YES) {
+ NSData *chunk = [fileHandle readDataOfLength:kChunkSize];
+ if (chunk.length == 0) {
+ break;
+ }
+ CC_SHA256_Update(&context, chunk.bytes, (CC_LONG)chunk.length);
+ }
+ } @finally {
+ [fileHandle closeFile];
+ }
+
+ unsigned char digest[CC_SHA256_DIGEST_LENGTH];
+ CC_SHA256_Final(digest, &context);
+ return hexStringForDigest(digest);
+}
diff --git a/ios/CodePush/CodePushUpdateUtils.m b/ios/CodePush/CodePushUpdateUtils.m
index e0f170b7..ceada205 100644
--- a/ios/CodePush/CodePushUpdateUtils.m
+++ b/ios/CodePush/CodePushUpdateUtils.m
@@ -1,5 +1,5 @@
#import "CodePush.h"
-#include
+#import "CodePushSha256.h"
#import "JWT.h"
@implementation CodePushUpdateUtils
@@ -57,8 +57,10 @@ + (BOOL)addContentsOfFolderToManifest:(NSString *)folderPath
return NO;
}
} else {
- NSData *fileContents = [NSData dataWithContentsOfFile:fullFilePath];
- NSString *fileContentsHash = [self computeHashForData:fileContents];
+ NSString *fileContentsHash = CodePushSha256HexForFile(fullFilePath, error);
+ if (!fileContentsHash) {
+ return NO;
+ }
[manifest addObject:[[relativePath stringByAppendingString:@":"] stringByAppendingString:fileContentsHash]];
}
}
@@ -66,14 +68,18 @@ + (BOOL)addContentsOfFolderToManifest:(NSString *)folderPath
return YES;
}
-+ (void)addFileToManifest:(NSURL *)fileURL
++ (BOOL)addFileToManifest:(NSURL *)fileURL
manifest:(NSMutableArray *)manifest
+ error:(NSError **)error
{
if ([[NSFileManager defaultManager] fileExistsAtPath:[fileURL path]]) {
- NSData *fileContents = [NSData dataWithContentsOfURL:fileURL];
- NSString *fileContentsHash = [self computeHashForData:fileContents];
+ NSString *fileContentsHash = CodePushSha256HexForFile([fileURL path], error);
+ if (!fileContentsHash) {
+ return NO;
+ }
[manifest addObject:[NSString stringWithFormat:@"%@/%@:%@", [self manifestFolderPrefix], [fileURL lastPathComponent], fileContentsHash]];
}
+ return YES;
}
+ (NSString *)computeFinalHashFromManifest:(NSMutableArray *)manifest
@@ -93,19 +99,7 @@ + (NSString *)computeFinalHashFromManifest:(NSMutableArray *)manifest
// The JSON serialization turns path separators into "\/", e.g. "CodePush\/assets\/image.png"
manifestString = [manifestString stringByReplacingOccurrencesOfString:@"\\/"
withString:@"/"];
- return [self computeHashForData:[NSData dataWithBytes:manifestString.UTF8String length:[manifestString lengthOfBytesUsingEncoding:NSUTF8StringEncoding]]];
-}
-
-+ (NSString *)computeHashForData:(NSData *)inputData
-{
- uint8_t digest[CC_SHA256_DIGEST_LENGTH];
- CC_SHA256(inputData.bytes, (CC_LONG)inputData.length, 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;
+ return CodePushSha256HexForData([NSData dataWithBytes:manifestString.UTF8String length:[manifestString lengthOfBytesUsingEncoding:NSUTF8StringEncoding]]);
}
+ (BOOL)copyEntriesInFolder:(NSString *)sourceFolder
@@ -230,8 +224,12 @@ + (NSString *)getHashForBinaryContents:(NSURL *)binaryBundleUrl
}
}
- [self addFileToManifest:binaryBundleUrl manifest:manifest];
- [self addFileToManifest:[binaryBundleUrl URLByAppendingPathExtension:@"meta"] manifest:manifest];
+ if (![self addFileToManifest:binaryBundleUrl manifest:manifest error:error]) {
+ return nil;
+ }
+ if (![self addFileToManifest:[binaryBundleUrl URLByAppendingPathExtension:@"meta"] manifest:manifest error:error]) {
+ return nil;
+ }
binaryHash = [self computeFinalHashFromManifest:manifest error:error];
diff --git a/ios/CodePushDiffPatchTests/CodePushBinaryDiffPatcherTests.swift b/ios/CodePushDiffPatchTests/CodePushBinaryDiffPatcherTests.swift
new file mode 100644
index 00000000..fba5a5da
--- /dev/null
+++ b/ios/CodePushDiffPatchTests/CodePushBinaryDiffPatcherTests.swift
@@ -0,0 +1,193 @@
+import XCTest
+
+final class CodePushBinaryDiffPatcherTests: XCTestCase {
+
+ private var tempDir: URL!
+ private var currentPackageFolder: URL!
+ private var unzippedFolder: URL!
+ private var newUpdateFolder: URL!
+
+ private let relativePath = "main.jsbundle"
+ private let patchRelativePath = "__hcp_patches/main.jsbundle.bsdiff"
+
+ override func setUpWithError() throws {
+ tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ currentPackageFolder = tempDir.appendingPathComponent("current")
+ unzippedFolder = tempDir.appendingPathComponent("unzipped")
+ newUpdateFolder = tempDir.appendingPathComponent("new")
+
+ let scratchDirs: [URL] = [currentPackageFolder, unzippedFolder, newUpdateFolder]
+ for dir in scratchDirs {
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ }
+ try FileManager.default.createDirectory(
+ at: unzippedFolder.appendingPathComponent("__hcp_patches"),
+ withIntermediateDirectories: true)
+ }
+
+ override func tearDownWithError() throws {
+ try? FileManager.default.removeItem(at: tempDir)
+ }
+
+ private func fixtureURL(_ relativePath: String) -> URL {
+ let bundle = Bundle(for: type(of: self))
+ guard let resourceURL = bundle.url(forResource: "Fixtures", withExtension: nil) else {
+ fatalError("Fixtures resource folder not found in test bundle")
+ }
+ return resourceURL.appendingPathComponent(relativePath)
+ }
+
+ private func hashOfFixture(_ relativePath: String) throws -> String {
+ var error: NSError?
+ guard let hex = CodePushSha256HexForFile(fixtureURL(relativePath).path, &error) else {
+ throw error ?? NSError(domain: "test", code: 1)
+ }
+ return hex
+ }
+
+ private func installBasicFixtures() throws {
+ try FileManager.default.copyItem(
+ at: fixtureURL("basic/old.dat"),
+ to: currentPackageFolder.appendingPathComponent(relativePath))
+ try FileManager.default.copyItem(
+ at: fixtureURL("basic/patch.bsdiff"),
+ to: unzippedFolder.appendingPathComponent(patchRelativePath))
+ }
+
+ private func manifest(baseHash: String, targetHash: String, algo: String = "bsdiff", patchPath: String? = nil, relativePath: String? = nil) -> CodePushDiffManifest {
+ let entry = CodePushPatchedFileEntry(
+ algo: algo,
+ baseHash: baseHash,
+ targetHash: targetHash,
+ patch: patchPath ?? patchRelativePath)
+ return CodePushDiffManifest(
+ version: 2,
+ deletedFiles: [],
+ patchedFiles: [relativePath ?? self.relativePath: entry])
+ }
+
+ func testApply_happyPath_producesExpectedOutputFile() throws {
+ try installBasicFixtures()
+ let baseHash = try hashOfFixture("basic/old.dat")
+ let targetHash = try hashOfFixture("basic/new.dat")
+
+ try CodePushBinaryDiffPatcher.applyBinaryDiffPatches(
+ manifest: manifest(baseHash: baseHash, targetHash: targetHash),
+ currentPackageFolder: currentPackageFolder.path,
+ unzippedFolder: unzippedFolder.path,
+ newUpdateFolder: newUpdateFolder.path)
+
+ let producedData = try Data(contentsOf: newUpdateFolder.appendingPathComponent(relativePath))
+ let expectedData = try Data(contentsOf: fixtureURL("basic/new.dat"))
+ XCTAssertEqual(producedData, expectedData)
+ }
+
+ func testApply_baseHashMismatch_throws() throws {
+ try installBasicFixtures()
+ let targetHash = try hashOfFixture("basic/new.dat")
+
+ XCTAssertThrowsError(
+ try CodePushBinaryDiffPatcher.applyBinaryDiffPatches(
+ manifest: manifest(baseHash: "not-the-real-hash", targetHash: targetHash),
+ currentPackageFolder: currentPackageFolder.path,
+ unzippedFolder: unzippedFolder.path,
+ newUpdateFolder: newUpdateFolder.path))
+ }
+
+ func testApply_targetHashMismatch_throws() throws {
+ try installBasicFixtures()
+ let baseHash = try hashOfFixture("basic/old.dat")
+
+ XCTAssertThrowsError(
+ try CodePushBinaryDiffPatcher.applyBinaryDiffPatches(
+ manifest: manifest(baseHash: baseHash, targetHash: "not-the-real-hash"),
+ currentPackageFolder: currentPackageFolder.path,
+ unzippedFolder: unzippedFolder.path,
+ newUpdateFolder: newUpdateFolder.path))
+ }
+
+ func testApply_unsupportedAlgo_throwsWithoutTouchingFiles() {
+ // No fixtures installed: an unsupported algo must be rejected before any file I/O.
+ XCTAssertThrowsError(
+ try CodePushBinaryDiffPatcher.applyBinaryDiffPatches(
+ manifest: manifest(baseHash: "irrelevant", targetHash: "irrelevant", algo: "xdelta"),
+ currentPackageFolder: currentPackageFolder.path,
+ unzippedFolder: unzippedFolder.path,
+ newUpdateFolder: newUpdateFolder.path))
+ }
+
+ func testApply_pathTraversalInPatchedFilesKey_isRejected() throws {
+ try installBasicFixtures()
+ let baseHash = try hashOfFixture("basic/old.dat")
+ let targetHash = try hashOfFixture("basic/new.dat")
+
+ XCTAssertThrowsError(
+ try CodePushBinaryDiffPatcher.applyBinaryDiffPatches(
+ manifest: manifest(baseHash: baseHash, targetHash: targetHash, relativePath: "../../etc/passwd"),
+ currentPackageFolder: currentPackageFolder.path,
+ unzippedFolder: unzippedFolder.path,
+ newUpdateFolder: newUpdateFolder.path))
+ }
+
+ func testApply_pathTraversalInManifestPatchField_isRejected() throws {
+ try installBasicFixtures()
+ let baseHash = try hashOfFixture("basic/old.dat")
+ let targetHash = try hashOfFixture("basic/new.dat")
+
+ XCTAssertThrowsError(
+ try CodePushBinaryDiffPatcher.applyBinaryDiffPatches(
+ manifest: manifest(baseHash: baseHash, targetHash: targetHash, patchPath: "../../../etc/passwd"),
+ currentPackageFolder: currentPackageFolder.path,
+ unzippedFolder: unzippedFolder.path,
+ newUpdateFolder: newUpdateFolder.path))
+ }
+
+ // The update zip is extracted before anything verifies it, so it can plant a
+ // symlink in the new update folder and patch through it.
+ func testApply_outputPathThroughSymlink_isRejectedAndWritesNothing() throws {
+ let nestedRelativePath = "escape/main.jsbundle"
+ try FileManager.default.createDirectory(
+ at: currentPackageFolder.appendingPathComponent("escape"),
+ withIntermediateDirectories: true)
+ try FileManager.default.copyItem(
+ at: fixtureURL("basic/old.dat"),
+ to: currentPackageFolder.appendingPathComponent(nestedRelativePath))
+ try FileManager.default.copyItem(
+ at: fixtureURL("basic/patch.bsdiff"),
+ to: unzippedFolder.appendingPathComponent(patchRelativePath))
+
+ let outsideFolder = tempDir.appendingPathComponent("outside")
+ try FileManager.default.createDirectory(at: outsideFolder, withIntermediateDirectories: true)
+ try FileManager.default.createSymbolicLink(
+ at: newUpdateFolder.appendingPathComponent("escape"),
+ withDestinationURL: outsideFolder)
+
+ let baseHash = try hashOfFixture("basic/old.dat")
+ let targetHash = try hashOfFixture("basic/new.dat")
+
+ XCTAssertThrowsError(
+ try CodePushBinaryDiffPatcher.applyBinaryDiffPatches(
+ manifest: manifest(baseHash: baseHash, targetHash: targetHash, relativePath: nestedRelativePath),
+ currentPackageFolder: currentPackageFolder.path,
+ unzippedFolder: unzippedFolder.path,
+ newUpdateFolder: newUpdateFolder.path))
+ XCTAssertFalse(
+ FileManager.default.fileExists(atPath: outsideFolder.appendingPathComponent("main.jsbundle").path))
+ }
+
+ func testApply_corruptPatchFile_throws() throws {
+ try FileManager.default.copyItem(
+ at: fixtureURL("basic/old.dat"),
+ to: currentPackageFolder.appendingPathComponent(relativePath))
+ try Data([0x00, 0x01, 0x02, 0x03]).write(
+ to: unzippedFolder.appendingPathComponent(patchRelativePath))
+ let baseHash = try hashOfFixture("basic/old.dat")
+
+ XCTAssertThrowsError(
+ try CodePushBinaryDiffPatcher.applyBinaryDiffPatches(
+ manifest: manifest(baseHash: baseHash, targetHash: "irrelevant-not-reached-on-failure"),
+ currentPackageFolder: currentPackageFolder.path,
+ unzippedFolder: unzippedFolder.path,
+ newUpdateFolder: newUpdateFolder.path))
+ }
+}
diff --git a/ios/CodePushDiffPatchTests/CodePushDiffManifestTests.swift b/ios/CodePushDiffPatchTests/CodePushDiffManifestTests.swift
new file mode 100644
index 00000000..172f1af7
--- /dev/null
+++ b/ios/CodePushDiffPatchTests/CodePushDiffManifestTests.swift
@@ -0,0 +1,135 @@
+import XCTest
+
+final class CodePushDiffManifestTests: XCTestCase {
+
+ func testManifest_missingVersionField_defaultsToOne() throws {
+ let manifest = try CodePushDiffManifest(json: [:])
+
+ XCTAssertEqual(manifest.version, 1)
+ XCTAssertEqual(manifest.deletedFiles, [])
+ XCTAssertEqual(manifest.patchedFiles.count, 0)
+ }
+
+ func testManifest_deletedFilesAndPatchedFiles_areParsed() throws {
+ let json: [AnyHashable: Any] = [
+ "version": 2,
+ "deletedFiles": ["assets/old.png"],
+ "patchedFiles": [
+ "main.jsbundle": [
+ "algo": "bsdiff",
+ "baseHash": "aaaa",
+ "targetHash": "bbbb",
+ "patch": "__hcp_patches/main.jsbundle.bsdiff",
+ ]
+ ],
+ ]
+
+ let manifest = try CodePushDiffManifest(json: json)
+
+ XCTAssertEqual(manifest.version, 2)
+ XCTAssertEqual(manifest.deletedFiles, ["assets/old.png"])
+
+ let entry = manifest.patchedFiles["main.jsbundle"]
+ XCTAssertNotNil(entry)
+ XCTAssertEqual(entry?.algo, "bsdiff")
+ XCTAssertEqual(entry?.baseHash, "aaaa")
+ XCTAssertEqual(entry?.targetHash, "bbbb")
+ XCTAssertEqual(entry?.patch, "__hcp_patches/main.jsbundle.bsdiff")
+ }
+
+ func testManifest_patchedFilesEntryMissingRequiredField_throws() {
+ let json: [AnyHashable: Any] = [
+ "version": 2,
+ "patchedFiles": [
+ "main.jsbundle": [
+ "algo": "bsdiff",
+ "baseHash": "aaaa",
+ // targetHash is missing.
+ "patch": "__hcp_patches/main.jsbundle.bsdiff",
+ ]
+ ],
+ ]
+
+ XCTAssertThrowsError(try CodePushDiffManifest(json: json))
+ }
+
+ // A version that is not a number must not fall back to 1: that would skip
+ // every patch of a version 2 manifest and install the old bytes.
+ func testManifest_nonNumericVersion_throws() {
+ XCTAssertThrowsError(try CodePushDiffManifest(json: ["version": "2"]))
+ }
+
+ func testManifest_patchedFilesWithoutVersionTwo_throws() {
+ let json: [AnyHashable: Any] = [
+ "version": 1,
+ "patchedFiles": [
+ "main.jsbundle": [
+ "algo": "bsdiff",
+ "baseHash": "aaaa",
+ "targetHash": "bbbb",
+ "patch": "__hcp_patches/main.jsbundle.bsdiff",
+ ]
+ ],
+ ]
+
+ XCTAssertThrowsError(try CodePushDiffManifest(json: json))
+ }
+
+ // MARK: - resolvePath(_:withinFolder:)
+
+ private func makeFolder() throws -> URL {
+ let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
+ addTeardownBlock { try? FileManager.default.removeItem(at: folder) }
+ return folder
+ }
+
+ func testResolvePath_pathThatDoesNotExistYet_resolvesUnderFolder() throws {
+ let folder = try makeFolder()
+
+ let resolved = CodePushDiffManifest.resolvePath("assets/new.png", withinFolder: folder.path)
+
+ // The folder itself is compared canonically: on the simulator the
+ // temporary directory is reached through a symlinked prefix.
+ XCTAssertEqual(resolved, folder.resolvingSymlinksInPath().appendingPathComponent("assets/new.png").path)
+ }
+
+ func testResolvePath_traversalAndAbsolutePaths_areRejected() throws {
+ let folder = try makeFolder()
+
+ XCTAssertNil(CodePushDiffManifest.resolvePath("../escaped.txt", withinFolder: folder.path))
+ XCTAssertNil(CodePushDiffManifest.resolvePath("assets/../../escaped.txt", withinFolder: folder.path))
+ XCTAssertNil(CodePushDiffManifest.resolvePath("/etc/passwd", withinFolder: folder.path))
+ XCTAssertNil(CodePushDiffManifest.resolvePath("", withinFolder: folder.path))
+ }
+
+ // An update zip can contain symlink entries, and they are extracted before
+ // anything verifies the update's contents.
+ func testResolvePath_pathThroughSymlinkOutOfFolder_isRejected() throws {
+ let folder = try makeFolder()
+ let outsideFolder = try makeFolder()
+ try FileManager.default.createSymbolicLink(
+ at: folder.appendingPathComponent("escape"),
+ withDestinationURL: outsideFolder)
+
+ XCTAssertNil(CodePushDiffManifest.resolvePath("escape/evil.txt", withinFolder: folder.path))
+ }
+
+ func testResolvePath_danglingSymlinkLeaf_isRejected() throws {
+ let folder = try makeFolder()
+ let outsideFolder = try makeFolder()
+ // The link target does not exist, so the link itself is all that can be
+ // resolved - and writing to it would still land outside the folder.
+ try FileManager.default.createSymbolicLink(
+ at: folder.appendingPathComponent("evil.txt"),
+ withDestinationURL: outsideFolder.appendingPathComponent("evil.txt"))
+
+ XCTAssertNil(CodePushDiffManifest.resolvePath("evil.txt", withinFolder: folder.path))
+ }
+
+ func testResolvePath_missingFolder_isRejected() {
+ let missingFolder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+
+ XCTAssertNil(CodePushDiffManifest.resolvePath("main.jsbundle", withinFolder: missingFolder.path))
+ }
+}
diff --git a/ios/CodePushDiffPatchTests/CodePushSha256Tests.swift b/ios/CodePushDiffPatchTests/CodePushSha256Tests.swift
new file mode 100644
index 00000000..02c8a9c0
--- /dev/null
+++ b/ios/CodePushDiffPatchTests/CodePushSha256Tests.swift
@@ -0,0 +1,51 @@
+import XCTest
+
+final class CodePushSha256Tests: XCTestCase {
+
+ private var tempDir: URL!
+
+ override func setUpWithError() throws {
+ tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ }
+
+ override func tearDownWithError() throws {
+ try? FileManager.default.removeItem(at: tempDir)
+ }
+
+ private func writeFile(named name: String, contents: Data) throws -> URL {
+ let url = tempDir.appendingPathComponent(name)
+ try contents.write(to: url)
+ return url
+ }
+
+ func testHexForFile_matchesExpectedDigest() throws {
+ let url = try writeFile(named: "abc.dat", contents: Data("abc".utf8))
+
+ var error: NSError?
+ let hex = CodePushSha256HexForFile(url.path, &error)
+
+ XCTAssertNil(error)
+ XCTAssertEqual(hex, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")
+ }
+
+ func testHexForFile_emptyFile_matchesEmptyStringDigest() throws {
+ let url = try writeFile(named: "empty.dat", contents: Data())
+
+ var error: NSError?
+ let hex = CodePushSha256HexForFile(url.path, &error)
+
+ XCTAssertNil(error)
+ XCTAssertEqual(hex, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
+ }
+
+ func testHexForFile_missingFile_returnsNilAndSetsError() {
+ let missingURL = tempDir.appendingPathComponent("does_not_exist.dat")
+
+ var error: NSError?
+ let hex = CodePushSha256HexForFile(missingURL.path, &error)
+
+ XCTAssertNil(hex)
+ XCTAssertNotNil(error)
+ }
+}
diff --git a/ios/CodePushDiffPatchTests/DiffPatchTests-Bridging-Header.h b/ios/CodePushDiffPatchTests/DiffPatchTests-Bridging-Header.h
index 43382433..6aad74d6 100644
--- a/ios/CodePushDiffPatchTests/DiffPatchTests-Bridging-Header.h
+++ b/ios/CodePushDiffPatchTests/DiffPatchTests-Bridging-Header.h
@@ -1 +1,4 @@
#import "bspatch_bridge.h"
+#import "CodePushSha256.h"
+#import "CodePushDiffManifest.h"
+#import "CodePushBinaryDiffPatcher.h"