From d648e54b267b26b0ff74e475388a6b21a111c30a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliv=C3=A9r=20Falvai?= Date: Tue, 25 Aug 2026 17:49:32 +0200 Subject: [PATCH 1/2] iOS: consolidate SHA-256 hashing into a shared utility --- ios/CodePush/CodePushSha256.h | 12 +++++++ ios/CodePush/CodePushSha256.m | 53 ++++++++++++++++++++++++++++++ ios/CodePush/CodePushUpdateUtils.m | 40 +++++++++++----------- 3 files changed, 84 insertions(+), 21 deletions(-) create mode 100644 ios/CodePush/CodePushSha256.h create mode 100644 ios/CodePush/CodePushSha256.m 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]; From 5aefe557cddac96f4cb48ed1970adf25d2064d9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliv=C3=A9r=20Falvai?= Date: Tue, 25 Aug 2026 17:49:32 +0200 Subject: [PATCH 2/2] iOS: apply bsdiff patches during package install --- docs/setup-ios.md | 16 ++ ios/CodePush.xcodeproj/project.pbxproj | 54 +++++ ios/CodePush/CodePush.h | 2 + ios/CodePush/CodePush.m | 2 + ios/CodePush/CodePushBinaryDiffPatcher.h | 23 +++ ios/CodePush/CodePushBinaryDiffPatcher.m | 105 ++++++++++ ios/CodePush/CodePushConfig.m | 9 +- ios/CodePush/CodePushDiffManifest.h | 57 +++++ ios/CodePush/CodePushDiffManifest.m | 194 ++++++++++++++++++ ios/CodePush/CodePushPackage.m | 114 +++++++++- .../CodePushBinaryDiffPatcherTests.swift | 193 +++++++++++++++++ .../CodePushDiffManifestTests.swift | 135 ++++++++++++ .../CodePushSha256Tests.swift | 51 +++++ .../DiffPatchTests-Bridging-Header.h | 3 + 14 files changed, 950 insertions(+), 8 deletions(-) create mode 100644 ios/CodePush/CodePushBinaryDiffPatcher.h create mode 100644 ios/CodePush/CodePushBinaryDiffPatcher.m create mode 100644 ios/CodePush/CodePushDiffManifest.h create mode 100644 ios/CodePush/CodePushDiffManifest.m create mode 100644 ios/CodePushDiffPatchTests/CodePushBinaryDiffPatcherTests.swift create mode 100644 ios/CodePushDiffPatchTests/CodePushDiffManifestTests.swift create mode 100644 ios/CodePushDiffPatchTests/CodePushSha256Tests.swift 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/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"