diff --git a/plugin-platform/plugin-repository/src/desktopMain/kotlin/ai/rever/boss/plugin/repository/remote/PluginDownloadCache.kt b/plugin-platform/plugin-repository/src/desktopMain/kotlin/ai/rever/boss/plugin/repository/remote/PluginDownloadCache.kt index e042fc4d2d..74ba554867 100644 --- a/plugin-platform/plugin-repository/src/desktopMain/kotlin/ai/rever/boss/plugin/repository/remote/PluginDownloadCache.kt +++ b/plugin-platform/plugin-repository/src/desktopMain/kotlin/ai/rever/boss/plugin/repository/remote/PluginDownloadCache.kt @@ -3,317 +3,239 @@ package ai.rever.boss.plugin.repository.remote import ai.rever.boss.plugin.logging.BossLogger import ai.rever.boss.plugin.logging.LogCategory import ai.rever.boss.plugin.pathutils.BossDirectories +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json import java.io.File +import java.nio.channels.Channels +import java.nio.file.Files +import java.nio.file.LinkOption.NOFOLLOW_LINKS +import java.nio.file.Path +import java.nio.file.StandardCopyOption.ATOMIC_MOVE +import java.nio.file.StandardCopyOption.REPLACE_EXISTING +import java.nio.file.StandardOpenOption.READ +import java.nio.file.attribute.BasicFileAttributes import java.security.MessageDigest +import kotlin.io.path.name -/** - * Local cache for downloaded plugin JARs. - * - * Caches downloaded JARs to avoid re-downloading plugins that haven't changed. - * Uses SHA-256 verification to ensure cache integrity. - * - * @param cacheDir Directory to store cached JARs (defaults to ~/.boss/plugin-cache) - */ +/** Local, hash-verified cache. Exact IDs and versions are addressed by SHA-256 keys. */ class PluginDownloadCache( - private val cacheDir: File = BossDirectories.resolve("plugin-cache"), + cacheDir: File = BossDirectories.resolve("plugin-cache"), ) { private val logger = BossLogger.forComponent("PluginDownloadCache") - init { - if (!cacheDir.exists()) { - cacheDir.mkdirs() - logger.debug(LogCategory.SYSTEM, "Created plugin cache directory", mapOf("path" to cacheDir.absolutePath)) + // Resolve the host-supplied root once, including intentional host directory links. + // Retain initialization failure so it cannot prevent repository construction. + private val location = + runCatching { + val path = Files.createDirectories(cacheDir.toPath()).toRealPath() + path to Files.readAttributes(path, BasicFileAttributes::class.java, NOFOLLOW_LINKS).fileKey() } - } + private val root: Path get() = location.getOrThrow().first + private val rootIdentity: Any? get() = location.getOrThrow().second + + @Serializable + private data class CacheMetadata( + val pluginId: String, + val version: String, + ) - /** - * Get a cached JAR if it exists and matches the expected SHA-256 hash. - * - * @param pluginId The plugin ID - * @param version The plugin version - * @param expectedSha256 The expected SHA-256 hash (must be non-blank) - * @return The cached file if valid, null otherwise - */ + @Synchronized fun getCachedJar( pluginId: String, version: String, expectedSha256: String, ): File? { - val cachedFile = getCacheFile(pluginId, version) - - if (!cachedFile.exists()) { + val path = cacheFile(pluginId, version) + if (!Files.isRegularFile(path, NOFOLLOW_LINKS) || readMetadata(path) == null) { + if (Files.exists(path, NOFOLLOW_LINKS)) { + logger.warn(LogCategory.SYSTEM, "Ignoring plugin cache entry with invalid identity metadata") + } return null } - - // Verify SHA-256. A blank or mismatching hash invalidates the cache — - // we never load unhashed or tampered JARs from cache. - val actualSha256 = cachedFile.sha256() - if (!actualSha256.equals(expectedSha256, ignoreCase = true)) { - logger.warn( - LogCategory.SYSTEM, - "Cached JAR SHA-256 mismatch, removing", - mapOf( - "pluginId" to pluginId, - "version" to version, - "expected" to expectedSha256.take(16) + "...", - "actual" to actualSha256.take(16) + "...", - ), - ) - cachedFile.delete() - return null + val digest = MessageDigest.getInstance("SHA-256") + Files.newByteChannel(path, setOf(READ, NOFOLLOW_LINKS)).use { channel -> + Channels.newInputStream(channel).use { input -> + val buffer = ByteArray(8192) + var size = input.read(buffer) + while (size != -1) { + digest.update(buffer, 0, size) + size = input.read(buffer) + } + } + } + val actual = digest.digest().joinToString("") { "%02x".format(it) } + return if (actual.equals(expectedSha256, ignoreCase = true)) { + path.toFile() + } else { + logger.warn(LogCategory.SYSTEM, "Ignoring plugin cache entry with mismatching SHA-256") + Files.deleteIfExists(path) + Files.deleteIfExists(metadataPath(path)) + null } - - logger.debug( - LogCategory.SYSTEM, - "Found valid cached JAR", - mapOf( - "pluginId" to pluginId, - "version" to version, - ), - ) - - return cachedFile } - /** - * Cache a downloaded JAR file. - * - * @param pluginId The plugin ID - * @param version The plugin version - * @param sourceFile The downloaded JAR file to cache - * @return The cached file - */ + @Synchronized fun cacheJar( pluginId: String, version: String, sourceFile: File, ): File { - val cachedFile = getCacheFile(pluginId, version) - - // Ensure parent directory exists - cachedFile.parentFile?.mkdirs() - - // Copy to cache - sourceFile.copyTo(cachedFile, overwrite = true) - - logger.debug( - LogCategory.SYSTEM, - "Cached plugin JAR", - mapOf( - "pluginId" to pluginId, - "version" to version, - "size" to cachedFile.length(), - ), - ) - - return cachedFile + val target = cacheFile(pluginId, version) + Files.createDirectories(target.parent) + checkDirectory(target.parent) + val temporary = Files.createTempFile(target.parent, ".download-", ".part") + val metadataPart = Files.createTempFile(target.parent, ".metadata-", ".part") + try { + Files.copy(sourceFile.toPath(), temporary, REPLACE_EXISTING) + Files.writeString(metadataPart, Json.encodeToString(CacheMetadata(pluginId, version))) + // Metadata names the same ID/version on every write; it does not vouch for bytes. + // Publish it first so a failed metadata write cannot replace an existing artifact. + Files.move(metadataPart, metadataPath(target), ATOMIC_MOVE, REPLACE_EXISTING) + Files.move(temporary, target, ATOMIC_MOVE, REPLACE_EXISTING) + } finally { + Files.deleteIfExists(temporary) + Files.deleteIfExists(metadataPart) + } + return target.toFile() } - /** - * Remove a specific cached JAR. - * - * @param pluginId The plugin ID - * @param version The plugin version - * @return true if removed, false if not found - */ + @Synchronized fun removeCachedJar( pluginId: String, version: String, ): Boolean { - val cachedFile = getCacheFile(pluginId, version) - - if (!cachedFile.exists()) { - return false - } - - val deleted = cachedFile.delete() - - if (deleted) { - logger.debug( - LogCategory.SYSTEM, - "Removed cached JAR", - mapOf( - "pluginId" to pluginId, - "version" to version, - ), - ) - - // Clean up empty parent directory - val pluginDir = cachedFile.parentFile - if (pluginDir?.listFiles()?.isEmpty() == true) { - pluginDir.delete() - } - } - - return deleted + val path = cacheFile(pluginId, version) + val removed = Files.deleteIfExists(path) + Files.deleteIfExists(metadataPath(path)) + return removed } - /** - * Remove all cached JARs for a plugin (all versions). - * - * @param pluginId The plugin ID - * @return Number of versions removed - */ + @Synchronized fun removeAllVersions(pluginId: String): Int { - val pluginDir = File(cacheDir, sanitizePluginId(pluginId)) - - if (!pluginDir.exists()) { - return 0 - } - - val files = pluginDir.listFiles() ?: return 0 - val count = files.size - - pluginDir.deleteRecursively() - - logger.info( - LogCategory.SYSTEM, - "Removed all cached versions", - mapOf( - "pluginId" to pluginId, - "count" to count, - ), - ) - - return count + val directory = pluginDirectory(pluginId) + if (!Files.exists(directory, NOFOLLOW_LINKS)) return 0 + checkDirectory(directory) + return deleteEntries(directory, includeRoot = true) } - /** - * Clear the entire cache. - * - * @return Number of files removed - */ + @Synchronized fun clearCache(): Int { - val files = cacheDir.walkTopDown().filter { it.isFile }.toList() - val count = files.size - - cacheDir.deleteRecursively() - cacheDir.mkdirs() - - logger.info( - LogCategory.SYSTEM, - "Cleared plugin cache", - mapOf( - "filesRemoved" to count, - ), - ) - - return count + checkRoot() + return deleteEntries(root, includeRoot = false) } - /** - * Get the total size of the cache in bytes. - */ - fun getCacheSize(): Long = - cacheDir - .walkTopDown() - .filter { it.isFile } - .sumOf { it.length() } - - /** - * Get the number of cached plugins. - */ - fun getCachedPluginCount(): Int = cacheDir.listFiles()?.count { it.isDirectory } ?: 0 + @Synchronized + fun getCacheSize(): Long = entries().filter { Files.isRegularFile(it, NOFOLLOW_LINKS) }.sumOf { Files.size(it) } - /** - * Get the number of cached JAR files. - */ - fun getCachedFileCount(): Int = - cacheDir - .walkTopDown() - .filter { it.isFile && it.extension == "jar" } - .count() + @Synchronized + fun getCachedPluginCount(): Int = listCachedPlugins().size - /** - * List all cached plugins with their versions. - * - * @return Map of plugin ID to list of cached versions - */ - fun listCachedPlugins(): Map> { - val result = mutableMapOf>() + @Synchronized + fun getCachedFileCount(): Int = entries().count(::isJar) - cacheDir.listFiles()?.filter { it.isDirectory }?.forEach { pluginDir -> - val versions = - pluginDir - .listFiles() - ?.filter { it.isFile && it.extension == "jar" } - ?.map { it.nameWithoutExtension.substringAfterLast("_") } - ?: emptyList() + private fun isJar(path: Path): Boolean = Files.isRegularFile(path, NOFOLLOW_LINKS) && path.name.endsWith(".jar") - if (versions.isNotEmpty()) { - result[pluginDir.name] = versions - } + @Synchronized + fun listCachedPlugins(): Map> { + checkRoot() + val result = mutableMapOf>() + for (path in entries().filter(::isJar)) { + val metadata = readMetadata(path) ?: continue + result.getOrPut(metadata.pluginId) { mutableListOf() }.add(metadata.version) } - return result } - /** - * Clean up old cache entries. - * Removes cache entries older than the specified number of days. - * - * @param maxAgeDays Maximum age in days (default: 30) - * @return Number of files removed - */ - fun cleanOldEntries(maxAgeDays: Int = 30): Int { - val cutoffTime = System.currentTimeMillis() - (maxAgeDays * 24 * 60 * 60 * 1000L) - var removed = 0 + private fun metadataPath(jar: Path): Path = jar.resolveSibling(jar.name.removeSuffix(".jar") + ".json") - cacheDir - .walkTopDown() - .filter { it.isFile && it.lastModified() < cutoffTime } - .forEach { file -> - if (file.delete()) { - removed++ + private fun readMetadata(jar: Path): CacheMetadata? = + runCatching { + val path = metadataPath(jar) + if (!Files.isRegularFile(path, NOFOLLOW_LINKS)) return@runCatching null + val text = + Files.newByteChannel(path, setOf(READ, NOFOLLOW_LINKS)).use { channel -> + Channels.newInputStream(channel).use { input -> input.readNBytes(16_385).toString(Charsets.UTF_8) } } - } + if (text.length > 16_384) return@runCatching null + val metadata = Json.decodeFromString(text) + metadata.takeIf { cacheFile(it.pluginId, it.version) == jar } + }.getOrNull() - // Clean up empty directories - cacheDir - .walkBottomUp() - .filter { it.isDirectory && it != cacheDir && (it.listFiles()?.isEmpty() == true) } - .forEach { it.delete() } + @Synchronized + fun cleanOldEntries(maxAgeDays: Int = 30): Int { + require(maxAgeDays >= 0) { "Cache age must not be negative" } + val cutoff = System.currentTimeMillis() - maxAgeDays.toLong() * 86_400_000L + var removed = 0 + for (path in entries()) { + val expired = Files.getLastModifiedTime(path, NOFOLLOW_LINKS).toMillis() < cutoff + if (Files.isRegularFile(path, NOFOLLOW_LINKS) && expired) { + if (Files.deleteIfExists(path)) removed++ + } + } + return removed + } - if (removed > 0) { - logger.info( - LogCategory.SYSTEM, - "Cleaned old cache entries", - mapOf( - "removed" to removed, - "maxAgeDays" to maxAgeDays, - ), - ) + private fun checkRoot() { + checkDirectory(root) + // Some providers (including Windows) return null: only path checks apply there. + check(Files.readAttributes(root, BasicFileAttributes::class.java, NOFOLLOW_LINKS).fileKey() == rootIdentity) { + "Plugin cache directory was replaced" } + } - return removed + private fun checkDirectory(path: Path) { + check(Files.isDirectory(path, NOFOLLOW_LINKS) && path.toRealPath() == path) { + "Plugin cache directory must not be a symbolic link" + } } - // ============================================================================ - // Helper Functions - // ============================================================================ + private fun pluginDirectory(pluginId: String): Path { + val component = cacheKey(pluginId) + checkRoot() + return root.resolve("p-$component").also { + if (Files.exists(it, NOFOLLOW_LINKS)) checkDirectory(it) + } + } - private fun getCacheFile( + private fun cacheFile( pluginId: String, version: String, - ): File { - val sanitizedId = sanitizePluginId(pluginId) - return File(cacheDir, "$sanitizedId/${sanitizedId}_$version.jar") + ): Path { + val component = cacheKey(version) + val directory = pluginDirectory(pluginId) + return directory.resolve("v-$component.jar").also { + check(!Files.isSymbolicLink(it)) { "Plugin cache entry must not be a symbolic link" } + } } - private fun sanitizePluginId(pluginId: String): String { - // Replace characters that might cause issues in file paths - return pluginId.replace(Regex("[^a-zA-Z0-9._-]"), "_") + private fun cacheKey(value: String): String { + // Hash the exact value so long IDs, case variants and punctuation never alias. + // The bound exceeds portable legacy filename limits while bounding metadata reads. + require(value.length in 1..4096 && value.matches(Regex("[A-Za-z0-9._+-]+")) && !value.endsWith('.')) { + "Invalid plugin cache ID or version" + } + val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8)) + return bytes.joinToString("") { "%02x".format(it) } } - private fun File.sha256(): String { - val digest = MessageDigest.getInstance("SHA-256") - inputStream().use { fis -> - val buffer = ByteArray(8192) - var bytesRead: Int - while (fis.read(buffer).also { bytesRead = it } != -1) { - digest.update(buffer, 0, bytesRead) + private fun entries(): List { + checkRoot() + // Files.walk does not follow links. Legacy entries are never used for lookups, + // but remain removable by clear/expiry without traversing their link targets. + return Files.walk(root).use { paths -> paths.filter { it != root }.toList() } + } + + private fun deleteEntries( + directory: Path, + includeRoot: Boolean, + ): Int { + var count = 0 + Files.walk(directory).use { paths -> + paths.filter { it != directory || includeRoot }.sorted(Comparator.reverseOrder()).forEach { path -> + val isFile = isJar(path) + if (Files.deleteIfExists(path) && isFile) count++ } } - return digest.digest().joinToString("") { "%02x".format(it) } + return count } } diff --git a/plugin-platform/plugin-repository/src/desktopMain/kotlin/ai/rever/boss/plugin/repository/remote/RemotePluginRepository.kt b/plugin-platform/plugin-repository/src/desktopMain/kotlin/ai/rever/boss/plugin/repository/remote/RemotePluginRepository.kt index 76fd61374c..d3c371df49 100644 --- a/plugin-platform/plugin-repository/src/desktopMain/kotlin/ai/rever/boss/plugin/repository/remote/RemotePluginRepository.kt +++ b/plugin-platform/plugin-repository/src/desktopMain/kotlin/ai/rever/boss/plugin/repository/remote/RemotePluginRepository.kt @@ -47,9 +47,45 @@ class RemotePluginRepository( PluginStoreClient.getDownloadUrl(pluginId) } }, + private val copyCachedJar: (File, File) -> Unit = { source, target -> + source.copyTo(target, overwrite = true) + Unit + }, ) : PluginRepository { private val logger = BossLogger.forComponent("RemotePluginRepository") + // Cache availability must never decide whether a verified download succeeds. + private fun cacheOrNull( + operation: String, + action: () -> T, + ): T? = + try { + action() + } catch (cancelled: CancellationException) { + // CancellationException extends IllegalStateException; it must be handled before cache refusals. + throw cancelled + } catch (failure: java.io.IOException) { + cacheUnavailable(operation, failure) + } catch (failure: IllegalStateException) { + cacheUnavailable(operation, failure) + } catch (failure: SecurityException) { + cacheUnavailable(operation, failure) + } catch (failure: IllegalArgumentException) { + cacheUnavailable(operation, failure) + } + + private fun cacheUnavailable( + operation: String, + failure: Exception, + ): Nothing? { + logger.warn( + LogCategory.SYSTEM, + "Plugin cache unavailable; continuing without cache", + mapOf("operation" to operation, "failureType" to failure.javaClass.simpleName), + ) + return null + } + /** * Enforce the store's anchor signature for a JAR whose SHA-256 has * already been verified to equal [sha256]. The signature must cover the @@ -327,7 +363,10 @@ class RemotePluginRepository( // below binds the cached bytes to the store key too — a JAR // cached during the warn-and-allow window doesn't dodge // enforcement through the cache path. - val cachedFile = downloadCache.getCachedJar(pluginId, downloadInfo.version, downloadInfo.sha256) + val cachedFile = + cacheOrNull("lookup") { + downloadCache.getCachedJar(pluginId, downloadInfo.version, downloadInfo.sha256) + } if (cachedFile != null) { logger.info( LogCategory.NETWORK, @@ -346,14 +385,21 @@ class RemotePluginRepository( pluginId = pluginId, versionLabel = downloadInfo.version, requestedVersion = version, - onVerificationFailure = { downloadCache.removeCachedJar(pluginId, downloadInfo.version) }, + onVerificationFailure = { + cacheOrNull("purge") { downloadCache.removeCachedJar(pluginId, downloadInfo.version) } + }, ) - cachedFile.copyTo(File(targetPath), overwrite = true) - PluginSignatureSidecar.persist(targetPath, downloadInfo.signature) - // Nothing was fetched, but the caller's progress row exists and - // would otherwise sit at 0% until the next phase moved it. - onProgress?.invoke(1f) - return@runCatching targetPath + val copied = + cacheOrNull("copy") { + copyCachedJar(cachedFile, File(targetPath)) + true + } == true + if (copied) { + PluginSignatureSidecar.persist(targetPath, downloadInfo.signature) + // Nothing was fetched, but the caller still needs completed progress. + onProgress?.invoke(1f) + return@runCatching targetPath + } } // Initialize progress tracking @@ -435,7 +481,7 @@ class RemotePluginRepository( PluginSignatureSidecar.persist(targetPath, downloadInfo.signature) // Cache the downloaded JAR - downloadCache.cacheJar(pluginId, downloadInfo.version, File(targetPath)) + cacheOrNull("write") { downloadCache.cacheJar(pluginId, downloadInfo.version, File(targetPath)) } progressFlow.value = 1f onProgress?.invoke(1f) diff --git a/plugin-platform/plugin-repository/src/desktopTest/kotlin/ai/rever/boss/plugin/repository/remote/PluginDownloadCacheTest.kt b/plugin-platform/plugin-repository/src/desktopTest/kotlin/ai/rever/boss/plugin/repository/remote/PluginDownloadCacheTest.kt new file mode 100644 index 0000000000..8619680ab8 --- /dev/null +++ b/plugin-platform/plugin-repository/src/desktopTest/kotlin/ai/rever/boss/plugin/repository/remote/PluginDownloadCacheTest.kt @@ -0,0 +1,140 @@ +package ai.rever.boss.plugin.repository.remote + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.file.Files +import java.nio.file.attribute.FileTime +import java.security.MessageDigest +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PluginDownloadCacheTest { + @TempDir + lateinit var temporary: File + + private fun hash(file: File): String { + val digest = MessageDigest.getInstance("SHA-256").digest(file.readBytes()) + return digest.joinToString("") { "%02x".format(it) } + } + + @Test + fun rejectsPathSyntaxWithoutTouchingOutsideFiles() { + val root = File(temporary, "cache") + val cache = PluginDownloadCache(root) + val source = File(temporary, "sentinel.jar").also { it.writeText("sentinel") } + val unsafeValues = + listOf( + "", + ".", + "..", + "../sentinel", + "a/b", + "a\\b", + "/absolute", + "C:drive", + "nul\u0000", + "trailing.", + ) + for (value in unsafeValues) { + assertFailsWith { cache.cacheJar(value, "1.0.0", source) } + assertFailsWith { cache.cacheJar("normal", value, source) } + assertFailsWith { cache.getCachedJar("normal", value, hash(source)) } + assertFailsWith { cache.removeCachedJar("normal", value) } + assertFailsWith { cache.removeAllVersions(value) } + } + assertEquals("sentinel", source.readText()) + assertEquals(0, cache.getCachedFileCount()) + } + + @Test + fun preservesIdsVersionsAndCaseWithoutCacheCollisions() { + val cache = PluginDownloadCache(File(temporary, "cache")) + val source = File(temporary, "source.jar").also { it.writeText("jar") } + val versions = listOf("1.2.3", "1.2.3-rc.1+build_5") + for (id in listOf("ai.rever.Test-plugin", "ai.rever.test-plugin", "custom_plugin")) { + for (version in versions) { + cache.cacheJar(id, version, source) + assertNotNull(cache.getCachedJar(id, version, hash(source))) + } + assertEquals(versions.toSet(), cache.listCachedPlugins()[id]?.toSet()) + } + assertEquals(3, cache.getCachedPluginCount()) + assertEquals(6, cache.getCachedFileCount()) + assertTrue(cache.getCacheSize() >= 18L) + assertEquals(2, cache.removeAllVersions("ai.rever.Test-plugin")) + assertNotNull(cache.getCachedJar("ai.rever.test-plugin", versions[0], hash(source))) + assertNull(cache.getCachedJar("ai.rever.test-plugin", versions[0], "invalid")) + assertEquals(3, cache.clearCache()) + assertEquals(0, cache.getCachedFileCount()) + } + + @Test + fun lookupRequiresMatchingRegularIdentityMetadata() { + val cache = PluginDownloadCache(File(temporary, "cache")) + val source = File(temporary, "source.jar").also { it.writeText("jar") } + val cached = cache.cacheJar("plugin", "1.0.0", source) + val metadata = File(cached.parentFile, cached.nameWithoutExtension + ".json") + val original = metadata.readText() + metadata.writeText("""{"pluginId":"other-plugin","version":"1.0.0"}""") + assertNull(cache.getCachedJar("plugin", "1.0.0", hash(source))) + metadata.writeText("""{"pluginId":"plugin","version":"2.0.0"}""") + assertNull(cache.getCachedJar("plugin", "1.0.0", hash(source))) + metadata.writeText("invalid JSON") + assertNull(cache.getCachedJar("plugin", "1.0.0", hash(source))) + metadata.delete() + assertNull(cache.getCachedJar("plugin", "1.0.0", hash(source))) + val outside = File(temporary, "outside.json").also { it.writeText(original) } + Files.createSymbolicLink(metadata.toPath(), outside.toPath()) + assertNull(cache.getCachedJar("plugin", "1.0.0", hash(source))) + Files.delete(metadata.toPath()) + metadata.writeText(original) + assertNotNull(cache.getCachedJar("plugin", "1.0.0", hash(source))) + assertEquals(original, outside.readText()) + } + + @Test + fun acceptsLongIdsThatFitTheOriginalStoreContract() { + val cache = PluginDownloadCache(File(temporary, "cache")) + val source = File(temporary, "source.jar").also { it.writeText("jar") } + val id = "com.example." + "a".repeat(200) + val version = "1.0.0+" + "build".repeat(40) + cache.cacheJar(id, version, source) + assertNotNull(cache.getCachedJar(id, version, hash(source))) + assertEquals(listOf(version), cache.listCachedPlugins()[id]) + assertEquals(1, cache.removeAllVersions(id)) + } + + @Test + fun cleanupDoesNotTraverseLinkedDirectories() { + val root = File(temporary, "cache") + val cache = PluginDownloadCache(root) + val outside = File(temporary, "outside").also { it.mkdir() } + val sentinel = File(outside, "sentinel.jar").also { it.writeText("sentinel") } + Files.setLastModifiedTime(sentinel.toPath(), FileTime.fromMillis(0)) + Files.createSymbolicLink(File(root, "legacy-link").toPath(), outside.toPath()) + assertEquals(0L, cache.getCacheSize()) + assertEquals(0, cache.cleanOldEntries(0)) + cache.clearCache() + assertEquals("sentinel", sentinel.readText()) + } + + @Test + fun refusesSymlinkedEntryAndPluginDirectory() { + val cache = PluginDownloadCache(File(temporary, "cache")) + val source = File(temporary, "source.jar").also { it.writeText("sentinel") } + val cached = cache.cacheJar("plugin", "1.0.0", source) + Files.delete(cached.toPath()) + Files.createSymbolicLink(cached.toPath(), source.toPath()) + assertFailsWith { cache.getCachedJar("plugin", "1.0.0", hash(source)) } + assertFailsWith { cache.cacheJar("plugin", "1.0.0", source) } + cache.removeAllVersions("plugin") + Files.createSymbolicLink(cached.parentFile.toPath(), temporary.toPath()) + assertFailsWith { cache.removeAllVersions("plugin") } + assertEquals("sentinel", source.readText()) + assertTrue(source.exists()) + } +} diff --git a/plugin-platform/plugin-repository/src/desktopTest/kotlin/ai/rever/boss/plugin/repository/remote/RemotePluginRepositoryDownloadTest.kt b/plugin-platform/plugin-repository/src/desktopTest/kotlin/ai/rever/boss/plugin/repository/remote/RemotePluginRepositoryDownloadTest.kt index dae03ad1f7..92a17eb69a 100644 --- a/plugin-platform/plugin-repository/src/desktopTest/kotlin/ai/rever/boss/plugin/repository/remote/RemotePluginRepositoryDownloadTest.kt +++ b/plugin-platform/plugin-repository/src/desktopTest/kotlin/ai/rever/boss/plugin/repository/remote/RemotePluginRepositoryDownloadTest.kt @@ -8,6 +8,7 @@ import com.sun.net.httpserver.HttpServer import kotlinx.coroutines.runBlocking import java.io.File import java.net.InetSocketAddress +import java.nio.file.Files import java.security.KeyPair import java.security.KeyPairGenerator import java.security.MessageDigest @@ -118,6 +119,30 @@ class RemotePluginRepositoryDownloadTest { private fun target(name: String) = File(tempDir, name).absolutePath + @Test + fun `cache removed after lookup falls back to a verified download`() = + runBlocking { + val source = File(tempDir, "source.jar").apply { writeBytes(jarBytes) } + cache.cacheJar(pluginId, "1.0.0", source) + val signature = signAnchor("1.0.0") + var copies = 0 + val repository = + RemotePluginRepository( + downloadCache = cache, + storeVerifier = verifier, + downloadInfoProvider = { _, _ -> downloadInfo("1.0.0", signature) }, + copyCachedJar = { cached, destination -> + copies++ + Files.delete(cached.toPath()) + cached.copyTo(destination, overwrite = true) + }, + ) + val installed = repository.downloadPlugin(pluginId, "1.0.0", target("lost-cache.jar")).getOrThrow() + assertEquals(1, copies) + assertTrue(File(installed).readBytes().contentEquals(jarBytes)) + assertEquals(signature, PluginSignatureSidecar.read(installed)) + } + @Test fun `fresh download with valid signature succeeds, caches, and writes the sidecar`() = runBlocking { @@ -133,6 +158,71 @@ class RemotePluginRepositoryDownloadTest { assertEquals(sig, PluginSignatureSidecar.read(path)) } + @Test + fun `linked cache entry does not block a verified download or touch its target`() = + runBlocking { + val outside = File(tempDir, "sentinel.jar").apply { writeText("sentinel") } + val cached = cache.cacheJar(pluginId, "1.0.0", outside) + Files.delete(cached.toPath()) + Files.createSymbolicLink(cached.toPath(), outside.toPath()) + val sig = signAnchor("1.0.0") + val path = + repositoryReturning(downloadInfo("1.0.0", sig)) + .downloadPlugin(pluginId, "1.0.0", target("linked-ok.jar")) + .getOrThrow() + assertTrue(File(path).readBytes().contentEquals(jarBytes)) + assertEquals(sig, PluginSignatureSidecar.read(path)) + assertEquals("sentinel", outside.readText()) + } + + @Test + fun `cache promotion failure preserves a successful verified download`() = + runBlocking { + val seed = File(tempDir, "seed.jar").apply { writeBytes(jarBytes) } + val cached = cache.cacheJar(pluginId, "1.0.0", seed) + Files.delete(cached.toPath()) + cached.mkdir() + val sentinel = File(cached, "sentinel").apply { writeText("sentinel") } + val sig = signAnchor("1.0.0") + val path = + repositoryReturning(downloadInfo("1.0.0", sig)) + .downloadPlugin(pluginId, "1.0.0", target("promotion-ok.jar")) + .getOrThrow() + assertTrue(File(path).readBytes().contentEquals(jarBytes)) + assertEquals(sig, PluginSignatureSidecar.read(path)) + assertEquals("sentinel", sentinel.readText()) + } + + @Test + fun `unsupported cache version syntax still downloads and verifies`() = + runBlocking { + val version = "1.0.0 (beta)" + val sig = signAnchor(version) + val path = + repositoryReturning(downloadInfo(version, sig)) + .downloadPlugin(pluginId, version, target("syntax-ok.jar")) + .getOrThrow() + assertTrue(File(path).readBytes().contentEquals(jarBytes)) + assertEquals(sig, PluginSignatureSidecar.read(path)) + } + + @Test + fun `unavailable cache does not prevent repository construction or verified download`() = + runBlocking { + val blocked = File(tempDir, "not-a-directory").apply { writeText("sentinel") } + val sig = signAnchor("1.0.0") + val repository = + RemotePluginRepository( + downloadCache = PluginDownloadCache(File(blocked, "cache")), + storeVerifier = verifier, + downloadInfoProvider = { _, _ -> downloadInfo("1.0.0", sig) }, + ) + val path = repository.downloadPlugin(pluginId, "1.0.0", target("uncached-ok.jar")).getOrThrow() + assertTrue(File(path).readBytes().contentEquals(jarBytes)) + assertEquals(sig, PluginSignatureSidecar.read(path)) + assertEquals("sentinel", blocked.readText()) + } + @Test fun `unsigned download writes no sidecar`() = runBlocking {