diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystem.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystem.kt index fea6db80..22b82374 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystem.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystem.kt @@ -17,35 +17,20 @@ class DefaultExecutionFileSystem( SystemFileSystem.createDirectories(Path(sandboxPath)) } - private fun resolvePath(relativePath: RelativePath): Path { - val resolved = Path(sandboxPath, relativePath.value) - val file = java.io.File(resolved.toString()) - val normalized = try { - file.canonicalPath - } catch (e: Exception) { - throw SecurityException("Failed to resolve canonical path for '${relativePath.value}': ${e.message}") - } - val baseFile = java.io.File(sandboxPath) - val baseCanonical = try { - baseFile.canonicalPath - } catch (e: Exception) { - throw SecurityException("Failed to resolve base canonical path for '$sandboxPath': ${e.message}") - } + private val sandboxOperations = SandboxFileOperations(sandboxPath) - if (normalized != baseCanonical && !normalized.startsWith(baseCanonical + java.io.File.separator)) { - throw SecurityException("Access to path '${relativePath.value}' is denied. It is outside the sandbox.") - } - return resolved + private fun resolvePath(relativePath: RelativePath): Path { + return sandboxOperations.resolve(relativePath) } override suspend fun readFile(relativePath: RelativePath): ByteArray? { - val path = resolvePath(relativePath) + val path = sandboxOperations.resolveIfRootExists(relativePath) ?: return null if (!SystemFileSystem.exists(path)) return null return SystemFileSystem.source(path).buffered().use { it.readByteArray() } } override suspend fun readTextFile(relativePath: RelativePath): String? { - val path = resolvePath(relativePath) + val path = sandboxOperations.resolveIfRootExists(relativePath) ?: return null if (!SystemFileSystem.exists(path)) return null return SystemFileSystem.source(path).buffered().use { it.readString() } } @@ -73,11 +58,12 @@ class DefaultExecutionFileSystem( } override suspend fun exists(relativePath: RelativePath): Boolean { - return SystemFileSystem.exists(resolvePath(relativePath)) + val path = sandboxOperations.resolveIfRootExists(relativePath) ?: return false + return SystemFileSystem.exists(path) } override suspend fun listFiles(relativePath: RelativePath): List { - val path = resolvePath(relativePath) + val path = sandboxOperations.resolveIfRootExists(relativePath) ?: return emptyList() if (!SystemFileSystem.exists(path)) return emptyList() val metadata = SystemFileSystem.metadataOrNull(path) if (metadata?.isDirectory != true) return emptyList() @@ -97,5 +83,14 @@ class DefaultExecutionFileSystem( } } + override suspend fun createDirectory(relativePath: RelativePath): Result = runCatching { + SystemFileSystem.createDirectories(resolvePath(relativePath)) + } + + override suspend fun deleteDirectory(relativePath: RelativePath, recursive: Boolean): Result = runCatching { + require(relativePath.value.isNotEmpty()) { "The execution sandbox root cannot be deleted" } + sandboxOperations.deleteDirectory(resolvePath(relativePath), recursive) + } + override fun getBasePath(): String = sandboxPath } diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultPluginFileSystem.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultPluginFileSystem.kt index e32f5a16..3e32e1db 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultPluginFileSystem.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultPluginFileSystem.kt @@ -1,6 +1,8 @@ package org.wip.plugintoolkit.features.plugin.logic import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow import kotlinx.io.buffered import kotlinx.io.files.Path import kotlinx.io.files.SystemFileSystem @@ -23,25 +25,21 @@ class DefaultPluginFileSystem( SystemFileSystem.createDirectories(Path(cachePath)) } - private fun resolvePath(relativePath: RelativePath): Path { - val resolved = Path(basePath, relativePath.value) - val normalized = resolved.toString().replace('\\', '/') - val baseCanonical = Path(basePath).toString().replace('\\', '/') + private val filesOperations = SandboxFileOperations(basePath) + private val cacheOperations = SandboxFileOperations(cachePath) - if (normalized != baseCanonical && !normalized.startsWith(if (baseCanonical.endsWith("/")) baseCanonical else "$baseCanonical/")) { - throw SecurityException("Access to path '${relativePath.value}' is denied. It is outside the plugin files directory.") - } - return resolved + private fun resolvePath(relativePath: RelativePath): Path { + return filesOperations.resolve(relativePath) } override suspend fun readFile(relativePath: RelativePath): ByteArray? { - val path = resolvePath(relativePath) + val path = filesOperations.resolveIfRootExists(relativePath) ?: return null if (!SystemFileSystem.exists(path)) return null return SystemFileSystem.source(path).buffered().use { it.readByteArray() } } override suspend fun readTextFile(relativePath: RelativePath): String? { - val path = resolvePath(relativePath) + val path = filesOperations.resolveIfRootExists(relativePath) ?: return null if (!SystemFileSystem.exists(path)) return null return SystemFileSystem.source(path).buffered().use { it.readString() } } @@ -69,11 +67,12 @@ class DefaultPluginFileSystem( } override suspend fun exists(relativePath: RelativePath): Boolean { - return SystemFileSystem.exists(resolvePath(relativePath)) + val path = filesOperations.resolveIfRootExists(relativePath) ?: return false + return SystemFileSystem.exists(path) } override suspend fun listFiles(relativePath: RelativePath): List { - val path = resolvePath(relativePath) + val path = filesOperations.resolveIfRootExists(relativePath) ?: return emptyList() if (!SystemFileSystem.exists(path)) return emptyList() val metadata = SystemFileSystem.metadataOrNull(path) if (metadata?.isDirectory != true) return emptyList() @@ -93,6 +92,15 @@ class DefaultPluginFileSystem( } } + override suspend fun createDirectory(relativePath: RelativePath): Result = runCatching { + SystemFileSystem.createDirectories(resolvePath(relativePath)) + } + + override suspend fun deleteDirectory(relativePath: RelativePath, recursive: Boolean): Result = runCatching { + require(relativePath.value.isNotEmpty()) { "The plugin files root cannot be deleted" } + filesOperations.deleteDirectory(resolvePath(relativePath), recursive) + } + override suspend fun extractResource(resourcePath: String, targetRelativePath: RelativePath): Result { if (resourcePath.contains("..") || resourcePath.startsWith("/") || resourcePath.startsWith("\\") || resourcePath.contains("\u0000")) { return Result.failure(SecurityException("Invalid resource path: $resourcePath")) @@ -116,10 +124,10 @@ class DefaultPluginFileSystem( override fun getBasePath(): String = basePath companion object { - fun createCacheOnly(pluginInstallPath: String): PluginFileSystem { - return DefaultPluginFileSystem(pluginInstallPath).let { fs -> + fun createCacheOnly(pluginInstallPath: String, jarPath: String? = null): PluginFileSystem { + return DefaultPluginFileSystem(pluginInstallPath, jarPath).let { fs -> // Create a variant that uses cachePath as basePath - object : PluginFileSystem by fs { + object : PluginFileSystem { override fun getBasePath(): String = fs.cachePath override suspend fun readFile(relativePath: RelativePath): ByteArray? = fs.readFromCache(relativePath) @@ -133,35 +141,83 @@ class DefaultPluginFileSystem( override suspend fun writeTextFile(relativePath: RelativePath, text: String): Result = fs.writeTextToCache(relativePath, text) + // Keep every compound/stream operation explicitly cache-routed. In + // particular, do not use Kotlin interface delegation here: generated + // forwards would bypass these overrides and touch persistent files. + override suspend fun readStream(relativePath: RelativePath): Flow = flow { + fs.readFromCache(relativePath)?.let { emit(it) } + } + + override suspend fun writeStream( + relativePath: RelativePath, + stream: Flow + ): Result = runCatching { + val bytes = mutableListOf() + stream.collect { chunk -> chunk.forEach { byte -> bytes.add(byte) } } + fs.writeToCache(relativePath, bytes.toByteArray()).getOrThrow() + } + + override suspend fun copyFile( + source: RelativePath, + destination: RelativePath + ): Result = runCatching { + val content = fs.readFromCache(source) + ?: throw IllegalArgumentException("Source file does not exist") + fs.writeToCache(destination, content).getOrThrow() + } + + override suspend fun moveFile( + source: RelativePath, + destination: RelativePath + ): Result = runCatching { + copyFile(source, destination).getOrThrow() + fs.deleteFromCache(source).getOrThrow() + } + override suspend fun exists(relativePath: RelativePath): Boolean = - SystemFileSystem.exists(fs.resolveCachePath(relativePath)) + fs.cacheOperations.resolveIfRootExists(relativePath)?.let(SystemFileSystem::exists) ?: false + + override suspend fun listFiles(relativePath: RelativePath): List { + val path = fs.cacheOperations.resolveIfRootExists(relativePath) ?: return emptyList() + if (!SystemFileSystem.exists(path)) return emptyList() + if (SystemFileSystem.metadataOrNull(path)?.isDirectory != true) return emptyList() + return SystemFileSystem.list(path).map { it.name } + } override suspend fun deleteFile(relativePath: RelativePath): Result = fs.deleteFromCache(relativePath) + + override suspend fun createDirectory(relativePath: RelativePath): Result = runCatching { + SystemFileSystem.createDirectories(fs.resolveCachePath(relativePath)) + } + + override suspend fun deleteDirectory(relativePath: RelativePath, recursive: Boolean): Result = + runCatching { + require(relativePath.value.isNotEmpty()) { "The plugin cache root cannot be deleted" } + fs.cacheOperations.deleteDirectory(fs.resolveCachePath(relativePath), recursive) + } + + override suspend fun extractResource( + resourcePath: String, + targetRelativePath: RelativePath + ): Result = fs.extractResourceToCache(resourcePath, targetRelativePath) } } } } private fun resolveCachePath(relativePath: RelativePath): Path { - val resolved = Path(cachePath, relativePath.value) - val normalized = resolved.toString().replace('\\', '/') - val baseCanonical = Path(cachePath).toString().replace('\\', '/') - - if (normalized != baseCanonical && !normalized.startsWith(if (baseCanonical.endsWith("/")) baseCanonical else "$baseCanonical/")) { - throw SecurityException("Access to path '${relativePath.value}' is denied. It is outside the plugin cache directory.") - } - return resolved + return cacheOperations.resolve(relativePath) } private suspend fun readFromCache(relativePath: RelativePath): ByteArray? { - val path = resolveCachePath(relativePath) + val path = cacheOperations.resolveIfRootExists(relativePath) ?: return null if (!SystemFileSystem.exists(path)) return null return SystemFileSystem.source(path).buffered().use { it.readByteArray() } } private suspend fun readTextFromCache(relativePath: RelativePath): String? { - val path = resolveCachePath(relativePath) + val path = cacheOperations.resolveIfRootExists(relativePath) ?: return null if (!SystemFileSystem.exists(path)) return null return SystemFileSystem.source(path).buffered().use { it.readString() } } @@ -199,4 +255,22 @@ class DefaultPluginFileSystem( Result.failure(e) } } + + private suspend fun extractResourceToCache( + resourcePath: String, + targetRelativePath: RelativePath + ): Result { + if (resourcePath.contains("..") || resourcePath.startsWith("/") || + resourcePath.startsWith("\\") || resourcePath.contains("\u0000")) { + return Result.failure(SecurityException("Invalid resource path: $resourcePath")) + } + return runCatching { + withContext(loomDispatcher) { + val jar = jarPath ?: error("No JAR path configured for resource extraction") + val data = org.wip.plugintoolkit.core.utils.PlatformUtils.readBytesFromZip(jar, resourcePath) + ?: error("Resource not found in JAR: $resourcePath") + writeToCache(targetRelativePath, data).getOrThrow() + } + } + } } diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginLifecycleManager.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginLifecycleManager.kt index 6119f11e..bd8d897d 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginLifecycleManager.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginLifecycleManager.kt @@ -359,7 +359,7 @@ class PluginLifecycleManager( logger = pluginLogger, progress = progressReporter, fileSystem = DefaultPluginFileSystem(installPath, jarFullPath), - cacheFileSystem = DefaultPluginFileSystem.createCacheOnly(installPath), + cacheFileSystem = DefaultPluginFileSystem.createCacheOnly(installPath, jarFullPath), executionFileSystem = executionFileSystem ?: DefaultExecutionFileSystem("${installPath}/temp_execution"), hostFileSystem = HostFileSystemImpl(allowedPaths, isDestructiveAllowed), settings = mergedSettings, diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileOperations.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileOperations.kt new file mode 100644 index 00000000..e4e2d902 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileOperations.kt @@ -0,0 +1,69 @@ +package org.wip.plugintoolkit.features.plugin.logic + +import kotlinx.io.files.Path +import org.wip.plugintoolkit.api.RelativePath +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.NoSuchFileException +import java.nio.file.Path as NioPath +import java.nio.file.Paths +import java.nio.file.SimpleFileVisitor +import java.nio.file.attribute.BasicFileAttributes + +internal class SandboxFileOperations(root: String) { + private val base = Paths.get(root).toAbsolutePath().normalize() + private val realBase = base.toRealPath() + + fun resolve(relativePath: RelativePath): Path { + if (base.toRealPath() != realBase) { + throw SecurityException("The sandbox root changed after it was initialized") + } + val candidate = base.resolve(relativePath.value).normalize() + if (!candidate.startsWith(base)) { + throw SecurityException("Access to path '${relativePath.value}' is outside the sandbox") + } + + var current = base + base.relativize(candidate).forEach { segment -> + current = current.resolve(segment) + if (Files.exists(current, LinkOption.NOFOLLOW_LINKS)) { + val realCurrent = current.toRealPath() + if (!realCurrent.startsWith(realBase)) { + throw SecurityException("Access to path '${relativePath.value}' escapes the sandbox through a symbolic link") + } + } + } + return Path(candidate.toString()) + } + + fun resolveIfRootExists(relativePath: RelativePath): Path? = try { + resolve(relativePath) + } catch (_: NoSuchFileException) { + null + } + + fun deleteDirectory(path: Path, recursive: Boolean) { + val nioPath = Paths.get(path.toString()) + if (!Files.exists(nioPath, LinkOption.NOFOLLOW_LINKS)) return + require(Files.isDirectory(nioPath, LinkOption.NOFOLLOW_LINKS)) { "Path is not a directory: $path" } + + if (!recursive) { + Files.delete(nioPath) + return + } + + Files.walkFileTree(nioPath, object : SimpleFileVisitor() { + override fun visitFile(file: NioPath, attrs: BasicFileAttributes): FileVisitResult { + Files.delete(file) + return FileVisitResult.CONTINUE + } + + override fun postVisitDirectory(dir: NioPath, error: java.io.IOException?): FileVisitResult { + if (error != null) throw error + Files.delete(dir) + return FileVisitResult.CONTINUE + } + }) + } +} diff --git a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystemTest.kt b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystemTest.kt index 51da2951..406a0b05 100644 --- a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystemTest.kt +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystemTest.kt @@ -84,6 +84,24 @@ class DefaultExecutionFileSystemTest { assertTrue(files.contains("file2.txt")) } + @Test + fun testCreateAndDeleteDirectory() = runTest { + val directory = RelativePath.from("models/nested").getOrThrow() + assertTrue(fileSystem.createDirectory(directory).isSuccess) + assertTrue(fileSystem.exists(directory)) + + fileSystem.writeTextFile(RelativePath.from("models/nested/model.txt").getOrThrow(), "model") + assertTrue(fileSystem.deleteDirectory(RelativePath.from("models").getOrThrow()).isFailure) + assertTrue(fileSystem.deleteDirectory(RelativePath.from("models").getOrThrow(), recursive = true).isSuccess) + assertFalse(fileSystem.exists(RelativePath.from("models").getOrThrow())) + } + + @Test + fun testCannotDeleteSandboxRoot() = runTest { + assertTrue(fileSystem.deleteDirectory(RelativePath.ROOT, recursive = true).isFailure) + assertTrue(SystemFileSystem.exists(Path(sandboxPath))) + } + @Test fun testPathTraversalPrevention() = runTest { // Attempt to create a path outside the sandbox using ../ diff --git a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileSystemSecurityTest.kt b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileSystemSecurityTest.kt new file mode 100644 index 00000000..63059d18 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileSystemSecurityTest.kt @@ -0,0 +1,177 @@ +package org.wip.plugintoolkit.features.plugin.logic + +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.wip.plugintoolkit.api.RelativePath +import org.wip.plugintoolkit.api.ScopedFileSystem +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.attribute.BasicFileAttributes +import java.util.jar.JarEntry +import java.util.jar.JarOutputStream +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SandboxFileSystemSecurityTest { + private lateinit var testRoot: Path + + @BeforeTest + fun setUp() { + testRoot = Files.createTempDirectory("plugin-toolkit-sandbox-") + } + + @AfterTest + fun tearDown() { + if (!Files.exists(testRoot)) return + Files.walkFileTree(testRoot, object : SimpleFileVisitor() { + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + Files.deleteIfExists(file) + return FileVisitResult.CONTINUE + } + + override fun postVisitDirectory(dir: Path, error: java.io.IOException?): FileVisitResult { + if (error != null) throw error + Files.deleteIfExists(dir) + return FileVisitResult.CONTINUE + } + }) + } + + @Test + fun rootAliasesCannotDeleteAnySandboxRoot() = runTest { + val alias = RelativePath.from("././").getOrThrow() + val execution = DefaultExecutionFileSystem(testRoot.resolve("execution").toString()) + val pluginInstall = testRoot.resolve("plugin").toString() + val plugin = DefaultPluginFileSystem(pluginInstall) + val cache = DefaultPluginFileSystem.createCacheOnly(pluginInstall) + + assertTrue(execution.deleteDirectory(alias, recursive = true).isFailure) + assertTrue(plugin.deleteDirectory(alias, recursive = true).isFailure) + assertTrue(cache.deleteDirectory(alias, recursive = true).isFailure) + assertTrue(Files.isDirectory(testRoot.resolve("execution"))) + assertTrue(Files.isDirectory(testRoot.resolve("plugin/files"))) + assertTrue(Files.isDirectory(testRoot.resolve("plugin/cache"))) + } + + @Test + fun executionSandboxCannotReadThroughSymlinkAndDoesNotFollowItOnDelete() = runTest { + val sandbox = testRoot.resolve("execution") + val outside = createOutsideSecret() + val fileSystem = DefaultExecutionFileSystem(sandbox.toString()) + + verifySymlinkIsContained(fileSystem, sandbox, outside) + } + + @Test + fun pluginFilesCannotReadThroughSymlinkAndDoNotFollowItOnDelete() = runTest { + val install = testRoot.resolve("plugin") + val outside = createOutsideSecret() + val fileSystem = DefaultPluginFileSystem(install.toString()) + + verifySymlinkIsContained(fileSystem, install.resolve("files"), outside) + } + + @Test + fun pluginCacheCannotReadThroughSymlinkAndDoesNotFollowItOnDelete() = runTest { + val install = testRoot.resolve("plugin") + val outside = createOutsideSecret() + val fileSystem = DefaultPluginFileSystem.createCacheOnly(install.toString()) + + verifySymlinkIsContained(fileSystem, install.resolve("cache"), outside) + } + + @Test + fun cacheOnlyVariantListsAndExtractsResourcesInsideCache() = runTest { + val install = testRoot.resolve("plugin") + Files.createDirectories(install) + val jar = install.resolve("plugin.jar") + JarOutputStream(Files.newOutputStream(jar)).use { output -> + output.putNextEntry(JarEntry("assets/example.txt")) + output.write("resource".encodeToByteArray()) + output.closeEntry() + } + val fileSystem = DefaultPluginFileSystem.createCacheOnly(install.toString(), jar.toString()) + val target = RelativePath.from("nested/example.txt").getOrThrow() + + assertTrue(fileSystem.extractResource("assets/example.txt", target).isSuccess) + assertEquals("resource", fileSystem.readTextFile(target)) + assertEquals(listOf("example.txt"), fileSystem.listFiles(RelativePath.from("nested").getOrThrow())) + assertTrue(Files.notExists(install.resolve("files/nested/example.txt"))) + } + + @Test + fun cacheOnlyCompoundAndStreamOperationsNeverTouchPersistentFiles() = runTest { + val install = testRoot.resolve("plugin") + val fileSystem = DefaultPluginFileSystem.createCacheOnly(install.toString()) + val source = RelativePath.from("source.bin").getOrThrow() + val copied = RelativePath.from("copied.bin").getOrThrow() + val moved = RelativePath.from("moved.bin").getOrThrow() + val streamed = RelativePath.from("streamed.bin").getOrThrow() + + Files.writeString(install.resolve("files/source.bin"), "persistent") + assertTrue(fileSystem.writeFile(source, "cache".encodeToByteArray()).isSuccess) + + assertTrue(fileSystem.copyFile(source, copied).isSuccess) + assertEquals("cache", Files.readString(install.resolve("cache/copied.bin"))) + assertTrue(Files.notExists(install.resolve("files/copied.bin"))) + + assertTrue(fileSystem.moveFile(source, moved).isSuccess) + assertTrue(Files.notExists(install.resolve("cache/source.bin"))) + assertEquals("cache", Files.readString(install.resolve("cache/moved.bin"))) + assertEquals("persistent", Files.readString(install.resolve("files/source.bin"))) + assertTrue(Files.notExists(install.resolve("files/moved.bin"))) + + assertTrue( + fileSystem.writeStream( + streamed, + flowOf("stream-".encodeToByteArray(), "cache".encodeToByteArray()) + ).isSuccess + ) + val chunks = mutableListOf() + fileSystem.readStream(streamed).collect { chunks.add(it) } + assertEquals("stream-cache", chunks.flatMap { it.asIterable() }.toByteArray().decodeToString()) + assertTrue(Files.notExists(install.resolve("files/streamed.bin"))) + } + + @Test + fun missingSandboxRootUsesReadSemanticsAndRejectsWrites() = runTest { + val install = testRoot.resolve("plugin") + val fileSystem = DefaultPluginFileSystem(install.toString()) + val file = RelativePath.from("missing.txt").getOrThrow() + Files.delete(install.resolve("files")) + + assertNull(fileSystem.readFile(file)) + assertNull(fileSystem.readTextFile(file)) + assertFalse(fileSystem.exists(file)) + assertEquals(emptyList(), fileSystem.listFiles()) + assertTrue(fileSystem.writeTextFile(file, "data").isFailure) + } + + private fun createOutsideSecret(): Path { + val outside = Files.createDirectories(testRoot.resolve("outside")) + Files.writeString(outside.resolve("secret.txt"), "must survive") + return outside + } + + private suspend fun verifySymlinkIsContained( + fileSystem: ScopedFileSystem, + sandbox: Path, + outside: Path + ) { + val nested = Files.createDirectories(sandbox.resolve("nested")) + Files.createSymbolicLink(nested.resolve("escape"), outside) + val escapedFile = RelativePath.from("nested/escape/secret.txt").getOrThrow() + + assertFailsWith { fileSystem.readTextFile(escapedFile) } + assertTrue(fileSystem.deleteDirectory(RelativePath.from("nested").getOrThrow(), recursive = true).isSuccess) + assertTrue(Files.readString(outside.resolve("secret.txt")) == "must survive") + } +} diff --git a/docs/PluginDevelopment.md b/docs/PluginDevelopment.md index d69b5025..bfa1153a 100644 --- a/docs/PluginDevelopment.md +++ b/docs/PluginDevelopment.md @@ -317,6 +317,7 @@ While you can set a plugin to "not support cancellation" the host app can force- The `PluginContext` (and focused interfaces like `PluginLogger`, `PluginFileSystem`, `ExecutionFileSystem`, `HostFileSystem`) provide access to host services: - **Logger**: `PluginLogger` (e.g. `logger.info("Message")`) - **Plugin File System**: `PluginFileSystem` (Persistent, isolated storage for the plugin. Preserved across executions. e.g. `fileSystem.getBasePath()`) +- **Directory operations**: scoped file systems support `createDirectory` and guarded `deleteDirectory`; recursive deletion must be requested explicitly and the sandbox root can never be deleted. - **Execution File System**: `ExecutionFileSystem` (Temporary, isolated sandbox storage for the current execution. Cleared automatically after the flow finishes.) - **Host File System**: `HostFileSystem` (External file access. Restricted to paths explicitly granted by the user via file input/output parameters: `@CapabilityInput` and `@CapabilityOutput`.) - **Plugin Storage**: `PluginStorage` (`context.storage`) provides a persistent, internal key-value store (`get`, `put`, `getAll`, `remove`) for saving plugin-internal state without polluting user settings. diff --git a/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/Interfaces.kt b/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/Interfaces.kt index 5b4702b9..3b86f7c0 100644 --- a/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/Interfaces.kt +++ b/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/Interfaces.kt @@ -102,6 +102,10 @@ interface ScopedFileSystem { suspend fun exists(relativePath: RelativePath): Boolean suspend fun listFiles(relativePath: RelativePath = RelativePath.ROOT): List suspend fun deleteFile(relativePath: RelativePath): Result + suspend fun createDirectory(relativePath: RelativePath): Result = + Result.failure(UnsupportedOperationException("Directory creation is not supported by this host")) + suspend fun deleteDirectory(relativePath: RelativePath, recursive: Boolean = false): Result = + Result.failure(UnsupportedOperationException("Directory deletion is not supported by this host")) /** * Get the absolute base path of the managed file area. diff --git a/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/RelativePath.kt b/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/RelativePath.kt index 7940117a..a9b1104c 100644 --- a/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/RelativePath.kt +++ b/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/RelativePath.kt @@ -13,7 +13,6 @@ value class RelativePath private constructor(val value: String) { companion object { val ROOT = RelativePath("") private val NULL_BYTE_REGEX = Regex("\u0000") - private val TRAVERSAL_REGEX = Regex("""(?:^|/|\\|\.|\u2024|\uFF0E|\u3002)(?:\.\.|\u2024\u2024|\uFF0E\uFF0E|%2e%2e|%2E%2E|%252e%252e)(?:/|\\|${'$'}|\.)""", RegexOption.IGNORE_CASE) private val ENCODED_SLASH_REGEX = Regex("""%2f|%5c""", RegexOption.IGNORE_CASE) /** @@ -38,25 +37,27 @@ value class RelativePath private constructor(val value: String) { return Result.failure(SecurityException("Path must be relative, but contains drive letter: $normalized")) } - // Check for encoded slashes or traversal sequences - if (normalized.contains(ENCODED_SLASH_REGEX) || normalized.contains(TRAVERSAL_REGEX)) { + if (normalized.contains(ENCODED_SLASH_REGEX)) { return Result.failure(SecurityException("Path traversal attempt detected: $normalized")) } - // Normalized path checks: convert unicode dot variants to regular dot for safety check - val sanitized = normalized - .replace("\u2024", ".") - .replace("\uFF0E", ".") - .replace("\u3002", ".") - .replace("%2e", ".", ignoreCase = true) - .replace("%2f", "/", ignoreCase = true) - .replace("%5c", "\\", ignoreCase = true) - - if (sanitized.contains(TRAVERSAL_REGEX) || sanitized.contains("../") || sanitized.contains("..\\")) { + val segments = normalized.replace('\\', '/').split('/').filter { it.isNotEmpty() && it != "." } + val validationSegments = segments.map { segment -> + segment + .replace("\u2024", ".") + .replace("\uFF0E", ".") + .replace("\u3002", ".") + .replace("%2e", ".", ignoreCase = true) + } + if (validationSegments.any { + it == ".." || it.contains(Regex("%25(?:2e|2f|5c)", RegexOption.IGNORE_CASE)) + } + ) { return Result.failure(SecurityException("Path traversal attempt detected: $normalized")) } - - return Result.success(RelativePath(normalized)) + + // Validation uses a security-normalized view, but the filename itself is not decoded or rewritten. + return Result.success(RelativePath(segments.joinToString("/"))) } } } diff --git a/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/RelativePathTest.kt b/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/RelativePathTest.kt index 96ae01bd..b328f843 100644 --- a/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/RelativePathTest.kt +++ b/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/RelativePathTest.kt @@ -52,5 +52,11 @@ class RelativePathTest { val path = " foo/bar.txt ".toRelativePath() assertTrue(path.isSuccess) assertEquals("foo/bar.txt", path.getOrNull()?.value, "Should trim whitespace") + assertEquals(RelativePath.ROOT, ".".toRelativePath().getOrThrow()) + assertEquals(RelativePath.ROOT, "././".toRelativePath().getOrThrow()) + assertEquals("foo/bar", "foo/./bar".toRelativePath().getOrThrow().value) + assertEquals("foo/bar", "foo\\bar".toRelativePath().getOrThrow().value) + assertEquals("file%2ename.txt", "file%2ename.txt".toRelativePath().getOrThrow().value) + assertEquals("file\u2024txt", "file\u2024txt".toRelativePath().getOrThrow().value) } }