From 54b486be5c89fdcdfba01508291df87a662e07a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliv=C3=A9r=20Falvai?= Date: Wed, 23 Sep 2026 14:58:43 +0200 Subject: [PATCH] Binary patching: replace copy + delete with a filtered copy --- .../codepush/react/CodePushUpdateManager.java | 12 ++-- .../microsoft/codepush/react/FileUtils.java | 8 +++ .../codepush/react/diffpatch/DiffManifest.kt | 25 ++++++- .../react/CodePushUpdateManagerTest.kt | 37 ++++++++++ .../microsoft/codepush/react/FileUtilsTest.kt | 22 ++++++ .../react/diffpatch/DiffManifestTest.kt | 71 +++++++++++++++++++ ios/CodePush/CodePush.h | 6 ++ ios/CodePush/CodePushDiffManifest.h | 5 ++ ios/CodePush/CodePushDiffManifest.m | 39 ++++++++-- ios/CodePush/CodePushPackage.m | 27 +++---- ios/CodePush/CodePushUpdateUtils.m | 14 ++++ .../CodePushDiffManifestTests.swift | 58 +++++++++++++++ 12 files changed, 293 insertions(+), 31 deletions(-) diff --git a/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java b/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java index d85d119e..491a2a2c 100644 --- a/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java +++ b/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java @@ -273,22 +273,26 @@ void installDownloadedUpdate(JSONObject updatePackage, String expectedBundleFile diffManifestFile.delete(); } - FileUtils.copyDirectoryContents(unzippedFolderPath, newUpdateFolderPath); + // The patches folder of a binary diff must not end up 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. The patcher reads it from unzippedFolderPath. + boolean isBinaryDiffUpdate = isDiffUpdate && diffManifest.isBinaryDiff(); + FileUtils.copyDirectoryContents(unzippedFolderPath, newUpdateFolderPath, + isBinaryDiffUpdate ? CodePushConstants.DIFF_PATCHES_FOLDER_NAME : null); if (isDiffUpdate) { // Run patching after copyNecessaryFilesFromCurrentPackage() so patched output overwrites // bytes copied in from the old package at the same paths. if (diffManifest.getVersion() > 2 || diffManifest.getVersion() < 1) { throw new IOException("Diff manifest version " + diffManifest.getVersion() + " is not supported by this SDK version."); - } else if (diffManifest.getVersion() == 2 && !mEnableDeltaUpdates) { + } else if (diffManifest.isBinaryDiff() && !mEnableDeltaUpdates) { throw new IOException("Received a binary diff update, but delta updates are not enabled on this client. Set CodePushEnableDeltaUpdates to true in strings.xml to enable them."); - } else if (diffManifest.getVersion() == 2) { + } else if (diffManifest.isBinaryDiff()) { String currentPackageFolderPath = getCurrentPackageFolderPath(); if (currentPackageFolderPath == null) { throw new CodePushInvalidUpdateException("Received a binary diff update, but no currently installed package exists to diff against (this is likely the first CodePush update for this app install). Diffing against the embedded app binary is not yet supported."); } BinaryDiffPatcher.applyBinaryDiffPatches(diffManifest, new File(currentPackageFolderPath), new File(unzippedFolderPath), new File(newUpdateFolderPath)); - FileUtils.deleteDirectoryAtPath(new File(newUpdateFolderPath, CodePushConstants.DIFF_PATCHES_FOLDER_NAME).getPath()); } } diff --git a/android/app/src/main/java/com/microsoft/codepush/react/FileUtils.java b/android/app/src/main/java/com/microsoft/codepush/react/FileUtils.java index 45de5b74..48ee9ecd 100644 --- a/android/app/src/main/java/com/microsoft/codepush/react/FileUtils.java +++ b/android/app/src/main/java/com/microsoft/codepush/react/FileUtils.java @@ -16,6 +16,11 @@ public class FileUtils { private static final int WRITE_BUFFER_SIZE = 1024 * 8; public static void copyDirectoryContents(String sourceDirectoryPath, String destinationDirectoryPath) throws IOException { + copyDirectoryContents(sourceDirectoryPath, destinationDirectoryPath, null); + } + + // excludedEntryName applies only to the top level of sourceDirectoryPath: nested entries with the same name are copied. + public static void copyDirectoryContents(String sourceDirectoryPath, String destinationDirectoryPath, String excludedEntryName) throws IOException { File sourceDir = new File(sourceDirectoryPath); File destDir = new File(destinationDirectoryPath); if (!destDir.exists()) { @@ -23,6 +28,9 @@ public static void copyDirectoryContents(String sourceDirectoryPath, String dest } for (File sourceFile : sourceDir.listFiles()) { + if (sourceFile.getName().equals(excludedEntryName)) { + continue; + } if (sourceFile.isDirectory()) { copyDirectoryContents( CodePushUtils.appendPathComponent(sourceDirectoryPath, sourceFile.getName()), diff --git a/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/DiffManifest.kt b/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/DiffManifest.kt index 64b023d6..ad1d1907 100644 --- a/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/DiffManifest.kt +++ b/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/DiffManifest.kt @@ -24,7 +24,14 @@ data class DiffManifest( val deletedFiles: List, // Map key: file's relative path in the package being installed. val patchedFiles: Map, -) +) { + // True if this manifest describes a binary diff update, which ships its patches under CodePushConstants.DIFF_PATCHES_FOLDER_NAME. + // This is a subset of "diff updates" in general, as a diff update payload can also consist of: + // - The modified files included in the ZIP, which are applied on top of the existing files (without any binary patching) + // - The list of files to delete from the old package + val isBinaryDiff: Boolean + get() = version == 2 +} @Throws(JSONException::class) fun parseDiffManifest(json: JSONObject): DiffManifest { @@ -41,6 +48,12 @@ fun parseDiffManifest(json: JSONObject): DiffManifest { val patchedFilesJson = json.optJSONObject("patchedFiles") val patchedFiles = if (patchedFilesJson != null) { patchedFilesJson.keys().asSequence().associateWith { relativePath -> + if (relativePath.split('/').contains("..")) { + throw JSONException("Diff manifest patchedFiles[\"$relativePath\"] must not contain \"..\" components.") + } + if (topLevelComponentOf(relativePath) == CodePushConstants.DIFF_PATCHES_FOLDER_NAME) { + throw JSONException("Diff manifest patchedFiles[\"$relativePath\"] targets the reserved \"$reservedPatchesFolderPrefix\" folder, which is not part of the installed package.") + } val entry = patchedFilesJson.getJSONObject(relativePath) val patch = entry.getString("patch") if (!patch.startsWith(reservedPatchesFolderPrefix)) { @@ -57,9 +70,15 @@ fun parseDiffManifest(json: JSONObject): DiffManifest { emptyMap() } - if (version != 2 && patchedFiles.isNotEmpty()) { + val manifest = DiffManifest(version = version, deletedFiles = deletedFiles, patchedFiles = patchedFiles) + if (!manifest.isBinaryDiff && patchedFiles.isNotEmpty()) { throw JSONException("Diff manifest declares version $version but contains patchedFiles, which requires version 2.") } - return DiffManifest(version = version, deletedFiles = deletedFiles, patchedFiles = patchedFiles) + return manifest } + +// The top-level entry that `relativePath` names under a base folder. Empty and "." components are skipped. +// The caller rejects ".." components first, so they need no handling here. +private fun topLevelComponentOf(relativePath: String): String? = + relativePath.split('/').firstOrNull { it.isNotEmpty() && it != "." } diff --git a/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerTest.kt b/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerTest.kt index c054eb9a..2e6731fb 100644 --- a/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerTest.kt +++ b/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerTest.kt @@ -14,6 +14,7 @@ import org.junit.rules.TemporaryFolder import org.mockito.MockedStatic import org.mockito.Mockito import java.io.File +import java.security.MessageDigest import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream @@ -61,6 +62,15 @@ class CodePushUpdateManagerTest { return file } + // The package hash the CLI computes for a release with the given file contents: the SHA-256 of the + // JSON array of sorted ":" entries. + private fun releaseHashOf(files: Map): String { + fun sha256Hex(bytes: ByteArray) = + MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } + val entries = files.map { (relativePath, content) -> "$relativePath:${sha256Hex(content.toByteArray())}" }.sorted() + return sha256Hex(entries.joinToString(",", "[", "]") { "\"$it\"" }.toByteArray()) + } + // Registers `hash` as the currently installed package, with the given file contents, so that // getCurrentPackageFolderPath() resolves to it. Needed to set up diff-update scenarios. private fun installCurrentPackage(update: CodePushUpdateManager, hash: String, files: Map): String { @@ -303,4 +313,31 @@ class CodePushUpdateManagerTest { assertEquals("new bundle contents", File(newUpdateFolderPath, "index.android.bundle").readText()) assertFalse("the manifest itself should not be carried into the installed package", File(newUpdateFolderPath, CodePushConstants.DIFF_MANIFEST_FILE_NAME).exists()) } + + @Test + fun installDownloadedUpdate_binaryDiffUpdate_passesFolderHashCheckWithoutPatchesFolder() { + // Given + val update = manager(enableDeltaUpdates = true) + installCurrentPackage(update, "current-hash", mapOf("kept.txt" to "kept contents")) + val downloadFile = zipOf( + CodePushConstants.DIFF_MANIFEST_FILE_NAME to """{"version":2,"deletedFiles":[],"patchedFiles":{}}""", + "index.android.bundle" to "new bundle contents", + "${CodePushConstants.DIFF_PATCHES_FOLDER_NAME}/unused.bsdiff" to "patch bytes", + ) + // The release contents, as the CLI hashes them: the patches folder and the manifest are not part of them. + val pkg = updatePackage(releaseHashOf(mapOf( + "kept.txt" to "kept contents", + "index.android.bundle" to "new bundle contents", + ))) + val newUpdateFolderPath = update.getPackageFolderPath("new-hash") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) + + // Then + assertEquals("kept contents", File(newUpdateFolderPath, "kept.txt").readText()) + assertEquals("new bundle contents", File(newUpdateFolderPath, "index.android.bundle").readText()) + assertFalse(File(newUpdateFolderPath, CodePushConstants.DIFF_PATCHES_FOLDER_NAME).exists()) + } } diff --git a/android/app/src/test/java/com/microsoft/codepush/react/FileUtilsTest.kt b/android/app/src/test/java/com/microsoft/codepush/react/FileUtilsTest.kt index 25039628..212d3063 100644 --- a/android/app/src/test/java/com/microsoft/codepush/react/FileUtilsTest.kt +++ b/android/app/src/test/java/com/microsoft/codepush/react/FileUtilsTest.kt @@ -1,6 +1,7 @@ package com.microsoft.codepush.react import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -31,4 +32,25 @@ class FileUtilsTest { assertTrue(copiedNestedFile.exists()) assertEquals("child contents", copiedNestedFile.readText()) } + + @Test + fun copyDirectoryContents_withExcludedEntry_skipsItOnlyAtTopLevel() { + // Given + val sourceDir = tempFolder.newFolder("source") + File(sourceDir, "root.txt").writeText("root contents") + File(sourceDir, "excluded").mkdir() + File(sourceDir, "excluded/skipped.txt").writeText("skipped contents") + File(sourceDir, "nested/excluded").mkdirs() + File(sourceDir, "nested/excluded/kept.txt").writeText("kept contents") + + val destinationDir = File(tempFolder.root, "destination") + + // When + FileUtils.copyDirectoryContents(sourceDir.absolutePath, destinationDir.absolutePath, "excluded") + + // Then + assertEquals("root contents", File(destinationDir, "root.txt").readText()) + assertFalse(File(destinationDir, "excluded").exists()) + assertEquals("kept contents", File(destinationDir, "nested/excluded/kept.txt").readText()) + } } diff --git a/android/app/src/test/java/com/microsoft/codepush/react/diffpatch/DiffManifestTest.kt b/android/app/src/test/java/com/microsoft/codepush/react/diffpatch/DiffManifestTest.kt index 26eeabe5..e6611ed8 100644 --- a/android/app/src/test/java/com/microsoft/codepush/react/diffpatch/DiffManifestTest.kt +++ b/android/app/src/test/java/com/microsoft/codepush/react/diffpatch/DiffManifestTest.kt @@ -152,6 +152,77 @@ class DiffManifestTest { } } + private fun manifestWithPatchedFileKey(relativePath: String) = JSONObject().apply { + put("version", 2) + put("patchedFiles", JSONObject().apply { + put(relativePath, JSONObject().apply { + put("algo", "bsdiff") + put("baseHash", "base-hash-1") + put("targetHash", "target-hash-1") + put("patch", "__hcp_patches/relative/path.js.bsdiff") + }) + }) + } + + @Test + fun parseDiffManifest_patchedFileKeyResolvingIntoReservedFolder_throws() { + val keys = listOf( + "__hcp_patches", + "__hcp_patches/", + "__hcp_patches/relative/path.js", + "./__hcp_patches/relative/path.js", + "/__hcp_patches/relative/path.js", + ) + for (key in keys) { + try { + parseDiffManifest(manifestWithPatchedFileKey(key)) + fail("expected JSONException for key \"$key\"") + } catch (e: org.json.JSONException) { + assertTrue(e.message, e.message!!.contains("reserved \"__hcp_patches/\" folder")) + } + } + } + + @Test + fun parseDiffManifest_patchedFileKeyOutsideReservedFolder_isAccepted() { + val keys = listOf( + "assets/__hcp_patches/path.js", + "__hcp_patches_extra/path.js", + ) + for (key in keys) { + val manifest = parseDiffManifest(manifestWithPatchedFileKey(key)) + assertEquals(setOf(key), manifest.patchedFiles.keys) + } + } + + @Test + fun parseDiffManifest_patchedFileKeyWithParentComponent_throws() { + val keys = listOf( + "relative/../__hcp_patches/path.js", + "__hcp_patches/../relative/path.js", + "relative/../path.js", + "../path.js", + "relative/..", + ) + for (key in keys) { + try { + parseDiffManifest(manifestWithPatchedFileKey(key)) + fail("expected JSONException for key \"$key\"") + } catch (e: org.json.JSONException) { + assertTrue(e.message, e.message!!.contains("must not contain \"..\" components")) + } + } + } + + @Test + fun parseDiffManifest_patchedFileKeyWithDotsInComponentName_isAccepted() { + val keys = listOf("relative/..path.js", "relative/path..js", "...") + for (key in keys) { + val manifest = parseDiffManifest(manifestWithPatchedFileKey(key)) + assertEquals(setOf(key), manifest.patchedFiles.keys) + } + } + @Test fun parseDiffManifest_patchedFileEntryPatchOutsideReservedPrefix_throws() { assertPatchFieldRejectedByParser("relative/path.js.bsdiff") diff --git a/ios/CodePush/CodePush.h b/ios/CodePush/CodePush.h index 644bdeeb..47f4ae39 100644 --- a/ios/CodePush/CodePush.h +++ b/ios/CodePush/CodePush.h @@ -180,6 +180,12 @@ failCallback:(void (^)(NSError *err))failCallback; destFolder:(NSString *)destFolder error:(NSError **)error; +// excludedEntryName applies only to the top level of sourceFolder: nested entries with the same name are copied. ++ (BOOL)copyEntriesInFolder:(NSString *)sourceFolder + destFolder:(NSString *)destFolder + excludingEntry:(NSString *)excludedEntryName + error:(NSError **)error; + + (NSString *)findMainBundleInFolder:(NSString *)folderPath expectedFileName:(NSString *)expectedFileName error:(NSError **)error; diff --git a/ios/CodePush/CodePushDiffManifest.h b/ios/CodePush/CodePushDiffManifest.h index efe28276..25aecdae 100644 --- a/ios/CodePush/CodePushDiffManifest.h +++ b/ios/CodePush/CodePushDiffManifest.h @@ -28,6 +28,11 @@ extern NSString *const CodePushDiffPatchesFolderName; // 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; +// YES if this manifest describes a binary diff update, which ships its patches under CodePushDiffPatchesFolderName. +// This is a subset of "diff updates" in general, as a diff update payload can also consist of: +// - The modified files included in the ZIP, which are applied on top of the existing files (without any binary patching) +// - The list of files to delete from the old package +@property (nonatomic, readonly, assign) BOOL isBinaryDiff; // 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. diff --git a/ios/CodePush/CodePushDiffManifest.m b/ios/CodePush/CodePushDiffManifest.m index b00015c0..33663f07 100644 --- a/ios/CodePush/CodePushDiffManifest.m +++ b/ios/CodePush/CodePushDiffManifest.m @@ -16,6 +16,19 @@ NSString *const CodePushDiffPatchesFolderName = @"__hcp_patches"; +// The top-level entry that `relativePath` names under a base folder. Empty and +// "." components are skipped. The caller rejects ".." components first, so +// they need no handling here. +static NSString *topLevelComponentOf(NSString *relativePath) +{ + for (NSString *component in [relativePath componentsSeparatedByString:@"/"]) { + if (component.length > 0 && ![component isEqualToString:@"."]) { + return component; + } + } + return nil; +} + static BOOL isAbsent(id value) { return value == nil || [value isKindOfClass:[NSNull class]]; @@ -111,6 +124,11 @@ - (instancetype)initWithVersion:(NSInteger)version return self; } +- (BOOL)isBinaryDiff +{ + return self.version == 2; +} + + (nullable instancetype)manifestFromJSON:(NSDictionary *)json error:(NSError **)error { if (![json isKindOfClass:[NSDictionary class]]) { @@ -157,6 +175,17 @@ + (nullable instancetype)manifestFromJSON:(NSDictionary *)json error:(NSError ** } for (NSString *relativePath in (NSDictionary *)patchedFilesJSON) { NSString *context = [NSString stringWithFormat:@"patchedFiles[\"%@\"]", relativePath]; + NSString *reservedPatchesFolderPrefix = [CodePushDiffPatchesFolderName stringByAppendingString:@"/"]; + + if ([[relativePath componentsSeparatedByString:@"/"] containsObject:@".."]) { + if (error) *error = [CodePushErrorUtils errorWithMessage:[NSString stringWithFormat:@"Diff manifest %@ must not contain \"..\" components", context]]; + return nil; + } + + if ([topLevelComponentOf(relativePath) isEqualToString:CodePushDiffPatchesFolderName]) { + if (error) *error = [CodePushErrorUtils errorWithMessage:[NSString stringWithFormat:@"Diff manifest %@ targets the reserved \"%@\" folder, which is not part of the installed package", context, reservedPatchesFolderPrefix]]; + return nil; + } id entryJSON = patchedFilesJSON[relativePath]; if (![entryJSON isKindOfClass:[NSDictionary class]]) { @@ -173,7 +202,6 @@ + (nullable instancetype)manifestFromJSON:(NSDictionary *)json error:(NSError ** } NSString *patch = entryJSON[@"patch"]; - NSString *reservedPatchesFolderPrefix = [CodePushDiffPatchesFolderName stringByAppendingString:@"/"]; if (![patch hasPrefix:reservedPatchesFolderPrefix]) { if (error) *error = [CodePushErrorUtils errorWithMessage:[NSString stringWithFormat:@"Diff manifest %@ field \"patch\" must be under the reserved \"%@\" prefix, but is \"%@\"", context, reservedPatchesFolderPrefix, patch]]; return nil; @@ -189,14 +217,15 @@ + (nullable instancetype)manifestFromJSON:(NSDictionary *)json error:(NSError ** // Only version 2 defines file patching. // A manifest of any other version that lists patched files is malformed, // and applying none of them would leave the old bytes behind. - if (version != 2 && patchedFiles.count > 0) { + CodePushDiffManifest *manifest = [[CodePushDiffManifest alloc] initWithVersion:version + deletedFiles:deletedFiles + patchedFiles:patchedFiles]; + if (!manifest.isBinaryDiff && patchedFiles.count > 0) { if (error) *error = [CodePushErrorUtils errorWithMessage:[NSString stringWithFormat:@"Diff manifest declares version %ld but lists %lu patchedFiles, which require version 2", (long)version, (unsigned long)patchedFiles.count]]; return nil; } - return [[CodePushDiffManifest alloc] initWithVersion:version - deletedFiles:deletedFiles - patchedFiles:patchedFiles]; + return manifest; } + (nullable NSString *)resolvePath:(NSString *)relativePath diff --git a/ios/CodePush/CodePushPackage.m b/ios/CodePush/CodePushPackage.m index a3859292..10f7df98 100644 --- a/ios/CodePush/CodePushPackage.m +++ b/ios/CodePush/CodePushPackage.m @@ -33,13 +33,13 @@ + (BOOL)validateDiffManifest:(CodePushDiffManifest *)diffManifest [NSString stringWithFormat:@"Diff manifest version %ld is not supported by this SDK version.", (long)diffManifest.version]]; } return NO; - } else if (diffManifest.version == 2 && !enableDeltaUpdates) { + } else if (diffManifest.isBinaryDiff && !enableDeltaUpdates) { if (error) { *error = [CodePushErrorUtils errorWithMessage: @"Received a binary diff update, but delta updates are not enabled on this client. Set CodePushEnableDeltaUpdates to true in Info.plist to enable them."]; } return NO; - } else if (diffManifest.version == 2 && currentPackageFolderPath == nil) { + } else if (diffManifest.isBinaryDiff && currentPackageFolderPath == nil) { if (error) { *error = [CodePushErrorUtils errorWithMessage: @"Received a binary diff update, but no currently installed package exists to diff against (this is likely the first CodePush update for this app install). Diffing against the embedded app binary is not yet supported."]; @@ -56,7 +56,7 @@ + (BOOL)applyDiffManifest:(CodePushDiffManifest *)diffManifest newUpdateFolder:(NSString *)newUpdateFolderPath error:(NSError **)error { - if (diffManifest.version != 2) { + if (!diffManifest.isBinaryDiff) { return YES; } @@ -73,22 +73,6 @@ + (BOOL)applyDiffManifest:(CodePushDiffManifest *)diffManifest 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:CodePushDiffPatchesFolderName]; - if ([[NSFileManager defaultManager] fileExistsAtPath:patchesFolderPath]) { - NSError *removeError = nil; - BOOL patchesFolderRemoved = [[NSFileManager defaultManager] removeItemAtPath:patchesFolderPath - error:&removeError]; - if (!patchesFolderRemoved) { - if (error) { - *error = removeError; - } - return NO; - } - } - return YES; } @@ -296,8 +280,13 @@ + (void)downloadPackage:(NSDictionary *)updatePackage } } + // The patches folder of a binary diff must not end up 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. The patcher reads it + // from unzippedFolderPath. [CodePushUpdateUtils copyEntriesInFolder:unzippedFolderPath destFolder:newUpdateFolderPath + excludingEntry:(diffManifest.isBinaryDiff ? CodePushDiffPatchesFolderName : nil) error:&error]; if (error) { failCallback(error); diff --git a/ios/CodePush/CodePushUpdateUtils.m b/ios/CodePush/CodePushUpdateUtils.m index 94c6e84a..ba0f8f81 100644 --- a/ios/CodePush/CodePushUpdateUtils.m +++ b/ios/CodePush/CodePushUpdateUtils.m @@ -106,6 +106,17 @@ + (NSString *)computeFinalHashFromManifest:(NSMutableArray *)manifest + (BOOL)copyEntriesInFolder:(NSString *)sourceFolder destFolder:(NSString *)destFolder error:(NSError **)error +{ + return [self copyEntriesInFolder:sourceFolder + destFolder:destFolder + excludingEntry:nil + error:error]; +} + ++ (BOOL)copyEntriesInFolder:(NSString *)sourceFolder + destFolder:(NSString *)destFolder + excludingEntry:(NSString *)excludedEntryName + error:(NSError **)error { NSArray *files = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:sourceFolder @@ -115,6 +126,9 @@ + (BOOL)copyEntriesInFolder:(NSString *)sourceFolder } for (NSString *fileName in files) { + if ([fileName isEqualToString:excludedEntryName]) { + continue; + } NSString * fullFilePath = [sourceFolder stringByAppendingPathComponent:fileName]; BOOL isDir = NO; if ([[NSFileManager defaultManager] fileExistsAtPath:fullFilePath diff --git a/ios/CodePushTests/CodePushDiffManifestTests.swift b/ios/CodePushTests/CodePushDiffManifestTests.swift index e9d31af8..81c9ec82 100644 --- a/ios/CodePushTests/CodePushDiffManifestTests.swift +++ b/ios/CodePushTests/CodePushDiffManifestTests.swift @@ -138,6 +138,64 @@ final class CodePushDiffManifestTests: XCTestCase { } } + private func manifestJSON(patchedFileKey: String) -> [AnyHashable: Any] { + return [ + "version": 2, + "patchedFiles": [ + patchedFileKey: [ + "algo": "bsdiff", + "baseHash": "aaaa", + "targetHash": "bbbb", + "patch": "__hcp_patches/main.jsbundle.bsdiff", + ] + ], + ] + } + + func testManifest_patchedFilesKeyInReservedFolder_throws() { + let keys = [ + "__hcp_patches", + "__hcp_patches/", + "__hcp_patches/main.jsbundle", + "./__hcp_patches/main.jsbundle", + "/__hcp_patches/main.jsbundle", + ] + for key in keys { + XCTAssertThrowsError(try CodePushDiffManifest(json: manifestJSON(patchedFileKey: key)), key) { error in + XCTAssertTrue(error.localizedDescription.contains("reserved \"__hcp_patches/\" folder"), error.localizedDescription) + } + } + } + + func testManifest_patchedFilesKeyOutsideReservedFolder_isAccepted() throws { + for key in ["assets/__hcp_patches/main.jsbundle", "__hcp_patches_extra/main.jsbundle"] { + let manifest = try CodePushDiffManifest(json: manifestJSON(patchedFileKey: key)) + XCTAssertEqual(Array(manifest.patchedFiles.keys), [key]) + } + } + + func testManifest_patchedFilesKeyWithParentComponent_throws() { + let keys = [ + "relative/../__hcp_patches/main.jsbundle", + "__hcp_patches/../relative/main.jsbundle", + "relative/../main.jsbundle", + "../main.jsbundle", + "relative/..", + ] + for key in keys { + XCTAssertThrowsError(try CodePushDiffManifest(json: manifestJSON(patchedFileKey: key)), key) { error in + XCTAssertTrue(error.localizedDescription.contains("must not contain \"..\" components"), error.localizedDescription) + } + } + } + + func testManifest_patchedFilesKeyWithDotsInComponentName_isAccepted() throws { + for key in ["relative/..main.jsbundle", "relative/main..jsbundle", "..."] { + let manifest = try CodePushDiffManifest(json: manifestJSON(patchedFileKey: key)) + XCTAssertEqual(Array(manifest.patchedFiles.keys), [key]) + } + } + func testManifest_patchedFilesWithoutVersionTwo_throws() { let json: [AnyHashable: Any] = [ "version": 1,