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 3a1670e92a..818ea3bcb1 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 @@ -32,7 +32,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..e53bc1e737 --- /dev/null +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeEnvironmentService.kt @@ -0,0 +1,48 @@ +package com.itsaky.androidide.plugins.services + +import java.io.File +import java.io.IOException + +/** + * 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. + * + * @throws IOException if the directory does not exist and cannot be created. + */ + @Throws(IOException::class) + 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 b39935bb6f..b614cef6ce 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. @@ -228,6 +229,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 476813e76f..321fad719b 100644 --- a/plugin-manager/build.gradle.kts +++ b/plugin-manager/build.gradle.kts @@ -29,4 +29,6 @@ dependencies { implementation(libs.androidx.appcompat) implementation(libs.gson.v2101) implementation(libs.brotli4j) + 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 1d558d3973..734a984e13 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 @@ -43,7 +43,11 @@ 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.IdeEditorService +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.IdeEditorServiceImpl import com.itsaky.androidide.plugins.manager.services.IdeThemeServiceImpl @@ -1082,6 +1086,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, @@ -1175,7 +1206,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 ) } @@ -1263,6 +1299,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..a5acfe6c0c --- /dev/null +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeArchiveServiceImpl.kt @@ -0,0 +1,301 @@ +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 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, + 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(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, + onProgress + ) + ArchiveFormat.GZIP -> extractSingleStream( + GzipCompressorInputStream(buffered), + destination, + onProgress + ) + ArchiveFormat.TAR -> extractTar(buffered, destination, onProgress) + ArchiveFormat.TAR_GZ -> extractTar( + GzipCompressorInputStream(buffered), + destination, + onProgress + ) + ArchiveFormat.ZIP -> extractZip(buffered, destination, onProgress) + } + } catch (e: Exception) { + logger.error("error in extract ${e}") + 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 allowed = if (validator != null) { + validator.isPathAllowed(path) + } else { + PluginPathAllowlist.isAllowed(path, permissions, pluginId) + } + + if (!allowed) { + throw SecurityException("Plugin $pluginId does not have access to path: ${path.absolutePath}") + } + } + + private class NonClosingInputStream(delegate: InputStream) : FilterInputStream(delegate) { + override fun close() { + // no-op: IdeArchiveService contract states the caller owns the source stream + } + } + + private fun extractTarXzViaTermux(archiveFile: File, outputDir: File): Boolean { + 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/tar", "-xJf", archiveFile.absolutePath, + "-C", outputDir.canonicalPath, "--no-same-owner" + ).redirectErrorStream(true).apply { + environment()["PATH"] = TERMUX_BIN_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 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..c9b3cfd38f --- /dev/null +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEnvironmentServiceImpl.kt @@ -0,0 +1,31 @@ +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 +) : 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() && !dir.isDirectory) { + throw IOException("Failed to create plugin data directory: ${dir.absolutePath}") + } + 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..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,8 +2,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 +18,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 +34,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 +47,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 +60,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 +82,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 +100,98 @@ 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 + } } - private fun isPathAllowed(path: File): Boolean { - pathValidator?.let { validator -> - return validator.isPathAllowed(path) + 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 } + } - return isPathAllowedDefault(path) + 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 isPathAllowedDefault(path: File): Boolean { - val allowedPaths = getDefaultAllowedPaths() + private fun ensureWritePermission() { + if (!hasAnyWritePermission()) { + throw SecurityException( + "Plugin $pluginId does not have required permissions: ${writePermissions.joinToString(", ") { it.name }}" + ) + } + } - val canonicalPath = try { - path.canonicalPath - } catch (e: Exception) { - return false + 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 } - return allowedPaths.any { allowedPath -> - canonicalPath.startsWith(allowedPath) + private fun isPathAllowed(path: File): Boolean { + pathValidator?.let { validator -> + return validator.isPathAllowed(path) } + return PluginPathAllowlist.isAllowed(path, permissions, pluginId) } - private fun getDefaultAllowedPaths(): List { - return listOf( - "/storage/emulated/0/${Environment.PROJECTS_FOLDER}", - "/sdcard/${Environment.PROJECTS_FOLDER}", - System.getProperty("user.home", "/") + "/${Environment.PROJECTS_FOLDER}", - "/tmp/CodeOnTheGoProject" + private companion object { + const val COPY_BUFFER_SIZE = 64 * 1024 + const val FAILED_WRITE = -1L + val writePermissions = setOf( + PluginPermission.FILESYSTEM_WRITE, + PluginPermission.IDE_ENVIRONMENT_WRITE ) } -} \ No newline at end of file +} 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 + } +}