diff --git a/android/app/build.gradle b/android/app/build.gradle index 3b152015..47c673e4 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -97,6 +97,7 @@ dependencies { testImplementation 'junit:junit:4.13.2' testImplementation 'org.json:json:20231013' + testImplementation 'org.mockito:mockito-core:5.14.2' androidTestImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.2.1' 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 a4fd6998..0689e452 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 @@ -234,6 +234,15 @@ public void downloadPackage(JSONObject updatePackage, String expectedBundleFileN } } + installDownloadedUpdate(updatePackage, expectedBundleFileName, stringPublicKey, + downloadFile, isZip, newUpdateFolderPath, newUpdateMetadataPath); + } + + void installDownloadedUpdate(JSONObject updatePackage, String expectedBundleFileName, + String stringPublicKey, File downloadFile, boolean isZip, + String newUpdateFolderPath, String newUpdateMetadataPath) throws IOException { + String newUpdateHash = updatePackage.optString(CodePushConstants.PACKAGE_HASH_KEY, null); + if (isZip) { // Unzip the downloaded file and then delete the zip String unzippedFolderPath = getUnzippedFolderPath(); @@ -301,30 +310,26 @@ public void downloadPackage(JSONObject updatePackage, String expectedBundleFileN String signaturePath = CodePushUpdateUtils.getSignatureFilePath(newUpdateFolderPath); boolean isSignatureAppearedInBundle = FileUtils.fileAtPathExists(signaturePath); + if (isSignatureVerificationEnabled && !isSignatureAppearedInBundle) { + throw new CodePushInvalidUpdateException( + "Error! Public key was provided but there is no JWT signature within app bundle to verify. " + + "Possible reasons, why that might happen: \n" + + "1. You've been released CodePush bundle update using version of CodePush CLI that is not support code signing.\n" + + "2. You've been released CodePush bundle update without providing --privateKeyPath option." + ); + } + + if (!isSignatureVerificationEnabled && isSignatureAppearedInBundle) { + CodePushUtils.log( + "Warning! JWT signature exists in codepush update but code integrity check couldn't be performed because there is no public key configured. " + + "Please ensure that public key is properly configured within your application." + ); + } + + CodePushUpdateUtils.verifyFolderHash(newUpdateFolderPath, newUpdateHash); + if (isSignatureVerificationEnabled) { - if (isSignatureAppearedInBundle) { - CodePushUpdateUtils.verifyFolderHash(newUpdateFolderPath, newUpdateHash); - CodePushUpdateUtils.verifyUpdateSignature(newUpdateFolderPath, newUpdateHash, stringPublicKey); - } else { - throw new CodePushInvalidUpdateException( - "Error! Public key was provided but there is no JWT signature within app bundle to verify. " + - "Possible reasons, why that might happen: \n" + - "1. You've been released CodePush bundle update using version of CodePush CLI that is not support code signing.\n" + - "2. You've been released CodePush bundle update without providing --privateKeyPath option." - ); - } - } else { - if (isSignatureAppearedInBundle) { - CodePushUtils.log( - "Warning! JWT signature exists in codepush update but code integrity check couldn't be performed because there is no public key configured. " + - "Please ensure that public key is properly configured within your application." - ); - CodePushUpdateUtils.verifyFolderHash(newUpdateFolderPath, newUpdateHash); - } else { - if (isDiffUpdate) { - CodePushUpdateUtils.verifyFolderHash(newUpdateFolderPath, newUpdateHash); - } - } + CodePushUpdateUtils.verifyUpdateSignature(newUpdateFolderPath, newUpdateHash, stringPublicKey); } CodePushUtils.setJSONValueForKey(updatePackage, CodePushConstants.RELATIVE_BUNDLE_PATH_KEY, relativeBundlePath); 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 new file mode 100644 index 00000000..8b1bf641 --- /dev/null +++ b/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerTest.kt @@ -0,0 +1,306 @@ +package com.microsoft.codepush.react + +import android.util.Log +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.MockedStatic +import org.mockito.Mockito +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +class CodePushUpdateManagerTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var logMock: MockedStatic + + @Before + fun mockAndroidLog() { + // CodePushUtils.log() is used deep inside the SDK classes, which isn't stubbed for plain JVM unit tests. + // We'd rather hack around this (as long there is nothing else to mock) than moving these tests to instrumented Android tests. + logMock = Mockito.mockStatic(Log::class.java) + } + + @After + fun unmockAndroidLog() { + logMock.close() + } + + private fun manager(enableDeltaUpdates: Boolean = false) = + CodePushUpdateManager(tempFolder.newFolder("documents").absolutePath, enableDeltaUpdates) + + private fun updatePackage(hash: String) = JSONObject().apply { + put(CodePushConstants.PACKAGE_HASH_KEY, hash) + } + + private fun zipOf(vararg entries: Pair): File { + val zipFile = tempFolder.newFile("download.zip") + ZipOutputStream(zipFile.outputStream()).use { zip -> + for ((path, content) in entries) { + zip.putNextEntry(ZipEntry(path)) + zip.write(content.toByteArray()) + zip.closeEntry() + } + } + return zipFile + } + + private fun rawBundleFile(content: String): File { + val file = tempFolder.newFile("download.bundle") + file.writeText(content) + return file + } + + // 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 { + val folderPath = update.getPackageFolderPath(hash) + File(folderPath).mkdirs() + for ((relativePath, content) in files) { + val file = File(folderPath, relativePath) + file.parentFile?.mkdirs() + file.writeText(content) + } + update.updateCurrentPackageInfo(JSONObject().apply { put(CodePushConstants.CURRENT_PACKAGE_KEY, hash) }) + return folderPath + } + + @Test + fun installDownloadedUpdate_rawBundle_movesFileIntoPlaceAndWritesMetadataWithoutBundlePath() { + // Given + val update = manager() + val pkg = updatePackage("hash1") + val downloadFile = rawBundleFile("raw jsbundle contents") + val newUpdateFolderPath = update.getPackageFolderPath("hash1") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, false, newUpdateFolderPath, newUpdateMetadataPath) + + // Then + val installedBundle = File(newUpdateFolderPath, "index.android.bundle") + assertTrue(installedBundle.exists()) + assertEquals("raw jsbundle contents", installedBundle.readText()) + val metadata = JSONObject(File(newUpdateMetadataPath).readText()) + assertEquals("hash1", metadata.getString(CodePushConstants.PACKAGE_HASH_KEY)) + assertFalse("raw bundle updates never set a bundlePath", metadata.has(CodePushConstants.RELATIVE_BUNDLE_PATH_KEY)) + } + + @Test + fun installDownloadedUpdate_zipFullUpdate_findsBundleInNestedFolderAndRecordsItsRelativePath() { + // Given + val update = manager() + val entries = arrayOf( + "sub/index.android.bundle" to "new bundle contents", + "sub/asset.png" to "fake asset bytes", + ) + val downloadFile = zipOf(*entries) + val pkg = updatePackage("ff53f424bd583841638ff4e65f32dd71944ba72022d27ad6b8d8db8401b5bbf2") + val newUpdateFolderPath = update.getPackageFolderPath("hash2") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) + + // Then + assertEquals("new bundle contents", File(newUpdateFolderPath, "sub/index.android.bundle").readText()) + val metadata = JSONObject(File(newUpdateMetadataPath).readText()) + assertEquals( + CodePushUtils.appendPathComponent("sub", "index.android.bundle"), + metadata.getString(CodePushConstants.RELATIVE_BUNDLE_PATH_KEY), + ) + } + + @Test + fun installDownloadedUpdate_zipMissingExpectedBundle_throwsInvalidUpdateException() { + // Given + val update = manager() + val downloadFile = zipOf("other.txt" to "not a bundle") + val pkg = updatePackage("hash3") + val newUpdateFolderPath = update.getPackageFolderPath("hash3") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When / Then + try { + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) + fail("expected CodePushInvalidUpdateException") + } catch (e: CodePushInvalidUpdateException) { + assertTrue(e.message!!.contains("A JS bundle file named \"index.android.bundle\" could not be found")) + } + } + + @Test + fun installDownloadedUpdate_zipFullUpdateWithNoPublicKeyAndNoSignatureAndWrongHash_throwsInvalidUpdateException() { + // Given + val update = manager() + val downloadFile = zipOf("index.android.bundle" to "new bundle contents") + val pkg = updatePackage("this-hash-does-not-match-the-real-contents") + val newUpdateFolderPath = update.getPackageFolderPath("hash4") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When / Then + try { + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) + fail("expected CodePushInvalidUpdateException") + } catch (e: CodePushInvalidUpdateException) { + assertTrue(e.message!!.contains("The update contents failed the data integrity check.")) + } + } + + @Test + fun installDownloadedUpdate_publicKeyConfiguredButNoSignatureInBundle_throwsInvalidUpdateException() { + // Given + val update = manager() + val downloadFile = zipOf("index.android.bundle" to "new bundle contents") + val pkg = updatePackage("hash5") + val newUpdateFolderPath = update.getPackageFolderPath("hash5") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When / Then + try { + update.installDownloadedUpdate(pkg, "index.android.bundle", "dummy-public-key", downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) + fail("expected CodePushInvalidUpdateException") + } catch (e: CodePushInvalidUpdateException) { + assertTrue(e.message!!.contains("Error! Public key was provided but there is no JWT signature within app bundle to verify.")) + } + } + + @Test + fun installDownloadedUpdate_publicKeyConfiguredAndSignaturePresentButHashMismatch_throwsBeforeSignatureCheck() { + // Given + val update = manager() + val downloadFile = zipOf( + "index.android.bundle" to "new bundle contents", + "CodePush/.codepushrelease" to "not-a-real-jwt", + ) + val pkg = updatePackage("this-hash-does-not-match-the-real-contents") + val newUpdateFolderPath = update.getPackageFolderPath("hash6") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When / Then + try { + update.installDownloadedUpdate(pkg, "index.android.bundle", "dummy-public-key", downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) + fail("expected CodePushInvalidUpdateException") + } catch (e: CodePushInvalidUpdateException) { + assertTrue(e.message!!.contains("The update contents failed the data integrity check.")) + } + } + + @Test + fun installDownloadedUpdate_noPublicKeyButSignaturePresentInBundle_stillVerifiesFolderHash() { + // Given + val update = manager() + val downloadFile = zipOf( + "index.android.bundle" to "new bundle contents", + "CodePush/.codepushrelease" to "not-a-real-jwt", + ) + val pkg = updatePackage("this-hash-does-not-match-the-real-contents") + val newUpdateFolderPath = update.getPackageFolderPath("hash7") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When / Then + try { + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) + fail("expected CodePushInvalidUpdateException") + } catch (e: CodePushInvalidUpdateException) { + assertTrue(e.message!!.contains("The update contents failed the data integrity check.")) + } + } + + @Test + fun installDownloadedUpdate_diffManifestVersionOutOfRange_throwsIOException() { + // Given + val update = manager() + val downloadFile = zipOf(CodePushConstants.DIFF_MANIFEST_FILE_NAME to """{"version":3,"deletedFiles":[],"patchedFiles":{}}""") + val pkg = updatePackage("hash8") + val newUpdateFolderPath = update.getPackageFolderPath("hash8") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When / Then + try { + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) + fail("expected IOException") + } catch (e: java.io.IOException) { + assertTrue(e.message!!.contains("Diff manifest version 3 is not supported by this SDK version")) + } + } + + @Test + fun installDownloadedUpdate_binaryDiffUpdateWhenDisabledOnClient_throwsIOException() { + // Given + val update = manager(enableDeltaUpdates = false) + val downloadFile = zipOf(CodePushConstants.DIFF_MANIFEST_FILE_NAME to """{"version":2,"deletedFiles":[],"patchedFiles":{}}""") + val pkg = updatePackage("hash9") + val newUpdateFolderPath = update.getPackageFolderPath("hash9") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When / Then + try { + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) + fail("expected IOException") + } catch (e: java.io.IOException) { + assertTrue(e.message!!.contains("Received a binary diff update, but delta updates are not enabled on this client.")) + } + } + + @Test + fun installDownloadedUpdate_binaryDiffUpdateWithNoCurrentPackageInstalled_throwsInvalidUpdateException() { + // Given + val update = manager(enableDeltaUpdates = true) + val downloadFile = zipOf(CodePushConstants.DIFF_MANIFEST_FILE_NAME to """{"version":2,"deletedFiles":[],"patchedFiles":{}}""") + val pkg = updatePackage("hash10") + val newUpdateFolderPath = update.getPackageFolderPath("hash10") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When / Then + try { + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) + fail("expected CodePushInvalidUpdateException") + } catch (e: CodePushInvalidUpdateException) { + assertTrue(e.message!!.contains("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.")) + } + } + + @Test + fun installDownloadedUpdate_versionOneDiffUpdate_carriesOverKeptFilesDeletesRemovedOnesAndAppliesNewOnes() { + // Given + val update = manager() + installCurrentPackage(update, "current-hash", mapOf( + "kept.txt" to "kept contents", + "old_extra.txt" to "stale contents", + )) + val downloadFile = zipOf( + CodePushConstants.DIFF_MANIFEST_FILE_NAME to """{"version":1,"deletedFiles":["old_extra.txt"],"patchedFiles":{}}""", + "index.android.bundle" to "new bundle contents", + ) + // Deliberately wrong, so the folder-hash check at the end of the diff-update path throws - + // but only after the merge below has already run, so we can still assert on its result. + val pkg = updatePackage("this-hash-does-not-match-the-real-contents") + val newUpdateFolderPath = update.getPackageFolderPath("new-hash") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When / Then + try { + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) + fail("expected CodePushInvalidUpdateException from the folder hash check") + } catch (e: CodePushInvalidUpdateException) { + assertTrue(e.message!!.contains("The update contents failed the data integrity check.")) + } + + // Then (the merge above already ran, so its filesystem side effects are still checkable) + assertEquals("kept contents", File(newUpdateFolderPath, "kept.txt").readText()) + assertFalse("deletedFiles entry should have been removed", File(newUpdateFolderPath, "old_extra.txt").exists()) + 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()) + } +} diff --git a/code-push-plugin-testing-framework/script/serverUtil.js b/code-push-plugin-testing-framework/script/serverUtil.js index 30ff5ef3..88509f39 100644 --- a/code-push-plugin-testing-framework/script/serverUtil.js +++ b/code-push-plugin-testing-framework/script/serverUtil.js @@ -22,6 +22,7 @@ function setupServer(targetPlatform) { }); app.get("/v0.1/public/codepush/update_check", function (req, res) { exports.updateCheckCallback && exports.updateCheckCallback(req); + applyKnownPackageHash(); res.send(exports.updateResponse); console.log("Update check called from the app."); console.log("Request: " + JSON.stringify(req.query)); @@ -53,6 +54,36 @@ function setupServer(targetPlatform) { exports.server = app.listen(+targetPlatform.getServerUrl().match(serverPortRegEx)[1]); } exports.setupServer = setupServer; +/** + * The real content hash of each update archive built during this run, keyed by archive path. + * Populated by setPackageHashForPath and applied to exports.updateResponse. + */ +var packageHashesByPath = {}; +var _updatePackagePath; +Object.defineProperty(exports, "updatePackagePath", { + enumerable: true, + configurable: true, + get: function () { return _updatePackagePath; }, + set: function (value) { + _updatePackagePath = value; + applyKnownPackageHash(); + } +}); +/** + * Records the real content hash for an update archive, so that any update_check response + * pointing exports.updatePackagePath at this archive gets the matching package_hash instead + * of the one filled in by default. + */ +function setPackageHashForPath(archivePath, packageHash) { + packageHashesByPath[archivePath] = packageHash; +} +exports.setPackageHashForPath = setPackageHashForPath; +function applyKnownPackageHash() { + var knownHash = _updatePackagePath && packageHashesByPath[_updatePackagePath]; + if (knownHash && exports.updateResponse && exports.updateResponse.update_info) { + exports.updateResponse.update_info.package_hash = knownHash; + } +} /** * Closes the server. */ diff --git a/code-push-plugin-testing-framework/typings/code-push-plugin-testing-framework.d.ts b/code-push-plugin-testing-framework/typings/code-push-plugin-testing-framework.d.ts index 0692e505..8baaae8c 100644 --- a/code-push-plugin-testing-framework/typings/code-push-plugin-testing-framework.d.ts +++ b/code-push-plugin-testing-framework/typings/code-push-plugin-testing-framework.d.ts @@ -290,6 +290,11 @@ declare module 'code-push-plugin-testing-framework/script/serverUtil' { * Closes the server. */ export function cleanupServer(): void; + /** + * Records the real content hash for an update archive at archivePath, so any future + * update_check response pointing updatePackagePath at it gets a matching package_hash. + */ + export function setPackageHashForPath(archivePath: string, packageHash: string): void; /** * Class used to mock the codePush.checkForUpdate() response from the server. */ diff --git a/ios/CodePush/CodePushPackage.m b/ios/CodePush/CodePushPackage.m index 992b651f..861375a7 100644 --- a/ios/CodePush/CodePushPackage.m +++ b/ios/CodePush/CodePushPackage.m @@ -243,69 +243,50 @@ + (void)downloadPackage:(NSDictionary *)updatePackage NSString *signatureFilePath = [CodePushUpdateUtils getSignatureFilePath:newUpdateFolderPath]; BOOL isSignatureAppearedInBundle = [[NSFileManager defaultManager] fileExistsAtPath:signatureFilePath]; + if (isSignatureVerificationEnabled && !isSignatureAppearedInBundle) { + error = [CodePushErrorUtils errorWithMessage: + @"Error! Public key was provided but there is no JWT signature within app bundle to verify " \ + "Possible reasons, why that might happen: \n" \ + "1. You've been released CodePush bundle update using version of CodePush CLI that is not support code signing.\n" \ + "2. You've been released CodePush bundle update without providing --privateKeyPath option."]; + failCallback(error); + return; + } + + if (!isSignatureVerificationEnabled && isSignatureAppearedInBundle) { + CPLog(@"Warning! JWT signature exists in codepush update but code integrity check couldn't be performed" \ + " because there is no public key configured. " \ + "Please ensure that public key is properly configured within your application."); + } + + if (![CodePushUpdateUtils verifyFolderHash:newUpdateFolderPath + expectedHash:newUpdateHash + error:&error]) { + CPLog(@"The update contents failed the data integrity check."); + if (!error) { + error = [CodePushErrorUtils errorWithMessage:@"The update contents failed the data integrity check."]; + } + + failCallback(error); + return; + } else { + CPLog(@"The update contents succeeded the data integrity check."); + } + if (isSignatureVerificationEnabled) { - if (isSignatureAppearedInBundle) { - if (![CodePushUpdateUtils verifyFolderHash:newUpdateFolderPath - expectedHash:newUpdateHash - error:&error]) { - CPLog(@"The update contents failed the data integrity check."); - if (!error) { - error = [CodePushErrorUtils errorWithMessage:@"The update contents failed the data integrity check."]; - } - - failCallback(error); - return; - } else { - CPLog(@"The update contents succeeded the data integrity check."); + BOOL isSignatureValid = [CodePushUpdateUtils verifyUpdateSignatureFor:newUpdateFolderPath + expectedHash:newUpdateHash + withPublicKey:publicKey + error:&error]; + if (!isSignatureValid) { + CPLog(@"The update contents failed code signing check."); + if (!error) { + error = [CodePushErrorUtils errorWithMessage:@"The update contents failed code signing check."]; } - BOOL isSignatureValid = [CodePushUpdateUtils verifyUpdateSignatureFor:newUpdateFolderPath - expectedHash:newUpdateHash - withPublicKey:publicKey - error:&error]; - if (!isSignatureValid) { - CPLog(@"The update contents failed code signing check."); - if (!error) { - error = [CodePushErrorUtils errorWithMessage:@"The update contents failed code signing check."]; - } - failCallback(error); - return; - } else { - CPLog(@"The update contents succeeded the code signing check."); - } - } else { - error = [CodePushErrorUtils errorWithMessage: - @"Error! Public key was provided but there is no JWT signature within app bundle to verify " \ - "Possible reasons, why that might happen: \n" \ - "1. You've been released CodePush bundle update using version of CodePush CLI that is not support code signing.\n" \ - "2. You've been released CodePush bundle update without providing --privateKeyPath option."]; failCallback(error); return; - } - - } else { - BOOL needToVerifyHash; - if (isSignatureAppearedInBundle) { - CPLog(@"Warning! JWT signature exists in codepush update but code integrity check couldn't be performed" \ - " because there is no public key configured. " \ - "Please ensure that public key is properly configured within your application."); - needToVerifyHash = true; } else { - needToVerifyHash = isDiffUpdate; - } - if(needToVerifyHash){ - if (![CodePushUpdateUtils verifyFolderHash:newUpdateFolderPath - expectedHash:newUpdateHash - error:&error]) { - CPLog(@"The update contents failed the data integrity check."); - if (!error) { - error = [CodePushErrorUtils errorWithMessage:@"The update contents failed the data integrity check."]; - } - - failCallback(error); - return; - } else { - CPLog(@"The update contents succeeded the data integrity check."); - } + CPLog(@"The update contents succeeded the code signing check."); } } } else { diff --git a/test/test.ts b/test/test.ts index c117014f..cbb75ca6 100644 --- a/test/test.ts +++ b/test/test.ts @@ -2,6 +2,7 @@ import assert = require("assert"); import childProcess = require("child_process"); +import crypto = require("crypto"); import fs = require("fs"); import mkdirp = require("mkdirp"); import os = require("os"); @@ -68,6 +69,47 @@ function installExpoBundleTooling(projectPath: string): Q.Promise { ).then(() => { return null; }); } +const CODEPUSH_METADATA_FILE_NAME = ".codepushrelease"; + +function isHashIgnored(relativePath: string): boolean { + return relativePath.startsWith("__MACOSX/") + || relativePath === ".DS_Store" + || relativePath.endsWith("/.DS_Store") + || relativePath === CODEPUSH_METADATA_FILE_NAME + || relativePath.endsWith(`/${CODEPUSH_METADATA_FILE_NAME}`); +} + +/** + * Computes the same content hash that the native SDKs compute over an installed update folder, so the mock server + * can hand back a package_hash that will actually match what the client expects. + */ +function computeUpdateContentsHash(folderPath: string): string { + const manifest: string[] = []; + + const walk = (currentPath: string, relativePrefix: string) => { + for (const entryName of fs.readdirSync(currentPath)) { + const entryPath = path.join(currentPath, entryName); + const relativePath = relativePrefix ? `${relativePrefix}/${entryName}` : entryName; + + if (isHashIgnored(relativePath)) { + continue; + } + + if (fs.statSync(entryPath).isDirectory()) { + walk(entryPath, relativePath); + } else { + const fileHash = crypto.createHash("sha256").update(fs.readFileSync(entryPath)).digest("hex"); + manifest.push(`${relativePath}:${fileHash}`); + } + } + }; + + walk(folderPath, ""); + manifest.sort(); + + return crypto.createHash("sha256").update(JSON.stringify(manifest)).digest("hex"); +} + ////////////////////////////////////////////////////////////////////////////////////////// // Create the platforms to run the tests on. @@ -498,16 +540,27 @@ class RNProjectManager extends ProjectManager { .then(TestUtil.getProcessOutput.bind(undefined, "npx react-native bundle --entry-file index.js --platform " + targetPlatform.getName() + " --bundle-output " + bundlePath + " --assets-dest " + bundleFolder + " --dev false", { cwd: path.join(projectDirectory, TestConfig.TestAppName), noLogStdOut: true })) .then(TestUtil.archiveFolder.bind(undefined, bundleFolder, "", path.join(projectDirectory, TestConfig.TestAppName, "update.zip"), isDiff)) + .then(this.updateMockPackageHash.bind(this, bundleFolder, isDiff)) .then((result) => { console.log(`[TIMING] createUpdateArchive(${projectDirectory}, ${targetPlatform.getName()}) took ${Date.now() - t0}ms`); return result; }); } else { return deferred.promise .then(TestUtil.getProcessOutput.bind(undefined, "npx react-native bundle --entry-file index.js --platform " + targetPlatform.getName() + " --bundle-output " + bundlePath + " --assets-dest " + bundleFolder + " --dev false", { cwd: path.join(projectDirectory, TestConfig.TestAppName), noLogStdOut: true })) .then(TestUtil.archiveFolder.bind(undefined, bundleFolder, "", path.join(projectDirectory, TestConfig.TestAppName, "update.zip"), isDiff)) + .then(this.updateMockPackageHash.bind(this, bundleFolder, isDiff)) .then((result) => { console.log(`[TIMING] createUpdateArchive(${projectDirectory}, ${targetPlatform.getName()}) took ${Date.now() - t0}ms`); return result; }); } } + // Records the real hash of bundleFolder of an archive, so the mock server can hand back a + // package_hash that matches what the client's verifyFolderHash integrity check will compute. + private updateMockPackageHash(bundleFolder: string, isDiff: boolean, archivePath: string): string { + if (!isDiff) { + ServerUtil.setPackageHashForPath(archivePath, computeUpdateContentsHash(bundleFolder)); + } + return archivePath; + } + /** JSON file containing the platforms the plugin is currently installed for. * Keys must match targetPlatform.getName()! * @@ -1013,16 +1066,9 @@ PluginTestingFramework.initializeTests(new RNProjectManager(), supportedTargetPl ServerUtil.TestMessage.DEVICE_READY_AFTER_UPDATE]); }) .then(() => { - /* restart the app to ensure it was reverted and send it another update */ - ServerUtil.updateResponse = { update_info: ServerUtil.createUpdateResponse(false, targetPlatform) }; - targetPlatform.getEmulatorManager().restartApplication(TestConfig.TestNamespace); - return ServerUtil.expectTestMessages([ - ServerUtil.TestMessage.CHECK_UPDATE_AVAILABLE, - ServerUtil.TestMessage.DOWNLOAD_SUCCEEDED, - ServerUtil.TestMessage.DEVICE_READY_AFTER_UPDATE]); - }) - .then(() => { - /* restart the app again to ensure it was reverted again and send the same update and expect it to reject it */ + /* restart the app to ensure it was reverted; the native rollback path marks + the failed update's hash as failed immediately, so the same update should + now be rejected outright rather than being re-downloaded and retried */ targetPlatform.getEmulatorManager().restartApplication(TestConfig.TestNamespace); return ServerUtil.expectTestMessages([ServerUtil.TestMessage.UPDATE_FAILED_PREVIOUSLY]); })