From e77a5703668260adf7ce1049b0c9ee60578c8f42 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Tue, 21 Apr 2026 11:47:04 +0100 Subject: [PATCH 1/4] ADFA-3787: add archive and environment services for plugins Lets plugins install IDE-managed tooling (NDK, build-tools) without each bundling compression libraries. - IdeEnvironmentService exposes ANDROIDIDE_HOME, ANDROID_HOME, NDK, tmp, and per-plugin data dirs. - IdeArchiveService extracts XZ/GZIP/TAR/TAR.XZ/TAR.GZ/ZIP with path-traversal + symlink checks and interruptible progress. - New IDE_ENVIRONMENT_WRITE permission gates writes into IDE-managed dirs; filesystem allow-list extended accordingly. - IdeFileService gains writeBinary, writeStream, delete. - ResourceManager gains openPluginResource and openPluginAsset for streaming large bundled payloads. --- gradle/libs.versions.toml | 2 + .../com/itsaky/androidide/plugins/IPlugin.kt | 3 +- .../androidide/plugins/PluginContext.kt | 26 ++ .../plugins/services/IdeArchiveService.kt | 76 +++++ .../plugins/services/IdeEnvironmentService.kt | 44 +++ .../plugins/services/IdeServices.kt | 32 +++ plugin-manager/build.gradle.kts | 2 + .../manager/context/PluginContextImpl.kt | 35 ++- .../plugins/manager/core/PluginManager.kt | 65 ++++- .../manager/services/IdeArchiveServiceImpl.kt | 263 ++++++++++++++++++ .../services/IdeEnvironmentServiceImpl.kt | 30 ++ .../manager/services/IdeFileServiceImpl.kt | 162 +++++++---- 12 files changed, 682 insertions(+), 58 deletions(-) create mode 100644 plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeArchiveService.kt create mode 100644 plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeEnvironmentService.kt create mode 100644 plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeArchiveServiceImpl.kt create mode 100644 plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEnvironmentServiceImpl.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a62003b2bc..d8e60c15da 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,6 +7,7 @@ appcompatVersion = "1.7.1" bcprovJdk18on = "1.80" colorpickerview = "2.3.0" commonsCompress = "1.27.1" +tukaaniXz = "1.9" commonsText = "1.11.0" commonsTextVersion = "1.14.0" constraintlayout = "2.1.4" @@ -104,6 +105,7 @@ bcpkix-jdk18on = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bc bcprov-jdk18on = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcprovJdk18on" } colorpickerview = { module = "com.github.skydoves:colorpickerview", version.ref = "colorpickerview" } commons-compress = { module = "org.apache.commons:commons-compress", version.ref = "commonsCompress" } +tukaani-xz = { module = "org.tukaani:xz", version.ref = "tukaaniXz" } commons-text = { module = "org.apache.commons:commons-text", version.ref = "commonsText" } commons-text-v1140 = { module = "org.apache.commons:commons-text", version.ref = "commonsTextVersion" } composite-appintro = { module = "com.itsaky.androidide.build:appintro" } diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/IPlugin.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/IPlugin.kt index c4050b5b3d..8731b1e11b 100644 --- a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/IPlugin.kt +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/IPlugin.kt @@ -30,7 +30,8 @@ enum class PluginPermission(val key: String, val description: String) { SYSTEM_COMMANDS("system.commands", "Execute system commands"), IDE_SETTINGS("ide.settings", "Modify IDE settings"), PROJECT_STRUCTURE("project.structure", "Modify project structure"), - NATIVE_CODE("native.code", "Execute native machine code") + NATIVE_CODE("native.code", "Execute native machine code"), + IDE_ENVIRONMENT_WRITE("ide.environment.write", "Write to IDE-managed directories such as the Android SDK, NDK, and cache") } data class PluginInfo( diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/PluginContext.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/PluginContext.kt index 688986ace8..fc04aed802 100644 --- a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/PluginContext.kt +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/PluginContext.kt @@ -5,6 +5,7 @@ package com.itsaky.androidide.plugins import android.content.Context // Note: EventBus and ILogger are referenced but not directly imported to avoid Android dependencies import java.io.File +import java.io.InputStream interface PluginContext { val androidContext: Context @@ -38,6 +39,31 @@ interface ResourceManager { fun getPluginDirectory(): File fun getPluginFile(path: String): File fun getPluginResource(name: String): ByteArray? + + /** + * Opens a plugin-bundled classpath resource as a stream. Prefer this over + * [getPluginResource] for payloads larger than a few megabytes since + * [getPluginResource] materializes the entire blob on the heap. + * + * Reads from `src/main/resources/`. For Android-style bundled binaries + * (files under `src/main/assets/`), use [openPluginAsset] instead. + * + * Callers own the returned stream and must close it. + */ + fun openPluginResource(name: String): InputStream? + + /** + * Opens a file bundled in the plugin's `src/main/assets/` directory. + * Preferred for large binary payloads such as toolchains or models, + * since assets are the Android-native location and are not subject to + * classpath scanning. + * + * Callers own the returned stream and must close it. + * + * @param path Path relative to the plugin's assets root. Supports + * subdirectories (e.g. `"toolchains/ndk-cmake.tar.xz"`). + */ + fun openPluginAsset(path: String): InputStream? } interface PluginLogger { diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeArchiveService.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeArchiveService.kt new file mode 100644 index 0000000000..05b6553a2b --- /dev/null +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeArchiveService.kt @@ -0,0 +1,76 @@ +package com.itsaky.androidide.plugins.services + +import java.io.File +import java.io.InputStream + +/** + * Supported archive formats for [IdeArchiveService.extract]. + * + * - [XZ] and [GZIP] describe a single compressed stream. The stream content + * is written to [IdeArchiveService.extract]'s destination as a single file. + * - [TAR], [TAR_XZ], [TAR_GZ], and [ZIP] describe multi-entry archives. + * Entries are extracted into [IdeArchiveService.extract]'s destination, + * which must be a directory. + */ +enum class ArchiveFormat { + XZ, + GZIP, + TAR, + TAR_XZ, + TAR_GZ, + ZIP +} + +/** + * Outcome of an extraction call. Uses a sealed hierarchy so callers + * handle both paths exhaustively. + */ +sealed class ExtractResult { + data class Success( + val bytesWritten: Long, + val filesExtracted: Int + ) : ExtractResult() + + data class Failure(val error: Throwable) : ExtractResult() +} + +/** + * Service interface that extracts archives bundled inside plugin assets. + * + * The implementation handles compression libraries centrally so plugins do + * not each need to bundle `org.tukaani:xz` or `commons-compress`. Destination + * paths are validated against the plugin's write policy, so an extraction + * that would write outside an allowed directory fails rather than leaks data. + * + * Extraction is synchronous and CPU-bound for large archives. Plugins should + * invoke from a background dispatcher. The call cooperates with coroutine + * cancellation by checking [Thread.interrupted] between entries; on + * interruption [extract] returns [ExtractResult.Failure] carrying an + * [InterruptedException]. + */ +interface IdeArchiveService { + /** + * Extracts [source] into [destination]. + * + * For single-stream formats ([ArchiveFormat.XZ], [ArchiveFormat.GZIP]), + * [destination] is the output file path. + * For multi-entry formats, [destination] is the directory into which + * entries are written; it is created if it does not exist. + * + * [onProgress] is invoked periodically with the running byte count and, + * for multi-entry formats, the name of the entry currently being + * extracted. + * + * The caller owns [source] and is responsible for closing it. + * + * @return [ExtractResult.Success] with the byte and file counts on + * success, [ExtractResult.Failure] carrying the cause on any error + * (IO, malformed archive, path traversal, cancellation). + */ + fun extract( + source: InputStream, + format: ArchiveFormat, + destination: File, + onProgress: ((bytesProcessed: Long, currentEntry: String?) -> Unit)? = null + ): ExtractResult +} diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeEnvironmentService.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeEnvironmentService.kt new file mode 100644 index 0000000000..73b276f14e --- /dev/null +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeEnvironmentService.kt @@ -0,0 +1,44 @@ +package com.itsaky.androidide.plugins.services + +import java.io.File + +/** + * Service interface that exposes IDE-managed directories to plugins. + * + * Reading these paths is unrestricted. Writing to them through + * [IdeFileService] or [IdeArchiveService] requires the plugin to declare the + * [com.itsaky.androidide.plugins.PluginPermission.IDE_ENVIRONMENT_WRITE] + * permission in its manifest. + */ +interface IdeEnvironmentService { + /** + * Root of IDE-managed data. Holds caches, templates, snippets, tooling, + * and per-plugin data. + */ + fun getIdeHomeDirectory(): File + + /** + * Root of the Android SDK installation managed by the IDE. Platforms, + * build-tools, and the NDK live beneath this directory. + */ + fun getAndroidHomeDirectory(): File + + /** + * Directory where NDK installations reside. Equivalent to + * `$ANDROID_HOME/ndk`. + */ + fun getNdkDirectory(): File + + /** + * Scratch directory for short-lived files such as staging archives + * during extraction. Contents may be evicted at any time between plugin + * activations; do not rely on persistence. + */ + fun getTmpDirectory(): File + + /** + * Per-plugin persistent data directory. Scoped to the calling plugin and + * created on first access. Survives IDE restarts. + */ + fun getPluginDataDirectory(): File +} diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt index 9a8804c257..515fc794a8 100644 --- a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt @@ -4,6 +4,7 @@ package com.itsaky.androidide.plugins.services import android.app.Activity import com.itsaky.androidide.plugins.extensions.IProject import java.io.File +import java.io.InputStream /** * Service interface that provides access to Code On the Go project information. @@ -159,6 +160,37 @@ interface IdeFileService { * @return true if the replacement was successful, false otherwise */ fun replaceInFile(file: File, oldText: String, newText: String): Boolean + + /** + * Writes binary content to a file, replacing any existing content. + * Use this instead of [writeFile] for non-text data: UTF-8 transcoding in + * [writeFile] corrupts arbitrary bytes. + * @param file The file to write to + * @param data The bytes to write + * @return true if the write operation was successful, false otherwise + */ + fun writeBinary(file: File, data: ByteArray): Boolean + + /** + * Writes content from an input stream to a file, replacing any existing + * content. Preferred for large payloads (archives, toolchain assets) since + * no intermediate buffer of the full payload is held in memory. + * + * The caller owns [input] and is responsible for closing it. + * @param file The file to write to + * @param input The stream to read from + * @return The number of bytes written, or -1 if the operation failed + */ + fun writeStream(file: File, input: InputStream): Long + + /** + * Deletes a file or directory. Directories are removed recursively. + * Required for plugins that need to clean up installed assets in + * [com.itsaky.androidide.plugins.IPlugin.deactivate]. + * @param file The file or directory to delete + * @return true if the deletion was successful, false otherwise + */ + fun delete(file: File): Boolean } /** diff --git a/plugin-manager/build.gradle.kts b/plugin-manager/build.gradle.kts index 278fd96d41..6f1f1928c2 100644 --- a/plugin-manager/build.gradle.kts +++ b/plugin-manager/build.gradle.kts @@ -28,4 +28,6 @@ dependencies { implementation(libs.androidx.appcompat) implementation(libs.gson.v2101) + implementation(libs.commons.compress) + implementation(libs.tukaani.xz) } diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/context/PluginContextImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/context/PluginContextImpl.kt index 2f1de99916..d04dbb7b0a 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/context/PluginContextImpl.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/context/PluginContextImpl.kt @@ -3,9 +3,11 @@ package com.itsaky.androidide.plugins.manager.context import android.content.Context +import android.content.res.AssetManager import com.itsaky.androidide.plugins.* import com.itsaky.androidide.plugins.manager.security.PluginSecurityManager import java.io.File +import java.io.InputStream import java.util.concurrent.ConcurrentHashMap class PluginContextImpl( @@ -42,31 +44,32 @@ class ServiceRegistryImpl : ServiceRegistry { class ResourceManagerImpl( private val pluginId: String, private val pluginsDir: File, - private val classLoader: ClassLoader + private val classLoader: ClassLoader, + private val assetManager: AssetManager? = null ) : ResourceManager { - + private val pluginDirectory = File(pluginsDir, pluginId) private val securityManager = PluginSecurityManager() - + init { if (!pluginDirectory.exists()) { pluginDirectory.mkdirs() } } - + override fun getPluginDirectory(): File = pluginDirectory - + override fun getPluginFile(path: String): File { val file = File(pluginDirectory, path) - + // Security check: ensure file is within plugin directory if (!file.canonicalPath.startsWith(pluginDirectory.canonicalPath)) { throw SecurityException("Access denied: Path traversal detected") } - + return file } - + override fun getPluginResource(name: String): ByteArray? { return try { classLoader.getResourceAsStream(name)?.use { @@ -77,6 +80,22 @@ class ResourceManagerImpl( } } + override fun openPluginResource(name: String): InputStream? { + return try { + classLoader.getResourceAsStream(name) + } catch (e: Exception) { + null + } + } + + override fun openPluginAsset(path: String): InputStream? { + val assets = assetManager ?: return null + return try { + assets.open(path) + } catch (e: Exception) { + null + } + } } class PluginLoggerImpl( diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt index 22f9e9a10f..3d59063b95 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt @@ -41,7 +41,11 @@ import com.itsaky.androidide.plugins.services.IdeTooltipService import com.itsaky.androidide.plugins.services.IdeEditorTabService import com.itsaky.androidide.plugins.services.IdeFileService import com.itsaky.androidide.plugins.services.IdeSidebarService +import com.itsaky.androidide.plugins.services.IdeEnvironmentService +import com.itsaky.androidide.plugins.services.IdeArchiveService import com.itsaky.androidide.plugins.manager.services.IdeFileServiceImpl +import com.itsaky.androidide.plugins.manager.services.IdeEnvironmentServiceImpl +import com.itsaky.androidide.plugins.manager.services.IdeArchiveServiceImpl import com.itsaky.androidide.plugins.manager.services.IdeSidebarServiceImpl import com.itsaky.androidide.plugins.manager.services.IdeThemeServiceImpl import com.itsaky.androidide.plugins.services.IdeThemeService @@ -936,6 +940,33 @@ class PluginManager private constructor( ) } + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeEnvironmentService::class.java, + pluginId, + "environment" + ) { + IdeEnvironmentServiceImpl(pluginId) + } + + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeArchiveService::class.java, + pluginId, + "archive" + ) { + IdeArchiveServiceImpl( + pluginId = pluginId, + permissions = permissions, + pathValidator = pathValidator?.let { validator -> + object : IdeFileServiceImpl.PathValidator { + override fun isPathAllowed(path: File): Boolean = validator.isPathAllowed(path) + override fun getAllowedPaths(): List = validator.getAllowedPaths() + } + } + ) + } + // Sidebar service for plugin sidebar slot management registerServiceWithErrorHandling( pluginServiceRegistry, @@ -1010,7 +1041,12 @@ class PluginManager private constructor( services = pluginServiceRegistry, eventBus = eventBus, logger = PluginLoggerImpl(pluginId, logger), - resources = ResourceManagerImpl(pluginId, pluginsDir, classLoader), + resources = ResourceManagerImpl( + pluginId = pluginId, + pluginsDir = pluginsDir, + classLoader = classLoader, + assetManager = resourceContext.assets + ), pluginId = pluginId ) } @@ -1098,6 +1134,33 @@ class PluginManager private constructor( ) } + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeEnvironmentService::class.java, + pluginId, + "environment" + ) { + IdeEnvironmentServiceImpl(pluginId) + } + + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeArchiveService::class.java, + pluginId, + "archive" + ) { + IdeArchiveServiceImpl( + pluginId = pluginId, + permissions = permissions, + pathValidator = pathValidator?.let { validator -> + object : IdeFileServiceImpl.PathValidator { + override fun isPathAllowed(path: File): Boolean = validator.isPathAllowed(path) + override fun getAllowedPaths(): List = validator.getAllowedPaths() + } + } + ) + } + registerServiceWithErrorHandling( pluginServiceRegistry, IdeSidebarService::class.java, diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeArchiveServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeArchiveServiceImpl.kt new file mode 100644 index 0000000000..caa0aac650 --- /dev/null +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeArchiveServiceImpl.kt @@ -0,0 +1,263 @@ +package com.itsaky.androidide.plugins.manager.services + +import com.itsaky.androidide.plugins.PluginPermission +import com.itsaky.androidide.plugins.services.ArchiveFormat +import com.itsaky.androidide.plugins.services.ExtractResult +import com.itsaky.androidide.plugins.services.IdeArchiveService +import com.itsaky.androidide.utils.Environment +import org.apache.commons.compress.archivers.ArchiveEntry +import org.apache.commons.compress.archivers.tar.TarArchiveEntry +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream +import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream +import org.apache.commons.compress.compressors.xz.XZCompressorInputStream +import java.io.BufferedInputStream +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream +import java.io.OutputStream + +class IdeArchiveServiceImpl( + private val pluginId: String, + private val permissions: Set, + private val pathValidator: IdeFileServiceImpl.PathValidator? = null +) : IdeArchiveService { + + override fun extract( + source: InputStream, + format: ArchiveFormat, + destination: File, + onProgress: ((bytesProcessed: Long, currentEntry: String?) -> Unit)? + ): ExtractResult { + return try { + ensureWritePermission() + ensurePathAllowed(destination) + + val buffered = BufferedInputStream(source) + when (format) { + ArchiveFormat.XZ -> extractSingleStream( + XZCompressorInputStream(buffered), + destination, + onProgress + ) + ArchiveFormat.GZIP -> extractSingleStream( + GzipCompressorInputStream(buffered), + destination, + onProgress + ) + ArchiveFormat.TAR -> extractTar(buffered, destination, onProgress) + ArchiveFormat.TAR_XZ -> extractTar( + XZCompressorInputStream(buffered), + destination, + onProgress + ) + ArchiveFormat.TAR_GZ -> extractTar( + GzipCompressorInputStream(buffered), + destination, + onProgress + ) + ArchiveFormat.ZIP -> extractZip(buffered, destination, onProgress) + } + } catch (e: Exception) { + ExtractResult.Failure(e) + } + } + + private fun extractSingleStream( + input: InputStream, + destination: File, + onProgress: ((Long, String?) -> Unit)? + ): ExtractResult { + destination.parentFile?.mkdirs() + if (destination.isDirectory) { + return ExtractResult.Failure( + IllegalArgumentException("destination must be a file for single-stream formats: ${destination.absolutePath}") + ) + } + val written = input.use { stream -> + FileOutputStream(destination).use { out -> + copyInterruptible(stream, out, onProgress, entryName = destination.name) + } + } + return ExtractResult.Success(bytesWritten = written, filesExtracted = 1) + } + + private fun extractTar( + input: InputStream, + destination: File, + onProgress: ((Long, String?) -> Unit)? + ): ExtractResult { + ensureDirectory(destination) + val destRoot = destination.canonicalFile + var totalBytes = 0L + var fileCount = 0 + + TarArchiveInputStream(input).use { tar -> + while (true) { + if (Thread.interrupted()) throw InterruptedException("extract interrupted") + val entry = tar.nextEntry ?: break + if (!tar.canReadEntryData(entry)) { + throw SecurityException("unreadable tar entry: ${entry.name}") + } + if (entry.isSymbolicLink || entry.isLink) { + throw SecurityException("tar entry is a link, refusing: ${entry.name}") + } + val target = resolveEntryTarget(destRoot, entry) + if (entry.isDirectory) { + target.mkdirs() + } else { + target.parentFile?.mkdirs() + FileOutputStream(target).use { out -> + totalBytes += copyInterruptible(tar, out, onProgress, entryName = entry.name, alreadyWritten = totalBytes) + } + fileCount += 1 + applyTarMode(entry, target) + } + } + } + + return ExtractResult.Success(bytesWritten = totalBytes, filesExtracted = fileCount) + } + + private fun extractZip( + input: InputStream, + destination: File, + onProgress: ((Long, String?) -> Unit)? + ): ExtractResult { + ensureDirectory(destination) + val destRoot = destination.canonicalFile + var totalBytes = 0L + var fileCount = 0 + + ZipArchiveInputStream(input).use { zip -> + while (true) { + if (Thread.interrupted()) throw InterruptedException("extract interrupted") + val entry = zip.nextEntry ?: break + if (!zip.canReadEntryData(entry)) { + throw SecurityException("unreadable zip entry: ${entry.name}") + } + val target = resolveEntryTarget(destRoot, entry) + if (entry.isDirectory) { + target.mkdirs() + } else { + target.parentFile?.mkdirs() + FileOutputStream(target).use { out -> + totalBytes += copyInterruptible(zip, out, onProgress, entryName = entry.name, alreadyWritten = totalBytes) + } + fileCount += 1 + } + } + } + + return ExtractResult.Success(bytesWritten = totalBytes, filesExtracted = fileCount) + } + + private fun resolveEntryTarget(destRoot: File, entry: ArchiveEntry): File { + val target = File(destRoot, entry.name).canonicalFile + val rootPath = destRoot.path + if (target.path != rootPath && !target.path.startsWith(rootPath + File.separator)) { + throw SecurityException("archive entry escapes destination: ${entry.name}") + } + return target + } + + private fun applyTarMode(entry: TarArchiveEntry, target: File) { + val mode = entry.mode + if (mode and OWNER_EXECUTE_BIT != 0) { + target.setExecutable(true, false) + } + } + + private fun ensureDirectory(destination: File) { + if (destination.exists() && !destination.isDirectory) { + throw IllegalArgumentException("destination exists and is not a directory: ${destination.absolutePath}") + } + destination.mkdirs() + } + + private fun copyInterruptible( + input: InputStream, + output: OutputStream, + onProgress: ((Long, String?) -> Unit)?, + entryName: String?, + alreadyWritten: Long = 0L + ): Long { + val buffer = ByteArray(COPY_BUFFER_SIZE) + var written = 0L + var sinceReport = 0L + while (true) { + if (Thread.interrupted()) throw InterruptedException("extract interrupted") + val read = input.read(buffer) + if (read <= 0) break + output.write(buffer, 0, read) + written += read + sinceReport += read + if (onProgress != null && sinceReport >= PROGRESS_REPORT_INTERVAL) { + onProgress(alreadyWritten + written, entryName) + sinceReport = 0L + } + } + output.flush() + onProgress?.invoke(alreadyWritten + written, entryName) + return written + } + + private fun ensureWritePermission() { + if (writePermissions.none { it in permissions }) { + throw SecurityException( + "Plugin $pluginId does not have required permissions: ${writePermissions.joinToString(", ") { it.name }}" + ) + } + } + + private fun ensurePathAllowed(path: File) { + val validator = pathValidator + val canonical = try { + path.canonicalPath + } catch (e: Exception) { + throw SecurityException("Plugin $pluginId cannot canonicalize destination: ${path.absolutePath}") + } + + val allowed = if (validator != null) { + validator.isPathAllowed(path) + } else { + defaultAllowedPaths().any { canonical.startsWith(it) } + } + + if (!allowed) { + throw SecurityException("Plugin $pluginId does not have access to path: ${path.absolutePath}") + } + } + + private fun defaultAllowedPaths(): List { + val paths = mutableListOf( + "/storage/emulated/0/${Environment.PROJECTS_FOLDER}", + "/sdcard/${Environment.PROJECTS_FOLDER}", + System.getProperty("user.home", "/") + "/${Environment.PROJECTS_FOLDER}", + "/tmp/CodeOnTheGoProject" + ) + if (PluginPermission.IDE_ENVIRONMENT_WRITE in permissions) { + paths += canonicalOrSelf(Environment.ANDROID_HOME) + paths += canonicalOrSelf(Environment.TMP_DIR) + paths += canonicalOrSelf(File(File(Environment.ANDROIDIDE_HOME, PLUGIN_DATA_ROOT), pluginId)) + } + return paths + } + + private fun canonicalOrSelf(file: File): String = try { + file.canonicalPath + } catch (e: Exception) { + file.absolutePath + } + + private companion object { + const val COPY_BUFFER_SIZE = 64 * 1024 + const val PROGRESS_REPORT_INTERVAL = 1L * 1024 * 1024 + const val OWNER_EXECUTE_BIT = 0b001_000_000 + const val PLUGIN_DATA_ROOT = "plugins" + val writePermissions = setOf( + PluginPermission.FILESYSTEM_WRITE, + PluginPermission.IDE_ENVIRONMENT_WRITE + ) + } +} diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEnvironmentServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEnvironmentServiceImpl.kt new file mode 100644 index 0000000000..bd5d0c71a0 --- /dev/null +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEnvironmentServiceImpl.kt @@ -0,0 +1,30 @@ +package com.itsaky.androidide.plugins.manager.services + +import com.itsaky.androidide.plugins.services.IdeEnvironmentService +import com.itsaky.androidide.utils.Environment +import java.io.File + +class IdeEnvironmentServiceImpl( + private val pluginId: String +) : IdeEnvironmentService { + + override fun getIdeHomeDirectory(): File = Environment.ANDROIDIDE_HOME + + override fun getAndroidHomeDirectory(): File = Environment.ANDROID_HOME + + override fun getNdkDirectory(): File = Environment.NDK_DIR + + override fun getTmpDirectory(): File = Environment.TMP_DIR + + override fun getPluginDataDirectory(): File { + val dir = File(File(Environment.ANDROIDIDE_HOME, PLUGIN_DATA_ROOT), "$pluginId/data") + if (!dir.exists()) { + dir.mkdirs() + } + return dir + } + + private companion object { + const val PLUGIN_DATA_ROOT = "plugins" + } +} diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeFileServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeFileServiceImpl.kt index 3ffb694ba8..5873230b4a 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeFileServiceImpl.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeFileServiceImpl.kt @@ -3,7 +3,10 @@ package com.itsaky.androidide.plugins.manager.services import com.itsaky.androidide.plugins.PluginPermission import com.itsaky.androidide.plugins.services.IdeFileService import com.itsaky.androidide.utils.Environment +import java.io.BufferedInputStream import java.io.File +import java.io.FileOutputStream +import java.io.InputStream class IdeFileServiceImpl( private val pluginId: String, @@ -16,16 +19,9 @@ class IdeFileServiceImpl( fun getAllowedPaths(): List } - private val requiredPermissions = setOf(PluginPermission.FILESYSTEM_WRITE) - override fun readFile(file: File): String? { - if (!hasRequiredPermissions()) { - throw SecurityException("Plugin $pluginId does not have required permissions: ${getRequiredPermissionsString()}") - } - - if (!isPathAllowed(file)) { - throw SecurityException("Plugin $pluginId does not have access to path: ${file.absolutePath}") - } + ensureWritePermission() + ensurePathAllowed(file) return try { if (file.exists() && file.isFile) { @@ -39,13 +35,8 @@ class IdeFileServiceImpl( } override fun writeFile(file: File, content: String): Boolean { - if (!hasRequiredPermissions()) { - throw SecurityException("Plugin $pluginId does not have required permissions: ${getRequiredPermissionsString()}") - } - - if (!isPathAllowed(file)) { - throw SecurityException("Plugin $pluginId does not have access to path: ${file.absolutePath}") - } + ensureWritePermission() + ensurePathAllowed(file) return try { file.parentFile?.mkdirs() @@ -57,13 +48,8 @@ class IdeFileServiceImpl( } override fun appendToFile(file: File, content: String): Boolean { - if (!hasRequiredPermissions()) { - throw SecurityException("Plugin $pluginId does not have required permissions: ${getRequiredPermissionsString()}") - } - - if (!isPathAllowed(file)) { - throw SecurityException("Plugin $pluginId does not have access to path: ${file.absolutePath}") - } + ensureWritePermission() + ensurePathAllowed(file) return try { file.parentFile?.mkdirs() @@ -75,13 +61,8 @@ class IdeFileServiceImpl( } override fun insertAfterPattern(file: File, pattern: String, content: String): Boolean { - if (!hasRequiredPermissions()) { - throw SecurityException("Plugin $pluginId does not have required permissions: ${getRequiredPermissionsString()}") - } - - if (!isPathAllowed(file)) { - throw SecurityException("Plugin $pluginId does not have access to path: ${file.absolutePath}") - } + ensureWritePermission() + ensurePathAllowed(file) return try { val fileContent = file.readText() @@ -102,13 +83,8 @@ class IdeFileServiceImpl( } override fun replaceInFile(file: File, oldText: String, newText: String): Boolean { - if (!hasRequiredPermissions()) { - throw SecurityException("Plugin $pluginId does not have required permissions: ${getRequiredPermissionsString()}") - } - - if (!isPathAllowed(file)) { - throw SecurityException("Plugin $pluginId does not have access to path: ${file.absolutePath}") - } + ensureWritePermission() + ensurePathAllowed(file) return try { val fileContent = file.readText() @@ -125,44 +101,134 @@ class IdeFileServiceImpl( } } - private fun hasRequiredPermissions(): Boolean { - return requiredPermissions.all { permission -> - permissions.contains(permission) + override fun writeBinary(file: File, data: ByteArray): Boolean { + ensureWritePermission() + ensurePathAllowed(file) + + return try { + file.parentFile?.mkdirs() + file.writeBytes(data) + true + } catch (e: Exception) { + false } } - private fun getRequiredPermissionsString(): String { - return requiredPermissions.joinToString(", ") { it.name } + override fun writeStream(file: File, input: InputStream): Long { + ensureWritePermission() + ensurePathAllowed(file) + + return try { + file.parentFile?.mkdirs() + BufferedInputStream(input).use { buffered -> + FileOutputStream(file).use { output -> + copyInterruptible(buffered, output) + } + } + } catch (e: Exception) { + FAILED_WRITE + } } + override fun delete(file: File): Boolean { + ensureWritePermission() + ensurePathAllowed(file) + + return try { + if (!file.exists()) { + true + } else if (file.isDirectory) { + file.deleteRecursively() + } else { + file.delete() + } + } catch (e: Exception) { + false + } + } + + private fun copyInterruptible(input: InputStream, output: FileOutputStream): Long { + val buffer = ByteArray(COPY_BUFFER_SIZE) + var total = 0L + while (true) { + if (Thread.interrupted()) { + throw InterruptedException("Stream copy interrupted") + } + val read = input.read(buffer) + if (read <= 0) break + output.write(buffer, 0, read) + total += read + } + output.flush() + return total + } + + private fun ensureWritePermission() { + if (!hasAnyWritePermission()) { + throw SecurityException( + "Plugin $pluginId does not have required permissions: ${writePermissions.joinToString(", ") { it.name }}" + ) + } + } + + private fun ensurePathAllowed(path: File) { + if (!isPathAllowed(path)) { + throw SecurityException("Plugin $pluginId does not have access to path: ${path.absolutePath}") + } + } + + private fun hasAnyWritePermission(): Boolean = + writePermissions.any { it in permissions } + private fun isPathAllowed(path: File): Boolean { pathValidator?.let { validator -> return validator.isPathAllowed(path) } - return isPathAllowedDefault(path) } private fun isPathAllowedDefault(path: File): Boolean { - val allowedPaths = getDefaultAllowedPaths() - val canonicalPath = try { path.canonicalPath } catch (e: Exception) { return false } - return allowedPaths.any { allowedPath -> + return getDefaultAllowedPaths().any { allowedPath -> canonicalPath.startsWith(allowedPath) } } private fun getDefaultAllowedPaths(): List { - return listOf( + val paths = mutableListOf( "/storage/emulated/0/${Environment.PROJECTS_FOLDER}", "/sdcard/${Environment.PROJECTS_FOLDER}", System.getProperty("user.home", "/") + "/${Environment.PROJECTS_FOLDER}", "/tmp/CodeOnTheGoProject" ) + + if (permissions.contains(PluginPermission.IDE_ENVIRONMENT_WRITE)) { + paths += canonicalOrSelf(Environment.ANDROID_HOME) + paths += canonicalOrSelf(Environment.TMP_DIR) + paths += canonicalOrSelf(File(File(Environment.ANDROIDIDE_HOME, PLUGIN_DATA_ROOT), pluginId)) + } + + return paths + } + + private fun canonicalOrSelf(file: File): String = try { + file.canonicalPath + } catch (e: Exception) { + file.absolutePath + } + + private companion object { + const val COPY_BUFFER_SIZE = 64 * 1024 + const val FAILED_WRITE = -1L + const val PLUGIN_DATA_ROOT = "plugins" + val writePermissions = setOf( + PluginPermission.FILESYSTEM_WRITE, + PluginPermission.IDE_ENVIRONMENT_WRITE + ) } -} \ No newline at end of file +} From 881e407639e93d62d3b1eac64deba1fbd012de93 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Wed, 22 Apr 2026 13:34:43 +0100 Subject: [PATCH 2/4] fixes --- .../plugins/services/IdeEnvironmentService.kt | 4 ++ .../manager/services/IdeArchiveServiceImpl.kt | 34 +++----------- .../services/IdeEnvironmentServiceImpl.kt | 5 ++- .../manager/services/IdeFileServiceImpl.kt | 39 +--------------- .../manager/services/PluginPathAllowlist.kt | 45 +++++++++++++++++++ 5 files changed, 59 insertions(+), 68 deletions(-) create mode 100644 plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/PluginPathAllowlist.kt diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeEnvironmentService.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeEnvironmentService.kt index 73b276f14e..e53bc1e737 100644 --- a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeEnvironmentService.kt +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeEnvironmentService.kt @@ -1,6 +1,7 @@ package com.itsaky.androidide.plugins.services import java.io.File +import java.io.IOException /** * Service interface that exposes IDE-managed directories to plugins. @@ -39,6 +40,9 @@ interface IdeEnvironmentService { /** * Per-plugin persistent data directory. Scoped to the calling plugin and * created on first access. Survives IDE restarts. + * + * @throws IOException if the directory does not exist and cannot be created. */ + @Throws(IOException::class) fun getPluginDataDirectory(): File } diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeArchiveServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeArchiveServiceImpl.kt index caa0aac650..8952c2b47b 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeArchiveServiceImpl.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeArchiveServiceImpl.kt @@ -4,7 +4,6 @@ import com.itsaky.androidide.plugins.PluginPermission import com.itsaky.androidide.plugins.services.ArchiveFormat import com.itsaky.androidide.plugins.services.ExtractResult import com.itsaky.androidide.plugins.services.IdeArchiveService -import com.itsaky.androidide.utils.Environment import org.apache.commons.compress.archivers.ArchiveEntry import org.apache.commons.compress.archivers.tar.TarArchiveEntry import org.apache.commons.compress.archivers.tar.TarArchiveInputStream @@ -14,6 +13,7 @@ import org.apache.commons.compress.compressors.xz.XZCompressorInputStream import java.io.BufferedInputStream import java.io.File import java.io.FileOutputStream +import java.io.FilterInputStream import java.io.InputStream import java.io.OutputStream @@ -33,7 +33,7 @@ class IdeArchiveServiceImpl( ensureWritePermission() ensurePathAllowed(destination) - val buffered = BufferedInputStream(source) + val buffered = BufferedInputStream(NonClosingInputStream(source)) when (format) { ArchiveFormat.XZ -> extractSingleStream( XZCompressorInputStream(buffered), @@ -212,16 +212,10 @@ class IdeArchiveServiceImpl( private fun ensurePathAllowed(path: File) { val validator = pathValidator - val canonical = try { - path.canonicalPath - } catch (e: Exception) { - throw SecurityException("Plugin $pluginId cannot canonicalize destination: ${path.absolutePath}") - } - val allowed = if (validator != null) { validator.isPathAllowed(path) } else { - defaultAllowedPaths().any { canonical.startsWith(it) } + PluginPathAllowlist.isAllowed(path, permissions, pluginId) } if (!allowed) { @@ -229,32 +223,16 @@ class IdeArchiveServiceImpl( } } - private fun defaultAllowedPaths(): List { - val paths = mutableListOf( - "/storage/emulated/0/${Environment.PROJECTS_FOLDER}", - "/sdcard/${Environment.PROJECTS_FOLDER}", - System.getProperty("user.home", "/") + "/${Environment.PROJECTS_FOLDER}", - "/tmp/CodeOnTheGoProject" - ) - if (PluginPermission.IDE_ENVIRONMENT_WRITE in permissions) { - paths += canonicalOrSelf(Environment.ANDROID_HOME) - paths += canonicalOrSelf(Environment.TMP_DIR) - paths += canonicalOrSelf(File(File(Environment.ANDROIDIDE_HOME, PLUGIN_DATA_ROOT), pluginId)) + private class NonClosingInputStream(delegate: InputStream) : FilterInputStream(delegate) { + override fun close() { + // no-op: IdeArchiveService contract states the caller owns the source stream } - return paths - } - - private fun canonicalOrSelf(file: File): String = try { - file.canonicalPath - } catch (e: Exception) { - file.absolutePath } private companion object { const val COPY_BUFFER_SIZE = 64 * 1024 const val PROGRESS_REPORT_INTERVAL = 1L * 1024 * 1024 const val OWNER_EXECUTE_BIT = 0b001_000_000 - const val PLUGIN_DATA_ROOT = "plugins" val writePermissions = setOf( PluginPermission.FILESYSTEM_WRITE, PluginPermission.IDE_ENVIRONMENT_WRITE diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEnvironmentServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEnvironmentServiceImpl.kt index bd5d0c71a0..c9b3cfd38f 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEnvironmentServiceImpl.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEnvironmentServiceImpl.kt @@ -3,6 +3,7 @@ package com.itsaky.androidide.plugins.manager.services import com.itsaky.androidide.plugins.services.IdeEnvironmentService import com.itsaky.androidide.utils.Environment import java.io.File +import java.io.IOException class IdeEnvironmentServiceImpl( private val pluginId: String @@ -18,8 +19,8 @@ class IdeEnvironmentServiceImpl( override fun getPluginDataDirectory(): File { val dir = File(File(Environment.ANDROIDIDE_HOME, PLUGIN_DATA_ROOT), "$pluginId/data") - if (!dir.exists()) { - dir.mkdirs() + if (!dir.exists() && !dir.mkdirs() && !dir.isDirectory) { + throw IOException("Failed to create plugin data directory: ${dir.absolutePath}") } return dir } diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeFileServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeFileServiceImpl.kt index 5873230b4a..d73da14995 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeFileServiceImpl.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeFileServiceImpl.kt @@ -2,7 +2,6 @@ package com.itsaky.androidide.plugins.manager.services import com.itsaky.androidide.plugins.PluginPermission import com.itsaky.androidide.plugins.services.IdeFileService -import com.itsaky.androidide.utils.Environment import java.io.BufferedInputStream import java.io.File import java.io.FileOutputStream @@ -184,48 +183,12 @@ class IdeFileServiceImpl( pathValidator?.let { validator -> return validator.isPathAllowed(path) } - return isPathAllowedDefault(path) - } - - private fun isPathAllowedDefault(path: File): Boolean { - val canonicalPath = try { - path.canonicalPath - } catch (e: Exception) { - return false - } - - return getDefaultAllowedPaths().any { allowedPath -> - canonicalPath.startsWith(allowedPath) - } - } - - private fun getDefaultAllowedPaths(): List { - val paths = mutableListOf( - "/storage/emulated/0/${Environment.PROJECTS_FOLDER}", - "/sdcard/${Environment.PROJECTS_FOLDER}", - System.getProperty("user.home", "/") + "/${Environment.PROJECTS_FOLDER}", - "/tmp/CodeOnTheGoProject" - ) - - if (permissions.contains(PluginPermission.IDE_ENVIRONMENT_WRITE)) { - paths += canonicalOrSelf(Environment.ANDROID_HOME) - paths += canonicalOrSelf(Environment.TMP_DIR) - paths += canonicalOrSelf(File(File(Environment.ANDROIDIDE_HOME, PLUGIN_DATA_ROOT), pluginId)) - } - - return paths - } - - private fun canonicalOrSelf(file: File): String = try { - file.canonicalPath - } catch (e: Exception) { - file.absolutePath + return PluginPathAllowlist.isAllowed(path, permissions, pluginId) } private companion object { const val COPY_BUFFER_SIZE = 64 * 1024 const val FAILED_WRITE = -1L - const val PLUGIN_DATA_ROOT = "plugins" val writePermissions = setOf( PluginPermission.FILESYSTEM_WRITE, PluginPermission.IDE_ENVIRONMENT_WRITE diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/PluginPathAllowlist.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/PluginPathAllowlist.kt new file mode 100644 index 0000000000..eaa4a7be88 --- /dev/null +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/PluginPathAllowlist.kt @@ -0,0 +1,45 @@ +package com.itsaky.androidide.plugins.manager.services + +import com.itsaky.androidide.plugins.PluginPermission +import com.itsaky.androidide.utils.Environment +import java.io.File + +internal object PluginPathAllowlist { + + private const val PLUGIN_DATA_ROOT = "plugins" + + fun isAllowed(path: File, permissions: Set, pluginId: String): Boolean { + val canonical = try { + path.canonicalPath + } catch (e: Exception) { + return false + } + return defaultAllowedPaths(permissions, pluginId).any { root -> containsPath(canonical, root) } + } + + fun defaultAllowedPaths(permissions: Set, pluginId: String): List { + val paths = mutableListOf( + canonicalOrSelf(File("/storage/emulated/0/${Environment.PROJECTS_FOLDER}")), + canonicalOrSelf(File("/sdcard/${Environment.PROJECTS_FOLDER}")), + canonicalOrSelf(File((System.getProperty("user.home") ?: "/") + "/${Environment.PROJECTS_FOLDER}")), + canonicalOrSelf(File("/tmp/CodeOnTheGoProject")) + ) + if (PluginPermission.IDE_ENVIRONMENT_WRITE in permissions) { + paths += canonicalOrSelf(Environment.ANDROID_HOME) + paths += canonicalOrSelf(Environment.TMP_DIR) + paths += canonicalOrSelf(File(File(Environment.ANDROIDIDE_HOME, PLUGIN_DATA_ROOT), pluginId)) + } + return paths + } + + private fun containsPath(canonical: String, root: String): Boolean { + val trimmedRoot = root.trimEnd(File.separatorChar) + return canonical == trimmedRoot || canonical.startsWith(trimmedRoot + File.separatorChar) + } + + private fun canonicalOrSelf(file: File): String = try { + file.canonicalPath + } catch (e: Exception) { + file.absolutePath + } +} From 2ea87d2b1c101a342dc35dfc98a4a9e8c2c38d41 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Thu, 23 Apr 2026 13:02:19 +0100 Subject: [PATCH 3/4] refactor(archive): generalize TAR_XZ extraction and fix temp file leak --- .../manager/services/IdeArchiveServiceImpl.kt | 74 +++++++++++++++++-- 1 file changed, 68 insertions(+), 6 deletions(-) diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeArchiveServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeArchiveServiceImpl.kt index 8952c2b47b..c6b9a8b20c 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeArchiveServiceImpl.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeArchiveServiceImpl.kt @@ -4,18 +4,23 @@ import com.itsaky.androidide.plugins.PluginPermission import com.itsaky.androidide.plugins.services.ArchiveFormat import com.itsaky.androidide.plugins.services.ExtractResult import com.itsaky.androidide.plugins.services.IdeArchiveService +import com.itsaky.androidide.utils.FeatureFlags import org.apache.commons.compress.archivers.ArchiveEntry import org.apache.commons.compress.archivers.tar.TarArchiveEntry import org.apache.commons.compress.archivers.tar.TarArchiveInputStream import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream import org.apache.commons.compress.compressors.xz.XZCompressorInputStream +import org.slf4j.LoggerFactory import java.io.BufferedInputStream import java.io.File import java.io.FileOutputStream import java.io.FilterInputStream import java.io.InputStream import java.io.OutputStream +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread +import kotlin.system.measureTimeMillis class IdeArchiveServiceImpl( private val pluginId: String, @@ -35,6 +40,17 @@ class IdeArchiveServiceImpl( val buffered = BufferedInputStream(NonClosingInputStream(source)) when (format) { + ArchiveFormat.TAR_XZ -> { + val tempFile = File.createTempFile("archive", ".tar.xz", destination.parentFile) + try { + tempFile.outputStream().use { source.copyTo(it) } + val ok = extractTarXzViaTermux(tempFile, destination) + if (ok) ExtractResult.Success(0, 0) + else ExtractResult.Failure(Exception("Termux tar extraction failed")) + } finally { + tempFile.delete() + } + } ArchiveFormat.XZ -> extractSingleStream( XZCompressorInputStream(buffered), destination, @@ -46,11 +62,6 @@ class IdeArchiveServiceImpl( onProgress ) ArchiveFormat.TAR -> extractTar(buffered, destination, onProgress) - ArchiveFormat.TAR_XZ -> extractTar( - XZCompressorInputStream(buffered), - destination, - onProgress - ) ArchiveFormat.TAR_GZ -> extractTar( GzipCompressorInputStream(buffered), destination, @@ -59,6 +70,7 @@ class IdeArchiveServiceImpl( ArchiveFormat.ZIP -> extractZip(buffered, destination, onProgress) } } catch (e: Exception) { + logger.error("error in extract ${e}") ExtractResult.Failure(e) } } @@ -229,13 +241,63 @@ class IdeArchiveServiceImpl( } } + private fun extractTarXzViaTermux(archiveFile: File, outputDir: File): Boolean { + if (!FeatureFlags.isExperimentsEnabled) return false + if (!archiveFile.exists()) { + logger.debug("Archive not found: ${archiveFile.absolutePath}") + return false + } + + logger.debug("Starting Termux tar extraction: ${archiveFile.absolutePath}") + + return runCatching { + val output = StringBuilder() + var exitCode = -1 + + val elapsed = measureTimeMillis { + val process = ProcessBuilder( + "${TERMUX_BIN_PATH}/bash", "-c", + "tar -xJf ${archiveFile.absolutePath} -C ${outputDir.absolutePath} --no-same-owner" + ).redirectErrorStream(true).apply { + environment()["PATH"] = "${TERMUX_BIN_PATH}:${environment()["PATH"]}" + }.start() + + val reader = thread(name = "tar-xz-extract-output") { + process.inputStream.bufferedReader().useLines { it.forEach(output::appendLine) } + } + + val completed = process.waitFor(2, TimeUnit.MINUTES) + if (!completed) process.destroyForcibly() + reader.join() + exitCode = if (completed) process.exitValue() else -1 + } + + when (exitCode) { + 0 -> { + logger.debug("Extraction succeeded in ${elapsed}ms: $output") + true + } + else -> { + logger.error("Extraction failed (code=$exitCode): $output") + false + } + } + }.getOrElse { e -> + logger.error("Termux process error: ${e.message}") + false + } + } + + private companion object { const val COPY_BUFFER_SIZE = 64 * 1024 const val PROGRESS_REPORT_INTERVAL = 1L * 1024 * 1024 const val OWNER_EXECUTE_BIT = 0b001_000_000 + const val TERMUX_BIN_PATH = "/data/data/com.itsaky.androidide/files/usr/bin" val writePermissions = setOf( PluginPermission.FILESYSTEM_WRITE, PluginPermission.IDE_ENVIRONMENT_WRITE ) + private val logger = LoggerFactory.getLogger(IdeArchiveServiceImpl::class.java) } -} +} \ No newline at end of file From 93d8b0cb0025ca2b18dfb2b2ed9710dcb9db969b Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Thu, 23 Apr 2026 23:09:29 +0100 Subject: [PATCH 4/4] fixes --- .../plugins/manager/services/IdeArchiveServiceImpl.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeArchiveServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeArchiveServiceImpl.kt index c6b9a8b20c..a5acfe6c0c 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeArchiveServiceImpl.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeArchiveServiceImpl.kt @@ -4,7 +4,6 @@ import com.itsaky.androidide.plugins.PluginPermission import com.itsaky.androidide.plugins.services.ArchiveFormat import com.itsaky.androidide.plugins.services.ExtractResult import com.itsaky.androidide.plugins.services.IdeArchiveService -import com.itsaky.androidide.utils.FeatureFlags import org.apache.commons.compress.archivers.ArchiveEntry import org.apache.commons.compress.archivers.tar.TarArchiveEntry import org.apache.commons.compress.archivers.tar.TarArchiveInputStream @@ -242,7 +241,6 @@ class IdeArchiveServiceImpl( } private fun extractTarXzViaTermux(archiveFile: File, outputDir: File): Boolean { - if (!FeatureFlags.isExperimentsEnabled) return false if (!archiveFile.exists()) { logger.debug("Archive not found: ${archiveFile.absolutePath}") return false @@ -256,10 +254,10 @@ class IdeArchiveServiceImpl( val elapsed = measureTimeMillis { val process = ProcessBuilder( - "${TERMUX_BIN_PATH}/bash", "-c", - "tar -xJf ${archiveFile.absolutePath} -C ${outputDir.absolutePath} --no-same-owner" + "$TERMUX_BIN_PATH/tar", "-xJf", archiveFile.absolutePath, + "-C", outputDir.canonicalPath, "--no-same-owner" ).redirectErrorStream(true).apply { - environment()["PATH"] = "${TERMUX_BIN_PATH}:${environment()["PATH"]}" + environment()["PATH"] = TERMUX_BIN_PATH }.start() val reader = thread(name = "tar-xz-extract-output") {