-
Notifications
You must be signed in to change notification settings - Fork 349
feat(kotlin-sdk): implement secure native library downloading with SH… #416
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| plugins { | ||
| `kotlin-dsl` | ||
| } | ||
|
|
||
| repositories { | ||
| mavenCentral() | ||
| google() | ||
| gradlePluginPortal() | ||
| } | ||
|
|
||
| gradlePlugin { | ||
| plugins { | ||
| create("nativeDownloader") { | ||
| id = "com.runanywhere.native-downloader" | ||
| implementationClass = "com.runanywhere.buildlogic.NativeLibraryDownloadPlugin" | ||
| } | ||
| } | ||
| } | ||
|
|
||
| dependencies { | ||
|
|
||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| rootProject.name = "build-logic" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| package com.runanywhere.buildlogic | ||
|
|
||
| import org.gradle.api.DefaultTask | ||
| import org.gradle.api.Plugin | ||
| import org.gradle.api.Project | ||
| import org.gradle.api.file.ArchiveOperations | ||
| import org.gradle.api.file.DirectoryProperty | ||
| import org.gradle.api.file.FileSystemOperations | ||
| import org.gradle.api.file.RelativePath | ||
| import org.gradle.api.provider.ListProperty | ||
| import org.gradle.api.provider.Property | ||
| import org.gradle.api.tasks.Input | ||
| import org.gradle.api.tasks.OutputDirectory | ||
| import org.gradle.api.tasks.TaskAction | ||
| import java.io.File | ||
| import java.net.HttpURLConnection | ||
| import java.net.URL | ||
| import java.security.MessageDigest | ||
| import javax.inject.Inject | ||
|
|
||
| // A Gradle plugin that provides a task to securely download and verify native libraries using SHA-256 checksums. | ||
| abstract class DownloadAndVerifyNativeLibTask @Inject constructor( | ||
| private val fsOps: FileSystemOperations, | ||
| private val archiveOps: ArchiveOperations | ||
| ) : DefaultTask() { | ||
|
|
||
| // Inputs for the task: download URL, expected SHA256 URL, allowed .so files, and output directory | ||
| @get:Input | ||
| abstract val downloadUrl: Property<String> | ||
|
|
||
| @get:Input | ||
| abstract val expectedSha256Url: Property<String> | ||
|
|
||
| @get:Input | ||
| abstract val allowedSoFiles: ListProperty<String> | ||
|
|
||
| @get:OutputDirectory | ||
| abstract val outputDir: DirectoryProperty | ||
|
|
||
| // The main action of the task: download the file, verify its checksum, and extract it if valid | ||
| @TaskAction | ||
| fun execute() { | ||
| val url = downloadUrl.get() | ||
| val shaUrl = expectedSha256Url.get() | ||
| val validFiles = allowedSoFiles.get() | ||
| val destDir = outputDir.get().asFile | ||
|
|
||
| // Ensure output directory exists | ||
| destDir.mkdirs() | ||
|
|
||
| val tempFile = File(temporaryDir, "downloaded.zip") | ||
|
|
||
| logger.lifecycle("Fetching expected SHA256 from $shaUrl...") | ||
|
|
||
| // Fetch the expected SHA256 checksum from the provided URL, with error handling for network issues and invalid responses | ||
| val expectedHash = try { | ||
| val shaConnection = URL(shaUrl).openConnection() as HttpURLConnection | ||
| shaConnection.connectTimeout = 30_000 | ||
| shaConnection.readTimeout = 30_000 | ||
|
|
||
| val responseCode = shaConnection.responseCode | ||
| if (responseCode !in 200..299) { | ||
| error("Failed to fetch SHA256 checksum from $shaUrl - HTTP $responseCode. Ensure checksum files are published to the release.") | ||
| } | ||
|
|
||
| shaConnection.inputStream.bufferedReader().use { it.readText() } | ||
| .trim() | ||
| .split("\\s+".toRegex()) | ||
| .first() | ||
| } catch (e: Exception) { | ||
| error("Error fetching SHA256 checksum: ${e.message}") | ||
| } | ||
|
|
||
| logger.lifecycle("Downloading $url...") | ||
| var connection: HttpURLConnection? = null | ||
| val digest = MessageDigest.getInstance("SHA-256") | ||
|
|
||
| try { | ||
| connection = URL(url).openConnection() as HttpURLConnection | ||
| connection.connectTimeout = 30_000 // 30 seconds connect timeout | ||
| connection.readTimeout = 120_000 // 2 minutes read timeout for large files | ||
|
|
||
| val responseCode = connection.responseCode | ||
| if (responseCode !in 200..299) { | ||
| error("Download failed for $url — HTTP $responseCode") | ||
| } | ||
|
|
||
| // Stream the download directly to a temp file while updating the digest | ||
| connection.inputStream.use { input -> | ||
| tempFile.outputStream().use { output -> | ||
| val buffer = ByteArray(8192) // 8 KB buffer for efficient reading | ||
| var bytesRead = input.read(buffer) | ||
| while (bytesRead != -1) { | ||
| output.write(buffer, 0, bytesRead) | ||
| digest.update(buffer, 0, bytesRead) | ||
| bytesRead = input.read(buffer) | ||
| } | ||
| } | ||
| } | ||
| } finally { | ||
| connection?.disconnect() // Clean up hanging sockets | ||
| } | ||
|
|
||
| // Verify Checksum | ||
| val calculatedHash = digest.digest().joinToString("") { "%02x".format(it) } // Convert to hex string | ||
| if (!calculatedHash.equals(expectedHash, ignoreCase = true)) { | ||
| tempFile.delete() | ||
| error("Security failure: Checksum mismatch for $url!\nExpected: $expectedHash\nGot: $calculatedHash") | ||
| } | ||
|
|
||
| logger.lifecycle("Checksum verified! Extracting...") | ||
|
|
||
| // Extract the archive, but only include the allowed .so files to prevent zip slip vulnerabilities. Use Gradle's built-in archive handling for safety. | ||
| try { | ||
| fsOps.copy { | ||
| from(archiveOps.zipTree(tempFile)) | ||
| into(destDir) | ||
|
|
||
| // Fix: Explicitly include only the valid files to avoid eachFile exclude() bugs | ||
| validFiles.forEach { fileName -> | ||
| include("**/$fileName") | ||
| } | ||
|
|
||
| eachFile { | ||
| // Flatten the directory structure | ||
| relativePath = RelativePath(true, name) | ||
| } | ||
| includeEmptyDirs = false | ||
| } | ||
| } finally { | ||
| tempFile.delete() | ||
| } | ||
|
|
||
| logger.lifecycle("Successfully extracted to $destDir") | ||
| } | ||
| } | ||
|
|
||
| class NativeLibraryDownloadPlugin : Plugin<Project> { | ||
| override fun apply(project: Project) { | ||
| // Plugin registration logic if required | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,6 +15,7 @@ | |
| */ | ||
|
|
||
| plugins { | ||
| id("com.runanywhere.native-downloader") | ||
| alias(libs.plugins.kotlin.multiplatform) | ||
| alias(libs.plugins.android.library) | ||
| alias(libs.plugins.kotlin.serialization) | ||
|
|
@@ -152,89 +153,40 @@ android { | |
| // Downloaded from RABackendLLAMACPP-android GitHub release assets, or built locally. | ||
| } | ||
|
|
||
| // Native lib version for downloads | ||
| // Securely download native libraries from GitHub releases, with checksum verification and safe extraction | ||
| val nativeLibVersion: String = | ||
| rootProject.findProperty("runanywhere.nativeLibVersion")?.toString() | ||
| ?: project.findProperty("runanywhere.nativeLibVersion")?.toString() | ||
| ?: (System.getenv("SDK_VERSION")?.removePrefix("v") ?: "0.1.5-SNAPSHOT") | ||
|
|
||
| // Download LlamaCPP backend libs from GitHub releases (testLocal=false) | ||
| val releaseBaseUrl = "https://github.com/RunanywhereAI/runanywhere-sdks/releases/download/v$nativeLibVersion" | ||
| val targetAbis = listOf("arm64-v8a", "armeabi-v7a", "x86_64") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Confirm abiFilters vs targetAbis across both affected modules
rg -n "abiFilters|targetAbis" \
sdk/runanywhere-kotlin/modules/runanywhere-core-llamacpp/build.gradle.kts \
sdk/runanywhere-kotlin/modules/runanywhere-core-onnx/build.gradle.ktsRepository: RunanywhereAI/runanywhere-sdks Length of output: 853
Both 🤖 Prompt for AI Agents
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis is a comment left during a code review.
Path: sdk/runanywhere-kotlin/modules/runanywhere-core-llamacpp/build.gradle.kts
Line: 163
Comment:
`targetAbis` includes `"armeabi-v7a"` but the `ndk.abiFilters` on line 127 only includes `"arm64-v8a"` and `"x86_64"`. This mismatch means the `armeabi-v7a` libraries will be downloaded but never packaged into the AAR
How can I resolve this? If you propose a fix, please make it concise. |
||
| val packageType = "RABackendLLAMACPP-android" | ||
| val llamacppLibs = listOf("librac_backend_llamacpp.so", "librac_backend_llamacpp_jni.so") | ||
|
|
||
| // Create a secure download task for EACH architecture | ||
| val downloadTasks = targetAbis.map { abi -> | ||
| val sanitizedAbiName = abi.replace("-", "_") | ||
|
|
||
| tasks.register<com.runanywhere.buildlogic.DownloadAndVerifyNativeLibTask>("downloadJniLibs_$sanitizedAbiName") { | ||
| val packageName = "$packageType-$abi-v$nativeLibVersion.zip" | ||
|
|
||
| downloadUrl.set("$releaseBaseUrl/$packageName") | ||
| expectedSha256Url.set("$releaseBaseUrl/$packageName.sha256") | ||
| outputDir.set(file("src/androidMain/jniLibs/$abi")) | ||
| allowedSoFiles.set(llamacppLibs) | ||
|
|
||
| onlyIf { !testLocal } | ||
| } | ||
| } | ||
|
|
||
| // Aggregate task to download all ABIs | ||
| tasks.register("downloadJniLibs") { | ||
| group = "runanywhere" | ||
| description = "Download LlamaCPP backend JNI libraries from GitHub releases" | ||
| description = "Securely download and verify LlamaCPP backend JNI libraries from GitHub releases" | ||
|
|
||
| onlyIf { !testLocal } | ||
|
|
||
| val outputDir = file("src/androidMain/jniLibs") | ||
| val tempDir = file("${layout.buildDirectory.get()}/jni-temp") | ||
|
|
||
| val releaseBaseUrl = "https://github.com/RunanywhereAI/runanywhere-sdks/releases/download/v$nativeLibVersion" | ||
| val targetAbis = listOf("arm64-v8a", "armeabi-v7a", "x86_64") | ||
| val packageType = "RABackendLLAMACPP-android" | ||
|
|
||
| val llamacppLibs = setOf( | ||
| "librac_backend_llamacpp.so", | ||
| "librac_backend_llamacpp_jni.so", | ||
| ) | ||
|
|
||
| outputs.dir(outputDir) | ||
|
|
||
| doLast { | ||
| val existingLibs = outputDir.walkTopDown().filter { it.extension == "so" }.count() | ||
| if (existingLibs > 0) { | ||
| logger.lifecycle("LlamaCPP: Skipping download, $existingLibs .so files already present") | ||
| return@doLast | ||
| } | ||
|
|
||
| outputDir.deleteRecursively() | ||
| tempDir.deleteRecursively() | ||
| outputDir.mkdirs() | ||
| tempDir.mkdirs() | ||
|
|
||
| logger.lifecycle("LlamaCPP Module: Downloading backend JNI libraries") | ||
|
|
||
| var totalDownloaded = 0 | ||
|
|
||
| targetAbis.forEach { abi -> | ||
| val abiOutputDir = file("$outputDir/$abi") | ||
| abiOutputDir.mkdirs() | ||
|
|
||
| val packageName = "$packageType-$abi-v$nativeLibVersion.zip" | ||
| val zipUrl = "$releaseBaseUrl/$packageName" | ||
| val tempZip = file("$tempDir/$packageName") | ||
|
|
||
| logger.lifecycle(" Downloading: $packageName") | ||
|
|
||
| try { | ||
| ant.withGroovyBuilder { | ||
| "get"("src" to zipUrl, "dest" to tempZip, "verbose" to false) | ||
| } | ||
|
|
||
| val extractDir = file("$tempDir/extracted-${packageName.replace(".zip", "")}") | ||
| extractDir.mkdirs() | ||
| ant.withGroovyBuilder { | ||
| "unzip"("src" to tempZip, "dest" to extractDir) | ||
| } | ||
|
|
||
| extractDir | ||
| .walkTopDown() | ||
| .filter { it.extension == "so" && it.name in llamacppLibs } | ||
| .forEach { soFile -> | ||
| val targetFile = file("$abiOutputDir/${soFile.name}") | ||
| soFile.copyTo(targetFile, overwrite = true) | ||
| logger.lifecycle(" ${soFile.name}") | ||
| totalDownloaded++ | ||
| } | ||
|
|
||
| tempZip.delete() | ||
| } catch (e: Exception) { | ||
| logger.warn(" Failed to download $packageName: ${e.message}") | ||
| } | ||
| } | ||
|
|
||
| tempDir.deleteRecursively() | ||
| logger.lifecycle("LlamaCPP: $totalDownloaded .so files downloaded") | ||
| } | ||
| dependsOn(downloadTasks) | ||
| } | ||
|
|
||
| tasks.matching { it.name.contains("merge") && it.name.contains("JniLibFolders") }.configureEach { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.