-
Notifications
You must be signed in to change notification settings - Fork 11
ADFA-3159 Post-install audit for correctness of binary file unpacking and deploy #1062
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: stage
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,184 @@ | ||
| /* | ||
| * This file is part of AndroidIDE. | ||
| * | ||
| * AndroidIDE is free software: you can redistribute it and/or modify | ||
| * it under the terms of the GNU General Public License as published by | ||
| * the Free Software Foundation, either version 3 of the License, or | ||
| * (at your option) any later version. | ||
| * | ||
| * AndroidIDE is distributed in the hope that it will be useful, | ||
| * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| * GNU General Public License for more details. | ||
| * | ||
| * You should have received a copy of the GNU General Public License | ||
| * along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>. | ||
| */ | ||
|
|
||
| package com.itsaky.androidide.assets | ||
|
|
||
| import android.content.Context | ||
| import androidx.annotation.WorkerThread | ||
| import com.itsaky.androidide.app.configuration.CpuArch | ||
| import com.itsaky.androidide.app.configuration.IDEBuildConfigProvider | ||
| import com.itsaky.androidide.utils.Environment | ||
| import org.adfa.constants.ANDROID_SDK_ZIP | ||
| import org.adfa.constants.DOCUMENTATION_DB | ||
| import org.adfa.constants.GRADLE_API_NAME_JAR | ||
| import org.adfa.constants.GRADLE_API_NAME_JAR_ZIP | ||
| import org.adfa.constants.GRADLE_DISTRIBUTION_ARCHIVE_NAME | ||
| import org.adfa.constants.LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME | ||
| import org.slf4j.LoggerFactory | ||
| import java.io.File | ||
|
|
||
| /** | ||
| * Post-installation audit: verifies that all binary assets were correctly | ||
| * delivered (exist and meet expected size) before marking setup complete. | ||
| */ | ||
| object AssetsInstallationAudit { | ||
|
|
||
| private const val SIZE_TOLERANCE = 0.95 | ||
|
|
||
| private val logger = LoggerFactory.getLogger(AssetsInstallationAudit::class.java) | ||
| private val installer = AssetsInstaller.CURRENT_INSTALLER | ||
|
|
||
| private val expectedEntries = | ||
| arrayOf( | ||
| GRADLE_DISTRIBUTION_ARCHIVE_NAME, | ||
| ANDROID_SDK_ZIP, | ||
| DOCUMENTATION_DB, | ||
| LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME, | ||
| AssetsInstallationHelper.BOOTSTRAP_ENTRY_NAME, | ||
| GRADLE_API_NAME_JAR_ZIP, | ||
| AssetsInstallationHelper.LLAMA_AAR, | ||
| AssetsInstallationHelper.PLUGIN_ARTIFACTS_ZIP, | ||
| ) | ||
|
|
||
| sealed interface Result { | ||
| data object Success : Result | ||
| data class Failure(val entryName: String, val message: String) : Result | ||
| } | ||
|
|
||
| /** | ||
| * Runs the audit. Call from IO dispatcher. | ||
| * Verifies each asset destination exists and (when expected size is known) | ||
| * meets minimum size (expectedSize * SIZE_TOLERANCE). | ||
| */ | ||
| @WorkerThread | ||
| fun run(context: Context): Result { | ||
| for (entryName in expectedEntries) { | ||
| val failure = checkEntry(context, entryName) | ||
| if (failure != null) { | ||
| logger.warn("Audit failed for '{}': {}", entryName, failure) | ||
| return Result.Failure(entryName, failure) | ||
| } | ||
| } | ||
| return Result.Success | ||
| } | ||
|
|
||
| private fun checkEntry(context: Context, entryName: String): String? { | ||
| val expectedSize = installer.expectedSize(entryName) | ||
| return when (entryName) { | ||
| GRADLE_DISTRIBUTION_ARCHIVE_NAME -> | ||
| checkDir(Environment.GRADLE_DISTS, entryName, expectedSize) | ||
| ANDROID_SDK_ZIP -> | ||
| checkDir(Environment.ANDROID_HOME, entryName, expectedSize) | ||
| DOCUMENTATION_DB -> | ||
| checkFile(Environment.DOC_DB, entryName, expectedSize) | ||
| LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME -> | ||
| checkDir(Environment.LOCAL_MAVEN_DIR, entryName, expectedSize) | ||
| AssetsInstallationHelper.BOOTSTRAP_ENTRY_NAME -> | ||
| checkBootstrap(entryName, expectedSize) | ||
| GRADLE_API_NAME_JAR_ZIP -> { | ||
| val jarFile = File(Environment.GRADLE_GEN_JARS, GRADLE_API_NAME_JAR) | ||
| checkFile(jarFile, entryName, expectedSize) | ||
| } | ||
| AssetsInstallationHelper.LLAMA_AAR -> | ||
| checkLlamaAar(context, entryName, expectedSize) | ||
| AssetsInstallationHelper.PLUGIN_ARTIFACTS_ZIP -> | ||
| checkPluginArtifacts(entryName, expectedSize) | ||
| else -> "Unknown entry: $entryName" | ||
| } | ||
| } | ||
|
|
||
| private fun checkFile(file: File, entryName: String, expectedSize: Long): String? { | ||
| if (!file.exists()) return "File missing: ${file.absolutePath}" | ||
| if (!file.isFile) return "Not a file: ${file.absolutePath}" | ||
| if (expectedSize > 0) { | ||
| val minSize = (expectedSize * SIZE_TOLERANCE).toLong() | ||
| if (file.length() < minSize) { | ||
| return "File too small: ${file.absolutePath} (${file.length()} < $minSize)" | ||
| } | ||
| } else if (file.length() == 0L) { | ||
| return "File empty: ${file.absolutePath}" | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| private fun checkDir(dir: File, entryName: String, expectedSize: Long): String? { | ||
| if (!dir.exists()) return "Directory missing: ${dir.absolutePath}" | ||
| if (!dir.isDirectory) return "Not a directory: ${dir.absolutePath}" | ||
| if (expectedSize > 0) { | ||
| val totalSize = dir.recursiveSize() | ||
| val minSize = (expectedSize * SIZE_TOLERANCE).toLong() | ||
| if (totalSize < minSize) { | ||
| return "Directory too small: ${dir.absolutePath} ($totalSize < $minSize)" | ||
| } | ||
| } else { | ||
| if (dir.recursiveSize() == 0L) return "Directory empty: ${dir.absolutePath}" | ||
| } | ||
| return null | ||
| } | ||
|
Comment on lines
+104
to
+131
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. Suggestion: a more robust approach would be to compare SHA-256/512 checksums of the files. The checksums would be computed at compile time (of the IDE), then verify at runtime. |
||
|
|
||
| private fun checkBootstrap(entryName: String, expectedSize: Long): String? { | ||
| val bash = Environment.BASH_SHELL | ||
| val login = Environment.LOGIN_SHELL | ||
| if (!bash.exists() || !bash.isFile || bash.length() == 0L) { | ||
| return "Bootstrap missing or empty: ${bash.absolutePath}" | ||
| } | ||
| if (!login.exists() || !login.isFile || login.length() == 0L) { | ||
| return "Bootstrap missing or empty: ${login.absolutePath}" | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| private fun checkLlamaAar(context: Context, entryName: String, expectedSize: Long): String? { | ||
| val cpuArch = IDEBuildConfigProvider.getInstance().cpuArch | ||
| when (cpuArch) { | ||
| CpuArch.AARCH64, | ||
| CpuArch.ARM, | ||
| -> { | ||
| val destDir = context.getDir("dynamic_libs", Context.MODE_PRIVATE) | ||
| val llamaFile = File(destDir, "llama.aar") | ||
| return checkFile(llamaFile, entryName, expectedSize) | ||
| } | ||
| else -> { | ||
| // Unsupported arch: installer skips; audit passes without file | ||
| return null | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private fun checkPluginArtifacts(entryName: String, expectedSize: Long): String? { | ||
| val pluginDir = Environment.PLUGIN_API_JAR.parentFile ?: return "Plugin dir null" | ||
| if (!pluginDir.exists()) return "Plugin directory missing: ${pluginDir.absolutePath}" | ||
| if (!pluginDir.isDirectory) return "Not a directory: ${pluginDir.absolutePath}" | ||
| if (!Environment.PLUGIN_API_JAR.exists()) { | ||
| return "Plugin API jar missing: ${Environment.PLUGIN_API_JAR.absolutePath}" | ||
| } | ||
| if (expectedSize > 0) { | ||
| val totalSize = pluginDir.recursiveSize() | ||
| val minSize = (expectedSize * SIZE_TOLERANCE).toLong() | ||
| if (totalSize < minSize) { | ||
| return "Plugin dir too small: ${pluginDir.absolutePath} ($totalSize < $minSize)" | ||
| } | ||
| } else if (pluginDir.recursiveSize() == 0L) { | ||
| return "Plugin directory empty: ${pluginDir.absolutePath}" | ||
| } | ||
| return null | ||
|
Comment on lines
+104
to
+178
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. These checks are too weak to prove the unpacked assets are actually correct.
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| private fun File.recursiveSize(): Long = | ||
| if (isFile) length() | ||
| else listFiles()?.sumOf { it.recursiveSize() } ?: 0L | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ import android.os.StatFs | |
| import androidx.lifecycle.ViewModel | ||
| import androidx.lifecycle.viewModelScope | ||
| import com.itsaky.androidide.app.configuration.IJdkDistributionProvider | ||
| import com.itsaky.androidide.assets.AssetsInstallationAudit | ||
| import com.itsaky.androidide.assets.AssetsInstallationHelper | ||
| import com.itsaky.androidide.events.InstallationEvent | ||
| import com.itsaky.androidide.models.StorageInfo | ||
|
|
@@ -81,10 +82,29 @@ class InstallationViewModel : ViewModel() { | |
|
|
||
| when (result) { | ||
| is AssetsInstallationHelper.Result.Success -> { | ||
| val distributionProvider = IJdkDistributionProvider.getInstance() | ||
| distributionProvider.loadDistributions() | ||
|
|
||
| _state.update { InstallationComplete } | ||
| _installationProgress.value = | ||
| context.getString(R.string.verifying_installation) | ||
| val auditResult = AssetsInstallationAudit.run(context) | ||
| when (auditResult) { | ||
| is AssetsInstallationAudit.Result.Success -> { | ||
| val distributionProvider = IJdkDistributionProvider.getInstance() | ||
| distributionProvider.loadDistributions() | ||
| _state.update { InstallationComplete } | ||
| } | ||
| is AssetsInstallationAudit.Result.Failure -> { | ||
| val errorMsg = | ||
| context.getString( | ||
| R.string.asset_verification_failed, | ||
| auditResult.entryName, | ||
| auditResult.message, | ||
| ) | ||
| log.warn("Post-installation audit failed: {}", errorMsg) | ||
| viewModelScope.launch { | ||
| _events.emit(InstallationEvent.ShowError(errorMsg)) | ||
| } | ||
| _state.update { InstallationError(errorMsg) } | ||
| } | ||
| } | ||
|
Comment on lines
+85
to
+107
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. This only enforces the audit on fresh installs. The new verification runs only after 🤖 Prompt for AI Agents |
||
| } | ||
| is AssetsInstallationHelper.Result.Failure -> { | ||
| if (result.shouldReportToSentry) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: we can accumulate audit results for all entries instead of the fast-fail approach here. So, when debugging, we can know which entries are corrupted/unexpected at once, instead of making the audit fail for each entry sequentially.