From ae873bc83ce1a2546c67d785d334c1c6d232053e Mon Sep 17 00:00:00 2001 From: Joel Menchavez Date: Mon, 20 Jul 2026 14:13:04 +0800 Subject: [PATCH 1/5] ADFA-3689: Reformat ZipRecipeExecutor.kt with Spotless Mechanical commit, no functional change. The file predates the Spotless ratchet (space-indented), so touching it forces a full reformat; done separately to keep the behavioral change reviewable. Co-Authored-By: Claude Fable 5 --- .../templates/impl/zip/ZipRecipeExecutor.kt | 1052 +++++++++-------- 1 file changed, 582 insertions(+), 470 deletions(-) diff --git a/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutor.kt b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutor.kt index 519e52d73f..73891272bd 100644 --- a/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutor.kt +++ b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutor.kt @@ -3,19 +3,6 @@ package com.itsaky.androidide.templates.impl.zip import android.annotation.SuppressLint import android.content.Context import androidx.annotation.StringRes -import dalvik.system.DexClassLoader - -import java.io.File -import java.io.StringWriter -import java.util.zip.ZipFile -import java.util.ServiceLoader - -import io.pebbletemplates.pebble.PebbleEngine -import io.pebbletemplates.pebble.loader.StringLoader -import io.pebbletemplates.pebble.lexer.Syntax -import io.pebbletemplates.pebble.error.PebbleException -import io.pebbletemplates.pebble.extension.Extension - import com.itsaky.androidide.templates.Language import com.itsaky.androidide.templates.ModuleTemplateData import com.itsaky.androidide.templates.Parameter @@ -26,468 +13,593 @@ import com.itsaky.androidide.templates.TemplateRecipe import com.itsaky.androidide.templates.impl.R import com.itsaky.androidide.templates.impl.base.ProjectTemplateRecipeResultImpl import com.itsaky.androidide.utils.Environment - -import org.slf4j.LoggerFactory +import dalvik.system.DexClassLoader +import io.pebbletemplates.pebble.PebbleEngine +import io.pebbletemplates.pebble.error.PebbleException +import io.pebbletemplates.pebble.extension.Extension +import io.pebbletemplates.pebble.lexer.Syntax +import io.pebbletemplates.pebble.loader.StringLoader import org.adfa.constants.ANDROID_GRADLE_PLUGIN_VERSION import org.adfa.constants.KOTLIN_VERSION import org.adfa.constants.Sdk +import org.slf4j.LoggerFactory +import java.io.File +import java.io.StringWriter +import java.util.ServiceLoader import java.util.zip.ZipEntry +import java.util.zip.ZipFile class ZipRecipeExecutor( - private val zipProvider: () -> ZipFile, - private val metaJson: TemplateJson, - private val params: MutableMap>, - private val basePath: String, - private val data: ProjectTemplateData, - private val defModule: ModuleTemplateData, + private val zipProvider: () -> ZipFile, + private val metaJson: TemplateJson, + private val params: MutableMap>, + private val basePath: String, + private val data: ProjectTemplateData, + private val defModule: ModuleTemplateData, ) : TemplateRecipe { - - var hasErrorsWarnings: Boolean = false - - companion object { - private val log = LoggerFactory.getLogger(ZipRecipeExecutor::class.java) - private val CLASS_NAME_PATTERN = Regex("[^a-zA-Z0-9]") - } - - override fun execute( - executor: RecipeExecutor - ): ProjectTemplateRecipeResult { - val ctx = requireNotNull(executor.context) { "context null" } - - info(ctx, R.string.template_exec_info_basepath, basePath) - - val projectDir = data.projectDir - if (projectDir.exists()) { - return ProjectTemplateRecipeResultImpl(data, hasErrorsWarnings) - } - - val projectRoot = projectDir.canonicalFile - - val flags: Map = - params.mapNotNull { (identifier, param) -> - (param.value as? Boolean)?.let { identifier to it } - }.toMap() - - zipProvider().use { zip -> - - val customSyntax = Syntax.Builder() - .setPrintOpenDelimiter(DELIM_PRINT_OPEN) - .setPrintCloseDelimiter(DELIM_PRINT_CLOSE) - .setExecuteOpenDelimiter(DELIM_EXECUTE_OPEN) - .setExecuteCloseDelimiter(DELIM_EXECUTE_CLOSE) - .setCommentOpenDelimiter(DELIM_COMMENT_OPEN) - .setCommentCloseDelimiter(DELIM_COMMENT_CLOSE) - .build() - - val builder = PebbleEngine.Builder() - - val extensionsEntry = zip.getEntry(META_EXTENSION_JAR) - if (extensionsEntry != null) { - val extensions = loadExtensionFromArchive(zip, extensionsEntry, ctx) - for (ext in extensions) { - builder.extension(ext) - } - } - - val pebbleEngine = builder.loader(StringLoader()) - .strictVariables(true) - .syntax(customSyntax) - .build() - - - val className = data.name.replace(CLASS_NAME_PATTERN, "") - val (baseIdentifiers, warnings) = metaJson.pebbleParams(ctx, data, defModule, params) - val identifiers = baseIdentifiers + (KEY_CLASS_NAME to className) - - if (warnings.isNotEmpty()) { - warn(ctx, R.string.template_exec_warn_identifiers, warnings.joinToString(System.lineSeparator())) - } - - val packageName = - resolveString(metaJson.parameters?.required?.packageName?.identifier, KEY_PACKAGE_NAME) - - for (entry in zip.entries()) { - if (!entry.name.startsWith("$basePath/")) continue - if (entry.name == "$basePath/") continue - if (entry.name.startsWith("$basePath/$META_FOLDER/")) continue - - if ((metaJson.parameters?.optional?.language != null) && - (data.language != null) && - shouldSkipFile( - entry.name.removeSuffix(TEMPLATE_EXTENSION), - safeLanguageName(data.language) - ) - ) continue - - val normalized = filterAndNormalizeZipEntry(entry.name, flags) ?: continue - - val relativePath = normalized.removePrefix("$basePath/") - .replace(packageName.value, defModule.packageName.replace(".", "/")) - .replace(KEY_CLASS_NAME, className) - - val outFile = File(projectDir, relativePath.removeSuffix(TEMPLATE_EXTENSION)).canonicalFile - - if (!outFile.toPath().startsWith(projectRoot.toPath())) { - warn(ctx, R.string.template_exec_warn_suspicious_entry, entry.name) - continue - } - - if (entry.isDirectory) { - outFile.mkdirs() - } else { - try { - outFile.parentFile?.mkdirs() - - if (entry.name.endsWith(TEMPLATE_EXTENSION)) { - info(ctx, R.string.template_exec_info_processing, entry.name) - val content = try { - zip.getInputStream(entry).bufferedReader().use { it.readText() } - } catch (e: Exception) { - throw e.wrap(ctx, R.string.template_exec_error_read_fail, entry.name) - } - - val template = try { - pebbleEngine.getTemplate(content) - } catch (e: PebbleException) { - throw e.wrap( - ctx, R.string.template_exec_error_parse_line, - entry.name, e.lineNumber, e.message - ) - } catch (e: Exception) { - throw e.wrap(ctx, R.string.template_exec_error_parse, entry.name) - } - - val writer = StringWriter() - val rendered = try { - template.evaluate(writer, identifiers) - } catch (e: PebbleException) { - error( - ctx, R.string.template_exec_error_evaluate_line, - entry.name, e.lineNumber, e.message - ) - null - } catch (e: Exception) { - error( - ctx, R.string.template_exec_error_evaluate, - entry.name, e.toString() - ) - null - } - if (rendered == null) continue - - try { - outFile.writeText(writer.toString(), Charsets.UTF_8) - } catch (e: Exception) { - error( - ctx, R.string.template_exec_error_write, - outFile.absolutePath, e.toString() - ) - } - - } else { - try { - zip.getInputStream(entry).use { input -> - outFile.outputStream().use { output -> - input.copyTo(output) - } - } - } catch (e: Exception) { - error( - ctx, R.string.template_exec_error_write, - entry.name, e.toString() - ) - } - } - } catch (e: Exception) { - error( - ctx, R.string.template_exec_error_process, - entry.name, e.toString() - ) - } - } - } - } - - keystore(executor) - - return ProjectTemplateRecipeResultImpl(data, hasErrorsWarnings) - } - - private fun keystore(executor: RecipeExecutor) { - val storeSrc = Environment.KEYSTORE_RELEASE - val storeDest = File(data.projectDir, Environment.KEYSTORE_RELEASE_NAME) - if (storeSrc.exists()) { - executor.copy(storeSrc, storeDest) - } - - - val propsSrc = Environment.KEYSTORE_PROPERTIES - val propsDest = File(data.projectDir, Environment.KEYSTORE_PROPERTIES_NAME) - if (propsSrc.exists()) { - executor.copy(propsSrc, propsDest) - } - } - - private fun shouldSkipFile(name: String, language: String): Boolean { - // If language is Kotlin, skip .java files - // If language is Java, skip .kt files - val ext = name.substringAfterLast('.', "").lowercase() - return when (language.lowercase()) { - LANGUAGE_KOTLIN -> ext == FILE_EXT_JAVA - LANGUAGE_JAVA -> ext == FILE_EXT_KOTLIN - else -> false - } - } - - private fun safeLanguageName(language: Language?): String = - language?.name?.lowercase() ?: "" - - private fun safeMinSdkApi(minSdk: Sdk?): String = - minSdk?.api?.toString() ?: "" - - private fun TemplateJson.pebbleParams( - ctx: Context, - data: ProjectTemplateData, - defModule: ModuleTemplateData, - params: MutableMap> - ): Pair, List> { - - val warnings = mutableListOf() - - val appName = resolveString(parameters?.required?.appName?.identifier, KEY_APP_NAME) - if (appName.usedDefault) warnings += ctx.getString(R.string.template_exec_warn_map_appname, - KEY_APP_NAME) - - val packageName = resolveString(parameters?.required?.packageName?.identifier, KEY_PACKAGE_NAME) - if (packageName.usedDefault) warnings += ctx.getString(R.string.template_exec_warn_map_pkgname, - KEY_PACKAGE_NAME) - - val saveLocation = resolveString(parameters?.required?.saveLocation?.identifier, KEY_SAVE_LOCATION) - if (saveLocation.usedDefault) warnings += ctx.getString(R.string.template_exec_warn_map_location, - KEY_SAVE_LOCATION) - - val language = resolveString(parameters?.optional?.language?.identifier, KEY_LANGUAGE) - - val minSdk = resolveString(parameters?.optional?.minsdk?.identifier, KEY_MIN_SDK) - - val agpVersion = resolveString(system?.agpVersion?.identifier, KEY_AGP_VERSION) - if (agpVersion.usedDefault) warnings += ctx.getString(R.string.template_exec_warn_map_agp, - KEY_AGP_VERSION) - - val kotlinVersion = resolveString(system?.kotlinVersion?.identifier, KEY_KOTLIN_VERSION) - if (kotlinVersion.usedDefault) warnings += ctx.getString(R.string.template_exec_warn_map_kotlin, - KEY_KOTLIN_VERSION) - - val gradleVersion = resolveString(system?.gradleVersion?.identifier, KEY_GRADLE_VERSION) - if (gradleVersion.usedDefault) warnings += ctx.getString(R.string.template_exec_warn_map_gradle, - KEY_GRADLE_VERSION) - - val compileSdk = resolveString(system?.compileSdk?.identifier, KEY_COMPILE_SDK) - if (compileSdk.usedDefault) warnings += ctx.getString(R.string.template_exec_warn_map_compilesdk, - KEY_COMPILE_SDK) - - val targetSdk = resolveString(system?.targetSdk?.identifier, KEY_TARGET_SDK) - if (targetSdk.usedDefault) warnings += ctx.getString(R.string.template_exec_warn_map_targetsdk, - KEY_TARGET_SDK) - - val javaSourceCompat = resolveString(system?.javaSourceCompat?.identifier, KEY_JAVA_SOURCE_COMPAT) - if (javaSourceCompat.usedDefault) warnings += ctx.getString(R.string.template_exec_warn_map_javasource_compat, - KEY_JAVA_SOURCE_COMPAT) - - val javaTargetCompat = resolveString(system?.javaTargetCompat?.identifier, KEY_JAVA_TARGET_COMPAT) - if (javaTargetCompat.usedDefault) warnings += ctx.getString(R.string.template_exec_warn_map_javatarget_compat, - KEY_JAVA_TARGET_COMPAT) - - val javaTarget = resolveString(system?.javaTarget?.identifier, KEY_JAVA_TARGET) - if (javaTarget.usedDefault) warnings += ctx.getString(R.string.template_exec_warn_map_javatarget, - KEY_JAVA_TARGET) - - val baseMap = mapOf( - appName.value to data.name, - packageName.value to defModule.packageName, - saveLocation.value to data.projectDir.toString(), - language.value to safeLanguageName(data.language), - minSdk.value to safeMinSdkApi(defModule.versions.minSdk), - agpVersion.value to ANDROID_GRADLE_PLUGIN_VERSION, - kotlinVersion.value to KOTLIN_VERSION, - gradleVersion.value to data.version.gradle, - compileSdk.value to defModule.versions.compileSdk.api.toString(), - targetSdk.value to defModule.versions.targetSdk.api.toString(), - javaSourceCompat.value to defModule.versions.javaSource(), - javaTargetCompat.value to defModule.versions.javaTarget(), - javaTarget.value to defModule.versions.javaTarget - ) - - val map = baseMap + params.mapValues { (_, param) -> - param.value ?: "" - } - - return map to warnings - } - - data class ResolvedParam( - val value: T, - val usedDefault: Boolean - ) - - private fun resolveString(value: String?, default: String): ResolvedParam { - return if (value.isNullOrBlank()) ResolvedParam(default, true) - else ResolvedParam(value, false) - } - - private fun resolveBoolean(raw: Boolean?, default: Boolean): ResolvedParam { - return if (raw == null) ResolvedParam(default, true) - else ResolvedParam(raw, false) - } - - private fun filterAndNormalizeZipEntry( - entryName: String, - flags: Map - ): String? { - val parts = entryName.split(File.separator).filter { it.isNotEmpty() } - if (parts.isEmpty()) return null - - val normalizedParts = mutableListOf() - - for (part in parts) { - when (flags[part]) { - null -> normalizedParts.add(part) - true -> { } - false -> return null - } - } - - return normalizedParts.joinToString(File.separator) - } - - private fun warn(msg: String) { - hasErrorsWarnings = true - log.warn(msg) - } - - private fun warn( - context: Context, - @StringRes resId: Int, - vararg args: Any? - ) { - val msg = context.getString(resId, *args) - warn(msg) - } - - private fun info(msg: String) { - log.info(msg) - } - - private fun info( - context: Context, - @StringRes resId: Int, - vararg args: Any? - ) { - val msg = context.getString(resId, *args) - info(msg) - } - - - private fun error(msg: String, e: Exception) { - hasErrorsWarnings = true - log.error(msg, e) - } - - private fun error( - context: Context, - @StringRes resId: Int, - vararg args: Any? - ) { - hasErrorsWarnings = true - val msg = context.getString(resId, *args) - log.error(msg) - } - - private fun Exception.wrap( - context: Context, - @StringRes resId: Int, - vararg args: Any? - ): RuntimeException { - val msg = context.getString(resId, *args) - return RuntimeException(msg, this) - } - - @SuppressLint("SetWorldReadable") - private fun loadExtensionFromArchive( - zip: ZipFile, - entry: ZipEntry, - context: Context, - ): List { - - val tempJar = File.createTempFile("ext_", ".jar", context.codeCacheDir) - - try { - zip.getInputStream(entry).use { input -> - tempJar.outputStream().use { output -> - input.copyTo(output) - } - } - } catch (e: Exception) { - error("Failed to extract ${entry.name} to ${tempJar.absolutePath}", e) - return emptyList() - } - - try { - tempJar.setReadable(true, false) - tempJar.setWritable(false) - tempJar.setExecutable(false) - } catch (e: SecurityException) { - warn("Could not adjust permissions on ${tempJar.absolutePath} $e") - } - - val optimizedDir = File( - Environment.TEMPLATES_DIR, - "$DEX_OPT_FOLDER/${basePath}" - ) - - if (!optimizedDir.exists() && !optimizedDir.mkdirs()) { - error("Failed to create optimized dex directory: ${optimizedDir.absolutePath}", - IllegalStateException("mkdirs() failed for ${optimizedDir.absolutePath}")) - return emptyList() - } - - val classLoader = try { - DexClassLoader( - tempJar.absolutePath, - optimizedDir.absolutePath, - null, - context.classLoader - ) - } catch (e: Exception) { - error("Failed to create DexClassLoader for ${entry.name}", e) - return emptyList() - } - - val serviceLoader = try { - ServiceLoader.load(Extension::class.java, classLoader) - } catch (e: Throwable) { - error("ServiceLoader failed for ${entry.name}", - Exception("ServiceLoader failed", e)) - return emptyList() - } - - val extensions = mutableListOf() - - try { - for (ext in serviceLoader) { - try { - log.debug("Loading ${ext::class.java.name}") - extensions += ext - } catch (e: Throwable) { - error("Failed to instantiate extension from ${entry.name}", - Exception("Failed to instantiate extension", e)) - } - } - } catch (e: Throwable) { - error("ServiceLoader iteration failed for ${entry.name}", - Exception("ServiceLoader iteration failed", e)) - } - - return extensions - } + var hasErrorsWarnings: Boolean = false + + companion object { + private val log = LoggerFactory.getLogger(ZipRecipeExecutor::class.java) + private val CLASS_NAME_PATTERN = Regex("[^a-zA-Z0-9]") + } + + override fun execute(executor: RecipeExecutor): ProjectTemplateRecipeResult { + val ctx = requireNotNull(executor.context) { "context null" } + + info(ctx, R.string.template_exec_info_basepath, basePath) + + val projectDir = data.projectDir + if (projectDir.exists()) { + return ProjectTemplateRecipeResultImpl(data, hasErrorsWarnings) + } + + val projectRoot = projectDir.canonicalFile + + val flags: Map = + params + .mapNotNull { (identifier, param) -> + (param.value as? Boolean)?.let { identifier to it } + }.toMap() + + zipProvider().use { zip -> + + val customSyntax = + Syntax + .Builder() + .setPrintOpenDelimiter(DELIM_PRINT_OPEN) + .setPrintCloseDelimiter(DELIM_PRINT_CLOSE) + .setExecuteOpenDelimiter(DELIM_EXECUTE_OPEN) + .setExecuteCloseDelimiter(DELIM_EXECUTE_CLOSE) + .setCommentOpenDelimiter(DELIM_COMMENT_OPEN) + .setCommentCloseDelimiter(DELIM_COMMENT_CLOSE) + .build() + + val builder = PebbleEngine.Builder() + + val extensionsEntry = zip.getEntry(META_EXTENSION_JAR) + if (extensionsEntry != null) { + val extensions = loadExtensionFromArchive(zip, extensionsEntry, ctx) + for (ext in extensions) { + builder.extension(ext) + } + } + + val pebbleEngine = + builder + .loader(StringLoader()) + .strictVariables(true) + .syntax(customSyntax) + .build() + + val className = data.name.replace(CLASS_NAME_PATTERN, "") + val (baseIdentifiers, warnings) = metaJson.pebbleParams(ctx, data, defModule, params) + val identifiers = baseIdentifiers + (KEY_CLASS_NAME to className) + + if (warnings.isNotEmpty()) { + warn(ctx, R.string.template_exec_warn_identifiers, warnings.joinToString(System.lineSeparator())) + } + + val packageName = + resolveString( + metaJson.parameters + ?.required + ?.packageName + ?.identifier, + KEY_PACKAGE_NAME, + ) + + for (entry in zip.entries()) { + if (!entry.name.startsWith("$basePath/")) continue + if (entry.name == "$basePath/") continue + if (entry.name.startsWith("$basePath/$META_FOLDER/")) continue + + if ((metaJson.parameters?.optional?.language != null) && + (data.language != null) && + shouldSkipFile( + entry.name.removeSuffix(TEMPLATE_EXTENSION), + safeLanguageName(data.language), + ) + ) { + continue + } + + val normalized = filterAndNormalizeZipEntry(entry.name, flags) ?: continue + + val relativePath = + normalized + .removePrefix("$basePath/") + .replace(packageName.value, defModule.packageName.replace(".", "/")) + .replace(KEY_CLASS_NAME, className) + + val outFile = File(projectDir, relativePath.removeSuffix(TEMPLATE_EXTENSION)).canonicalFile + + if (!outFile.toPath().startsWith(projectRoot.toPath())) { + warn(ctx, R.string.template_exec_warn_suspicious_entry, entry.name) + continue + } + + if (entry.isDirectory) { + outFile.mkdirs() + } else { + try { + outFile.parentFile?.mkdirs() + + if (entry.name.endsWith(TEMPLATE_EXTENSION)) { + info(ctx, R.string.template_exec_info_processing, entry.name) + val content = + try { + zip.getInputStream(entry).bufferedReader().use { it.readText() } + } catch (e: Exception) { + throw e.wrap(ctx, R.string.template_exec_error_read_fail, entry.name) + } + + val template = + try { + pebbleEngine.getTemplate(content) + } catch (e: PebbleException) { + throw e.wrap( + ctx, + R.string.template_exec_error_parse_line, + entry.name, + e.lineNumber, + e.message, + ) + } catch (e: Exception) { + throw e.wrap(ctx, R.string.template_exec_error_parse, entry.name) + } + + val writer = StringWriter() + val rendered = + try { + template.evaluate(writer, identifiers) + } catch (e: PebbleException) { + error( + ctx, + R.string.template_exec_error_evaluate_line, + entry.name, + e.lineNumber, + e.message, + ) + null + } catch (e: Exception) { + error( + ctx, + R.string.template_exec_error_evaluate, + entry.name, + e.toString(), + ) + null + } + if (rendered == null) continue + + try { + outFile.writeText(writer.toString(), Charsets.UTF_8) + } catch (e: Exception) { + error( + ctx, + R.string.template_exec_error_write, + outFile.absolutePath, + e.toString(), + ) + } + } else { + try { + zip.getInputStream(entry).use { input -> + outFile.outputStream().use { output -> + input.copyTo(output) + } + } + } catch (e: Exception) { + error( + ctx, + R.string.template_exec_error_write, + entry.name, + e.toString(), + ) + } + } + } catch (e: Exception) { + error( + ctx, + R.string.template_exec_error_process, + entry.name, + e.toString(), + ) + } + } + } + } + + keystore(executor) + + return ProjectTemplateRecipeResultImpl(data, hasErrorsWarnings) + } + + private fun keystore(executor: RecipeExecutor) { + val storeSrc = Environment.KEYSTORE_RELEASE + val storeDest = File(data.projectDir, Environment.KEYSTORE_RELEASE_NAME) + if (storeSrc.exists()) { + executor.copy(storeSrc, storeDest) + } + + val propsSrc = Environment.KEYSTORE_PROPERTIES + val propsDest = File(data.projectDir, Environment.KEYSTORE_PROPERTIES_NAME) + if (propsSrc.exists()) { + executor.copy(propsSrc, propsDest) + } + } + + private fun shouldSkipFile( + name: String, + language: String, + ): Boolean { + // If language is Kotlin, skip .java files + // If language is Java, skip .kt files + val ext = name.substringAfterLast('.', "").lowercase() + return when (language.lowercase()) { + LANGUAGE_KOTLIN -> ext == FILE_EXT_JAVA + LANGUAGE_JAVA -> ext == FILE_EXT_KOTLIN + else -> false + } + } + + private fun safeLanguageName(language: Language?): String = language?.name?.lowercase() ?: "" + + private fun safeMinSdkApi(minSdk: Sdk?): String = minSdk?.api?.toString() ?: "" + + private fun TemplateJson.pebbleParams( + ctx: Context, + data: ProjectTemplateData, + defModule: ModuleTemplateData, + params: MutableMap>, + ): Pair, List> { + val warnings = mutableListOf() + + val appName = resolveString(parameters?.required?.appName?.identifier, KEY_APP_NAME) + if (appName.usedDefault) { + warnings += + ctx.getString( + R.string.template_exec_warn_map_appname, + KEY_APP_NAME, + ) + } + + val packageName = resolveString(parameters?.required?.packageName?.identifier, KEY_PACKAGE_NAME) + if (packageName.usedDefault) { + warnings += + ctx.getString( + R.string.template_exec_warn_map_pkgname, + KEY_PACKAGE_NAME, + ) + } + + val saveLocation = resolveString(parameters?.required?.saveLocation?.identifier, KEY_SAVE_LOCATION) + if (saveLocation.usedDefault) { + warnings += + ctx.getString( + R.string.template_exec_warn_map_location, + KEY_SAVE_LOCATION, + ) + } + + val language = resolveString(parameters?.optional?.language?.identifier, KEY_LANGUAGE) + + val minSdk = resolveString(parameters?.optional?.minsdk?.identifier, KEY_MIN_SDK) + + val agpVersion = resolveString(system?.agpVersion?.identifier, KEY_AGP_VERSION) + if (agpVersion.usedDefault) { + warnings += + ctx.getString( + R.string.template_exec_warn_map_agp, + KEY_AGP_VERSION, + ) + } + + val kotlinVersion = resolveString(system?.kotlinVersion?.identifier, KEY_KOTLIN_VERSION) + if (kotlinVersion.usedDefault) { + warnings += + ctx.getString( + R.string.template_exec_warn_map_kotlin, + KEY_KOTLIN_VERSION, + ) + } + + val gradleVersion = resolveString(system?.gradleVersion?.identifier, KEY_GRADLE_VERSION) + if (gradleVersion.usedDefault) { + warnings += + ctx.getString( + R.string.template_exec_warn_map_gradle, + KEY_GRADLE_VERSION, + ) + } + + val compileSdk = resolveString(system?.compileSdk?.identifier, KEY_COMPILE_SDK) + if (compileSdk.usedDefault) { + warnings += + ctx.getString( + R.string.template_exec_warn_map_compilesdk, + KEY_COMPILE_SDK, + ) + } + + val targetSdk = resolveString(system?.targetSdk?.identifier, KEY_TARGET_SDK) + if (targetSdk.usedDefault) { + warnings += + ctx.getString( + R.string.template_exec_warn_map_targetsdk, + KEY_TARGET_SDK, + ) + } + + val javaSourceCompat = resolveString(system?.javaSourceCompat?.identifier, KEY_JAVA_SOURCE_COMPAT) + if (javaSourceCompat.usedDefault) { + warnings += + ctx.getString( + R.string.template_exec_warn_map_javasource_compat, + KEY_JAVA_SOURCE_COMPAT, + ) + } + + val javaTargetCompat = resolveString(system?.javaTargetCompat?.identifier, KEY_JAVA_TARGET_COMPAT) + if (javaTargetCompat.usedDefault) { + warnings += + ctx.getString( + R.string.template_exec_warn_map_javatarget_compat, + KEY_JAVA_TARGET_COMPAT, + ) + } + + val javaTarget = resolveString(system?.javaTarget?.identifier, KEY_JAVA_TARGET) + if (javaTarget.usedDefault) { + warnings += + ctx.getString( + R.string.template_exec_warn_map_javatarget, + KEY_JAVA_TARGET, + ) + } + + val baseMap = + mapOf( + appName.value to data.name, + packageName.value to defModule.packageName, + saveLocation.value to data.projectDir.toString(), + language.value to safeLanguageName(data.language), + minSdk.value to safeMinSdkApi(defModule.versions.minSdk), + agpVersion.value to ANDROID_GRADLE_PLUGIN_VERSION, + kotlinVersion.value to KOTLIN_VERSION, + gradleVersion.value to data.version.gradle, + compileSdk.value to + defModule.versions.compileSdk.api + .toString(), + targetSdk.value to + defModule.versions.targetSdk.api + .toString(), + javaSourceCompat.value to defModule.versions.javaSource(), + javaTargetCompat.value to defModule.versions.javaTarget(), + javaTarget.value to defModule.versions.javaTarget, + ) + + val map = + baseMap + + params.mapValues { (_, param) -> + param.value ?: "" + } + + return map to warnings + } + + data class ResolvedParam( + val value: T, + val usedDefault: Boolean, + ) + + private fun resolveString( + value: String?, + default: String, + ): ResolvedParam = + if (value.isNullOrBlank()) { + ResolvedParam(default, true) + } else { + ResolvedParam(value, false) + } + + private fun resolveBoolean( + raw: Boolean?, + default: Boolean, + ): ResolvedParam = + if (raw == null) { + ResolvedParam(default, true) + } else { + ResolvedParam(raw, false) + } + + private fun filterAndNormalizeZipEntry( + entryName: String, + flags: Map, + ): String? { + val parts = entryName.split(File.separator).filter { it.isNotEmpty() } + if (parts.isEmpty()) return null + + val normalizedParts = mutableListOf() + + for (part in parts) { + when (flags[part]) { + null -> { + normalizedParts.add(part) + } + + true -> { } + + false -> { + return null + } + } + } + + return normalizedParts.joinToString(File.separator) + } + + private fun warn(msg: String) { + hasErrorsWarnings = true + log.warn(msg) + } + + private fun warn( + context: Context, + @StringRes resId: Int, + vararg args: Any?, + ) { + val msg = context.getString(resId, *args) + warn(msg) + } + + private fun info(msg: String) { + log.info(msg) + } + + private fun info( + context: Context, + @StringRes resId: Int, + vararg args: Any?, + ) { + val msg = context.getString(resId, *args) + info(msg) + } + + private fun error( + msg: String, + e: Exception, + ) { + hasErrorsWarnings = true + log.error(msg, e) + } + + private fun error( + context: Context, + @StringRes resId: Int, + vararg args: Any?, + ) { + hasErrorsWarnings = true + val msg = context.getString(resId, *args) + log.error(msg) + } + + private fun Exception.wrap( + context: Context, + @StringRes resId: Int, + vararg args: Any?, + ): RuntimeException { + val msg = context.getString(resId, *args) + return RuntimeException(msg, this) + } + + @SuppressLint("SetWorldReadable") + private fun loadExtensionFromArchive( + zip: ZipFile, + entry: ZipEntry, + context: Context, + ): List { + val tempJar = File.createTempFile("ext_", ".jar", context.codeCacheDir) + + try { + zip.getInputStream(entry).use { input -> + tempJar.outputStream().use { output -> + input.copyTo(output) + } + } + } catch (e: Exception) { + error("Failed to extract ${entry.name} to ${tempJar.absolutePath}", e) + return emptyList() + } + + try { + tempJar.setReadable(true, false) + tempJar.setWritable(false) + tempJar.setExecutable(false) + } catch (e: SecurityException) { + warn("Could not adjust permissions on ${tempJar.absolutePath} $e") + } + + val optimizedDir = + File( + Environment.TEMPLATES_DIR, + "$DEX_OPT_FOLDER/$basePath", + ) + + if (!optimizedDir.exists() && !optimizedDir.mkdirs()) { + error( + "Failed to create optimized dex directory: ${optimizedDir.absolutePath}", + IllegalStateException("mkdirs() failed for ${optimizedDir.absolutePath}"), + ) + return emptyList() + } + + val classLoader = + try { + DexClassLoader( + tempJar.absolutePath, + optimizedDir.absolutePath, + null, + context.classLoader, + ) + } catch (e: Exception) { + error("Failed to create DexClassLoader for ${entry.name}", e) + return emptyList() + } + + val serviceLoader = + try { + ServiceLoader.load(Extension::class.java, classLoader) + } catch (e: Throwable) { + error( + "ServiceLoader failed for ${entry.name}", + Exception("ServiceLoader failed", e), + ) + return emptyList() + } + + val extensions = mutableListOf() + + try { + for (ext in serviceLoader) { + try { + log.debug("Loading ${ext::class.java.name}") + extensions += ext + } catch (e: Throwable) { + error( + "Failed to instantiate extension from ${entry.name}", + Exception("Failed to instantiate extension", e), + ) + } + } + } catch (e: Throwable) { + error( + "ServiceLoader iteration failed for ${entry.name}", + Exception("ServiceLoader iteration failed", e), + ) + } + + return extensions + } } From 6ca74c24e2582be6e72fad263daa5abc90d84a72 Mon Sep 17 00:00:00 2001 From: Joel Menchavez Date: Mon, 20 Jul 2026 14:13:49 +0800 Subject: [PATCH 2/5] ADFA-3689: Terminate project creation on Pebble template errors Pebble parse, evaluation, and file write/copy errors previously logged and continued, producing a partially rendered project that was reported as created successfully; the leftover directory also made retries return the broken project as success. - Introduce TemplateExecutionException; parse/evaluate/write/copy failures now abort execution instead of skipping the file - Delete the partial project directory on failure so a retry does not hit the projectDir.exists() early-return - Surface a user-facing error (failing file, line, cause, and next steps) through the existing ProjectCreationManager error path - Keep identifier-default warnings non-fatal (post-open notice) - Add ZipRecipeExecutorTest covering parse error, evaluation error, success path, and the existing-directory guard Co-Authored-By: Claude Fable 5 --- .../src/main/res/values-in-rID/strings.xml | 2 + resources/src/main/res/values/strings.xml | 2 + .../templates/impl/zip/ZipRecipeExecutor.kt | 99 +++++++----- .../impl/zip/ZipRecipeExecutorTest.kt | 143 ++++++++++++++++++ 4 files changed, 204 insertions(+), 42 deletions(-) create mode 100644 templates-impl/src/test/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutorTest.kt diff --git a/resources/src/main/res/values-in-rID/strings.xml b/resources/src/main/res/values-in-rID/strings.xml index ac217e0794..c941297363 100644 --- a/resources/src/main/res/values-in-rID/strings.xml +++ b/resources/src/main/res/values-in-rID/strings.xml @@ -1165,6 +1165,8 @@ Gagal menulis file output: %1$s %2$s Gagal menyalin entri biner: %1$s %2$s Gagal memproses entri template: %1$s %2$s + Pembuatan proyek dihentikan: %1$s\n\nProyek yang belum selesai telah dihapus. Perbaiki file template yang disebutkan di atas (atau pilih template lain), lalu coba lagi. + Tidak dapat menghapus proyek yang belum selesai di %1$s. Hapus secara manual sebelum mencoba lagi. appName tidak ditemukan, menggunakan default %1$s packageName tidak ditemukan, menggunakan default %1$s diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 059e605be8..76ab541502 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1348,6 +1348,8 @@ Failed writing output file: %1$s %2$s Failed copying binary entry: %1$s %2$s Failed to process template entry: %1$s %2$s + Project creation stopped: %1$s\n\nThe incomplete project was removed. Fix the template file mentioned above (or choose a different template) and try again. + Could not remove the incomplete project at %1$s. Delete it manually before trying again. Missing appName, defaulted to %1$s Missing packageName, defaulted to %1$s diff --git a/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutor.kt b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutor.kt index 73891272bd..6e717b03fb 100644 --- a/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutor.kt +++ b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutor.kt @@ -29,6 +29,15 @@ import java.util.ServiceLoader import java.util.zip.ZipEntry import java.util.zip.ZipFile +/** + * Fatal template error: execution terminates, the partially created project + * directory is removed and project creation is reported as failed. + */ +class TemplateExecutionException( + message: String, + cause: Throwable?, +) : RuntimeException(message, cause) + class ZipRecipeExecutor( private val zipProvider: () -> ZipFile, private val metaJson: TemplateJson, @@ -54,6 +63,28 @@ class ZipRecipeExecutor( return ProjectTemplateRecipeResultImpl(data, hasErrorsWarnings) } + try { + renderProject(ctx, projectDir) + keystore(executor) + } catch (e: Exception) { + // A partial project must not survive: the exists() check above would + // treat it as an already-created project on the next attempt. + if (!projectDir.deleteRecursively()) { + warn(ctx, R.string.template_exec_warn_cleanup_failed, projectDir.absolutePath) + } + throw TemplateExecutionException( + ctx.getString(R.string.template_exec_error_terminated, e.message ?: e.toString()), + e, + ) + } + + return ProjectTemplateRecipeResultImpl(data, hasErrorsWarnings) + } + + private fun renderProject( + ctx: Context, + projectDir: File, + ) { val projectRoot = projectDir.canonicalFile val flags: Map = @@ -170,33 +201,29 @@ class ZipRecipeExecutor( } val writer = StringWriter() - val rendered = - try { - template.evaluate(writer, identifiers) - } catch (e: PebbleException) { - error( - ctx, - R.string.template_exec_error_evaluate_line, - entry.name, - e.lineNumber, - e.message, - ) - null - } catch (e: Exception) { - error( - ctx, - R.string.template_exec_error_evaluate, - entry.name, - e.toString(), - ) - null - } - if (rendered == null) continue + try { + template.evaluate(writer, identifiers) + } catch (e: PebbleException) { + throw e.wrap( + ctx, + R.string.template_exec_error_evaluate_line, + entry.name, + e.lineNumber, + e.message, + ) + } catch (e: Exception) { + throw e.wrap( + ctx, + R.string.template_exec_error_evaluate, + entry.name, + e.toString(), + ) + } try { outFile.writeText(writer.toString(), Charsets.UTF_8) } catch (e: Exception) { - error( + throw e.wrap( ctx, R.string.template_exec_error_write, outFile.absolutePath, @@ -211,16 +238,18 @@ class ZipRecipeExecutor( } } } catch (e: Exception) { - error( + throw e.wrap( ctx, - R.string.template_exec_error_write, + R.string.template_exec_error_copy, entry.name, e.toString(), ) } } + } catch (e: TemplateExecutionException) { + throw e } catch (e: Exception) { - error( + throw e.wrap( ctx, R.string.template_exec_error_process, entry.name, @@ -230,10 +259,6 @@ class ZipRecipeExecutor( } } } - - keystore(executor) - - return ProjectTemplateRecipeResultImpl(data, hasErrorsWarnings) } private fun keystore(executor: RecipeExecutor) { @@ -495,23 +520,13 @@ class ZipRecipeExecutor( log.error(msg, e) } - private fun error( - context: Context, - @StringRes resId: Int, - vararg args: Any?, - ) { - hasErrorsWarnings = true - val msg = context.getString(resId, *args) - log.error(msg) - } - private fun Exception.wrap( context: Context, @StringRes resId: Int, vararg args: Any?, - ): RuntimeException { + ): TemplateExecutionException { val msg = context.getString(resId, *args) - return RuntimeException(msg, this) + return TemplateExecutionException(msg, this) } @SuppressLint("SetWorldReadable") diff --git a/templates-impl/src/test/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutorTest.kt b/templates-impl/src/test/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutorTest.kt new file mode 100644 index 0000000000..7a6bf2caae --- /dev/null +++ b/templates-impl/src/test/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutorTest.kt @@ -0,0 +1,143 @@ +package com.itsaky.androidide.templates.impl.zip + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.app.BaseApplication +import com.itsaky.androidide.templates.Language +import com.itsaky.androidide.templates.ModuleTemplateData +import com.itsaky.androidide.templates.ModuleType +import com.itsaky.androidide.templates.ProjectTemplateData +import com.itsaky.androidide.templates.ProjectVersionData +import com.itsaky.androidide.templates.RecipeExecutor +import com.itsaky.androidide.templates.TestRecipeExecutor +import com.itsaky.androidide.utils.Environment +import org.adfa.constants.Sdk +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipOutputStream + +@RunWith(RobolectricTestRunner::class) +@Config(application = BaseApplication::class) +class ZipRecipeExecutorTest { + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var projectDir: File + + @Before + fun setup() { + projectDir = File(tempFolder.root, "TestApp") + // point at non-existent files so keystore() is a no-op + Environment.KEYSTORE_RELEASE = File(tempFolder.root, "release.keystore") + Environment.KEYSTORE_PROPERTIES = File(tempFolder.root, "release.properties") + } + + @Test + fun `valid template renders and creates the project`() { + val zip = buildZip(mapOf("tpl/hello.txt.peb" to "Hello \${{ APP_NAME }}!")) + + executor(zip).execute(recipeExecutor()) + + assertThat(File(projectDir, "hello.txt").readText()) + .isEqualTo("Hello TestApp!") + } + + @Test + fun `pebble parse error terminates execution and removes the project dir`() { + val zip = + buildZip( + mapOf( + "tpl/good.txt.peb" to "Hello \${{ APP_NAME }}!", + "tpl/zbad.txt.peb" to "\${% if %}", + ), + ) + + val thrown = + assertThrows(TemplateExecutionException::class.java) { + executor(zip).execute(recipeExecutor()) + } + + assertThat(thrown).hasMessageThat().contains("zbad.txt.peb") + assertThat(projectDir.exists()).isFalse() + } + + @Test + fun `pebble evaluation error terminates execution and removes the project dir`() { + val zip = + buildZip( + mapOf( + "tpl/good.txt.peb" to "Hello \${{ APP_NAME }}!", + "tpl/zeval.txt.peb" to "\${{ MISSING_IDENTIFIER }}", + ), + ) + + val thrown = + assertThrows(TemplateExecutionException::class.java) { + executor(zip).execute(recipeExecutor()) + } + + assertThat(thrown).hasMessageThat().contains("zeval.txt.peb") + assertThat(projectDir.exists()).isFalse() + } + + @Test + fun `existing project dir is left untouched`() { + projectDir.mkdirs() + val marker = File(projectDir, "marker.txt").apply { writeText("keep") } + val zip = buildZip(mapOf("tpl/zbad.txt.peb" to "\${% if %}")) + + executor(zip).execute(recipeExecutor()) + + assertThat(marker.readText()).isEqualTo("keep") + } + + private fun buildZip(entries: Map): File { + val file = tempFolder.newFile("template.zip") + ZipOutputStream(file.outputStream()).use { out -> + entries.forEach { (name, content) -> + out.putNextEntry(ZipEntry(name)) + out.write(content.toByteArray()) + out.closeEntry() + } + } + return file + } + + private fun executor(zip: File): ZipRecipeExecutor { + val data = + ProjectTemplateData( + "TestApp", + projectDir, + ProjectVersionData(), + Language.Kotlin, + useKts = false, + ) + val module = + ModuleTemplateData( + name = "app", + appName = "TestApp", + packageName = "com.example.app", + projectDir = File(projectDir, "app"), + type = ModuleType.AndroidApp, + language = Language.Kotlin, + minSdk = Sdk.Lollipop, + ) + val metaJson = TemplateJson(name = "Test", description = null, version = null) + return ZipRecipeExecutor({ ZipFile(zip) }, metaJson, mutableMapOf(), "tpl", data, module) + } + + private fun recipeExecutor(): RecipeExecutor = + object : RecipeExecutor by TestRecipeExecutor() { + override val context: Context = ApplicationProvider.getApplicationContext() + } +} From 21ace0a9217d9a1a917a01aac2c9c5a3a63860e4 Mon Sep 17 00:00:00 2001 From: Joel Menchavez Date: Mon, 20 Jul 2026 14:47:58 +0800 Subject: [PATCH 3/5] ADFA-3689: Keep keystore copy failures non-fatal Review feedback (CodeRabbit): the release keystore is auxiliary, so a copy failure now warns and sets hasErrorsWarnings instead of deleting the fully rendered project. Test added. Co-Authored-By: Claude Fable 5 --- .../templates/impl/zip/ZipRecipeExecutor.kt | 9 +++++++- .../impl/zip/ZipRecipeExecutorTest.kt | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutor.kt b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutor.kt index 6e717b03fb..656ea8d6fe 100644 --- a/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutor.kt +++ b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutor.kt @@ -65,7 +65,6 @@ class ZipRecipeExecutor( try { renderProject(ctx, projectDir) - keystore(executor) } catch (e: Exception) { // A partial project must not survive: the exists() check above would // treat it as an already-created project on the next attempt. @@ -78,6 +77,14 @@ class ZipRecipeExecutor( ) } + try { + keystore(executor) + } catch (e: Exception) { + // The release keystore is auxiliary (only needed for release signing); + // failing to copy it must not discard the rendered project. + error("Failed to copy release keystore into ${projectDir.absolutePath}", e) + } + return ProjectTemplateRecipeResultImpl(data, hasErrorsWarnings) } diff --git a/templates-impl/src/test/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutorTest.kt b/templates-impl/src/test/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutorTest.kt index 7a6bf2caae..ad6ab9f662 100644 --- a/templates-impl/src/test/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutorTest.kt +++ b/templates-impl/src/test/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutorTest.kt @@ -22,6 +22,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import java.io.File +import java.io.IOException import java.util.zip.ZipEntry import java.util.zip.ZipFile import java.util.zip.ZipOutputStream @@ -90,6 +91,26 @@ class ZipRecipeExecutorTest { assertThat(projectDir.exists()).isFalse() } + @Test + fun `keystore copy failure does not discard the rendered project`() { + Environment.KEYSTORE_RELEASE = tempFolder.newFile("release.keystore") + val zip = buildZip(mapOf("tpl/hello.txt.peb" to "Hello \${{ APP_NAME }}!")) + val failingCopyExecutor = + object : RecipeExecutor by TestRecipeExecutor() { + override val context: Context = ApplicationProvider.getApplicationContext() + + override fun copy( + source: File, + dest: File, + ): Unit = throw IOException("disk full") + } + + val result = executor(zip).execute(failingCopyExecutor) + + assertThat(File(projectDir, "hello.txt").readText()).isEqualTo("Hello TestApp!") + assertThat(result.hasErrorsWarnings).isTrue() + } + @Test fun `existing project dir is left untouched`() { projectDir.mkdirs() From c4d819286d58409d5cbcb6e55e3f19edeb47c03d Mon Sep 17 00:00:00 2001 From: Joel Menchavez Date: Mon, 20 Jul 2026 15:04:27 +0800 Subject: [PATCH 4/5] ADFA-3689: Contain dex-opt dir against basePath traversal Review feedback (CodeRabbit): basePath comes from the archive's templates.json, so a crafted path could escape TEMPLATES_DIR via the dex-opt directory. Apply the same canonical containment check used for outFile and skip extension loading on violation. Test added. Co-Authored-By: Claude Fable 5 --- .../templates/impl/zip/ZipRecipeExecutor.kt | 15 ++++++++--- .../impl/zip/ZipRecipeExecutorTest.kt | 27 +++++++++++++++++-- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutor.kt b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutor.kt index 656ea8d6fe..3bfffe98c7 100644 --- a/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutor.kt +++ b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutor.kt @@ -563,11 +563,18 @@ class ZipRecipeExecutor( warn("Could not adjust permissions on ${tempJar.absolutePath} $e") } - val optimizedDir = - File( - Environment.TEMPLATES_DIR, - "$DEX_OPT_FOLDER/$basePath", + // basePath comes from the archive's templates.json - keep the dex-opt + // dir contained the same way outFile is checked against projectRoot. + val dexOptRoot = File(Environment.TEMPLATES_DIR, DEX_OPT_FOLDER).canonicalFile + val optimizedDir = File(dexOptRoot, basePath).canonicalFile + + if (!optimizedDir.toPath().startsWith(dexOptRoot.toPath())) { + error( + "Template basePath escapes templates dir: $basePath", + IllegalArgumentException(basePath), ) + return emptyList() + } if (!optimizedDir.exists() && !optimizedDir.mkdirs()) { error( diff --git a/templates-impl/src/test/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutorTest.kt b/templates-impl/src/test/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutorTest.kt index ad6ab9f662..f6bb4cd65a 100644 --- a/templates-impl/src/test/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutorTest.kt +++ b/templates-impl/src/test/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutorTest.kt @@ -111,6 +111,26 @@ class ZipRecipeExecutorTest { assertThat(result.hasErrorsWarnings).isTrue() } + @Test + fun `malicious basePath cannot escape the dex-opt dir`() { + Environment.TEMPLATES_DIR = tempFolder.newFolder("ide", "templates") + val zip = + buildZip( + mapOf( + "extensions.jar" to "junk", + "../../pwned/hello.txt.peb" to "Hello \${{ APP_NAME }}!", + ), + ) + + val result = executor(zip, basePath = "../../pwned").execute(recipeExecutor()) + + // extension loading is rejected, nothing is created outside dex_opt + assertThat(File(tempFolder.root, "ide/pwned").exists()).isFalse() + assertThat(result.hasErrorsWarnings).isTrue() + // rendering itself still completes + assertThat(File(projectDir, "hello.txt").readText()).isEqualTo("Hello TestApp!") + } + @Test fun `existing project dir is left untouched`() { projectDir.mkdirs() @@ -134,7 +154,10 @@ class ZipRecipeExecutorTest { return file } - private fun executor(zip: File): ZipRecipeExecutor { + private fun executor( + zip: File, + basePath: String = "tpl", + ): ZipRecipeExecutor { val data = ProjectTemplateData( "TestApp", @@ -154,7 +177,7 @@ class ZipRecipeExecutorTest { minSdk = Sdk.Lollipop, ) val metaJson = TemplateJson(name = "Test", description = null, version = null) - return ZipRecipeExecutor({ ZipFile(zip) }, metaJson, mutableMapOf(), "tpl", data, module) + return ZipRecipeExecutor({ ZipFile(zip) }, metaJson, mutableMapOf(), basePath, data, module) } private fun recipeExecutor(): RecipeExecutor = From 425c70d4ce02167c20f4b7468b65aeb75204be9c Mon Sep 17 00:00:00 2001 From: Joel Menchavez Date: Mon, 20 Jul 2026 21:39:54 +0800 Subject: [PATCH 5/5] ADFA-3689: Flatten entry processing into helper methods Review feedback: extract processEntry / renderTemplateEntry / copyBinaryEntry from the entry loop. No behavior change; the loop body now reads skip checks -> path containment -> dispatch. Co-Authored-By: Claude Fable 5 --- .../templates/impl/zip/ZipRecipeExecutor.kt | 178 ++++++++++-------- 1 file changed, 95 insertions(+), 83 deletions(-) diff --git a/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutor.kt b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutor.kt index 3bfffe98c7..4f57d64655 100644 --- a/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutor.kt +++ b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutor.kt @@ -180,94 +180,106 @@ class ZipRecipeExecutor( if (entry.isDirectory) { outFile.mkdirs() } else { - try { - outFile.parentFile?.mkdirs() - - if (entry.name.endsWith(TEMPLATE_EXTENSION)) { - info(ctx, R.string.template_exec_info_processing, entry.name) - val content = - try { - zip.getInputStream(entry).bufferedReader().use { it.readText() } - } catch (e: Exception) { - throw e.wrap(ctx, R.string.template_exec_error_read_fail, entry.name) - } - - val template = - try { - pebbleEngine.getTemplate(content) - } catch (e: PebbleException) { - throw e.wrap( - ctx, - R.string.template_exec_error_parse_line, - entry.name, - e.lineNumber, - e.message, - ) - } catch (e: Exception) { - throw e.wrap(ctx, R.string.template_exec_error_parse, entry.name) - } - - val writer = StringWriter() - try { - template.evaluate(writer, identifiers) - } catch (e: PebbleException) { - throw e.wrap( - ctx, - R.string.template_exec_error_evaluate_line, - entry.name, - e.lineNumber, - e.message, - ) - } catch (e: Exception) { - throw e.wrap( - ctx, - R.string.template_exec_error_evaluate, - entry.name, - e.toString(), - ) - } - - try { - outFile.writeText(writer.toString(), Charsets.UTF_8) - } catch (e: Exception) { - throw e.wrap( - ctx, - R.string.template_exec_error_write, - outFile.absolutePath, - e.toString(), - ) - } - } else { - try { - zip.getInputStream(entry).use { input -> - outFile.outputStream().use { output -> - input.copyTo(output) - } - } - } catch (e: Exception) { - throw e.wrap( - ctx, - R.string.template_exec_error_copy, - entry.name, - e.toString(), - ) - } - } - } catch (e: TemplateExecutionException) { - throw e - } catch (e: Exception) { - throw e.wrap( - ctx, - R.string.template_exec_error_process, - entry.name, - e.toString(), - ) - } + processEntry(ctx, zip, entry, outFile, pebbleEngine, identifiers) } } } } + private fun processEntry( + ctx: Context, + zip: ZipFile, + entry: ZipEntry, + outFile: File, + pebbleEngine: PebbleEngine, + identifiers: Map, + ) { + try { + outFile.parentFile?.mkdirs() + + if (entry.name.endsWith(TEMPLATE_EXTENSION)) { + renderTemplateEntry(ctx, zip, entry, outFile, pebbleEngine, identifiers) + } else { + copyBinaryEntry(ctx, zip, entry, outFile) + } + } catch (e: TemplateExecutionException) { + throw e + } catch (e: Exception) { + throw e.wrap(ctx, R.string.template_exec_error_process, entry.name, e.toString()) + } + } + + private fun renderTemplateEntry( + ctx: Context, + zip: ZipFile, + entry: ZipEntry, + outFile: File, + pebbleEngine: PebbleEngine, + identifiers: Map, + ) { + info(ctx, R.string.template_exec_info_processing, entry.name) + + val content = + try { + zip.getInputStream(entry).bufferedReader().use { it.readText() } + } catch (e: Exception) { + throw e.wrap(ctx, R.string.template_exec_error_read_fail, entry.name) + } + + val template = + try { + pebbleEngine.getTemplate(content) + } catch (e: PebbleException) { + throw e.wrap( + ctx, + R.string.template_exec_error_parse_line, + entry.name, + e.lineNumber, + e.message, + ) + } catch (e: Exception) { + throw e.wrap(ctx, R.string.template_exec_error_parse, entry.name) + } + + val writer = StringWriter() + try { + template.evaluate(writer, identifiers) + } catch (e: PebbleException) { + throw e.wrap( + ctx, + R.string.template_exec_error_evaluate_line, + entry.name, + e.lineNumber, + e.message, + ) + } catch (e: Exception) { + throw e.wrap(ctx, R.string.template_exec_error_evaluate, entry.name, e.toString()) + } + + try { + outFile.writeText(writer.toString(), Charsets.UTF_8) + } catch (e: Exception) { + throw e.wrap(ctx, R.string.template_exec_error_write, outFile.absolutePath, e.toString()) + } + } + + private fun copyBinaryEntry( + ctx: Context, + zip: ZipFile, + entry: ZipEntry, + outFile: File, + ) { + try { + zip.getInputStream(entry).use { input -> + outFile.outputStream().use { output -> + input.copyTo(output) + } + } + } catch (e: Exception) { + throw e.wrap(ctx, R.string.template_exec_error_copy, entry.name, e.toString()) + } + } + private fun keystore(executor: RecipeExecutor) { val storeSrc = Environment.KEYSTORE_RELEASE val storeDest = File(data.projectDir, Environment.KEYSTORE_RELEASE_NAME)