From d6f03d371dc00149d7d7af6c58d1cf0799569c68 Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Sat, 11 Apr 2026 01:34:05 +0000 Subject: [PATCH] add plugin hash --- .../cloudstream3/plugins/PluginManager.kt | 22 ++++- .../cloudstream3/plugins/RepositoryManager.kt | 89 ++++++++++++++----- .../settings/extensions/PluginsViewModel.kt | 4 +- 3 files changed, 90 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt index feb0ba6d458..528686bcdc0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -13,6 +13,7 @@ import android.os.Build import android.os.Environment import android.util.Log import android.widget.Toast +import androidx.annotation.WorkerThread import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -45,6 +46,7 @@ import com.lagradost.cloudstream3.plugins.RepositoryManager.ONLINE_PLUGINS_FOLDE import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES import com.lagradost.cloudstream3.plugins.RepositoryManager.downloadPluginToFile import com.lagradost.cloudstream3.plugins.RepositoryManager.getRepoPlugins +import com.lagradost.cloudstream3.plugins.RepositoryManager.sha256 import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings @@ -78,6 +80,12 @@ data class PluginData( @JsonProperty("filePath") val filePath: String, @JsonProperty("version") val version: Int, ) { + @WorkerThread + fun getFileHash(): String { + return sha256(File(this.filePath)) + } + + @WorkerThread fun toSitePlugin(): SitePlugin { return SitePlugin( this.filePath, @@ -92,7 +100,8 @@ data class PluginData( null, null, null, - File(this.filePath).length() + File(this.filePath).length(), + getFileHash() ) } } @@ -302,6 +311,7 @@ object PluginManager { downloadPlugin( activity, pluginData.onlineData.second.url, + pluginData.onlineData.second.fileHash, pluginData.savedData.internalName, File(pluginData.savedData.filePath), true @@ -413,6 +423,7 @@ object PluginManager { downloadPlugin( activity, pluginData.onlineData.second.url, + pluginData.onlineData.second.fileHash, pluginData.savedData.internalName, pluginData.onlineData.first, !pluginData.isDisabled @@ -730,25 +741,27 @@ object PluginManager { suspend fun downloadPlugin( activity: Activity, pluginUrl: String, + pluginHash: String?, internalName: String, repositoryUrl: String, loadPlugin: Boolean ): Boolean { val file = getPluginPath(activity, internalName, repositoryUrl) - return downloadPlugin(activity, pluginUrl, internalName, file, loadPlugin) + return downloadPlugin(activity, pluginUrl, pluginHash, internalName, file, loadPlugin) } suspend fun downloadPlugin( activity: Activity, pluginUrl: String, + pluginHash: String?, internalName: String, file: File, - loadPlugin: Boolean + loadPlugin: Boolean, ): Boolean { try { Log.d(TAG, "Downloading plugin: $pluginUrl to ${file.absolutePath}") // The plugin file needs to be salted with the repository url hash as to allow multiple repositories with the same internal plugin names - val newFile = downloadPluginToFile(pluginUrl, file) ?: return false + val newFile = downloadPluginToFile(pluginUrl, file, pluginHash) ?: return false val data = PluginData( internalName, @@ -836,6 +849,7 @@ object PluginManager { if (downloadPlugin( activity, pluginData.onlineData.second.url, + pluginData.onlineData.second.fileHash, pluginData.savedData.internalName, existingFile, true diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt index 45ed65611e7..32a7b4cbdca 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt @@ -1,6 +1,7 @@ package com.lagradost.cloudstream3.plugins import android.content.Context +import androidx.annotation.WorkerThread import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey @@ -18,10 +19,11 @@ import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import java.io.BufferedInputStream import java.io.File -import java.io.InputStream -import java.io.OutputStream +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.security.MessageDigest /** * Comes with the app, always available in the app, non removable. @@ -67,6 +69,7 @@ data class SitePlugin( @JsonProperty("iconUrl") val iconUrl: String?, // Automatically generated by the gradle plugin @JsonProperty("fileSize") val fileSize: Long?, + @JsonProperty("fileHash") val fileHash: String?, ) @@ -75,7 +78,26 @@ object RepositoryManager { val PREBUILT_REPOSITORIES: Array by lazy { getKey("PREBUILT_REPOSITORIES") ?: emptyArray() } - private val GH_REGEX = Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$") + private val GH_REGEX = + Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$") + + + /** Returns a SHA-256 string of the file content. + * Example: "sha256-b70462c264cb7f90fc2860a8e58d7544ce747ff347d1d11fa093623901853573" **/ + @WorkerThread + fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + + file.inputStream().use { fis -> + val buffer = ByteArray(8192) + var read = fis.read(buffer) + while (read != -1) { + digest.update(buffer, 0, read) + read = fis.read(buffer) + } + } + return "sha256-" + digest.digest().joinToString("") { "%02x".format(it) } + } /* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */ fun convertRawGitUrl(url: String): String { @@ -142,19 +164,55 @@ object RepositoryManager { suspend fun downloadPluginToFile( pluginUrl: String, - file: File + file: File, + expectedFileHash: String? ): File? { return safeAsync { - file.mkdirs() + val parentDir = file.parentFile ?: return@safeAsync null + parentDir.mkdirs() - // Overwrite if exists - if (file.exists()) { - file.delete() + // Delete any temp files from crashed downloads (even if unlikely) + parentDir.listFiles { + it.extension == "tmp" + }?.forEach { + it.delete() } - file.createNewFile() + + // Prevent corrupting the plugin file if the operation fails + val tempFile = File.createTempFile(file.name, ".tmp", parentDir) val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body - write(body.byteStream(), file.outputStream()) + + body.byteStream().use { body -> + tempFile.outputStream().use { fileSteam -> + body.copyTo(fileSteam) + } + } + + if (expectedFileHash != null) { + val downloadHash = sha256(tempFile) + if (expectedFileHash != downloadHash) { + tempFile.delete() + throw IllegalStateException("Extension hash mismatch when validating '${file.name}'! Expected: '$expectedFileHash', got: '$downloadHash'.") + } + } + + // We prefer the operation to be atomic + try { + Files.move( + tempFile.toPath(), + file.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move( + tempFile.toPath(), + file.toPath(), + StandardCopyOption.REPLACE_EXISTING + ) + } + file } } @@ -202,13 +260,4 @@ object RepositoryManager { PluginManager.deleteRepositoryData(file.absolutePath) } - - private fun write(stream: InputStream, output: OutputStream) { - val input = BufferedInputStream(stream) - val dataBuffer = ByteArray(512) - var readBytes: Int - while (input.read(dataBuffer).also { readBytes = it } != -1) { - output.write(dataBuffer, 0, readBytes) - } - } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt index e0fd906b49f..37bdac5410d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt @@ -128,6 +128,7 @@ class PluginsViewModel : ViewModel() { PluginManager.downloadPlugin( activity, metadata.url, + metadata.fileHash, metadata.internalName, repo, metadata.status != PROVIDER_STATUS_DOWN @@ -179,6 +180,7 @@ class PluginsViewModel : ViewModel() { PluginManager.downloadPlugin( activity, metadata.url, + metadata.fileHash, metadata.internalName, repo, isEnabled @@ -286,7 +288,7 @@ class PluginsViewModel : ViewModel() { val downloadedPlugins = (PluginManager.getPluginsOnline() + PluginManager.getPluginsLocal()) .distinctBy { it.filePath } - .map { + .amap { PluginViewData("" to it.toSitePlugin(), true) }