Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
}
Expand Down Expand Up @@ -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<String> {
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()
Expand All @@ -97,5 +83,14 @@ class DefaultExecutionFileSystem(
}
}

override suspend fun createDirectory(relativePath: RelativePath): Result<Unit> = runCatching {
SystemFileSystem.createDirectories(resolvePath(relativePath))
}

override suspend fun deleteDirectory(relativePath: RelativePath, recursive: Boolean): Result<Unit> = runCatching {
require(relativePath.value.isNotEmpty()) { "The execution sandbox root cannot be deleted" }
sandboxOperations.deleteDirectory(resolvePath(relativePath), recursive)
}

override fun getBasePath(): String = sandboxPath
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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() }
}
Expand Down Expand Up @@ -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<String> {
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()
Expand All @@ -93,6 +92,15 @@ class DefaultPluginFileSystem(
}
}

override suspend fun createDirectory(relativePath: RelativePath): Result<Unit> = runCatching {
SystemFileSystem.createDirectories(resolvePath(relativePath))
}

override suspend fun deleteDirectory(relativePath: RelativePath, recursive: Boolean): Result<Unit> = 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<Unit> {
if (resourcePath.contains("..") || resourcePath.startsWith("/") || resourcePath.startsWith("\\") || resourcePath.contains("\u0000")) {
return Result.failure(SecurityException("Invalid resource path: $resourcePath"))
Expand All @@ -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)
Expand All @@ -133,35 +141,83 @@ class DefaultPluginFileSystem(
override suspend fun writeTextFile(relativePath: RelativePath, text: String): Result<Unit> =
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<ByteArray> = flow {
fs.readFromCache(relativePath)?.let { emit(it) }
}

override suspend fun writeStream(
relativePath: RelativePath,
stream: Flow<ByteArray>
): Result<Unit> = runCatching {
val bytes = mutableListOf<Byte>()
stream.collect { chunk -> chunk.forEach { byte -> bytes.add(byte) } }
fs.writeToCache(relativePath, bytes.toByteArray()).getOrThrow()
}

override suspend fun copyFile(
source: RelativePath,
destination: RelativePath
): Result<Unit> = 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<Unit> = 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<String> {
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<Unit> =
fs.deleteFromCache(relativePath)

override suspend fun createDirectory(relativePath: RelativePath): Result<Unit> = runCatching {
SystemFileSystem.createDirectories(fs.resolveCachePath(relativePath))
}

override suspend fun deleteDirectory(relativePath: RelativePath, recursive: Boolean): Result<Unit> =
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<Unit> = 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() }
}
Expand Down Expand Up @@ -199,4 +255,22 @@ class DefaultPluginFileSystem(
Result.failure(e)
}
}

private suspend fun extractResourceToCache(
resourcePath: String,
targetRelativePath: RelativePath
): Result<Unit> {
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()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<NioPath>() {
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
}
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 ../
Expand Down
Loading