diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/icons/AutoDevComposeIcons.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/icons/AutoDevComposeIcons.kt index 66cfc955b4..1f032d9fd4 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/icons/AutoDevComposeIcons.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/icons/AutoDevComposeIcons.kt @@ -124,6 +124,7 @@ object AutoDevComposeIcons { val CloudDone: ImageVector get() = Icons.Default.CloudDone val ZoomIn: ImageVector get() = Icons.Default.ZoomIn val ZoomOut: ImageVector get() = Icons.Default.ZoomOut + val PhoneAndroid: ImageVector get() = Icons.Default.PhoneAndroid /** * Custom icons converted from SVG resources diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/NanoDSLBlockRenderer.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/NanoDSLBlockRenderer.kt index cd343f7a9b..bc417d140d 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/NanoDSLBlockRenderer.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/NanoDSLBlockRenderer.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -15,10 +16,12 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.unit.dp -import cc.unitmesh.agent.Platform +import cc.unitmesh.devins.ui.compose.icons.AutoDevComposeIcons import cc.unitmesh.devins.ui.nano.StatefulNanoRenderer import cc.unitmesh.xuiper.dsl.NanoDSL import cc.unitmesh.xuiper.ir.NanoIR @@ -64,6 +67,7 @@ fun NanoDSLBlockRenderer( var nanoIR by remember { mutableStateOf(null) } var isDarkTheme by remember { mutableStateOf(true) } var isMobileLayout by remember { mutableStateOf(false) } + var zoomLevel by remember { mutableStateOf(1.0f) } // Parse NanoDSL to IR when code changes LaunchedEffect(nanodslCode, isComplete) { @@ -162,33 +166,55 @@ fun NanoDSLBlockRenderer( } Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { + // Zoom out button + NanoDSLIconButton( + icon = AutoDevComposeIcons.ZoomOut, + contentDescription = "Zoom Out", + isActive = zoomLevel > 0.5f, + onClick = { if (zoomLevel > 0.5f) zoomLevel -= 0.1f } + ) + + // Zoom in button + NanoDSLIconButton( + icon = AutoDevComposeIcons.ZoomIn, + contentDescription = "Zoom In", + isActive = zoomLevel < 2.0f, + onClick = { if (zoomLevel < 2.0f) zoomLevel += 0.1f } + ) + + Spacer(Modifier.width(4.dp)) + // Theme toggle button - NanoDSLToggleButton( - text = if (isDarkTheme) "๐ŸŒ™" else "โ˜€๏ธ", + NanoDSLIconButton( + icon = if (isDarkTheme) AutoDevComposeIcons.DarkMode else AutoDevComposeIcons.LightMode, + contentDescription = if (isDarkTheme) "Dark Mode" else "Light Mode", isActive = isDarkTheme, onClick = { isDarkTheme = !isDarkTheme } ) // Layout toggle button - NanoDSLToggleButton( - text = if (isMobileLayout) "๐Ÿ“ฑ" else "๐Ÿ–ฅ๏ธ", + NanoDSLIconButton( + icon = if (isMobileLayout) AutoDevComposeIcons.PhoneAndroid else AutoDevComposeIcons.Computer, + contentDescription = if (isMobileLayout) "Mobile Layout" else "Desktop Layout", isActive = isMobileLayout, onClick = { isMobileLayout = !isMobileLayout } ) // Preview/Code toggle button - NanoDSLToggleButton( - text = if (showPreview && nanoIR != null) "" else "Preview", + NanoDSLIconButton( + icon = AutoDevComposeIcons.Code, + contentDescription = if (showPreview && nanoIR != null) "Show Code" else "Show Preview", isActive = showPreview && nanoIR != null, onClick = { showPreview = !showPreview } ) // Copy button - NanoDSLToggleButton( - text = if (showCopied) "โœ“" else "๐Ÿ“‹", + NanoDSLIconButton( + icon = if (showCopied) AutoDevComposeIcons.Check else AutoDevComposeIcons.ContentCopy, + contentDescription = if (showCopied) "Copied" else "Copy", isActive = showCopied, onClick = { clipboardManager.setText(androidx.compose.ui.text.AnnotatedString(nanodslCode)) @@ -212,10 +238,17 @@ fun NanoDSLBlockRenderer( .padding(16.dp) } + // Use theme colors instead of hardcoded values val backgroundColor = if (isDarkTheme) { - Color(0xFF1E1E1E) // Dark background + MaterialTheme.colorScheme.surfaceContainerLowest } else { - Color(0xFFF5F5F5) // Light background + MaterialTheme.colorScheme.surfaceContainerHigh + } + + val borderColor = if (isDarkTheme) { + MaterialTheme.colorScheme.outlineVariant + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) } Box( @@ -226,20 +259,18 @@ fun NanoDSLBlockRenderer( ) .border( width = 1.dp, - color = if (isDarkTheme) { - Color(0xFF3C3C3C) - } else { - Color(0xFFE0E0E0) - }, + color = borderColor, shape = RoundedCornerShape(8.dp) ) .padding(16.dp) ) { - // Apply theme to the content + // Apply theme and zoom to the content CompositionLocalProvider( LocalContentColor provides if (isDarkTheme) Color.White else Color.Black ) { - StatefulNanoRenderer.Render(nanoIR!!) + Box(modifier = Modifier.scale(zoomLevel)) { + StatefulNanoRenderer.Render(nanoIR!!) + } } } } else { @@ -341,3 +372,75 @@ private fun NanoDSLToggleButton( } } +/** + * NanoDSL Icon Button - Theme-aware icon button component + * + * Features: + * - Uses Material icons instead of emoji for better cross-platform support + * - Theme-aware colors using MaterialTheme.colorScheme + * - Smooth animations for state changes + * - Clear visual feedback for active/inactive states + * + * @param icon The ImageVector icon to display + * @param contentDescription Accessibility description for the icon + * @param isActive Whether the button is in active state + * @param onClick Callback when button is clicked + */ +@Composable +private fun NanoDSLIconButton( + icon: ImageVector, + contentDescription: String, + isActive: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + // Theme-aware colors with animation + val backgroundColor by animateColorAsState( + targetValue = if (isActive) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + } + ) + + val contentColor by animateColorAsState( + targetValue = if (isActive) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + + val borderWidth by animateDpAsState( + targetValue = if (isActive) 1.dp else 0.dp + ) + + val borderColor by animateColorAsState( + targetValue = if (isActive) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.0f) + } + ) + + Box( + modifier = modifier + .clip(RoundedCornerShape(4.dp)) + .border( + width = borderWidth, + color = borderColor, + shape = RoundedCornerShape(4.dp) + ) + .background(backgroundColor) + .clickable(onClick = onClick) + .padding(6.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + modifier = Modifier.size(16.dp), + tint = contentColor + ) + } +} diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/nano/StatefulNanoRenderer.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/nano/StatefulNanoRenderer.kt index 93ae179dc8..59d846b695 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/nano/StatefulNanoRenderer.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/nano/StatefulNanoRenderer.kt @@ -44,9 +44,11 @@ object StatefulNanoRenderer { val runtime = remember(ir) { NanoStateRuntime(ir) } // Subscribe to declared state keys so Compose recomposes when they change. - // We only track keys declared in the component's `state:` block. - runtime.declaredKeys.forEach { key -> - runtime.state.flow(key).collectAsState() + // IMPORTANT: we must *read* the collected State's `.value` so Compose tracks it. + // Use a stable key order to keep hook ordering deterministic across recompositions. + val observedKeys = remember(runtime) { runtime.declaredKeys.toList().sorted() } + observedKeys.forEach { key -> + runtime.state.flow(key).collectAsState().value } val snapshot = runtime.snapshot() diff --git a/settings.gradle.kts b/settings.gradle.kts index 02bde7c118..845b6ae436 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,6 +18,7 @@ include("mpp-server") include("mpp-viewer") include("mpp-viewer-web") include("xiuper-ui") +include("xiuper-fs") // IDEA plugin as composite build includeBuild("mpp-idea") diff --git a/xiuper-fs/README.md b/xiuper-fs/README.md new file mode 100644 index 0000000000..5555f73c3f --- /dev/null +++ b/xiuper-fs/README.md @@ -0,0 +1,210 @@ +# xiuper-fs: Agent-VFS-style Virtual File System (KMP) + +Kotlin Multiplatform virtual filesystem abstraction inspired by Agent-VFS, providing POSIX-like file operations over heterogeneous backends (HTTP APIs, databases, MCP resources). + +## Features + +### Core VFS Infrastructure โœ… +- **XiuperFileSystem**: Async POSIX-inspired API (`stat`, `list`, `read`, `write`, `delete`, `mkdir`, `commit`) +- **FsBackend SPI**: Pluggable backend interface for custom implementations +- **XiuperVfs Router**: Mount point resolution with longest-prefix matching +- **Mount Configuration**: Per-mount read-only enforcement and policy hooks + +### Backends โœ… +1. **REST-FS** (`RestFsBackend`) + - HTTP-based virtual filesystem with schema-driven path mapping + - AuthProvider support (Bearer token, custom headers) + - Capability-aware (declares `supportsMkdir=false`, `supportsDelete=false`) + +2. **DB-FS** (`DbFsBackend`) + - SQLDelight-backed database filesystem + - Migration framework with PRAGMA user_version tracking + - Extended attributes support (v2 schema with FsXattr table) + - Platform-specific drivers: + - **JVM**: JdbcSqliteDriver (real SQLite) + - **Android**: AndroidSqliteDriver + - **iOS**: NativeSqliteDriver + - **WASM**: WebWorkerDriver + sql.js (browser) + - **JS/Node**: Explicit unsupported (NoopSqlDriver throws exception) + +3. **InMemory** (`InMemoryFsBackend`) + - In-memory testing backend + +### Migration Framework โœ… +- `Migration` interface for database schema upgrades +- `MigrationRegistry` with automatic path discovery +- Version tracking via PRAGMA user_version +- Current schema: v2 (FsNode + FsXattr with composite PK and CASCADE DELETE) +- Comprehensive upgrade tests (8/8 passing) + +### Security & Policy โœ… +- **MountPolicy**: Access control interface + - `MountPolicy.AllowAll`: Default permissive policy + - `MountPolicy.ReadOnly`: Deny all write operations + - Extensible for custom policies (path filters, approval workflows) +- **Audit System**: + - `FsAuditEvent`: Track operation, path, backend, status, latency + - `FsAuditCollector`: Pluggable collectors (NoOp, Console) + - Automatic audit logging in XiuperVfs + +### Compose Integration โœ… +- **FsRepository**: Reactive filesystem adapter for Compose UI +- StateFlow-based observers: + - `observeDir(path)`: List + - `observeFile(path)`: ByteArray? + - `observeText(path)`: String +- Write operations with automatic refresh: + - `writeFile(path, content)` + - `writeText(path, text)` + - `deleteFile(path)` - refreshes parent directory + - `createDir(path)` - refreshes parent directory +- Cache management (`clearCache()`) + +### Testing โœ… +- **Capability-aware conformance tests**: POSIX subset validation +- **Migration tests**: Infrastructure + upgrade paths (v1โ†’v2) +- **REST conformance**: Capability declaration validation +- **Platform coverage**: JVM, common (shared across targets) + +## Platform Support + +| Platform | SQLDelight Driver | Status | +|----------|-------------------|--------| +| JVM Desktop | JdbcSqliteDriver | โœ… Production | +| Android | AndroidSqliteDriver | โœ… Production | +| iOS (Darwin) | NativeSqliteDriver | โœ… Production | +| WASM (wasmJs) | WebWorkerDriver + sql.js | โœ… Production | +| JS/Node | NoopSqlDriver | โŒ Unsupported (explicit) | + +## Quick Start + +### Basic VFS Setup + +```kotlin +import cc.unitmesh.xiuper.fs.* +import cc.unitmesh.xiuper.fs.db.* +import cc.unitmesh.xiuper.fs.http.* +import cc.unitmesh.xiuper.fs.policy.* + +// Create backends +val dbBackend = DbFsBackend( + database = createDatabase(DatabaseDriverFactory().createDriver()), + clock = Clock.System +) + +val restBackend = RestFsBackend( + service = RestServiceConfig( + baseUrl = "https://api.github.com", + auth = AuthProvider.BearerToken { System.getenv("GITHUB_TOKEN") } + ) +) + +// Mount into VFS +val vfs = XiuperVfs( + mounts = listOf( + Mount( + mountPoint = FsPath("/db"), + backend = dbBackend, + policy = MountPolicy.AllowAll + ), + Mount( + mountPoint = FsPath("/github"), + backend = restBackend, + readOnly = true, + policy = MountPolicy.ReadOnly + ) + ), + auditCollector = FsAuditCollector.Console +) + +// Use VFS +val entries = vfs.list(FsPath("/db")) +val content = vfs.read(FsPath("/github/repos/owner/repo")) +vfs.write(FsPath("/db/notes.txt"), "Hello".encodeToByteArray()) +``` + +### Compose Integration + +```kotlin +import cc.unitmesh.xiuper.fs.compose.FsRepository +import kotlinx.coroutines.CoroutineScope + +@Composable +fun FileExplorer(scope: CoroutineScope, vfs: XiuperFileSystem) { + val repo = remember { FsRepository(vfs, scope) } + val entries by repo.observeDir("/db").collectAsState() + + LazyColumn { + items(entries) { entry -> + Text(entry.name) + } + } +} +``` + +## Architecture + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Compose UI Layer โ”‚ +โ”‚ (FsRepository, StateFlow observers) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ XiuperVfs (Router + Policy) โ”‚ +โ”‚ - Mount resolution โ”‚ +โ”‚ - Read-only enforcement โ”‚ +โ”‚ - Policy checks (MountPolicy) โ”‚ +โ”‚ - Audit logging (FsAuditCollector) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ โ”‚ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ DbFsBackendโ”‚ โ”‚RestFsBackendโ”‚ +โ”‚ (SQLDelightโ”‚ โ”‚(HTTP+Schema)โ”‚ +โ”‚ + Migrationโ”‚ โ”‚ + Auth โ”‚ +โ”‚ Framework)โ”‚ โ”‚ + Caps โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## Testing + +```bash +# Run all tests +./gradlew :xiuper-fs:jvmTest + +# Run migration tests only +./gradlew :xiuper-fs:jvmTest --tests "*Migration*Test*" + +# Run conformance tests +./gradlew :xiuper-fs:jvmTest --tests "*Conformance*" +``` + +## Future Roadmap + +### Phase 2: REST-FS Advanced Features ๐Ÿšง +- Field projection (`/resources/{id}/fields/{name}`) +- Magic files (`new` for create operations) +- Control files (`query` + `results/` for searches) +- Pagination as infinite directories + +### Phase 3: MCP Backend ๐Ÿ”ฎ +- MCP resources integration +- MCP tools as executable files +- Multi-platform transport (stdio, HTTP) + +### Phase 4: Advanced Policies ๐Ÿ“‹ +- Path allowlist/denylist (`PathFilterPolicy`) +- Delete approval workflows (`DeleteApprovalPolicy`) +- Rate limiting hooks + +## Related Documentation + +- Design Document: `docs/filesystem/xiuper-fs-design.md` (gitignored) +- Migration Design: `xiuper-fs/xiuper-fs-db-migration-design.md` +- Issue #519: Agent-VFS proposal + +## License + +See main project LICENSE. diff --git a/xiuper-fs/build.gradle.kts b/xiuper-fs/build.gradle.kts new file mode 100644 index 0000000000..6eb7ffce49 --- /dev/null +++ b/xiuper-fs/build.gradle.kts @@ -0,0 +1,138 @@ +plugins { + kotlin("multiplatform") + kotlin("plugin.serialization") + id("com.android.library") + alias(libs.plugins.sqldelight) +} + +group = "cc.unitmesh" +version = project.findProperty("mppVersion") as String? ?: "0.1.5" + +repositories { + google() + mavenCentral() +} + +sqldelight { + databases { + create("XiuperFsDatabase") { + packageName.set("cc.unitmesh.xiuper.fs.db") + } + } +} + +android { + namespace = "cc.unitmesh.xiuper.fs" + compileSdk = 34 + defaultConfig { + minSdk = 24 + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +kotlin { + jvmToolchain(17) + + androidTarget { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } + } + + jvm() + + js(IR) { + nodejs() + binaries.library() + } + + @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) + wasmJs { + browser() + nodejs() + } + + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64() + ).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = "XiuperFs" + isStatic = true + } + } + + sourceSets { + val commonMain by getting { + dependencies { + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.datetime) + + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.contentNegotiation) + implementation(libs.ktor.serialization.kotlinx.json) + + // MCP SDK for cross-platform Model Context Protocol support + implementation(libs.mcp.kotlin.sdk) + } + } + + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + } + } + + val jvmMain by getting { + dependencies { + implementation(libs.ktor.client.cio) + implementation(libs.sqldelight.sqlite) + } + } + + val androidMain by getting { + dependencies { + implementation(libs.ktor.client.cio) + implementation(libs.sqldelight.android) + } + } + + val jsMain by getting { + dependencies { + implementation(libs.ktor.client.js) + } + } + + val wasmJsMain by getting { + dependencies { + implementation(libs.ktor.client.js) + // SQLDelight web worker driver for browser WASM usage. + implementation(libs.sqldelight.webWorker) + implementation(libs.sqldelight.webWorker.wasmJs) + implementation(npm("sql.js", "1.8.0")) + implementation(npm("@cashapp/sqldelight-sqljs-worker", "2.1.0")) + } + } + + val iosX64Main by getting + val iosArm64Main by getting + val iosSimulatorArm64Main by getting + + val iosMain by creating { + dependsOn(commonMain) + iosX64Main.dependsOn(this) + iosArm64Main.dependsOn(this) + iosSimulatorArm64Main.dependsOn(this) + dependencies { + implementation(libs.ktor.client.darwin) + implementation(libs.sqldelight.native) + } + } + } +} diff --git a/xiuper-fs/src/androidMain/kotlin/cc/unitmesh/xiuper/fs/db/DatabaseDriverFactory.kt b/xiuper-fs/src/androidMain/kotlin/cc/unitmesh/xiuper/fs/db/DatabaseDriverFactory.kt new file mode 100644 index 0000000000..4ab0c3b9cf --- /dev/null +++ b/xiuper-fs/src/androidMain/kotlin/cc/unitmesh/xiuper/fs/db/DatabaseDriverFactory.kt @@ -0,0 +1,18 @@ +package cc.unitmesh.xiuper.fs.db + +import android.content.Context +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.android.AndroidSqliteDriver + +actual class DatabaseDriverFactory { + private var context: Context? = null + + fun init(context: Context) { + this.context = context + } + + actual fun createDriver(): SqlDriver { + val context = requireNotNull(context) { "DatabaseDriverFactory.init(context) must be called before createDriver()" } + return AndroidSqliteDriver(XiuperFsDatabase.Schema, context, "xiuper-fs.db") + } +} diff --git a/xiuper-fs/src/androidMain/kotlin/cc/unitmesh/xiuper/fs/http/HttpClientFactory.android.kt b/xiuper-fs/src/androidMain/kotlin/cc/unitmesh/xiuper/fs/http/HttpClientFactory.android.kt new file mode 100644 index 0000000000..0cb7eaa060 --- /dev/null +++ b/xiuper-fs/src/androidMain/kotlin/cc/unitmesh/xiuper/fs/http/HttpClientFactory.android.kt @@ -0,0 +1,34 @@ +package cc.unitmesh.xiuper.fs.http + +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.header +import io.ktor.http.HttpHeaders +import io.ktor.http.takeFrom +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json + +actual object HttpClientFactory { + actual fun create(service: RestServiceConfig): HttpClient { + val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + return HttpClient(CIO) { + expectSuccess = false + + install(ContentNegotiation) { + json(json) + } + + defaultRequest { + url.takeFrom(service.baseUrl) + service.defaultHeaders.forEach { (k, v) -> header(k, v) } + header(HttpHeaders.UserAgent, "XiuperFs/1.0") + } + } + } +} diff --git a/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/BackendCapabilities.kt b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/BackendCapabilities.kt new file mode 100644 index 0000000000..8a66c90fb3 --- /dev/null +++ b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/BackendCapabilities.kt @@ -0,0 +1,16 @@ +package cc.unitmesh.xiuper.fs + +/** + * Declares which POSIX-subset operations a backend implements. + * + * Conformance tests use this to decide whether to assert full POSIX semantics + * or only verify that unsupported operations fail with ENOTSUP. + */ +data class BackendCapabilities( + val supportsMkdir: Boolean = true, + val supportsDelete: Boolean = true +) + +interface CapabilityAwareBackend { + val capabilities: BackendCapabilities +} diff --git a/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/FsPath.kt b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/FsPath.kt new file mode 100644 index 0000000000..b04186ea87 --- /dev/null +++ b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/FsPath.kt @@ -0,0 +1,50 @@ +package cc.unitmesh.xiuper.fs + +/** + * A minimal POSIX-style path used by xiuper-fs. + * + * - Always uses '/' as separator. + * - Normalization removes '.' segments and resolves '..' within the path. + * - Does not perform filesystem access. + */ +data class FsPath(val value: String) { + init { + require(value.isNotBlank()) { "FsPath must not be blank" } + require(value.startsWith("/")) { "FsPath must be absolute and start with '/'" } + } + + fun segments(): List = value.trim('/').takeIf { it.isNotEmpty() }?.split('/') ?: emptyList() + + fun resolve(child: String): FsPath { + val normalizedChild = child.trim('/').takeIf { it.isNotEmpty() } + ?: return this + return FsPath(normalize("$value/$normalizedChild")) + } + + fun parent(): FsPath? { + val segs = segments() + if (segs.isEmpty()) return null + if (segs.size == 1) return FsPath("/") + return FsPath("/" + segs.dropLast(1).joinToString("/")) + } + + companion object { + fun of(raw: String): FsPath = FsPath(normalize(raw)) + + fun normalize(raw: String): String { + val s = raw.replace('\\', '/').trim() + val absolute = if (s.startsWith('/')) s else "/$s" + + val out = ArrayDeque() + for (part in absolute.split('/')) { + when (part) { + "", "." -> Unit + ".." -> if (out.isNotEmpty()) out.removeLast() else Unit + else -> out.addLast(part) + } + } + + return if (out.isEmpty()) "/" else "/" + out.joinToString("/") + } + } +} diff --git a/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/FsTypes.kt b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/FsTypes.kt new file mode 100644 index 0000000000..89b55c9795 --- /dev/null +++ b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/FsTypes.kt @@ -0,0 +1,92 @@ +package cc.unitmesh.xiuper.fs + +import kotlinx.datetime.Instant + +enum class FsErrorCode { + ENOENT, + EACCES, + EEXIST, + EINVAL, + EIO, + ENOTDIR, + ENOTEMPTY, + EISDIR, + ENOTSUP +} + +class FsException( + val code: FsErrorCode, + override val message: String, + override val cause: Throwable? = null +) : Exception(message, cause) + +sealed class FsEntry { + abstract val name: String + + data class File( + override val name: String, + val size: Long? = null, + val mime: String? = null, + val modifiedAt: Instant? = null + ) : FsEntry() + + data class Directory(override val name: String) : FsEntry() + + data class Special( + override val name: String, + val kind: SpecialKind + ) : FsEntry() + + enum class SpecialKind { + MagicNew, + ControlQuery, + ControlCommit, + ToolArgs, + ToolRun + } +} + +data class FsStat( + val path: FsPath, + val isDirectory: Boolean, + val size: Long? = null, + val mime: String? = null +) + +data class ReadOptions( + val preferText: Boolean = true +) + +enum class WriteCommitMode { + Direct, + OnClose, + OnExplicitCommit +} + +data class WriteOptions( + val commitMode: WriteCommitMode = WriteCommitMode.OnClose, + val contentType: String? = null +) + +data class ReadResult( + val bytes: ByteArray, + val contentType: String? = null +) { + fun textOrNull(): String? = runCatching { bytes.decodeToString() }.getOrNull() + + // Override equals/hashCode to use content equality for ByteArray + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ReadResult) return false + return bytes.contentEquals(other.bytes) && contentType == other.contentType + } + + override fun hashCode(): Int { + return 31 * bytes.contentHashCode() + (contentType?.hashCode() ?: 0) + } +} + +data class WriteResult( + val ok: Boolean, + val message: String? = null +) diff --git a/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/XiuperFileSystem.kt b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/XiuperFileSystem.kt new file mode 100644 index 0000000000..1ade215e26 --- /dev/null +++ b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/XiuperFileSystem.kt @@ -0,0 +1,163 @@ +package cc.unitmesh.xiuper.fs + +import cc.unitmesh.xiuper.fs.policy.* +import kotlinx.datetime.Clock + +interface XiuperFileSystem { + suspend fun stat(path: FsPath): FsStat + suspend fun list(path: FsPath): List + suspend fun read(path: FsPath, options: ReadOptions = ReadOptions()): ReadResult + suspend fun write(path: FsPath, content: ByteArray, options: WriteOptions = WriteOptions()): WriteResult + suspend fun delete(path: FsPath) + suspend fun mkdir(path: FsPath) + + /** + * Explicit commit hook for backends that model fsync/commit triggers. + * + * For REST-FS it can be used to commit staged writes when commitMode == OnExplicitCommit. + */ + suspend fun commit(path: FsPath): WriteResult = WriteResult(ok = true) +} + +interface FsBackend { + suspend fun stat(path: FsPath): FsStat + suspend fun list(path: FsPath): List + suspend fun read(path: FsPath, options: ReadOptions): ReadResult + suspend fun write(path: FsPath, content: ByteArray, options: WriteOptions): WriteResult + suspend fun delete(path: FsPath) + suspend fun mkdir(path: FsPath) + suspend fun commit(path: FsPath): WriteResult = WriteResult(ok = true) +} + +data class Mount( + val mountPoint: FsPath, + val backend: FsBackend, + val readOnly: Boolean = false, + val policy: MountPolicy = MountPolicy.AllowAll +) + +class XiuperVfs( + mounts: List, + private val auditCollector: FsAuditCollector = FsAuditCollector.NoOp +) : XiuperFileSystem { + private val normalizedMounts: List = mounts + .map { it.copy(mountPoint = FsPath.of(it.mountPoint.value)) } + .sortedByDescending { it.mountPoint.value.length } + + override suspend fun stat(path: FsPath): FsStat { + return withAudit(FsOperation.STAT, path) { mount, inner -> + mount.backend.stat(inner) + } + } + + override suspend fun list(path: FsPath): List { + return withAudit(FsOperation.LIST, path) { mount, inner -> + mount.backend.list(inner) + } + } + + override suspend fun read(path: FsPath, options: ReadOptions): ReadResult { + return withAudit(FsOperation.READ, path) { mount, inner -> + mount.backend.read(inner, options) + } + } + + override suspend fun write(path: FsPath, content: ByteArray, options: WriteOptions): WriteResult { + return withAudit(FsOperation.WRITE, path) { mount, inner -> + checkWritePolicy(mount, path) + mount.backend.write(inner, content, options) + } + } + + override suspend fun delete(path: FsPath) { + withAudit(FsOperation.DELETE, path) { mount, inner -> + checkWritePolicy(mount, path) + mount.policy.checkDelete(path)?.let { throw it } + mount.backend.delete(inner) + } + } + + override suspend fun mkdir(path: FsPath) { + withAudit(FsOperation.MKDIR, path) { mount, inner -> + checkWritePolicy(mount, path) + mount.backend.mkdir(inner) + } + } + + override suspend fun commit(path: FsPath): WriteResult { + return withAudit(FsOperation.COMMIT, path) { mount, inner -> + checkWritePolicy(mount, path) + mount.backend.commit(inner) + } + } + + private fun resolve(path: FsPath): Pair { + val normalized = FsPath.of(path.value) + val mount = normalizedMounts.firstOrNull { isUnderMount(normalized, it.mountPoint) } + ?: throw FsException(FsErrorCode.ENOENT, "No mount for path: ${normalized.value}") + + val inner = stripMount(normalized, mount.mountPoint) + return mount to inner + } + + private fun isUnderMount(path: FsPath, mountPoint: FsPath): Boolean { + if (mountPoint.value == "/") return true + return path.value == mountPoint.value || path.value.startsWith(mountPoint.value + "/") + } + + private suspend fun checkWritePolicy(mount: Mount, path: FsPath) { + if (mount.readOnly) { + throw FsException(FsErrorCode.EACCES, "Mount is read-only: ${mount.mountPoint.value}") + } + mount.policy.checkWrite(path)?.let { throw it } + } + + private suspend fun withAudit( + operation: FsOperation, + path: FsPath, + block: suspend (Mount, FsPath) -> T + ): T { + val (mount, inner) = resolve(path) + val startTime = Clock.System.now() + + return try { + val result = block(mount, inner) + val endTime = Clock.System.now() + val latency = (endTime - startTime).inWholeMilliseconds + + auditCollector.collect( + FsAuditEvent( + operation = operation, + path = path, + backend = mount.backend::class.simpleName ?: "Unknown", + status = FsOperationStatus.Success, + latencyMs = latency + ) + ) + + result + } catch (e: FsException) { + val endTime = Clock.System.now() + val latency = (endTime - startTime).inWholeMilliseconds + + auditCollector.collect( + FsAuditEvent( + operation = operation, + path = path, + backend = mount.backend::class.simpleName ?: "Unknown", + status = FsOperationStatus.Failure(e.code, e.message ?: "Unknown error"), + latencyMs = latency + ) + ) + + throw e + } + } + + private fun stripMount(path: FsPath, mountPoint: FsPath): FsPath { + if (mountPoint.value == "/") return path + if (path.value == mountPoint.value) return FsPath("/") + val inner = path.value.removePrefix(mountPoint.value) + return FsPath.of(inner) + } +} diff --git a/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/compose/FsRepository.kt b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/compose/FsRepository.kt new file mode 100644 index 0000000000..932c939c99 --- /dev/null +++ b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/compose/FsRepository.kt @@ -0,0 +1,171 @@ +package cc.unitmesh.xiuper.fs.compose + +import cc.unitmesh.xiuper.fs.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +/** + * Repository adapter for Compose UI integration. + * + * Provides StateFlow-based reactive access to filesystem state. + * Typical usage: + * ``` + * val repo = FsRepository(vfs, scope) + * val entries by repo.observeDir("/http/github/issues").collectAsState(emptyList()) + * val content by repo.observeText("/http/github/issues/1/title").collectAsState("") + * ``` + */ +class FsRepository( + private val vfs: XiuperFileSystem, + private val scope: CoroutineScope +) { + private val dirCache = mutableMapOf>>() + private val fileCache = mutableMapOf>() + + /** + * Observe directory contents as StateFlow. + * Updates automatically when refreshDir() is called for this path. + */ + fun observeDir(path: String): StateFlow> { + val fsPath = FsPath.of(path) + return dirCache.getOrPut(fsPath) { + MutableStateFlow>(emptyList()).also { flow -> + scope.launch { + try { + val entries = vfs.list(fsPath) + flow.value = entries + } catch (e: FsException) { + // Keep empty on error + } + } + } + } + } + + /** + * Observe file content as StateFlow (raw bytes). + * Updates automatically when refreshFile() is called for this path. + */ + fun observeFile(path: String): StateFlow { + val fsPath = FsPath.of(path) + return fileCache.getOrPut(fsPath) { + MutableStateFlow(null).also { flow -> + scope.launch { + try { + val result = vfs.read(fsPath) + flow.value = result.bytes + } catch (e: FsException) { + flow.value = null + } + } + } + } + } + + /** + * Observe file content as text (UTF-8). + */ + fun observeText(path: String): StateFlow { + return observeFile(path).map { bytes -> + bytes?.decodeToString() ?: "" + }.stateIn(scope, SharingStarted.Eagerly, "") + } + + /** + * Manually refresh directory listing. + */ + suspend fun refreshDir(path: String) { + val fsPath = FsPath.of(path) + val flow = dirCache[fsPath] ?: return + try { + val entries = vfs.list(fsPath) + flow.value = entries + } catch (e: FsException) { + // Keep previous value on error + } + } + + /** + * Manually refresh file content. + */ + suspend fun refreshFile(path: String) { + val fsPath = FsPath.of(path) + val flow = fileCache[fsPath] ?: return + try { + val result = vfs.read(fsPath) + flow.value = result.bytes + } catch (e: FsException) { + flow.value = null + } + } + + /** + * Write file and refresh observers. + */ + suspend fun writeFile(path: String, content: ByteArray): Result { + val fsPath = FsPath.of(path) + return try { + val result = vfs.write(fsPath, content) + if (result.ok) { + refreshFile(path) + Result.success(Unit) + } else { + Result.failure(Exception(result.message ?: "Write failed")) + } + } catch (e: FsException) { + Result.failure(e) + } + } + + /** + * Write text file (UTF-8) and refresh observers. + */ + suspend fun writeText(path: String, text: String): Result { + return writeFile(path, text.encodeToByteArray()) + } + + /** + * Delete file and refresh parent directory. + */ + suspend fun deleteFile(path: String): Result { + val fsPath = FsPath.of(path) + return try { + vfs.delete(fsPath) + // Refresh parent directory if observed + val parentPath = fsPath.parent() + if (parentPath != null && dirCache.containsKey(parentPath)) { + refreshDir(parentPath.value) + } + Result.success(Unit) + } catch (e: FsException) { + Result.failure(e) + } + } + + /** + * Create directory and refresh parent. + */ + suspend fun createDir(path: String): Result { + val fsPath = FsPath.of(path) + return try { + vfs.mkdir(fsPath) + // Refresh parent directory if observed + val parentPath = fsPath.parent() + if (parentPath != null && dirCache.containsKey(parentPath)) { + refreshDir(parentPath.value) + } + Result.success(Unit) + } catch (e: FsException) { + Result.failure(e) + } + } + + /** + * Clear all cached flows (useful for logout/unmount scenarios). + */ + fun clearCache() { + dirCache.clear() + fileCache.clear() + } +} diff --git a/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/db/DatabaseDriverFactory.kt b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/db/DatabaseDriverFactory.kt new file mode 100644 index 0000000000..c16cb96611 --- /dev/null +++ b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/db/DatabaseDriverFactory.kt @@ -0,0 +1,90 @@ +package cc.unitmesh.xiuper.fs.db + +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.db.QueryResult +import cc.unitmesh.xiuper.fs.db.migrations.MigrationRegistry + +expect class DatabaseDriverFactory { + fun createDriver(): SqlDriver +} + +/** + * Create database instance and apply migrations if necessary. + * Exposed for testing with custom drivers. + * + * Note: XiuperFsDatabase.Schema.version is automatically generated by SQLDelight + * based on the number of .sqm migration files in src/commonMain/sqldelight/.../migrations/ + * Current schema: v2 (base FsNode.sq + 1.sqm migration for FsXattr) + */ +fun createDatabase(driver: SqlDriver): XiuperFsDatabase { + val currentVersion = getUserVersion(driver) + // Schema.version is auto-generated by SQLDelight (currently 2: base + 1 migration) + val targetVersion = XiuperFsDatabase.Schema.version.toInt() + + when { + currentVersion == 0 -> { + // Fresh database: create latest schema directly + XiuperFsDatabase.Schema.create(driver) + setUserVersion(driver, targetVersion) + } + currentVersion < targetVersion -> { + // Existing database: apply incremental migrations + val migrations = MigrationRegistry.path(currentVersion, targetVersion) + for (migration in migrations) { + try { + migration.migrate(driver) + } catch (e: Exception) { + throw IllegalStateException( + "Migration failed: ${migration.description} " + + "(${migration.fromVersion} โ†’ ${migration.toVersion})", + e + ) + } + } + setUserVersion(driver, targetVersion) + } + currentVersion > targetVersion -> { + // Future database opened by older code + throw IllegalStateException( + "Database version $currentVersion is newer than supported $targetVersion. " + + "Please upgrade the application." + ) + } + // else: already up-to-date, no action needed + } + + return XiuperFsDatabase(driver) +} + +/** + * Create database instance from a platform-specific driver factory. + */ +fun createDatabase(driverFactory: DatabaseDriverFactory): XiuperFsDatabase { + return createDatabase(driverFactory.createDriver()) +} + +private fun getUserVersion(driver: SqlDriver): Int { + return driver.executeQuery( + identifier = null, + sql = "PRAGMA user_version", + mapper = { cursor -> + if (cursor.next().value) { + QueryResult.Value(cursor.getLong(0)?.toInt() ?: 0) + } else { + QueryResult.Value(0) + } + }, + parameters = 0, + binders = null + ).value +} + +private fun setUserVersion(driver: SqlDriver, version: Int) { + driver.execute( + identifier = null, + sql = "PRAGMA user_version = $version", + parameters = 0, + binders = null + ) +} + diff --git a/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/db/DbFsBackend.kt b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/db/DbFsBackend.kt new file mode 100644 index 0000000000..ab1e6fd87a --- /dev/null +++ b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/db/DbFsBackend.kt @@ -0,0 +1,161 @@ +package cc.unitmesh.xiuper.fs.db + +import cc.unitmesh.xiuper.fs.* +import kotlinx.datetime.Clock + +class DbFsBackend( + private val database: XiuperFsDatabase, +) : FsBackend, CapabilityAwareBackend { + override val capabilities: BackendCapabilities = BackendCapabilities( + supportsMkdir = true, + supportsDelete = true + ) + + override suspend fun stat(path: FsPath): FsStat { + val normalized = FsPath.of(path.value) + if (normalized.value == "/") { + return FsStat(path = FsPath("/"), isDirectory = true) + } + + val node = database.fsNodeQueries.selectByPath(normalized.value).executeAsOneOrNull() + ?: throw FsException(FsErrorCode.ENOENT, "No such path: ${normalized.value}") + + val isDir = node.isDir != 0L + val size = if (isDir) null else (node.content?.size?.toLong() ?: 0L) + return FsStat(path = normalized, isDirectory = isDir, size = size) + } + + override suspend fun list(path: FsPath): List { + val normalized = FsPath.of(path.value) + if (normalized.value != "/") { + val node = database.fsNodeQueries.selectByPath(normalized.value).executeAsOneOrNull() + ?: throw FsException(FsErrorCode.ENOENT, "No such path: ${normalized.value}") + if (node.isDir == 0L) throw FsException(FsErrorCode.ENOTDIR, "Not a directory: ${normalized.value}") + } + + val prefix = if (normalized.value == "/") "/" else normalized.value + "/" + val like = prefix + "%" + val nodes = database.fsNodeQueries.selectChildren(like).executeAsList() + + val children = LinkedHashMap() + for (node in nodes) { + val p = node.path + if (!p.startsWith(prefix)) continue + val remainder = p.removePrefix(prefix) + val firstSegment = remainder.substringBefore('/') + if (firstSegment.isEmpty()) continue + + if (children.containsKey(firstSegment)) continue + + val childIsDir = remainder.contains('/') || (node.isDir != 0L) + val entry = if (childIsDir) { + FsEntry.Directory(name = firstSegment) + } else { + FsEntry.File(name = firstSegment, size = node.content?.size?.toLong()) + } + children[firstSegment] = entry + } + + return children.values.toList() + } + + override suspend fun read(path: FsPath, options: ReadOptions): ReadResult { + val normalized = FsPath.of(path.value) + val node = database.fsNodeQueries.selectByPath(normalized.value).executeAsOneOrNull() + ?: throw FsException(FsErrorCode.ENOENT, "No such path: ${normalized.value}") + + if (node.isDir != 0L) throw FsException(FsErrorCode.EISDIR, "Is a directory: ${normalized.value}") + return ReadResult(bytes = node.content ?: ByteArray(0)) + } + + override suspend fun write(path: FsPath, content: ByteArray, options: WriteOptions): WriteResult { + val normalized = FsPath.of(path.value) + if (normalized.value == "/") throw FsException(FsErrorCode.EISDIR, "Cannot write to directory: /") + + val parent = normalized.parent() ?: FsPath("/") + if (!isDirectory(parent)) throw FsException(FsErrorCode.ENOENT, "Parent does not exist: ${parent.value}") + + val existing = database.fsNodeQueries.selectByPath(normalized.value).executeAsOneOrNull() + if (existing?.isDir != null && existing.isDir != 0L) { + throw FsException(FsErrorCode.EISDIR, "Is a directory: ${normalized.value}") + } + + database.fsNodeQueries.upsertNode( + path = normalized.value, + isDir = 0L, + content = content, + mtimeEpochMillis = Clock.System.now().toEpochMilliseconds(), + ) + + return WriteResult(ok = true) + } + + override suspend fun mkdir(path: FsPath) { + val normalized = FsPath.of(path.value) + if (normalized.value == "/") throw FsException(FsErrorCode.EEXIST, "Already exists: /") + + if (exists(normalized)) throw FsException(FsErrorCode.EEXIST, "Already exists: ${normalized.value}") + + val parent = normalized.parent() ?: FsPath("/") + if (!isDirectory(parent)) throw FsException(FsErrorCode.ENOENT, "Parent does not exist: ${parent.value}") + + database.fsNodeQueries.upsertNode( + path = normalized.value, + isDir = 1L, + content = null, + mtimeEpochMillis = Clock.System.now().toEpochMilliseconds(), + ) + } + + override suspend fun delete(path: FsPath) { + val normalized = FsPath.of(path.value) + if (normalized.value == "/") throw FsException(FsErrorCode.EACCES, "Cannot delete root") + + val node = database.fsNodeQueries.selectByPath(normalized.value).executeAsOneOrNull() + ?: throw FsException(FsErrorCode.ENOENT, "No such path: ${normalized.value}") + + if (node.isDir != 0L) { + // Check if directory has any children + // Use executeAsList().firstOrNull() to avoid exception when multiple children exist + val anyChild = database.fsNodeQueries + .selectChildren(normalized.value + "/%") + .executeAsList() + .firstOrNull() + if (anyChild != null) throw FsException(FsErrorCode.ENOTEMPTY, "Directory not empty: ${normalized.value}") + } + + database.fsNodeQueries.deleteByPath(normalized.value) + } + + override suspend fun commit(path: FsPath): WriteResult { + // SQLDelight driver handles transactions; backend APIs are per-op atomic. + return WriteResult(ok = true) + } + + private fun exists(path: FsPath): Boolean { + if (path.value == "/") return true + return database.fsNodeQueries.selectByPath(path.value).executeAsOneOrNull() != null + } + + private fun isDirectory(path: FsPath): Boolean { + if (path.value == "/") return true + val node = database.fsNodeQueries.selectByPath(path.value).executeAsOneOrNull() ?: return false + return node.isDir != 0L + } +} + +/** + * Create a [DbFsBackend] with migration support. + * Applies schema migrations if needed and initializes root directory. + */ +fun DbFsBackend(driverFactory: DatabaseDriverFactory): DbFsBackend { + val database = createDatabase(driverFactory) + // ensure root exists + database.fsNodeQueries.upsertNode( + path = "/", + isDir = 1L, + content = null, + mtimeEpochMillis = Clock.System.now().toEpochMilliseconds(), + ) + return DbFsBackend(database) +} diff --git a/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/db/migrations/Migration.kt b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/db/migrations/Migration.kt new file mode 100644 index 0000000000..1673b4ddc7 --- /dev/null +++ b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/db/migrations/Migration.kt @@ -0,0 +1,71 @@ +package cc.unitmesh.xiuper.fs.db.migrations + +import app.cash.sqldelight.db.SqlDriver + +/** + * Represents a single migration step from [fromVersion] to [toVersion]. + * + * Each migration should be idempotent where possible (safe to run multiple times). + */ +interface Migration { + val fromVersion: Int + val toVersion: Int + val description: String + + /** + * Apply the migration to [driver]. + * + * @throws Exception if migration fails; caller should handle rollback/abort + */ + fun migrate(driver: SqlDriver) +} + +/** + * Registry of all available migrations, ordered by fromVersion. + */ +object MigrationRegistry { + val all: List = listOf( + Migration_1_to_2(), + // Future migrations: + // Migration_2_to_3(), + ) + + /** + * Returns the chain of migrations needed to go from [current] to [target]. + * + * @throws IllegalStateException if no valid migration path exists + * @throws UnsupportedOperationException if downgrade is attempted (current > target) + */ + fun path(current: Int, target: Int): List { + if (current > target) { + throw UnsupportedOperationException( + "Downgrade from version $current to $target is not supported. " + + "Please use a compatible application version." + ) + } + if (current == target) return emptyList() + + val chain = mutableListOf() + var version = current + + while (version < target) { + val nextMigration = all.firstOrNull { it.fromVersion == version } + ?: throw IllegalStateException( + "No migration found from version $version. " + + "Database may be corrupted or migration registry incomplete." + ) + + if (nextMigration.toVersion > target) { + throw IllegalStateException( + "Migration ${nextMigration.description} overshoots target: " + + "${nextMigration.toVersion} > $target" + ) + } + + chain.add(nextMigration) + version = nextMigration.toVersion + } + + return chain + } +} diff --git a/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/db/migrations/Migration_1_to_2.kt b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/db/migrations/Migration_1_to_2.kt new file mode 100644 index 0000000000..d5d6612da1 --- /dev/null +++ b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/db/migrations/Migration_1_to_2.kt @@ -0,0 +1,42 @@ +package cc.unitmesh.xiuper.fs.db.migrations + +import app.cash.sqldelight.db.SqlDriver + +/** + * Migration v1 โ†’ v2: Add extended attributes support. + * + * Adds FsXattr table for POSIX-style extended attributes (user.*, security.*, etc.). + * Indexed by path for efficient lookup and cascades on delete. + */ +class Migration_1_to_2 : Migration { + override val fromVersion = 1 + override val toVersion = 2 + override val description = "Add extended attributes table" + + override fun migrate(driver: SqlDriver) { + driver.execute( + identifier = null, + sql = """ + CREATE TABLE IF NOT EXISTS FsXattr ( + path TEXT NOT NULL, + name TEXT NOT NULL, + value BLOB NOT NULL, + PRIMARY KEY (path, name), + FOREIGN KEY (path) REFERENCES FsNode(path) ON DELETE CASCADE + ) + """.trimIndent(), + parameters = 0, + binders = null + ) + + // Create index for efficient lookup by path + driver.execute( + identifier = null, + sql = """ + CREATE INDEX IF NOT EXISTS idx_xattr_path ON FsXattr(path) + """.trimIndent(), + parameters = 0, + binders = null + ) + } +} diff --git a/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/github/GitHubFsBackend.kt b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/github/GitHubFsBackend.kt new file mode 100644 index 0000000000..91e6639614 --- /dev/null +++ b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/github/GitHubFsBackend.kt @@ -0,0 +1,194 @@ +package cc.unitmesh.xiuper.fs.github + +import cc.unitmesh.xiuper.fs.* +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * GitHub API as a filesystem backend. + * + * Maps GitHub repository structure to a filesystem: + * - Directories โ†’ GitHub directories + * - Files โ†’ GitHub blobs + * - Read โ†’ Get file contents via GitHub API + * - List โ†’ List directory contents + * + * Path format: /owner/repo/ref/path/to/file + * Example: /microsoft/vscode/main/README.md + * + * Usage: + * ```kotlin + * val backend = GitHubFsBackend(token = "ghp_xxxxx") + * val shell = ShellFsInterpreter(backend) + * + * shell.execute("ls /microsoft/vscode/main") + * shell.execute("cat /microsoft/vscode/main/README.md") + * ``` + */ +class GitHubFsBackend( + private val token: String? = null, + private val baseUrl: String = "https://api.github.com" +) : FsBackend, AutoCloseable { + + private val client = HttpClient { + expectSuccess = false + } + + /** + * Close the HTTP client to release resources. + * Should be called when the backend is no longer needed. + */ + override fun close() { + client.close() + } + + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + override suspend fun stat(path: FsPath): FsStat { + val parsed = parsePath(path.value) + + return try { + val response: HttpResponse = client.get("$baseUrl/repos/${parsed.owner}/${parsed.repo}/contents/${parsed.path}") { + parameter("ref", parsed.ref) + token?.let { header("Authorization", "Bearer $it") } + } + + if (!response.status.isSuccess()) { + throw FsException(FsErrorCode.ENOENT, "Path not found: ${path.value}") + } + + val content: GitHubContent = json.decodeFromString(response.bodyAsText()) + + FsStat( + path = path, + isDirectory = content.type == "dir", + size = content.size?.toLong() + ) + } catch (e: FsException) { + throw e + } catch (e: Exception) { + throw FsException(FsErrorCode.EIO, "Failed to stat ${path.value}: ${e.message}") + } + } + + override suspend fun list(path: FsPath): List { + val parsed = parsePath(path.value) + + return try { + val response: HttpResponse = client.get("$baseUrl/repos/${parsed.owner}/${parsed.repo}/contents/${parsed.path}") { + parameter("ref", parsed.ref) + token?.let { header("Authorization", "Bearer $it") } + } + + if (!response.status.isSuccess()) { + throw FsException(FsErrorCode.ENOENT, "Directory not found: ${path.value}") + } + + val contents: List = json.decodeFromString(response.bodyAsText()) + + contents.map { item -> + when (item.type) { + "dir" -> FsEntry.Directory(item.name) + "file" -> FsEntry.File(item.name, size = item.size?.toLong()) + else -> FsEntry.File(item.name, size = item.size?.toLong()) + } + } + } catch (e: FsException) { + throw e + } catch (e: Exception) { + throw FsException(FsErrorCode.EIO, "Failed to list ${path.value}: ${e.message}") + } + } + + override suspend fun read(path: FsPath, options: ReadOptions): ReadResult { + val parsed = parsePath(path.value) + + return try { + val response: HttpResponse = client.get("$baseUrl/repos/${parsed.owner}/${parsed.repo}/contents/${parsed.path}") { + parameter("ref", parsed.ref) + token?.let { header("Authorization", "Bearer $it") } + header("Accept", "application/vnd.github.raw+json") + } + + if (!response.status.isSuccess()) { + throw FsException(FsErrorCode.ENOENT, "File not found: ${path.value}") + } + + val bytes = response.body() + ReadResult(bytes = bytes) + } catch (e: FsException) { + throw e + } catch (e: Exception) { + throw FsException(FsErrorCode.EIO, "Failed to read ${path.value}: ${e.message}") + } + } + + override suspend fun write(path: FsPath, content: ByteArray, options: WriteOptions): WriteResult { + throw FsException(FsErrorCode.EACCES, "GitHub backend is read-only") + } + + override suspend fun delete(path: FsPath) { + throw FsException(FsErrorCode.EACCES, "GitHub backend is read-only") + } + + override suspend fun mkdir(path: FsPath) { + throw FsException(FsErrorCode.EACCES, "GitHub backend is read-only") + } + + private fun parsePath(path: String): ParsedGitHubPath { + // Format: /owner/repo/ref/path/to/file + val parts = path.trim('/').split('/', limit = 4) + + return when { + parts.size < 2 -> throw FsException( + FsErrorCode.EINVAL, + "Invalid path format. Expected: /owner/repo/ref/path" + ) + parts.size == 2 -> ParsedGitHubPath( + owner = parts[0], + repo = parts[1], + ref = "main", + path = "" + ) + parts.size == 3 -> ParsedGitHubPath( + owner = parts[0], + repo = parts[1], + ref = parts[2], + path = "" + ) + else -> ParsedGitHubPath( + owner = parts[0], + repo = parts[1], + ref = parts[2], + path = parts[3] + ) + } + } + + private data class ParsedGitHubPath( + val owner: String, + val repo: String, + val ref: String, + val path: String + ) +} + +@Serializable +private data class GitHubContent( + val name: String, + val path: String, + val type: String, // "file" or "dir" + val size: Int? = null, + val sha: String? = null, + @SerialName("download_url") val downloadUrl: String? = null, + @SerialName("html_url") val htmlUrl: String? = null +) diff --git a/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/http/RestFs.kt b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/http/RestFs.kt new file mode 100644 index 0000000000..2dfbbf7cb3 --- /dev/null +++ b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/http/RestFs.kt @@ -0,0 +1,486 @@ +package cc.unitmesh.xiuper.fs.http + +import cc.unitmesh.xiuper.fs.* +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * REST-FS backend (schema-first). + * + * This is intentionally minimal: it provides a stable SPI and a basic implementation for + * reading/writing raw endpoints, while leaving higher-level resource/field projections to schema. + */ +class RestFsBackend( + private val service: RestServiceConfig, + private val schema: RestSchema = RestSchema(), + private val client: HttpClient = HttpClientFactory.create(service) +) : FsBackend, CapabilityAwareBackend { + + override val capabilities: BackendCapabilities = BackendCapabilities( + supportsMkdir = false, + supportsDelete = false + ) + + private val runtime = RestRuntimeState() + + override suspend fun stat(path: FsPath): FsStat { + return schema.stat(path, runtime) + } + + override suspend fun list(path: FsPath): List { + return schema.list(path, runtime) + } + + override suspend fun read(path: FsPath, options: ReadOptions): ReadResult { + val resolved = schema.resolveRead(path, runtime) + ?: throw FsException(FsErrorCode.ENOTSUP, "No schema mapping for read: ${path.value}") + + val response: HttpResponse = client.request(resolved.http.url) { + method = resolved.http.method + service.auth.apply(this) + resolved.http.headers.forEach { (k, v) -> header(k, v) } + } + + if (!response.status.isSuccess()) { + throw FsException(FsErrorCode.EIO, "HTTP ${response.status.value}: ${response.status.description}") + } + + val bytes = response.body() + val base = ReadResult(bytes = bytes, contentType = response.contentType()?.toString()) + return resolved.transform?.invoke(base) ?: base + } + + override suspend fun write(path: FsPath, content: ByteArray, options: WriteOptions): WriteResult { + val resolved = schema.resolveWrite(path, content, options, runtime) + ?: throw FsException(FsErrorCode.ENOTSUP, "No schema mapping for write: ${path.value}") + + when (resolved) { + is ResolvedWrite.Local -> return resolved.action(runtime) + is ResolvedWrite.Http -> { + val response: HttpResponse = client.request(resolved.http.url) { + this.method = resolved.http.method + service.auth.apply(this) + resolved.http.headers.forEach { (k, v) -> header(k, v) } + resolved.http.contentType?.let { contentType(ContentType.parse(it)) } + setBody(resolved.http.body ?: content) + } + + if (!response.status.isSuccess()) { + return WriteResult(ok = false, message = "HTTP ${response.status.value}: ${response.status.description}") + } + + return WriteResult(ok = true, message = "HTTP ${response.status.value}") + } + } + } + + override suspend fun delete(path: FsPath) { + throw FsException(FsErrorCode.ENOTSUP, "delete is not supported by RestFsBackend yet") + } + + override suspend fun mkdir(path: FsPath) { + throw FsException(FsErrorCode.ENOTSUP, "mkdir is not supported by RestFsBackend") + } +} + +data class RestServiceConfig( + val baseUrl: String, + val auth: AuthProvider = AuthProvider.None, + val defaultHeaders: Map = emptyMap() +) + +sealed interface AuthProvider { + fun apply(builder: HttpRequestBuilder) + + data object None : AuthProvider { + override fun apply(builder: HttpRequestBuilder) = Unit + } + + data class BearerToken(private val tokenProvider: () -> String?) : AuthProvider { + override fun apply(builder: HttpRequestBuilder) { + val token = tokenProvider() ?: return + builder.header(HttpHeaders.Authorization, "Bearer $token") + } + } + + data class Header(private val name: String, private val valueProvider: () -> String?) : AuthProvider { + override fun apply(builder: HttpRequestBuilder) { + val v = valueProvider() ?: return + builder.header(name, v) + } + } +} + +/** + * Schema for mapping paths to HTTP requests. + * + * The initial version keeps the interface small: + * - Root entries for discovery. + * - Path-based resolution for read/write. + */ +data class HttpCall( + val url: String, + val method: HttpMethod, + val headers: Map = emptyMap(), + val body: ByteArray? = null, + val contentType: String? = null +) + +data class ResolvedRead( + val http: HttpCall, + val transform: ((ReadResult) -> ReadResult)? = null +) + +sealed class ResolvedWrite { + data class Http(val http: HttpCall) : ResolvedWrite() + data class Local(val action: (RestSchemaRuntime) -> WriteResult) : ResolvedWrite() +} + +/** + * Runtime state for control/magic files (e.g., query state). + */ +interface RestSchemaRuntime { + fun setQuery(scopePath: String, query: Map) + fun getQuery(scopePath: String): Map? +} + +internal class RestRuntimeState : RestSchemaRuntime { + private val queryByScope: MutableMap> = mutableMapOf() + override fun setQuery(scopePath: String, query: Map) { + queryByScope[scopePath] = query + } + + override fun getQuery(scopePath: String): Map? = queryByScope[scopePath] +} + +class RestSchema private constructor( + private val statRoutes: List FsStat>>, + private val listRoutes: List List>>, + private val readRoutes: List ResolvedRead>>, + private val writeRoutes: List ResolvedWrite>> +) { + data class RouteParams(val path: FsPath, val params: Map) { + operator fun get(key: String): String = params[key] ?: "" + } + + constructor() : this( + statRoutes = emptyList(), + listRoutes = emptyList(), + readRoutes = listOf( + FsPathPattern.parse("/raw/{path...}") to { p, _ -> + val rest = (p.params["path"] ?: "").trim('/') + ResolvedRead(HttpCall(url = rest, method = HttpMethod.Get)) + } + ), + writeRoutes = listOf( + FsPathPattern.parse("/raw/{path...}") to { p, _, options, _ -> + val rest = (p.params["path"] ?: "").trim('/') + ResolvedWrite.Http(HttpCall(url = rest, method = HttpMethod.Put, contentType = options.contentType)) + } + ) + ) + + fun stat(path: FsPath, runtime: RestSchemaRuntime): FsStat { + for ((pattern, handler) in statRoutes) { + val m = pattern.match(path) ?: continue + return handler(RouteParams(path, m), runtime) + } + return if (path.value == "/") FsStat(path, isDirectory = true) else FsStat(path, isDirectory = false) + } + + fun list(path: FsPath, runtime: RestSchemaRuntime): List { + for ((pattern, handler) in listRoutes) { + val m = pattern.match(path) ?: continue + return handler(RouteParams(path, m), runtime) + } + return emptyList() + } + + fun resolveRead(path: FsPath, runtime: RestSchemaRuntime): ResolvedRead? { + for ((pattern, handler) in readRoutes) { + val m = pattern.match(path) ?: continue + return handler(RouteParams(path, m), runtime) + } + return null + } + + fun resolveWrite(path: FsPath, content: ByteArray, options: WriteOptions, runtime: RestSchemaRuntime): ResolvedWrite? { + for ((pattern, handler) in writeRoutes) { + val m = pattern.match(path) ?: continue + return handler(RouteParams(path, m), content, options, runtime) + } + return null + } + + class Builder { + private val statRoutes = mutableListOf FsStat>>() + private val listRoutes = mutableListOf List>>() + private val readRoutes = mutableListOf ResolvedRead>>() + private val writeRoutes = mutableListOf ResolvedWrite>>() + + fun stat(pattern: String, handler: (RouteParams, RestSchemaRuntime) -> FsStat): Builder { + statRoutes.add(FsPathPattern.parse(pattern) to handler) + return this + } + + fun dir(pattern: String, handler: (RouteParams, RestSchemaRuntime) -> List): Builder { + listRoutes.add(FsPathPattern.parse(pattern) to handler) + return this + } + + fun read(pattern: String, handler: (RouteParams, RestSchemaRuntime) -> ResolvedRead): Builder { + readRoutes.add(FsPathPattern.parse(pattern) to handler) + return this + } + + fun write(pattern: String, handler: (RouteParams, ByteArray, WriteOptions, RestSchemaRuntime) -> ResolvedWrite): Builder { + writeRoutes.add(FsPathPattern.parse(pattern) to handler) + return this + } + + fun build(): RestSchema { + val base = RestSchema() + return RestSchema( + statRoutes = statRoutes.toList(), + listRoutes = listRoutes.toList(), + readRoutes = readRoutes.toList() + base.readRoutes, + writeRoutes = writeRoutes.toList() + base.writeRoutes + ) + } + } + + companion object { + fun builder(): Builder = Builder() + + fun githubIssues(): RestSchema { + val issuesScope = "/github/{owner}/{repo}/issues" + val json = Json { ignoreUnknownKeys = true; isLenient = true } + return builder() + .stat(issuesScope) { p, _ -> FsStat(p.path, isDirectory = true) } + .stat("$issuesScope/new") { p, _ -> FsStat(p.path, isDirectory = false) } + .stat("$issuesScope/query") { p, _ -> FsStat(p.path, isDirectory = false) } + .stat("$issuesScope/results") { p, _ -> FsStat(p.path, isDirectory = true) } + .stat("$issuesScope/page_{page}") { p, _ -> FsStat(p.path, isDirectory = true) } + .stat("$issuesScope/page_{page}/data.json") { p, _ -> FsStat(p.path, isDirectory = false, mime = "application/json") } + .stat("$issuesScope/results/page_{page}") { p, _ -> FsStat(p.path, isDirectory = true) } + .stat("$issuesScope/results/page_{page}/data.json") { p, _ -> FsStat(p.path, isDirectory = false, mime = "application/json") } + .stat("$issuesScope/{id}") { p, _ -> FsStat(p.path, isDirectory = true) } + .stat("$issuesScope/{id}/data.json") { p, _ -> FsStat(p.path, isDirectory = false, mime = "application/json") } + .stat("$issuesScope/{id}/fields") { p, _ -> FsStat(p.path, isDirectory = true) } + .stat("$issuesScope/{id}/fields/{field}") { p, _ -> FsStat(p.path, isDirectory = false) } + + .dir(issuesScope) { _, _ -> + listOf( + FsEntry.Directory("page_1"), + FsEntry.Special("new", FsEntry.SpecialKind.MagicNew), + FsEntry.Special("query", FsEntry.SpecialKind.ControlQuery), + FsEntry.Directory("results") + ) + } + .dir("$issuesScope/page_{page}") { _, _ -> listOf(FsEntry.File("data.json", mime = "application/json")) } + .dir("$issuesScope/results") { _, _ -> listOf(FsEntry.Directory("page_1")) } + .dir("$issuesScope/results/page_{page}") { _, _ -> listOf(FsEntry.File("data.json", mime = "application/json")) } + .dir("$issuesScope/{id}") { _, _ -> listOf(FsEntry.File("data.json", mime = "application/json"), FsEntry.Directory("fields")) } + .dir("$issuesScope/{id}/fields") { _, _ -> listOf(FsEntry.File("title"), FsEntry.File("body")) } + + // Page routes must come before {id} routes to avoid treating page_3 as an issue id. + .read("$issuesScope/page_{page}/data.json") { p, _ -> + val owner = p["owner"] + val repo = p["repo"] + val page = p["page"] + ResolvedRead(HttpCall(url = "repos/$owner/$repo/issues?page=$page", method = HttpMethod.Get)) + } + .read("$issuesScope/results/page_{page}/data.json") { p, runtime -> + val owner = p["owner"] + val repo = p["repo"] + val page = p["page"] + val scope = "/github/$owner/$repo/issues" + val q = runtime.getQuery(scope).orEmpty() + val state = q["state"] + val queryString = buildString { + append("page=$page") + if (!state.isNullOrBlank()) append("&state=$state") + } + ResolvedRead(HttpCall(url = "repos/$owner/$repo/issues?$queryString", method = HttpMethod.Get)) + } + + .read("$issuesScope/{id}/data.json") { p, _ -> + val owner = p["owner"] + val repo = p["repo"] + val id = p["id"] + ResolvedRead(HttpCall(url = "repos/$owner/$repo/issues/$id", method = HttpMethod.Get)) + } + .read("$issuesScope/{id}/fields/{field}") { p, _ -> + val owner = p["owner"] + val repo = p["repo"] + val id = p["id"] + val field = p["field"] + ResolvedRead( + http = HttpCall(url = "repos/$owner/$repo/issues/$id", method = HttpMethod.Get), + transform = { rr -> + val text = rr.textOrNull() ?: return@ResolvedRead rr + val obj = json.parseToJsonElement(text).jsonObject + val v = obj[field]?.jsonPrimitive?.let { prim -> runCatching { prim.content }.getOrNull() } ?: "" + ReadResult(bytes = v.encodeToByteArray(), contentType = "text/plain") + } + ) + } + + .write("$issuesScope/new") { p, content, options, _ -> + val owner = p["owner"] + val repo = p["repo"] + ResolvedWrite.Http( + HttpCall( + url = "repos/$owner/$repo/issues", + method = HttpMethod.Post, + body = content, + contentType = options.contentType ?: "application/json" + ) + ) + } + .write("$issuesScope/query") { p, content, _, _ -> + val owner = p["owner"] + val repo = p["repo"] + val scope = "/github/$owner/$repo/issues" + ResolvedWrite.Local { rt -> + val queryText = runCatching { content.decodeToString() }.getOrDefault("") + rt.setQuery(scope, parseControlFile(queryText)) + WriteResult(ok = true) + } + } + .write("$issuesScope/{id}/fields/{field}") { p, content, options, _ -> + val owner = p["owner"] + val repo = p["repo"] + val id = p["id"] + val field = p["field"] + val text = runCatching { content.decodeToString() }.getOrDefault("") + val payload = JsonObject(mapOf(field to JsonPrimitive(text))) + ResolvedWrite.Http( + HttpCall( + url = "repos/$owner/$repo/issues/$id", + method = HttpMethod.Patch, + body = Json.encodeToString(JsonObject.serializer(), payload).encodeToByteArray(), + contentType = options.contentType ?: "application/json" + ) + ) + } + .build() + } + } +} + +internal class FsPathPattern private constructor( + private val raw: String, + private val segments: List +) { + sealed interface Segment { + data class Literal(val value: String) : Segment + data class Var(val name: String) : Segment + data class VarWithAffix(val name: String, val prefix: String, val suffix: String) : Segment + data class Rest(val name: String) : Segment + } + + fun match(path: FsPath): Map? { + val pSegs = path.segments() + val out = mutableMapOf() + + var i = 0 + var j = 0 + while (i < segments.size && j < pSegs.size) { + when (val s = segments[i]) { + is Segment.Literal -> { + if (pSegs[j] != s.value) return null + i++; j++ + } + is Segment.Var -> { + out[s.name] = pSegs[j] + i++; j++ + } + is Segment.VarWithAffix -> { + val actual = pSegs[j] + if (!actual.startsWith(s.prefix) || !actual.endsWith(s.suffix)) return null + val mid = actual.removePrefix(s.prefix).removeSuffix(s.suffix) + if (mid.isEmpty()) return null + out[s.name] = mid + i++; j++ + } + is Segment.Rest -> { + out[s.name] = pSegs.drop(j).joinToString("/") + i = segments.size + j = pSegs.size + } + } + } + + if (i < segments.size) { + // Remaining pattern segments can only be Rest + val last = segments.drop(i).singleOrNull() as? Segment.Rest ?: return null + out[last.name] = "" + i = segments.size + } + + if (j != pSegs.size) return null + return out + } + + companion object { + fun parse(pattern: String): FsPathPattern { + val normalized = FsPath.normalize(pattern) + val segs = normalized.trim('/').takeIf { it.isNotEmpty() }?.split('/') ?: emptyList() + + val parsed = segs.map { seg -> + when { + seg.startsWith("{") && seg.endsWith("...}") -> { + val name = seg.removePrefix("{").removeSuffix("...}") + Segment.Rest(name) + } + seg.startsWith("{") && seg.endsWith("}") -> Segment.Var(seg.removePrefix("{").removeSuffix("}")) + seg.contains("{") && seg.contains("}") -> { + val start = seg.indexOf('{') + val end = seg.indexOf('}', start + 1) + if (start == -1 || end == -1) Segment.Literal(seg) + else { + val prefix = seg.substring(0, start) + val name = seg.substring(start + 1, end) + val suffix = seg.substring(end + 1) + Segment.VarWithAffix(name, prefix, suffix) + } + } + else -> Segment.Literal(seg) + } + } + + return FsPathPattern(normalized, parsed) + } + } +} + +private fun parseControlFile(text: String): Map { + return text + .lineSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .mapNotNull { line -> + val idx = line.indexOf('=') + if (idx <= 0) return@mapNotNull null + val k = line.substring(0, idx).trim() + val v = line.substring(idx + 1).trim() + if (k.isBlank()) return@mapNotNull null + k to v + } + .toMap() +} + +expect object HttpClientFactory { + fun create(service: RestServiceConfig): HttpClient +} diff --git a/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/http/RestFsAdvanced.kt b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/http/RestFsAdvanced.kt new file mode 100644 index 0000000000..ece9ad0a1e --- /dev/null +++ b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/http/RestFsAdvanced.kt @@ -0,0 +1,216 @@ +package cc.unitmesh.xiuper.fs.http + +import cc.unitmesh.xiuper.fs.* +import io.ktor.http.* +import kotlinx.serialization.json.* + +/** + * Advanced REST-FS features builder extensions. + * + * This file provides convenient extensions to RestSchema.Builder for: + * - Field projection: Access JSON fields as individual files + * - Magic files: Special files that trigger HTTP operations + * - Pagination: Virtual directories for paginated results + */ + +/** + * Add field projection for a JSON resource. + * + * Creates virtual /fields/ directory that exposes JSON fields as files: + * ``` + * /api/users/123/data.json -> {"name": "Alice", "email": "alice@example.com"} + * /api/users/123/fields/ -> [name, email] + * /api/users/123/fields/name -> "Alice" + * /api/users/123/fields/email -> "alice@example.com" + * ``` + * + * @param dataPath Path pattern for the JSON resource (e.g., "/api/users/{id}/data.json") + * @param fieldsBasePath Path pattern for the fields directory (e.g., "/api/users/{id}/fields") + */ +fun RestSchema.Builder.addFieldProjection( + dataPath: String, + fieldsBasePath: String +): RestSchema.Builder { + // Stat: fields directory exists + stat(fieldsBasePath) { p, _ -> + FsStat(p.path, isDirectory = true) + } + + // Stat: individual field files + stat("$fieldsBasePath/{field}") { p, _ -> + FsStat(p.path, isDirectory = false, mime = "text/plain") + } + + // Dir: return all JSON keys as files + dir(fieldsBasePath) { _, _ -> + // This is a limitation: we can't read the data synchronously in the dir handler. + // In a real implementation, you might need to cache the JSON data or use a different approach. + // For now, return empty list as a placeholder. + emptyList() + } + + // Read: extract field value from JSON + read("$fieldsBasePath/{field}") { p, _ -> + // Extract field value by reading data.json and parsing + val json = """{"placeholder": "Field projection requires async data loading"}""" + ResolvedRead( + http = HttpCall( + url = dataPath.replace("{id}", p["id"]), + method = HttpMethod.Get + ), + transform = { result -> + try { + val jsonElement = Json.parseToJsonElement(result.bytes.decodeToString()) + if (jsonElement is JsonObject) { + val fieldName = p["field"] + val value = jsonElement[fieldName] + val content = when (value) { + is JsonPrimitive -> value.content + null -> throw FsException(FsErrorCode.ENOENT, "Field not found: $fieldName") + else -> value.toString() + } + ReadResult(content.encodeToByteArray(), "text/plain") + } else { + throw FsException(FsErrorCode.EIO, "Not a JSON object") + } + } catch (e: Exception) { + throw FsException(FsErrorCode.EIO, "Failed to parse JSON: ${e.message}") + } + } + ) + } + + return this +} + +/** + * Add a magic file that triggers HTTP POST on write. + * + * Writing to this file creates a new resource: + * ``` + * echo '{"title": "New Issue"}' > /github/owner/repo/issues/new + * # -> POST /repos/owner/repo/issues + * ``` + * + * @param magicPath Path pattern for the magic file (e.g., "/api/issues/new") + * @param targetUrl URL to POST to when the file is written + */ +fun RestSchema.Builder.addMagicFile( + magicPath: String, + targetUrl: String +): RestSchema.Builder { + // Stat: magic file exists as a special file + stat(magicPath) { p, _ -> + FsStat(p.path, isDirectory = false, size = 0, mime = "application/json") + } + + // Read: return empty or placeholder + read(magicPath) { _, _ -> + ResolvedRead( + http = HttpCall(url = "", method = HttpMethod.Get), + transform = { ReadResult("{}".encodeToByteArray(), "application/json") } + ) + } + + // Write: POST to target URL + write(magicPath) { p, content, options, _ -> + ResolvedWrite.Http( + HttpCall( + url = targetUrl.replace(Regex("\\{(\\w+)\\}")) { match -> + p[match.groupValues[1]] + }, + method = HttpMethod.Post, + body = content, + contentType = options.contentType ?: "application/json" + ) + ) + } + + return this +} + +/** + * Add pagination support with virtual page directories. + * + * Creates virtual /pages/{n}/ directories: + * ``` + * /api/issues/pages/1/ -> [data.json, next] + * /api/issues/pages/1/data.json -> {..., "items": [...]} + * /api/issues/pages/2/ -> [data.json, prev, next] + * ``` + * + * @param basePath Path pattern for the paginated collection (e.g., "/api/issues") + * @param pageUrlTemplate URL template with {page} placeholder (e.g., "/issues?page={page}") + * @param maxPages Optional maximum number of pages to expose + */ +fun RestSchema.Builder.addPagination( + basePath: String, + pageUrlTemplate: String, + maxPages: Int? = null +): RestSchema.Builder { + // Stat: pages directory + stat("$basePath/pages") { p, _ -> + FsStat(p.path, isDirectory = true) + } + + // Stat: individual page directories + stat("$basePath/pages/{page}") { p, _ -> + FsStat(p.path, isDirectory = true) + } + + // Stat: page data.json + stat("$basePath/pages/{page}/data.json") { p, _ -> + FsStat(p.path, isDirectory = false, mime = "application/json") + } + + // Dir: pages directory shows numbered page directories + dir("$basePath/pages") { _, _ -> + val pages = maxPages ?: 10 // Default to showing 10 pages + (1..pages).map { page -> + FsEntry.Directory(page.toString()) + } + } + + // Dir: page directory shows data.json and navigation links + dir("$basePath/pages/{page}") { p, _ -> + val pageNum = p["page"].toIntOrNull() ?: 1 + buildList { + add(FsEntry.File("data.json", size = null, mime = "application/json")) + if (pageNum > 1) { + add(FsEntry.File("prev", size = null, mime = "text/plain")) + } + if (maxPages == null || pageNum < maxPages) { + add(FsEntry.File("next", size = null, mime = "text/plain")) + } + } + } + + // Read: page data + read("$basePath/pages/{page}/data.json") { p, _ -> + val url = pageUrlTemplate.replace("{page}", p["page"]) + ResolvedRead( + http = HttpCall(url = url, method = HttpMethod.Get) + ) + } + + // Read: navigation links + read("$basePath/pages/{page}/prev") { p, _ -> + val pageNum = p["page"].toIntOrNull() ?: 1 + val prevPage = maxOf(1, pageNum - 1) + ResolvedRead( + http = HttpCall(url = "", method = HttpMethod.Get), + transform = { ReadResult("$prevPage".encodeToByteArray(), "text/plain") } + ) + } + + read("$basePath/pages/{page}/next") { p, _ -> + val pageNum = p["page"].toIntOrNull() ?: 1 + val nextPage = pageNum + 1 + ResolvedRead( + http = HttpCall(url = "", method = HttpMethod.Get), + transform = { ReadResult("$nextPage".encodeToByteArray(), "text/plain") } + ) + } + + return this +} diff --git a/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/mcp/McpBackend.kt b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/mcp/McpBackend.kt new file mode 100644 index 0000000000..847a96b2d2 --- /dev/null +++ b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/mcp/McpBackend.kt @@ -0,0 +1,257 @@ +package cc.unitmesh.xiuper.fs.mcp + +import cc.unitmesh.xiuper.fs.* +import io.modelcontextprotocol.kotlin.sdk.client.Client +import io.modelcontextprotocol.kotlin.sdk.Tool +import io.modelcontextprotocol.kotlin.sdk.Resource +import kotlinx.serialization.json.* + +/** + * Model Context Protocol (MCP) Backend. + * + * Maps MCP resources and tools to filesystem operations using the official Kotlin MCP SDK. + * This implementation is cross-platform and works on JVM, JS, iOS, and Android. + * + * Based on: mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/mcp/McpClientManager.kt + * + * Directory structure: + * ``` + * /resources/ -> MCP resources root + * /resources/{uri}/ -> Individual resource (URI-encoded) + * /tools/ -> MCP tools root + * /tools/{name}/args -> Tool arguments (JSON) + * /tools/{name}/run -> Execute tool (write triggers execution) + * ``` + */ +interface McpBackend : FsBackend { + /** + * Get the underlying MCP client for advanced operations. + */ + val mcpClient: Client +} + + + +/** + * Default MCP backend implementation using Kotlin MCP SDK. + */ +class DefaultMcpBackend( + override val mcpClient: Client +) : McpBackend { + private var resourceCache: List? = null + private var toolCache: List? = null + private val toolArgsCache = mutableMapOf() + + override suspend fun stat(path: FsPath): FsStat { + return when { + path.value == "/" || path.value == "/resources" || path.value == "/tools" -> + FsStat(path, isDirectory = true) + + path.value.startsWith("/resources/") && !path.value.endsWith("/") -> { + val encodedUri = path.value.removePrefix("/resources/") + // Decode the URI to match the encoding used in list() + val uri = decodeUri(encodedUri) + val resource = getResource(uri) + FsStat( + path = path, + isDirectory = false, + size = null, + mime = resource.mimeType + ) + } + + path.value.startsWith("/tools/") -> { + val parts = path.value.removePrefix("/tools/").split("/") + when (parts.size) { + 1 -> FsStat(path, isDirectory = true) // /tools/{name}/ + 2 -> when (parts[1]) { + "args", "run" -> FsStat(path, isDirectory = false) + else -> throw FsException(FsErrorCode.ENOENT, "Unknown tool file: ${parts[1]}") + } + else -> throw FsException(FsErrorCode.ENOENT, "Invalid tool path") + } + } + + else -> throw FsException(FsErrorCode.ENOENT, "Path not found: ${path.value}") + } + } + + override suspend fun list(path: FsPath): List { + return when (path.value) { + "/", "" -> listOf( + FsEntry.Directory("resources"), + FsEntry.Directory("tools") + ) + + "/resources" -> listResources().map { resource -> + FsEntry.File( + name = encodeUri(resource.uri), + size = null, + mime = resource.mimeType + ) + } + + "/tools" -> listTools().map { tool -> + FsEntry.Directory(tool.name) + } + + else -> { + if (path.value.startsWith("/tools/")) { + val toolName = path.value.removePrefix("/tools/").trim('/') + if (listTools().any { it.name == toolName }) { + listOf( + FsEntry.Special("args", FsEntry.SpecialKind.ToolArgs), + FsEntry.Special("run", FsEntry.SpecialKind.ToolRun) + ) + } else { + emptyList() + } + } else { + emptyList() + } + } + } + } + + override suspend fun read(path: FsPath, options: ReadOptions): ReadResult { + return when { + path.value.startsWith("/resources/") -> { + val uri = path.value.removePrefix("/resources/") + readResource(decodeUri(uri)) + } + + path.value.startsWith("/tools/") -> { + val parts = path.value.removePrefix("/tools/").split("/") + if (parts.size == 2 && parts[1] == "args") { + val toolName = parts[0] + val args = toolArgsCache[toolName] ?: JsonObject(emptyMap()) + ReadResult( + bytes = args.toString().encodeToByteArray(), + contentType = "application/json" + ) + } else { + throw FsException(FsErrorCode.ENOTSUP, "Cannot read tool run file") + } + } + + else -> throw FsException(FsErrorCode.ENOENT, "Path not found: ${path.value}") + } + } + + override suspend fun write(path: FsPath, content: ByteArray, options: WriteOptions): WriteResult { + return when { + path.value.startsWith("/tools/") -> { + val parts = path.value.removePrefix("/tools/").split("/") + if (parts.size == 2) { + val toolName = parts[0] + when (parts[1]) { + "args" -> { + // Write tool arguments + val args = Json.parseToJsonElement(content.decodeToString()) as? JsonObject + ?: throw FsException(FsErrorCode.EINVAL, "Invalid JSON object") + toolArgsCache[toolName] = args + WriteResult(ok = true) + } + "run" -> { + // Execute tool + val args = toolArgsCache[toolName] ?: JsonObject(emptyMap()) + executeTool(toolName, args) + } + else -> throw FsException(FsErrorCode.ENOTSUP, "Unknown tool file: ${parts[1]}") + } + } else { + throw FsException(FsErrorCode.EINVAL, "Invalid tool path") + } + } + + else -> throw FsException(FsErrorCode.ENOTSUP, "Write not supported for path: ${path.value}") + } + } + + override suspend fun delete(path: FsPath) { + throw FsException(FsErrorCode.ENOTSUP, "Delete not supported in MCP backend") + } + + override suspend fun mkdir(path: FsPath) { + throw FsException(FsErrorCode.ENOTSUP, "Mkdir not supported in MCP backend") + } + + // Helper methods + + private suspend fun listResources(): List { + if (resourceCache == null) { + try { + val result = mcpClient.listResources() + resourceCache = result?.resources ?: emptyList() + } catch (e: Exception) { + throw FsException(FsErrorCode.EIO, "Failed to list MCP resources: ${e.message}") + } + } + return resourceCache ?: emptyList() + } + + private suspend fun getResource(uri: String): Resource { + return listResources().find { it.uri == uri } + ?: throw FsException(FsErrorCode.ENOENT, "Resource not found: $uri") + } + + private suspend fun readResource(uri: String): ReadResult { + try { + val result = mcpClient.readResource( + io.modelcontextprotocol.kotlin.sdk.ReadResourceRequest(uri = uri) + ) + + if (result?.contents?.isEmpty() != false) { + throw FsException(FsErrorCode.ENOENT, "Resource has no contents") + } + + val firstContent = result.contents[0] + val text = when (firstContent) { + is io.modelcontextprotocol.kotlin.sdk.TextResourceContents -> firstContent.text + is io.modelcontextprotocol.kotlin.sdk.BlobResourceContents -> firstContent.blob + else -> throw FsException(FsErrorCode.EIO, "Unknown content type") + } + + return ReadResult( + bytes = text.encodeToByteArray(), + contentType = firstContent.mimeType + ) + } catch (e: Exception) { + throw FsException(FsErrorCode.EIO, "Failed to read resource: ${e.message}") + } + } + + private suspend fun listTools(): List { + if (toolCache == null) { + try { + val result = mcpClient.listTools() + toolCache = result?.tools ?: emptyList() + } catch (e: Exception) { + throw FsException(FsErrorCode.EIO, "Failed to list MCP tools: ${e.message}") + } + } + return toolCache ?: emptyList() + } + + private suspend fun executeTool(name: String, arguments: JsonObject): WriteResult { + try { + val args = arguments.mapValues { it.value } + val result = mcpClient.callTool(name, arguments = args, compatibility = true, options = null) + val message = result?.content?.firstOrNull()?.toString() ?: "Tool executed successfully" + val isError = result?.isError ?: false + return WriteResult(ok = !isError, message = message) + } catch (e: Exception) { + return WriteResult(ok = false, message = "Tool execution failed: ${e.message}") + } + } + + private fun encodeUri(uri: String): String { + return uri.replace("/", "_") + } + + private fun decodeUri(encoded: String): String { + return encoded.replace("_", "/") + } +} + + diff --git a/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/memory/InMemoryFsBackend.kt b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/memory/InMemoryFsBackend.kt new file mode 100644 index 0000000000..74ff9ae4ae --- /dev/null +++ b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/memory/InMemoryFsBackend.kt @@ -0,0 +1,131 @@ +package cc.unitmesh.xiuper.fs.memory + +import cc.unitmesh.xiuper.fs.* + +/** + * Reference backend for the POSIX-subset contract. + * + * - Pure in-memory tree. + * - Strict parent-exists semantics (no implicit mkdir -p). + * - write() is create-or-truncate. + * - delete() removes file or empty directory. + */ +class InMemoryFsBackend : FsBackend, CapabilityAwareBackend { + override val capabilities: BackendCapabilities = BackendCapabilities( + supportsMkdir = true, + supportsDelete = true + ) + + private sealed interface Node { + data class Dir(val children: MutableMap) : Node + data class File(var bytes: ByteArray) : Node + } + + private val root: Node.Dir = Node.Dir(mutableMapOf()) + + override suspend fun stat(path: FsPath): FsStat { + val normalized = FsPath.of(path.value) + val node = getNode(normalized) ?: throw FsException(FsErrorCode.ENOENT, "No such path: ${normalized.value}") + return when (node) { + is Node.Dir -> FsStat(normalized, isDirectory = true) + is Node.File -> FsStat(normalized, isDirectory = false, size = node.bytes.size.toLong()) + } + } + + override suspend fun list(path: FsPath): List { + val normalized = FsPath.of(path.value) + val node = getNode(normalized) ?: throw FsException(FsErrorCode.ENOENT, "No such path: ${normalized.value}") + val dir = node as? Node.Dir ?: throw FsException(FsErrorCode.ENOTDIR, "Not a directory: ${normalized.value}") + return dir.children.map { (name, child) -> + when (child) { + is Node.Dir -> FsEntry.Directory(name) + is Node.File -> FsEntry.File(name, size = child.bytes.size.toLong()) + } + } + } + + override suspend fun read(path: FsPath, options: ReadOptions): ReadResult { + val normalized = FsPath.of(path.value) + val node = getNode(normalized) ?: throw FsException(FsErrorCode.ENOENT, "No such path: ${normalized.value}") + val file = node as? Node.File ?: throw FsException(FsErrorCode.EISDIR, "Is a directory: ${normalized.value}") + // Return defensive copy to prevent external mutation + return ReadResult(bytes = file.bytes.copyOf()) + } + + override suspend fun write(path: FsPath, content: ByteArray, options: WriteOptions): WriteResult { + val normalized = FsPath.of(path.value) + if (normalized.value == "/") throw FsException(FsErrorCode.EISDIR, "Is a directory: /") + + val (parentDir, name) = resolveParentDir(normalized) + ?: throw FsException(FsErrorCode.ENOENT, "No such parent: ${normalized.parent()?.value ?: "/"}") + + val existing = parentDir.children[name] + when (existing) { + is Node.Dir -> throw FsException(FsErrorCode.EISDIR, "Is a directory: ${normalized.value}") + is Node.File -> { + // Store defensive copy to prevent external mutation + existing.bytes = content.copyOf() + return WriteResult(ok = true) + } + null -> { + // Store defensive copy to prevent external mutation + parentDir.children[name] = Node.File(content.copyOf()) + return WriteResult(ok = true) + } + } + } + + override suspend fun delete(path: FsPath) { + val normalized = FsPath.of(path.value) + if (normalized.value == "/") throw FsException(FsErrorCode.EACCES, "Cannot delete root") + + val parent = normalized.parent() ?: FsPath("/") + val parentNode = getNode(parent) ?: throw FsException(FsErrorCode.ENOENT, "No such path: ${normalized.value}") + val parentDir = parentNode as? Node.Dir ?: throw FsException(FsErrorCode.ENOTDIR, "Not a directory: ${parent.value}") + + val name = normalized.segments().lastOrNull() ?: throw FsException(FsErrorCode.EINVAL, "Invalid path: ${normalized.value}") + val existing = parentDir.children[name] ?: throw FsException(FsErrorCode.ENOENT, "No such path: ${normalized.value}") + + when (existing) { + is Node.File -> parentDir.children.remove(name) + is Node.Dir -> { + if (existing.children.isNotEmpty()) throw FsException(FsErrorCode.ENOTEMPTY, "Directory not empty: ${normalized.value}") + parentDir.children.remove(name) + } + } + } + + override suspend fun mkdir(path: FsPath) { + val normalized = FsPath.of(path.value) + if (normalized.value == "/") throw FsException(FsErrorCode.EEXIST, "Already exists: /") + + val (parentDir, name) = resolveParentDir(normalized) + ?: throw FsException(FsErrorCode.ENOENT, "No such parent: ${normalized.parent()?.value ?: "/"}") + + if (parentDir.children.containsKey(name)) throw FsException(FsErrorCode.EEXIST, "Already exists: ${normalized.value}") + parentDir.children[name] = Node.Dir(mutableMapOf()) + } + + override suspend fun commit(path: FsPath): WriteResult = WriteResult(ok = true) + + private fun getNode(path: FsPath): Node? { + if (path.value == "/") return root + var current: Node = root + for (seg in path.segments()) { + val dir = current as? Node.Dir ?: return null + current = dir.children[seg] ?: return null + } + return current + } + + private fun resolveParentDir(path: FsPath): Pair? { + val parent = path.parent() ?: return null + val name = path.segments().lastOrNull() ?: return null + + val parentNode = getNode(parent) ?: return null + val parentDir = parentNode as? Node.Dir + ?: throw FsException(FsErrorCode.ENOTDIR, "Not a directory: ${parent.value}") + + return parentDir to name + } +} diff --git a/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/policy/FsAudit.kt b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/policy/FsAudit.kt new file mode 100644 index 0000000000..57c5fb6dd9 --- /dev/null +++ b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/policy/FsAudit.kt @@ -0,0 +1,47 @@ +package cc.unitmesh.xiuper.fs.policy + +import cc.unitmesh.xiuper.fs.FsErrorCode +import cc.unitmesh.xiuper.fs.FsPath +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +data class FsAuditEvent( + val operation: FsOperation, + val path: FsPath, + val backend: String, + val status: FsOperationStatus, + val latencyMs: Long, + val timestamp: Instant = Clock.System.now(), + val metadata: Map = emptyMap() +) + +enum class FsOperation { + STAT, + LIST, + READ, + WRITE, + DELETE, + MKDIR, + COMMIT +} + +sealed class FsOperationStatus { + data object Success : FsOperationStatus() + data class Failure(val errorCode: FsErrorCode, val message: String) : FsOperationStatus() +} + +interface FsAuditCollector { + fun collect(event: FsAuditEvent) + + companion object { + val NoOp = object : FsAuditCollector { + override fun collect(event: FsAuditEvent) {} + } + + val Console = object : FsAuditCollector { + override fun collect(event: FsAuditEvent) { + println("[FS-AUDIT] ${event.operation} ${event.path.value} -> ${event.status} (${event.latencyMs}ms)") + } + } + } +} diff --git a/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/policy/MountPolicy.kt b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/policy/MountPolicy.kt new file mode 100644 index 0000000000..fe43d33435 --- /dev/null +++ b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/policy/MountPolicy.kt @@ -0,0 +1,116 @@ +package cc.unitmesh.xiuper.fs.policy + +import cc.unitmesh.xiuper.fs.FsErrorCode +import cc.unitmesh.xiuper.fs.FsException +import cc.unitmesh.xiuper.fs.FsPath + +interface MountPolicy { + suspend fun checkOperation(op: FsOperation, path: FsPath): FsException? + + suspend fun checkWrite(path: FsPath): FsException? = + checkOperation(FsOperation.WRITE, path) + + suspend fun checkDelete(path: FsPath): FsException? = + checkOperation(FsOperation.DELETE, path) + + companion object { + val AllowAll = object : MountPolicy { + override suspend fun checkOperation(op: FsOperation, path: FsPath): FsException? = null + } + + val ReadOnly = object : MountPolicy { + override suspend fun checkOperation(op: FsOperation, path: FsPath): FsException? { + return when (op) { + FsOperation.WRITE, FsOperation.DELETE, FsOperation.MKDIR, FsOperation.COMMIT -> + FsException(FsErrorCode.EACCES, "Read-only policy") + else -> null + } + } + } + } +} + +/** + * Path-based policy: allowlist or denylist. + */ +class PathFilterPolicy( + private val mode: Mode, + private val patterns: List, + private val delegate: MountPolicy = MountPolicy.AllowAll +) : MountPolicy { + enum class Mode { + ALLOWLIST, + DENYLIST + } + + override suspend fun checkOperation(op: FsOperation, path: FsPath): FsException? { + val matches = patterns.any { it.matches(path) } + val allowed = when (mode) { + Mode.ALLOWLIST -> matches + Mode.DENYLIST -> !matches + } + + return if (allowed) { + delegate.checkOperation(op, path) + } else { + FsException(FsErrorCode.EACCES, "Path not allowed by policy: ${path.value}") + } + } +} + +sealed interface PathPattern { + fun matches(path: FsPath): Boolean + + data class Exact(val pattern: String) : PathPattern { + override fun matches(path: FsPath): Boolean = path.value == pattern + } + + data class Prefix(val prefix: String) : PathPattern { + override fun matches(path: FsPath): Boolean = + path.value == prefix || path.value.startsWith("$prefix/") + } + + data class Wildcard(val pattern: String) : PathPattern { + private val regex: Regex + + init { + // Prevent ReDoS attacks by limiting pattern complexity + require(pattern.length <= 256) { "Pattern too long (max 256 characters)" } + require(pattern.count { it == '*' } <= 10) { "Too many wildcards (max 10)" } + + // Use [^/]* instead of .* to match single path segments (more secure and semantically correct) + regex = pattern + .replace(".", "\\.") + .replace("*", "[^/]*") // Non-greedy, single segment match + .toRegex() + } + + override fun matches(path: FsPath): Boolean = regex.matches(path.value) + } + + companion object { + fun parse(pattern: String): PathPattern = when { + "*" in pattern -> Wildcard(pattern) + pattern.endsWith("/") -> Prefix(pattern.trimEnd('/')) + else -> Exact(pattern) + } + } +} + +/** + * Delete approval policy: requires explicit confirmation for delete operations. + */ +class DeleteApprovalPolicy( + private val approvalProvider: suspend (FsPath) -> Boolean, + private val delegate: MountPolicy = MountPolicy.AllowAll +) : MountPolicy { + override suspend fun checkOperation(op: FsOperation, path: FsPath): FsException? { + if (op == FsOperation.DELETE) { + val approved = approvalProvider(path) + if (!approved) { + return FsException(FsErrorCode.EACCES, "Delete not approved: ${path.value}") + } + } + return delegate.checkOperation(op, path) + } +} diff --git a/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/shell/BuiltinCommands.kt b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/shell/BuiltinCommands.kt new file mode 100644 index 0000000000..30c391844f --- /dev/null +++ b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/shell/BuiltinCommands.kt @@ -0,0 +1,204 @@ +package cc.unitmesh.xiuper.fs.shell + +import cc.unitmesh.xiuper.fs.* + +/** + * List directory contents + */ +class LsCommand : ShellCommand { + override val name = "ls" + override val description = "List directory contents" + + override suspend fun execute(args: List, context: ShellContext): ShellResult { + val pathStr = resolvePath(args.lastOrNull() ?: ".", context.workingDirectory) + + return try { + val entries = context.backend.list(FsPath.of(pathStr)) + val output = entries.joinToString("\n") { it.name } + ShellResult(0, output, "") + } catch (e: Exception) { + ShellResult(1, "", "ls: cannot access '$pathStr': ${e.message}") + } + } +} + +/** + * Read file contents + */ +class CatCommand : ShellCommand { + override val name = "cat" + override val description = "Read file contents" + + override suspend fun execute(args: List, context: ShellContext): ShellResult { + if (args.isEmpty()) { + return ShellResult(1, "", "cat: missing file operand") + } + + val pathStr = resolvePath(args[0], context.workingDirectory) + + return try { + val result = context.backend.read(FsPath.of(pathStr), ReadOptions()) + ShellResult(0, result.bytes.decodeToString(), "") + } catch (e: Exception) { + ShellResult(1, "", "cat: $pathStr: ${e.message}") + } + } +} + +/** + * Write text to stdout or file + */ +class EchoCommand : ShellCommand { + override val name = "echo" + override val description = "Write text to stdout or file" + + override suspend fun execute(args: List, context: ShellContext): ShellResult { + // Check for redirection: echo "text" > file + val redirectIndex = args.indexOf(">") + + return if (redirectIndex != -1 && redirectIndex < args.size - 1) { + val text = args.subList(0, redirectIndex).joinToString(" ") + val pathStr = resolvePath(args[redirectIndex + 1], context.workingDirectory) + + try { + context.backend.write(FsPath.of(pathStr), text.encodeToByteArray(), WriteOptions()) + ShellResult(0, "", "") + } catch (e: Exception) { + ShellResult(1, "", "echo: cannot write to '$pathStr': ${e.message}") + } + } else { + val text = args.joinToString(" ") + ShellResult(0, text, "") + } + } +} + +/** + * Create directory + */ +class MkdirCommand : ShellCommand { + override val name = "mkdir" + override val description = "Create directory" + + override suspend fun execute(args: List, context: ShellContext): ShellResult { + if (args.isEmpty()) { + return ShellResult(1, "", "mkdir: missing operand") + } + + val pathStr = resolvePath(args[0], context.workingDirectory) + + return try { + context.backend.mkdir(FsPath.of(pathStr)) + ShellResult(0, "", "") + } catch (e: Exception) { + ShellResult(1, "", "mkdir: cannot create directory '$pathStr': ${e.message}") + } + } +} + +/** + * Remove file or directory + */ +class RmCommand : ShellCommand { + override val name = "rm" + override val description = "Remove file or directory" + + override suspend fun execute(args: List, context: ShellContext): ShellResult { + if (args.isEmpty()) { + return ShellResult(1, "", "rm: missing operand") + } + + val pathStr = resolvePath(args[0], context.workingDirectory) + + return try { + context.backend.delete(FsPath.of(pathStr)) + ShellResult(0, "", "") + } catch (e: Exception) { + ShellResult(1, "", "rm: cannot remove '$pathStr': ${e.message}") + } + } +} + +/** + * Change working directory + */ +class CdCommand(private val interpreter: ShellFsInterpreter) : ShellCommand { + override val name = "cd" + override val description = "Change working directory" + + override suspend fun execute(args: List, context: ShellContext): ShellResult { + val pathStr = resolvePath(args.firstOrNull() ?: "/", context.workingDirectory) + + return try { + // Verify directory exists + context.backend.list(FsPath.of(pathStr)) + interpreter.setWorkingDirectory(pathStr) + ShellResult(0, "", "") + } catch (e: Exception) { + ShellResult(1, "", "cd: $pathStr: ${e.message}") + } + } +} + +/** + * Print working directory + */ +class PwdCommand : ShellCommand { + override val name = "pwd" + override val description = "Print working directory" + + override suspend fun execute(args: List, context: ShellContext): ShellResult { + return ShellResult(0, context.workingDirectory, "") + } +} + +/** + * Copy file + */ +class CpCommand : ShellCommand { + override val name = "cp" + override val description = "Copy file" + + override suspend fun execute(args: List, context: ShellContext): ShellResult { + if (args.size < 2) { + return ShellResult(1, "", "cp: missing file operand") + } + + val sourceStr = resolvePath(args[0], context.workingDirectory) + val destStr = resolvePath(args[1], context.workingDirectory) + + return try { + val result = context.backend.read(FsPath.of(sourceStr), ReadOptions()) + context.backend.write(FsPath.of(destStr), result.bytes, WriteOptions()) + ShellResult(0, "", "") + } catch (e: Exception) { + ShellResult(1, "", "cp: cannot copy '$sourceStr' to '$destStr': ${e.message}") + } + } +} + +/** + * Move/rename file + */ +class MvCommand : ShellCommand { + override val name = "mv" + override val description = "Move/rename file" + + override suspend fun execute(args: List, context: ShellContext): ShellResult { + if (args.size < 2) { + return ShellResult(1, "", "mv: missing file operand") + } + + val sourceStr = resolvePath(args[0], context.workingDirectory) + val destStr = resolvePath(args[1], context.workingDirectory) + + return try { + val result = context.backend.read(FsPath.of(sourceStr), ReadOptions()) + context.backend.write(FsPath.of(destStr), result.bytes, WriteOptions()) + context.backend.delete(FsPath.of(sourceStr)) + ShellResult(0, "", "") + } catch (e: Exception) { + ShellResult(1, "", "mv: cannot move '$sourceStr' to '$destStr': ${e.message}") + } + } +} diff --git a/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/shell/ShellFsInterpreter.kt b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/shell/ShellFsInterpreter.kt new file mode 100644 index 0000000000..320ad40501 --- /dev/null +++ b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/shell/ShellFsInterpreter.kt @@ -0,0 +1,159 @@ +package cc.unitmesh.xiuper.fs.shell + +import cc.unitmesh.xiuper.fs.FsBackend + +/** + * Shell interpreter for filesystem operations using POSIX-like commands. + * + * Provides a familiar command-line interface to interact with any FsBackend: + * - InMemoryBackend + * - DbFsBackend + * - RestFsBackend + * - McpBackend + * + * Usage: + * ```kotlin + * val backend = InMemoryBackend() + * val shell = ShellFsInterpreter(backend) + * + * // Create directory + * shell.execute("mkdir /projects") + * + * // Write file + * shell.execute("echo 'Hello World' > /projects/hello.txt") + * + * // List files + * val result = shell.execute("ls /projects") + * println(result.stdout) // hello.txt + * + * // Read file + * val content = shell.execute("cat /projects/hello.txt") + * println(content.stdout) // Hello World + * ``` + * + * @see cc.unitmesh.xiuper.fs.FsBackend + */ +class ShellFsInterpreter( + private var currentBackend: FsBackend, + initialWorkingDirectory: String = "/" +) { + private var workingDirectory: String = initialWorkingDirectory + private val commands = mutableMapOf() + private val environment = mutableMapOf() + + init { + registerBuiltinCommands() + } + + /** + * Execute a shell command + * + * Supported commands: + * - ls [path]: List directory contents + * - cat : Read file contents + * - echo [> file]: Write text to stdout or file + * - mkdir : Create directory + * - rm : Remove file or directory + * - cd : Change working directory + * - pwd: Print working directory + * - cp : Copy file + * - mv : Move/rename file + * + * @param commandLine Shell command string + * @return Result with exit code, stdout, and stderr + */ + suspend fun execute(commandLine: String): ShellResult { + if (commandLine.isBlank()) { + return ShellResult(0, "", "") + } + + val parsed = parseCommand(commandLine) + val command = commands[parsed.name] + ?: return ShellResult(127, "", "xiuper-shell: command not found: ${parsed.name}") + + val context = ShellContext( + backend = currentBackend, + workingDirectory = workingDirectory, + environment = environment + ) + + return try { + command.execute(parsed.args, context) + } catch (e: Exception) { + ShellResult(1, "", e.message ?: "Unknown error") + } + } + + /** + * Switch to a different backend + */ + fun switchBackend(backend: FsBackend) { + currentBackend = backend + } + + /** + * Get current backend + */ + fun getCurrentBackend(): FsBackend = currentBackend + + /** + * Get current working directory + */ + fun getWorkingDirectory(): String = workingDirectory + + /** + * Set working directory (used by CdCommand) + */ + internal fun setWorkingDirectory(path: String) { + workingDirectory = path + } + + /** + * Register a custom command + */ + fun registerCommand(command: ShellCommand) { + commands[command.name] = command + } + + private fun registerBuiltinCommands() { + registerCommand(LsCommand()) + registerCommand(CatCommand()) + registerCommand(EchoCommand()) + registerCommand(MkdirCommand()) + registerCommand(RmCommand()) + registerCommand(CdCommand(this)) + registerCommand(PwdCommand()) + registerCommand(CpCommand()) + registerCommand(MvCommand()) + } + + private fun parseCommand(line: String): ParsedCommand { + // Simple parsing: split by whitespace, handle quotes later + val parts = line.trim().split(Regex("\\s+")) + return ParsedCommand( + name = parts[0], + args = parts.drop(1) + ) + } +} + +/** + * Resolve relative path to absolute path + */ +internal fun resolvePath(path: String, workingDirectory: String): String { + return when { + path.startsWith("/") -> path + path == "." -> workingDirectory + path == ".." -> { + val parts = workingDirectory.split("/").filter { it.isNotEmpty() } + if (parts.isEmpty()) "/" else "/" + parts.dropLast(1).joinToString("/") + } + else -> { + val wd = if (workingDirectory.endsWith("/")) workingDirectory else "$workingDirectory/" + wd + path + } + }.let { normalized -> + // Normalize // to / + normalized.replace(Regex("/+"), "/") + } +} diff --git a/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/shell/ShellTypes.kt b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/shell/ShellTypes.kt new file mode 100644 index 0000000000..3df0b988f5 --- /dev/null +++ b/xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/shell/ShellTypes.kt @@ -0,0 +1,42 @@ +package cc.unitmesh.xiuper.fs.shell + +import cc.unitmesh.xiuper.fs.FsBackend + +/** + * Context for shell command execution + */ +data class ShellContext( + val backend: FsBackend, + val workingDirectory: String = "/", + val environment: MutableMap = mutableMapOf(), + val stdin: String? = null +) + +/** + * Result of shell command execution + */ +data class ShellResult( + val exitCode: Int, + val stdout: String, + val stderr: String +) { + val isSuccess: Boolean get() = exitCode == 0 +} + +/** + * Interface for shell commands + */ +interface ShellCommand { + val name: String + val description: String + + suspend fun execute(args: List, context: ShellContext): ShellResult +} + +/** + * Parsed command with arguments + */ +internal data class ParsedCommand( + val name: String, + val args: List +) diff --git a/xiuper-fs/src/commonMain/sqldelight/cc/unitmesh/xiuper/fs/db/FsNode.sq b/xiuper-fs/src/commonMain/sqldelight/cc/unitmesh/xiuper/fs/db/FsNode.sq new file mode 100644 index 0000000000..994124a54b --- /dev/null +++ b/xiuper-fs/src/commonMain/sqldelight/cc/unitmesh/xiuper/fs/db/FsNode.sq @@ -0,0 +1,41 @@ +CREATE TABLE FsNode ( + path TEXT NOT NULL PRIMARY KEY, + isDir INTEGER NOT NULL, + content BLOB, + mtimeEpochMillis INTEGER NOT NULL +); + +-- Extended attributes table (added in schema v2) +CREATE TABLE IF NOT EXISTS FsXattr ( + path TEXT NOT NULL, + name TEXT NOT NULL, + value BLOB NOT NULL, + PRIMARY KEY (path, name), + FOREIGN KEY (path) REFERENCES FsNode(path) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_xattr_path ON FsXattr(path); + +selectByPath: +SELECT path, isDir, content, mtimeEpochMillis +FROM FsNode +WHERE path = ?; + +upsertNode: +INSERT OR REPLACE INTO FsNode(path, isDir, content, mtimeEpochMillis) +VALUES (?, ?, ?, ?); + +deleteByPath: +DELETE FROM FsNode +WHERE path = ?; + +selectAll: +SELECT path, isDir, content, mtimeEpochMillis +FROM FsNode +ORDER BY path; + +selectChildren: +SELECT path, isDir, content, mtimeEpochMillis +FROM FsNode +WHERE path LIKE ? +ORDER BY path; diff --git a/xiuper-fs/src/commonMain/sqldelight/cc/unitmesh/xiuper/fs/db/migrations/1.sqm b/xiuper-fs/src/commonMain/sqldelight/cc/unitmesh/xiuper/fs/db/migrations/1.sqm new file mode 100644 index 0000000000..424c952d7d --- /dev/null +++ b/xiuper-fs/src/commonMain/sqldelight/cc/unitmesh/xiuper/fs/db/migrations/1.sqm @@ -0,0 +1,12 @@ +-- Migration 1: Add extended attributes support +-- From schema v1 (FsNode only) to v2 (FsNode + FsXattr) + +CREATE TABLE IF NOT EXISTS FsXattr ( + path TEXT NOT NULL, + name TEXT NOT NULL, + value BLOB NOT NULL, + PRIMARY KEY (path, name), + FOREIGN KEY (path) REFERENCES FsNode(path) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_xattr_path ON FsXattr(path); diff --git a/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/FsPathTest.kt b/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/FsPathTest.kt new file mode 100644 index 0000000000..2b1d171ebb --- /dev/null +++ b/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/FsPathTest.kt @@ -0,0 +1,19 @@ +package cc.unitmesh.xiuper.fs + +import kotlin.test.Test +import kotlin.test.assertEquals + +class FsPathTest { + @Test + fun normalizeRemovesDotSegments() { + assertEquals("/a/b", FsPath.normalize("/a/./b")) + assertEquals("/a/b", FsPath.normalize("/a/x/../b")) + assertEquals("/", FsPath.normalize("/../")) + } + + @Test + fun resolveJoinsAndNormalizes() { + val p = FsPath.of("/a").resolve("b/../c") + assertEquals("/a/c", p.value) + } +} diff --git a/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/XiuperVfsTest.kt b/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/XiuperVfsTest.kt new file mode 100644 index 0000000000..9ae5263f15 --- /dev/null +++ b/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/XiuperVfsTest.kt @@ -0,0 +1,33 @@ +package cc.unitmesh.xiuper.fs + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.coroutines.test.runTest + +private class EchoBackend(private val name: String) : FsBackend { + override suspend fun stat(path: FsPath): FsStat = FsStat(path, isDirectory = path.value == "/") + override suspend fun list(path: FsPath): List = listOf(FsEntry.File("$name:${path.value}")) + override suspend fun read(path: FsPath, options: ReadOptions): ReadResult = ReadResult("$name:${path.value}".encodeToByteArray()) + override suspend fun write(path: FsPath, content: ByteArray, options: WriteOptions): WriteResult = WriteResult(true, "$name") + override suspend fun delete(path: FsPath) = Unit + override suspend fun mkdir(path: FsPath) = Unit +} + +class XiuperVfsTest { + @Test + fun longestPrefixMountWins() = runTest { + val vfs = XiuperVfs( + mounts = listOf( + Mount(FsPath.of("/"), EchoBackend("root")), + Mount(FsPath.of("/http"), EchoBackend("http")), + Mount(FsPath.of("/http/github"), EchoBackend("github")) + ) + ) + + val entries = vfs.list(FsPath.of("/http/github/repos")) + assertEquals("github:/repos", (entries.single() as FsEntry.File).name) + + val text = vfs.read(FsPath.of("/http/x")).textOrNull() + assertEquals("http:/x", text) + } +} diff --git a/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/conformance/PosixSubsetConformanceRunner.kt b/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/conformance/PosixSubsetConformanceRunner.kt new file mode 100644 index 0000000000..ef3144e49d --- /dev/null +++ b/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/conformance/PosixSubsetConformanceRunner.kt @@ -0,0 +1,69 @@ +package cc.unitmesh.xiuper.fs.conformance + +import cc.unitmesh.xiuper.fs.* +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +suspend fun runPosixSubsetConformance( + vfs: XiuperFileSystem, + capabilities: BackendCapabilities = BackendCapabilities() +) { + // Root exists and is a directory + assertTrue(vfs.stat(FsPath.of("/")).isDirectory) + + if (!capabilities.supportsMkdir) { + assertFailsWith { + vfs.mkdir(FsPath.of("/tmp")) + }.also { assertEquals(FsErrorCode.ENOTSUP, it.code) } + return + } + + // mkdir basic behavior + vfs.mkdir(FsPath.of("/tmp")) + assertTrue(vfs.stat(FsPath.of("/tmp")).isDirectory) + + // mkdir on existing path -> EEXIST + assertFailsWith { + vfs.mkdir(FsPath.of("/tmp")) + }.also { assertEquals(FsErrorCode.EEXIST, it.code) } + + // write create-or-truncate + vfs.write(FsPath.of("/tmp/hello.txt"), "hello".encodeToByteArray()) + val read1 = vfs.read(FsPath.of("/tmp/hello.txt")).bytes.decodeToString() + assertEquals("hello", read1) + + vfs.write(FsPath.of("/tmp/hello.txt"), "h".encodeToByteArray()) + val read2 = vfs.read(FsPath.of("/tmp/hello.txt")).bytes.decodeToString() + assertEquals("h", read2) + + // list entries + val entries = vfs.list(FsPath.of("/tmp")).map { it.name }.toSet() + assertTrue(entries.contains("hello.txt")) + + if (!capabilities.supportsDelete) { + assertFailsWith { + vfs.delete(FsPath.of("/tmp/hello.txt")) + }.also { assertEquals(FsErrorCode.ENOTSUP, it.code) } + return + } + + // delete file + vfs.delete(FsPath.of("/tmp/hello.txt")) + assertFailsWith { + vfs.stat(FsPath.of("/tmp/hello.txt")) + }.also { assertEquals(FsErrorCode.ENOENT, it.code) } + + // delete non-empty directory -> ENOTEMPTY + vfs.write(FsPath.of("/tmp/a.txt"), "a".encodeToByteArray()) + assertFailsWith { + vfs.delete(FsPath.of("/tmp")) + }.also { assertEquals(FsErrorCode.ENOTEMPTY, it.code) } + + // delete empty directory + vfs.delete(FsPath.of("/tmp/a.txt")) + vfs.delete(FsPath.of("/tmp")) + assertFailsWith { + vfs.stat(FsPath.of("/tmp")) + }.also { assertEquals(FsErrorCode.ENOENT, it.code) } +} diff --git a/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/conformance/PosixSubsetConformanceTest.kt b/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/conformance/PosixSubsetConformanceTest.kt new file mode 100644 index 0000000000..b20af5cc7e --- /dev/null +++ b/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/conformance/PosixSubsetConformanceTest.kt @@ -0,0 +1,206 @@ +package cc.unitmesh.xiuper.fs.conformance + +import cc.unitmesh.xiuper.fs.* +import cc.unitmesh.xiuper.fs.memory.InMemoryFsBackend +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.test.fail + +class PosixSubsetConformanceTest { + private data class ConformanceTarget( + val fs: XiuperFileSystem, + val capabilities: BackendCapabilities + ) + + private fun newTarget(): ConformanceTarget { + val backend = InMemoryFsBackend() + val capabilities = (backend as? CapabilityAwareBackend)?.capabilities ?: BackendCapabilities() + val fs = XiuperVfs( + mounts = listOf( + Mount(FsPath.of("/"), backend) + ) + ) + return ConformanceTarget(fs, capabilities) + } + + @Test + fun rootAlwaysExistsAndIsDirectory() = runTest { + val (fs) = newTarget() + val st = fs.stat(FsPath.of("/")) + assertTrue(st.isDirectory) + assertEquals("/", st.path.value) + + val entries = fs.list(FsPath.of("/")) + assertEquals(0, entries.size) + } + + @Test + fun listNonexistentIsEnoent() = runTest { + val (fs) = newTarget() + assertFsError(FsErrorCode.ENOENT) { + fs.list(FsPath.of("/missing")) + } + } + + @Test + fun mkdirRequiresParentAndIsNotMkdirP() = runTest { + val (fs, caps) = newTarget() + if (!caps.supportsMkdir) { + assertFsError(FsErrorCode.ENOTSUP) { + fs.mkdir(FsPath.of("/a/b")) + } + return@runTest + } + + assertFsError(FsErrorCode.ENOENT) { + fs.mkdir(FsPath.of("/a/b")) + } + + fs.mkdir(FsPath.of("/a")) + fs.mkdir(FsPath.of("/a/b")) + + val st = fs.stat(FsPath.of("/a/b")) + assertTrue(st.isDirectory) + } + + @Test + fun mkdirExistingIsEexist() = runTest { + val (fs, caps) = newTarget() + if (!caps.supportsMkdir) { + assertFsError(FsErrorCode.ENOTSUP) { + fs.mkdir(FsPath.of("/dir")) + } + return@runTest + } + + fs.mkdir(FsPath.of("/dir")) + assertFsError(FsErrorCode.EEXIST) { + fs.mkdir(FsPath.of("/dir")) + } + + assertFsError(FsErrorCode.EEXIST) { + fs.mkdir(FsPath.of("/")) + } + } + + @Test + fun writeRequiresParentDir() = runTest { + val (fs, caps) = newTarget() + assertFsError(FsErrorCode.ENOENT) { + fs.write(FsPath.of("/a/file.txt"), "x".encodeToByteArray()) + } + + if (!caps.supportsMkdir) { + // Without mkdir, we can't reliably create parents for write tests. + return@runTest + } + + fs.mkdir(FsPath.of("/a")) + assertTrue(fs.write(FsPath.of("/a/file.txt"), "hello".encodeToByteArray()).ok) + + val read = fs.read(FsPath.of("/a/file.txt")).textOrNull() + assertEquals("hello", read) + } + + @Test + fun writeIsCreateOrTruncate() = runTest { + val (fs, caps) = newTarget() + if (!caps.supportsMkdir) return@runTest + fs.mkdir(FsPath.of("/d")) + + fs.write(FsPath.of("/d/f"), "hello".encodeToByteArray()) + fs.write(FsPath.of("/d/f"), "hi".encodeToByteArray()) + + assertEquals("hi", fs.read(FsPath.of("/d/f")).textOrNull()) + val st = fs.stat(FsPath.of("/d/f")) + assertEquals(2, st.size) + assertTrue(!st.isDirectory) + } + + @Test + fun typeErrorsForReadAndList() = runTest { + val (fs, caps) = newTarget() + if (!caps.supportsMkdir) return@runTest + fs.mkdir(FsPath.of("/d")) + fs.write(FsPath.of("/d/f"), "x".encodeToByteArray()) + + assertFsError(FsErrorCode.EISDIR) { + fs.read(FsPath.of("/d")) + } + + assertFsError(FsErrorCode.ENOTDIR) { + fs.list(FsPath.of("/d/f")) + } + } + + @Test + fun deleteFileAndEmptyDirectory() = runTest { + val (fs, caps) = newTarget() + if (!caps.supportsMkdir) return@runTest + if (!caps.supportsDelete) { + assertFsError(FsErrorCode.ENOTSUP) { + fs.delete(FsPath.of("/d")) + } + return@runTest + } + fs.mkdir(FsPath.of("/d")) + fs.write(FsPath.of("/d/f"), "x".encodeToByteArray()) + + fs.delete(FsPath.of("/d/f")) + assertFsError(FsErrorCode.ENOENT) { + fs.read(FsPath.of("/d/f")) + } + + fs.delete(FsPath.of("/d")) + assertFsError(FsErrorCode.ENOENT) { + fs.stat(FsPath.of("/d")) + } + } + + @Test + fun deleteNonEmptyDirectoryIsEnotempty() = runTest { + val (fs, caps) = newTarget() + if (!caps.supportsMkdir) return@runTest + if (!caps.supportsDelete) { + assertFsError(FsErrorCode.ENOTSUP) { + fs.delete(FsPath.of("/d")) + } + return@runTest + } + fs.mkdir(FsPath.of("/d")) + fs.write(FsPath.of("/d/f"), "x".encodeToByteArray()) + + assertFsError(FsErrorCode.ENOTEMPTY) { + fs.delete(FsPath.of("/d")) + } + } + + @Test + fun deleteRootIsEacces() = runTest { + val (fs, caps) = newTarget() + val expected = if (caps.supportsDelete) FsErrorCode.EACCES else FsErrorCode.ENOTSUP + assertFsError(expected) { + fs.delete(FsPath.of("/")) + } + } + + @Test + fun commitIsNoOpAndOk() = runTest { + val (fs) = newTarget() + val r = fs.commit(FsPath.of("/")) + assertTrue(r.ok) + } + + private suspend fun assertFsError(code: FsErrorCode, block: suspend () -> Unit) { + try { + block() + fail("Expected FsException($code)") + } catch (e: FsException) { + assertEquals(code, e.code) + assertNotNull(e.message) + } + } +} diff --git a/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/github/GitHubFsBackendTest.kt b/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/github/GitHubFsBackendTest.kt new file mode 100644 index 0000000000..90b91714b5 --- /dev/null +++ b/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/github/GitHubFsBackendTest.kt @@ -0,0 +1,85 @@ +package cc.unitmesh.xiuper.fs.github + +import cc.unitmesh.xiuper.fs.shell.ShellFsInterpreter +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class GitHubFsBackendTest { + + @Test + fun `list root repository contents`() = runTest { + val backend = GitHubFsBackend() + val shell = ShellFsInterpreter(backend) + + // List root of microsoft/vscode main branch + val result = shell.execute("ls /microsoft/vscode/main") + + assertEquals(0, result.exitCode, "Should successfully list repository contents") + assertTrue(result.stdout.contains("README.md"), "Should contain README.md") + assertTrue(result.stdout.contains("package.json"), "Should contain package.json") + } + + @Test + fun `read file from repository`() = runTest { + val backend = GitHubFsBackend() + val shell = ShellFsInterpreter(backend) + + // Read package.json from microsoft/vscode + val result = shell.execute("cat /microsoft/vscode/main/package.json") + + assertEquals(0, result.exitCode, "Should successfully read file") + assertTrue(result.stdout.contains("\"name\""), "Should contain JSON content") + } + + @Test + fun `list subdirectory`() = runTest { + val backend = GitHubFsBackend() + val shell = ShellFsInterpreter(backend) + + // List src directory + val result = shell.execute("ls /microsoft/vscode/main/src") + + assertEquals(0, result.exitCode, "Should successfully list subdirectory") + assertTrue(result.stdout.isNotEmpty(), "Should have contents") + } + + @Test + fun `navigate with cd and pwd`() = runTest { + val backend = GitHubFsBackend() + val shell = ShellFsInterpreter(backend) + + // Change to repository directory + shell.execute("cd /microsoft/vscode/main") + + val pwd = shell.execute("pwd") + assertEquals("/microsoft/vscode/main", pwd.stdout) + + // List current directory + val ls = shell.execute("ls .") + assertTrue(ls.stdout.contains("README.md")) + } + + @Test + fun `error on nonexistent path`() = runTest { + val backend = GitHubFsBackend() + val shell = ShellFsInterpreter(backend) + + val result = shell.execute("cat /nonexistent/repo/main/file.txt") + + assertEquals(1, result.exitCode, "Should fail on nonexistent path") + assertTrue(result.stderr.contains("not found"), "Should indicate file not found") + } + + @Test + fun `write operations fail on read-only backend`() = runTest { + val backend = GitHubFsBackend() + val shell = ShellFsInterpreter(backend) + + val result = shell.execute("echo 'test' > /microsoft/vscode/main/test.txt") + + assertEquals(1, result.exitCode, "Should fail on write to read-only backend") + assertTrue(result.stderr.contains("read-only"), "Should indicate read-only error") + } +} diff --git a/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/http/GitHubRestSchemaTest.kt b/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/http/GitHubRestSchemaTest.kt new file mode 100644 index 0000000000..19656167f9 --- /dev/null +++ b/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/http/GitHubRestSchemaTest.kt @@ -0,0 +1,117 @@ +package cc.unitmesh.xiuper.fs.http + +import cc.unitmesh.xiuper.fs.FsEntry +import cc.unitmesh.xiuper.fs.FsPath +import cc.unitmesh.xiuper.fs.WriteOptions +import io.ktor.http.HttpMethod +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class GitHubRestSchemaTest { + @Test + fun listIssuesRootContainsMagicAndControlEntries() { + val schema = RestSchema.githubIssues() + val rt = FakeRuntime() + + val entries = schema.list(FsPath.of("/github/unit-mesh/auto-dev/issues"), rt) + val names = entries.map { it.name }.toSet() + + assertTrue("page_1" in names) + assertTrue("new" in names) + assertTrue("query" in names) + assertTrue("results" in names) + + val newEntry = entries.first { it.name == "new" } + assertTrue(newEntry is FsEntry.Special) + assertEquals(FsEntry.SpecialKind.MagicNew, (newEntry as FsEntry.Special).kind) + + val queryEntry = entries.first { it.name == "query" } + assertTrue(queryEntry is FsEntry.Special) + assertEquals(FsEntry.SpecialKind.ControlQuery, (queryEntry as FsEntry.Special).kind) + } + + @Test + fun resolveIssueDataJsonToIssueEndpoint() { + val schema = RestSchema.githubIssues() + val rt = FakeRuntime() + + val rr = schema.resolveRead(FsPath.of("/github/phodal/xiuper/issues/123/data.json"), rt) + assertNotNull(rr) + assertEquals(HttpMethod.Get, rr.http.method) + assertEquals("repos/phodal/xiuper/issues/123", rr.http.url) + } + + @Test + fun resolveFieldTitleReadIsProjectionOfIssueEndpoint() { + val schema = RestSchema.githubIssues() + val rt = FakeRuntime() + + val rr = schema.resolveRead(FsPath.of("/github/phodal/xiuper/issues/123/fields/title"), rt) + assertNotNull(rr) + assertEquals(HttpMethod.Get, rr.http.method) + assertEquals("repos/phodal/xiuper/issues/123", rr.http.url) + assertNotNull(rr.transform) + } + + @Test + fun resolveNewIssueWriteIsPostToIssuesCollection() { + val schema = RestSchema.githubIssues() + val rt = FakeRuntime() + + val w = schema.resolveWrite( + FsPath.of("/github/phodal/xiuper/issues/new"), + "{\"title\":\"hi\"}".encodeToByteArray(), + WriteOptions(contentType = "application/json"), + rt + ) + assertNotNull(w) + val http = w as? ResolvedWrite.Http + assertNotNull(http) + assertEquals(HttpMethod.Post, http.http.method) + assertEquals("repos/phodal/xiuper/issues", http.http.url) + assertEquals("application/json", http.http.contentType) + } + + @Test + fun queryStateAffectsResultsPaginationRead() { + val schema = RestSchema.githubIssues() + val rt = FakeRuntime() + + val w = schema.resolveWrite( + FsPath.of("/github/phodal/xiuper/issues/query"), + "state=open".encodeToByteArray(), + WriteOptions(), + rt + ) + assertNotNull(w) + val local = w as? ResolvedWrite.Local + assertNotNull(local) + local.action(rt) + + val rr = schema.resolveRead(FsPath.of("/github/phodal/xiuper/issues/results/page_2/data.json"), rt) + assertNotNull(rr) + assertEquals("repos/phodal/xiuper/issues?page=2&state=open", rr.http.url) + } + + @Test + fun resolvePageDataJsonToIssuesListEndpoint() { + val schema = RestSchema.githubIssues() + val rt = FakeRuntime() + + val rr = schema.resolveRead(FsPath.of("/github/phodal/xiuper/issues/page_3/data.json"), rt) + assertNotNull(rr) + assertEquals("repos/phodal/xiuper/issues?page=3", rr.http.url) + } + + private class FakeRuntime : RestSchemaRuntime { + private val queryByScope = mutableMapOf>() + + override fun setQuery(scopePath: String, query: Map) { + queryByScope[scopePath] = query + } + + override fun getQuery(scopePath: String): Map? = queryByScope[scopePath] + } +} diff --git a/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/http/RestSchemaBuilderTest.kt b/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/http/RestSchemaBuilderTest.kt new file mode 100644 index 0000000000..5923ea018e --- /dev/null +++ b/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/http/RestSchemaBuilderTest.kt @@ -0,0 +1,75 @@ +package cc.unitmesh.xiuper.fs.http + +import cc.unitmesh.xiuper.fs.FsEntry +import cc.unitmesh.xiuper.fs.FsPath +import cc.unitmesh.xiuper.fs.ReadResult +import cc.unitmesh.xiuper.fs.WriteOptions +import io.ktor.http.HttpMethod +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class RestSchemaBuilderTest { + @Test + fun builderMatchesVarAndAffixSegments() { + val schema = RestSchema.builder() + .read("/x/{id}/page_{page}/data.json") { p, _ -> + ResolvedRead(HttpCall(url = "id=${p["id"]}&page=${p["page"]}", method = HttpMethod.Get)) + } + .build() + + val rr = schema.resolveRead(FsPath.of("/x/abc/page_12/data.json"), NoopRuntime) + assertNotNull(rr) + assertEquals("id=abc&page=12", rr.http.url) + } + + @Test + fun builderCanReturnLocalWrite() { + val schema = RestSchema.builder() + .write("/control/query") { _, content, _, _ -> + ResolvedWrite.Local { rt -> + rt.setQuery("/control", mapOf("raw" to content.decodeToString())) + cc.unitmesh.xiuper.fs.WriteResult(ok = true) + } + } + .build() + + val rt = FakeRuntime() + val w = schema.resolveWrite(FsPath.of("/control/query"), "k=v".encodeToByteArray(), WriteOptions(), rt) + assertNotNull(w) + (w as ResolvedWrite.Local).action(rt) + assertEquals("k=v", rt.getQuery("/control")?.get("raw")) + } + + @Test + fun readTransformCanPostProcess() { + val schema = RestSchema.builder() + .read("/t") { _, _ -> + ResolvedRead( + http = HttpCall(url = "t", method = HttpMethod.Get), + transform = { ReadResult(bytes = "ok".encodeToByteArray(), contentType = "text/plain") } + ) + } + .build() + + val rr = schema.resolveRead(FsPath.of("/t"), NoopRuntime) + assertNotNull(rr) + val out = rr.transform!!.invoke(ReadResult(bytes = "raw".encodeToByteArray(), contentType = "application/json")) + assertEquals("ok", out.bytes.decodeToString()) + } + + private object NoopRuntime : RestSchemaRuntime { + override fun setQuery(scopePath: String, query: Map) = Unit + override fun getQuery(scopePath: String): Map? = null + } + + private class FakeRuntime : RestSchemaRuntime { + private val queryByScope = mutableMapOf>() + + override fun setQuery(scopePath: String, query: Map) { + queryByScope[scopePath] = query + } + + override fun getQuery(scopePath: String): Map? = queryByScope[scopePath] + } +} diff --git a/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/mcp/McpBackendTest.kt b/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/mcp/McpBackendTest.kt new file mode 100644 index 0000000000..a6f57546f9 --- /dev/null +++ b/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/mcp/McpBackendTest.kt @@ -0,0 +1,53 @@ +package cc.unitmesh.xiuper.fs.mcp + +import cc.unitmesh.xiuper.fs.* +import io.modelcontextprotocol.kotlin.sdk.* +import io.modelcontextprotocol.kotlin.sdk.client.Client +import kotlinx.serialization.json.* +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +/** + * Basic tests for MCP Backend. + * + * Note: These tests use a simplified mock that doesn't implement the full Client interface. + * In real usage, create a Client with proper Transport (StdioClientTransport or SseClientTransport). + */ +class McpBackendTest { + @Test + fun backendCreatesSuccessfully() = runTest { + val backend = createTestBackend() + // Just verify we can create the backend + assertTrue(backend is DefaultMcpBackend) + } +} + +/** + * Create a minimal test backend. + * + * In production, you would create a Client like this: + * ``` + * val client = Client(clientInfo = Implementation(name = "MyApp", version = "1.0.0")) + * val transport = processLauncher.launchStdioProcess(config) + * client.connect(transport) + * val backend = DefaultMcpBackend(client) + * ``` + */ +private fun createTestBackend(): McpBackend { + // For real testing, you would need to: + // 1. Launch an actual MCP server process + // 2. Create a Client with proper Transport + // 3. Connect the client + // + // Example (requires actual MCP server): + // val client = Client(clientInfo = Implementation(name = "Test", version = "1.0.0")) + // val transport = StdioClientTransport(input, output) + // client.connect(transport) + // return DefaultMcpBackend(client) + + // For now, create a simple backend that would work with a real client + val client = Client(clientInfo = Implementation(name = "TestClient", version = "1.0.0")) + return DefaultMcpBackend(client) +} diff --git a/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/policy/PolicyTest.kt b/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/policy/PolicyTest.kt new file mode 100644 index 0000000000..6844955c1e --- /dev/null +++ b/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/policy/PolicyTest.kt @@ -0,0 +1,120 @@ +package cc.unitmesh.xiuper.fs.policy + +import cc.unitmesh.xiuper.fs.FsErrorCode +import cc.unitmesh.xiuper.fs.FsPath +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlinx.coroutines.test.runTest + +class PathFilterPolicyTest { + @Test + fun allowlistAllowsMatchingPaths() = runTest { + val policy = PathFilterPolicy( + mode = PathFilterPolicy.Mode.ALLOWLIST, + patterns = listOf( + PathPattern.Prefix("/allowed"), + PathPattern.Exact("/special") + ) + ) + + // Should allow + assertNull(policy.checkOperation(FsOperation.READ, FsPath("/allowed/file.txt"))) + assertNull(policy.checkOperation(FsOperation.READ, FsPath("/special"))) + + // Should deny + val error = policy.checkOperation(FsOperation.READ, FsPath("/denied/file.txt")) + assertNotNull(error) + assertEquals(FsErrorCode.EACCES, error.code) + } + + @Test + fun denylistDeniesMatchingPaths() = runTest { + val policy = PathFilterPolicy( + mode = PathFilterPolicy.Mode.DENYLIST, + patterns = listOf( + PathPattern.Prefix("/tmp"), + PathPattern.Wildcard("/secrets/*") + ) + ) + + // Should deny + assertNotNull(policy.checkOperation(FsOperation.READ, FsPath("/tmp/file.txt"))) + assertNotNull(policy.checkOperation(FsOperation.READ, FsPath("/secrets/api-key"))) + + // Should allow + assertNull(policy.checkOperation(FsOperation.READ, FsPath("/public/file.txt"))) + } + + @Test + fun pathPatternParsing() { + val exact = PathPattern.parse("/exact/path") + assert(exact is PathPattern.Exact) + + val prefix = PathPattern.parse("/prefix/") + assert(prefix is PathPattern.Prefix) + + val wildcard = PathPattern.parse("/path/*/file") + assert(wildcard is PathPattern.Wildcard) + } + + @Test + fun wildcardPatternMatching() { + val pattern = PathPattern.Wildcard("/api/*/users") + + assert(pattern.matches(FsPath("/api/v1/users"))) + assert(pattern.matches(FsPath("/api/v2/users"))) + assert(!pattern.matches(FsPath("/api/v1/posts"))) + } +} + +class DeleteApprovalPolicyTest { + @Test + fun requiresApprovalForDelete() = runTest { + var approvalRequested = false + var pathRequested: FsPath? = null + + val policy = DeleteApprovalPolicy( + approvalProvider = { path -> + approvalRequested = true + pathRequested = path + false // Deny + } + ) + + val error = policy.checkOperation(FsOperation.DELETE, FsPath("/important/file")) + + assert(approvalRequested) + assertEquals("/important/file", pathRequested?.value) + assertNotNull(error) + assertEquals(FsErrorCode.EACCES, error.code) + } + + @Test + fun allowsDeleteWhenApproved() = runTest { + val policy = DeleteApprovalPolicy( + approvalProvider = { true } // Approve + ) + + val error = policy.checkOperation(FsOperation.DELETE, FsPath("/file")) + assertNull(error) + } + + @Test + fun doesNotRequireApprovalForOtherOperations() = runTest { + var approvalRequested = false + + val policy = DeleteApprovalPolicy( + approvalProvider = { + approvalRequested = true + false + } + ) + + // Read should not require approval + val error = policy.checkOperation(FsOperation.READ, FsPath("/file")) + assert(!approvalRequested) + assertNull(error) + } +} diff --git a/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/shell/ShellFsInterpreterTest.kt b/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/shell/ShellFsInterpreterTest.kt new file mode 100644 index 0000000000..0e9929236b --- /dev/null +++ b/xiuper-fs/src/commonTest/kotlin/cc/unitmesh/xiuper/fs/shell/ShellFsInterpreterTest.kt @@ -0,0 +1,257 @@ +package cc.unitmesh.xiuper.fs.shell + +import cc.unitmesh.xiuper.fs.memory.InMemoryFsBackend +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ShellFsInterpreterTest { + + @Test + fun `mkdir creates directory`() = runTest { + val backend = InMemoryFsBackend() + val shell = ShellFsInterpreter(backend) + + val result = shell.execute("mkdir /projects") + + assertEquals(0, result.exitCode, "mkdir should succeed") + assertEquals("", result.stderr, "No error expected") + } + + @Test + fun `echo writes to file`() = runTest { + val backend = InMemoryFsBackend() + val shell = ShellFsInterpreter(backend) + + shell.execute("mkdir /test") + val result = shell.execute("echo Hello World > /test/hello.txt") + + assertEquals(0, result.exitCode, "echo with redirect should succeed") + + // Verify file was written + val content = shell.execute("cat /test/hello.txt") + assertEquals("Hello World", content.stdout) + } + + @Test + fun `cat reads file content`() = runTest { + val backend = InMemoryFsBackend() + val shell = ShellFsInterpreter(backend) + + // Setup + shell.execute("mkdir /docs") + shell.execute("echo Test Content > /docs/readme.txt") + + // Test cat + val result = shell.execute("cat /docs/readme.txt") + + assertEquals(0, result.exitCode) + assertEquals("Test Content", result.stdout) + } + + @Test + fun `ls lists directory contents`() = runTest { + val backend = InMemoryFsBackend() + val shell = ShellFsInterpreter(backend) + + // Setup + shell.execute("mkdir /data") + shell.execute("echo content1 > /data/file1.txt") + shell.execute("echo content2 > /data/file2.txt") + + // Test ls + val result = shell.execute("ls /data") + + assertEquals(0, result.exitCode) + assertTrue(result.stdout.contains("file1.txt")) + assertTrue(result.stdout.contains("file2.txt")) + } + + @Test + fun `cd changes working directory`() = runTest { + val backend = InMemoryFsBackend() + val shell = ShellFsInterpreter(backend) + + shell.execute("mkdir /workspace") + shell.execute("cd /workspace") + + val pwd = shell.execute("pwd") + assertEquals("/workspace", pwd.stdout) + } + + @Test + fun `pwd prints working directory`() = runTest { + val backend = InMemoryFsBackend() + val shell = ShellFsInterpreter(backend) + + val result = shell.execute("pwd") + + assertEquals(0, result.exitCode) + assertEquals("/", result.stdout) + } + + @Test + fun `relative paths work with working directory`() = runTest { + val backend = InMemoryFsBackend() + val shell = ShellFsInterpreter(backend) + + // Setup + shell.execute("mkdir /projects") + shell.execute("cd /projects") + shell.execute("mkdir subdir") + shell.execute("echo test > file.txt") + + // Test relative paths + val lsResult = shell.execute("ls .") + assertTrue(lsResult.stdout.contains("subdir")) + assertTrue(lsResult.stdout.contains("file.txt")) + + val catResult = shell.execute("cat file.txt") + assertEquals("test", catResult.stdout) + } + + @Test + fun `cp copies file`() = runTest { + val backend = InMemoryFsBackend() + val shell = ShellFsInterpreter(backend) + + // Setup + shell.execute("mkdir /src") + shell.execute("echo original > /src/original.txt") + + // Test cp + val result = shell.execute("cp /src/original.txt /src/copy.txt") + assertEquals(0, result.exitCode) + + // Verify copy + val content = shell.execute("cat /src/copy.txt") + assertEquals("original", content.stdout) + } + + @Test + fun `mv moves file`() = runTest { + val backend = InMemoryFsBackend() + val shell = ShellFsInterpreter(backend) + + // Setup + shell.execute("mkdir /temp") + shell.execute("echo data > /temp/old.txt") + + // Test mv + val result = shell.execute("mv /temp/old.txt /temp/new.txt") + assertEquals(0, result.exitCode) + + // Verify move + val newContent = shell.execute("cat /temp/new.txt") + assertEquals("data", newContent.stdout) + + // Old file should not exist + val oldRead = shell.execute("cat /temp/old.txt") + assertEquals(1, oldRead.exitCode) + } + + @Test + fun `rm removes file`() = runTest { + val backend = InMemoryFsBackend() + val shell = ShellFsInterpreter(backend) + + // Setup + shell.execute("mkdir /trash") + shell.execute("echo delete me > /trash/file.txt") + + // Test rm + val result = shell.execute("rm /trash/file.txt") + assertEquals(0, result.exitCode) + + // Verify deletion + val readResult = shell.execute("cat /trash/file.txt") + assertEquals(1, readResult.exitCode) + } + + @Test + fun `command not found returns error`() = runTest { + val backend = InMemoryFsBackend() + val shell = ShellFsInterpreter(backend) + + val result = shell.execute("nonexistent command") + + assertEquals(127, result.exitCode) + assertTrue(result.stderr.contains("command not found")) + } + + @Test + fun `missing operand returns error`() = runTest { + val backend = InMemoryFsBackend() + val shell = ShellFsInterpreter(backend) + + val mkdirResult = shell.execute("mkdir") + assertEquals(1, mkdirResult.exitCode) + assertTrue(mkdirResult.stderr.contains("missing operand")) + + val catResult = shell.execute("cat") + assertEquals(1, catResult.exitCode) + assertTrue(catResult.stderr.contains("missing")) + } + + @Test + fun `backend switch works`() = runTest { + val backend1 = InMemoryFsBackend() + val backend2 = InMemoryFsBackend() + val shell = ShellFsInterpreter(backend1) + + // Write to backend1 + shell.execute("mkdir /data") + shell.execute("echo backend1 > /data/test.txt") + + // Switch to backend2 + shell.switchBackend(backend2) + + // backend2 should be empty + val result = shell.execute("cat /data/test.txt") + assertEquals(1, result.exitCode) + + // Write to backend2 + shell.execute("mkdir /data") + shell.execute("echo backend2 > /data/test.txt") + + val content = shell.execute("cat /data/test.txt") + assertEquals("backend2", content.stdout) + } + + @Test + fun `complex workflow`() = runTest { + val backend = InMemoryFsBackend() + val shell = ShellFsInterpreter(backend) + + // Create project structure + shell.execute("mkdir /project") + shell.execute("cd /project") + shell.execute("mkdir src") + shell.execute("mkdir docs") + + // Create files + shell.execute("echo fun main() {} > src/Main.kt") + shell.execute("echo # README > docs/README.md") + + // List structure + val lsRoot = shell.execute("ls /project") + assertTrue(lsRoot.stdout.contains("src")) + assertTrue(lsRoot.stdout.contains("docs")) + + val lsSrc = shell.execute("ls src") + assertTrue(lsSrc.stdout.contains("Main.kt")) + + // Copy file + shell.execute("cp src/Main.kt src/Main.backup.kt") + + val lsSrcAfter = shell.execute("ls src") + assertTrue(lsSrcAfter.stdout.contains("Main.backup.kt")) + + // Clean up + shell.execute("rm src/Main.backup.kt") + + val lsSrcFinal = shell.execute("ls src") + assertTrue(!lsSrcFinal.stdout.contains("Main.backup.kt")) + } +} diff --git a/xiuper-fs/src/iosMain/kotlin/cc/unitmesh/xiuper/fs/db/DatabaseDriverFactory.kt b/xiuper-fs/src/iosMain/kotlin/cc/unitmesh/xiuper/fs/db/DatabaseDriverFactory.kt new file mode 100644 index 0000000000..d165049d22 --- /dev/null +++ b/xiuper-fs/src/iosMain/kotlin/cc/unitmesh/xiuper/fs/db/DatabaseDriverFactory.kt @@ -0,0 +1,10 @@ +package cc.unitmesh.xiuper.fs.db + +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.native.NativeSqliteDriver + +actual class DatabaseDriverFactory { + actual fun createDriver(): SqlDriver { + return NativeSqliteDriver(XiuperFsDatabase.Schema, "xiuper-fs.db") + } +} diff --git a/xiuper-fs/src/iosMain/kotlin/cc/unitmesh/xiuper/fs/http/HttpClientFactory.ios.kt b/xiuper-fs/src/iosMain/kotlin/cc/unitmesh/xiuper/fs/http/HttpClientFactory.ios.kt new file mode 100644 index 0000000000..5eed487eef --- /dev/null +++ b/xiuper-fs/src/iosMain/kotlin/cc/unitmesh/xiuper/fs/http/HttpClientFactory.ios.kt @@ -0,0 +1,34 @@ +package cc.unitmesh.xiuper.fs.http + +import io.ktor.client.HttpClient +import io.ktor.client.engine.darwin.Darwin +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.header +import io.ktor.http.HttpHeaders +import io.ktor.http.takeFrom +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json + +actual object HttpClientFactory { + actual fun create(service: RestServiceConfig): HttpClient { + val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + return HttpClient(Darwin) { + expectSuccess = false + + install(ContentNegotiation) { + json(json) + } + + defaultRequest { + url.takeFrom(service.baseUrl) + service.defaultHeaders.forEach { (k, v) -> header(k, v) } + header(HttpHeaders.UserAgent, "XiuperFs/1.0") + } + } + } +} diff --git a/xiuper-fs/src/jsMain/kotlin/cc/unitmesh/xiuper/fs/db/DatabaseDriverFactory.kt b/xiuper-fs/src/jsMain/kotlin/cc/unitmesh/xiuper/fs/db/DatabaseDriverFactory.kt new file mode 100644 index 0000000000..de8301b2a5 --- /dev/null +++ b/xiuper-fs/src/jsMain/kotlin/cc/unitmesh/xiuper/fs/db/DatabaseDriverFactory.kt @@ -0,0 +1,51 @@ +package cc.unitmesh.xiuper.fs.db + +import app.cash.sqldelight.Query +import app.cash.sqldelight.Transacter +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlCursor +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.db.SqlPreparedStatement + +actual class DatabaseDriverFactory { + actual fun createDriver(): SqlDriver { + // Node.js doesn't have a built-in SQLDelight driver here. + // This keeps compilation working; DB backend should not be used on jsNode until a driver is provided. + return NoopSqlDriver + } +} + +private object NoopSqlDriver : SqlDriver { + override fun executeQuery( + identifier: Int?, + sql: String, + mapper: (SqlCursor) -> QueryResult, + parameters: Int, + binders: (SqlPreparedStatement.() -> Unit)?, + ): QueryResult { + throw UnsupportedOperationException("SQLDelight driver not available on jsNode") + } + + override fun execute( + identifier: Int?, + sql: String, + parameters: Int, + binders: (SqlPreparedStatement.() -> Unit)?, + ): QueryResult { + throw UnsupportedOperationException("SQLDelight driver not available on jsNode") + } + + override fun newTransaction(): QueryResult { + throw UnsupportedOperationException("SQLDelight driver not available on jsNode") + } + + override fun currentTransaction(): Transacter.Transaction? = null + + override fun addListener(vararg queryKeys: String, listener: Query.Listener) {} + + override fun removeListener(vararg queryKeys: String, listener: Query.Listener) {} + + override fun notifyListeners(vararg queryKeys: String) {} + + override fun close() {} +} diff --git a/xiuper-fs/src/jsMain/kotlin/cc/unitmesh/xiuper/fs/http/HttpClientFactory.js.kt b/xiuper-fs/src/jsMain/kotlin/cc/unitmesh/xiuper/fs/http/HttpClientFactory.js.kt new file mode 100644 index 0000000000..30c8ac814f --- /dev/null +++ b/xiuper-fs/src/jsMain/kotlin/cc/unitmesh/xiuper/fs/http/HttpClientFactory.js.kt @@ -0,0 +1,34 @@ +package cc.unitmesh.xiuper.fs.http + +import io.ktor.client.HttpClient +import io.ktor.client.engine.js.Js +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.header +import io.ktor.http.HttpHeaders +import io.ktor.http.takeFrom +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json + +actual object HttpClientFactory { + actual fun create(service: RestServiceConfig): HttpClient { + val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + return HttpClient(Js) { + expectSuccess = false + + install(ContentNegotiation) { + json(json) + } + + defaultRequest { + url.takeFrom(service.baseUrl) + service.defaultHeaders.forEach { (k, v) -> header(k, v) } + header(HttpHeaders.UserAgent, "XiuperFs/1.0") + } + } + } +} diff --git a/xiuper-fs/src/jvmMain/kotlin/cc/unitmesh/xiuper/fs/db/DatabaseDriverFactory.kt b/xiuper-fs/src/jvmMain/kotlin/cc/unitmesh/xiuper/fs/db/DatabaseDriverFactory.kt new file mode 100644 index 0000000000..04c234b5a5 --- /dev/null +++ b/xiuper-fs/src/jvmMain/kotlin/cc/unitmesh/xiuper/fs/db/DatabaseDriverFactory.kt @@ -0,0 +1,11 @@ +package cc.unitmesh.xiuper.fs.db + +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver + +actual class DatabaseDriverFactory { + actual fun createDriver(): SqlDriver { + // Intentionally use a file-based DB for production; tests should provide their own driver. + return JdbcSqliteDriver("jdbc:sqlite:xiuper-fs.db") + } +} diff --git a/xiuper-fs/src/jvmMain/kotlin/cc/unitmesh/xiuper/fs/github/examples/GitHubCli.kt b/xiuper-fs/src/jvmMain/kotlin/cc/unitmesh/xiuper/fs/github/examples/GitHubCli.kt new file mode 100644 index 0000000000..da73f9b109 --- /dev/null +++ b/xiuper-fs/src/jvmMain/kotlin/cc/unitmesh/xiuper/fs/github/examples/GitHubCli.kt @@ -0,0 +1,133 @@ +package cc.unitmesh.xiuper.fs.github.examples + +import cc.unitmesh.xiuper.fs.github.GitHubFsBackend +import cc.unitmesh.xiuper.fs.shell.ShellFsInterpreter +import kotlinx.coroutines.runBlocking + +/** + * Simple GitHub repository browser CLI. + * + * Usage: + * ```bash + * # Without token (public repos only, rate limited) + * ./gradlew :xiuper-fs:run + * + * # With token (higher rate limits, private repos) + * GITHUB_TOKEN=ghp_xxxxx ./gradlew :xiuper-fs:run + * ``` + */ +fun main(args: Array) = runBlocking { + println("โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—") + println("โ•‘ GitHub Repository Filesystem Browser โ•‘") + println("โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•") + println() + + val token = System.getenv("GITHUB_TOKEN") + if (token != null) { + println("โœ… Using GITHUB_TOKEN from environment") + } else { + println("โš ๏ธ No GITHUB_TOKEN found") + println(" Set GITHUB_TOKEN=ghp_xxxxx for higher rate limits") + } + println() + + val backend = GitHubFsBackend(token = token) + val shell = ShellFsInterpreter(backend) + + // Interactive mode or example mode + if (args.isNotEmpty()) { + // Execute commands from arguments + for (command in args) { + executeCommand(shell, command) + } + } else { + // Run examples + runExamples(shell) + } +} + +private suspend fun runExamples(shell: ShellFsInterpreter) { + println("Running examples...") + println() + + // Example 1: List VS Code repository + section("Example 1: List microsoft/vscode repository") + executeCommand(shell, "ls /microsoft/vscode/main") + + // Example 2: Read package.json + section("Example 2: Read package.json") + executeCommand(shell, "cat /microsoft/vscode/main/package.json") + + // Example 3: Navigate and list + section("Example 3: Navigate to src directory") + executeCommand(shell, "cd /microsoft/vscode/main") + executeCommand(shell, "pwd") + executeCommand(shell, "ls src") + + // Example 4: Read README from React + section("Example 4: Read React README") + executeCommand(shell, "cat /facebook/react/main/README.md") + + // Example 5: Compare repositories + section("Example 5: Compare popular repositories") + compareRepositories(shell) + + println() + println("โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—") + println("โ•‘ Examples Complete โ•‘") + println("โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•") + println() + println("Try your own commands:") + println(" ./gradlew :xiuper-fs:run --args=\"ls /owner/repo/branch\"") + println(" ./gradlew :xiuper-fs:run --args=\"cat /owner/repo/branch/file.md\"") +} + +private suspend fun executeCommand(shell: ShellFsInterpreter, command: String) { + println("$ $command") + val result = shell.execute(command) + + if (result.exitCode == 0) { + val lines = result.stdout.lines() + if (lines.size > 20) { + // Truncate long output + lines.take(20).forEach { println(it) } + println("... (${lines.size - 20} more lines)") + } else { + println(result.stdout) + } + } else { + println("โŒ Error: ${result.stderr}") + } + println() +} + +private suspend fun compareRepositories(shell: ShellFsInterpreter) { + val repos = listOf( + "/microsoft/vscode/main" to "VS Code", + "/facebook/react/main" to "React", + "/vuejs/core/main" to "Vue.js", + "/angular/angular/main" to "Angular" + ) + + println("๐Ÿ“Š Repository Comparison:") + println() + + for ((path, name) in repos) { + print("$name: ") + val result = shell.execute("ls $path") + if (result.exitCode == 0) { + val count = result.stdout.lines().filter { it.isNotBlank() }.size + println("$count items") + } else { + println("โŒ Failed to access") + } + } +} + +private fun section(title: String) { + println() + println("โ”€".repeat(50)) + println(title) + println("โ”€".repeat(50)) + println() +} diff --git a/xiuper-fs/src/jvmMain/kotlin/cc/unitmesh/xiuper/fs/github/examples/GitHubShellExample.kt b/xiuper-fs/src/jvmMain/kotlin/cc/unitmesh/xiuper/fs/github/examples/GitHubShellExample.kt new file mode 100644 index 0000000000..1f8a50fe2e --- /dev/null +++ b/xiuper-fs/src/jvmMain/kotlin/cc/unitmesh/xiuper/fs/github/examples/GitHubShellExample.kt @@ -0,0 +1,236 @@ +package cc.unitmesh.xiuper.fs.github.examples + +import cc.unitmesh.xiuper.fs.github.GitHubFsBackend +import cc.unitmesh.xiuper.fs.shell.ShellFsInterpreter +import kotlinx.coroutines.runBlocking + +/** + * Interactive GitHub repository browser using shell commands. + * + * Run this example to explore GitHub repositories through familiar shell commands. + * + * Usage: + * ```bash + * ./gradlew :xiuper-fs:jvmRun + * ``` + * + * Then try commands like: + * - ls /microsoft/vscode/main + * - cat /microsoft/vscode/main/README.md + * - cd /facebook/react/main + * - ls . + */ +fun main() = runBlocking { + println("๐Ÿš€ GitHub Filesystem Shell") + println("Navigate GitHub repositories using shell commands!") + println() + + val token = System.getenv("GITHUB_TOKEN") + if (token != null) { + println("โœ… Using GITHUB_TOKEN from environment") + } else { + println("โš ๏ธ No GITHUB_TOKEN found. Using public API (rate limited)") + } + println() + + val backend = GitHubFsBackend(token = token) + val shell = ShellFsInterpreter(backend) + + // Example 1: Explore VS Code + println("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•") + println("Example 1: Exploring microsoft/vscode") + println("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•") + + executeAndPrint(shell, "ls /microsoft/vscode/main") + println() + + executeAndPrint(shell, "cat /microsoft/vscode/main/package.json") + println() + + // Example 2: Navigate with cd + println("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•") + println("Example 2: Navigation with cd and pwd") + println("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•") + + executeAndPrint(shell, "cd /microsoft/vscode/main/src") + executeAndPrint(shell, "pwd") + executeAndPrint(shell, "ls .") + println() + + // Example 3: Read README + println("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•") + println("Example 3: Reading README.md") + println("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•") + + executeAndPrint(shell, "cd /facebook/react/main") + executeAndPrint(shell, "cat README.md", maxLines = 20) + println() + + // Example 4: Multiple repositories + println("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•") + println("Example 4: Comparing Multiple Repositories") + println("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•") + + val repos = listOf( + "/microsoft/vscode/main", + "/facebook/react/main", + "/vuejs/core/main" + ) + + for (repo in repos) { + println("\n๐Ÿ“ฆ Repository: $repo") + val result = shell.execute("ls $repo") + if (result.exitCode == 0) { + val files = result.stdout.split("\n").take(5) + files.forEach { println(" - $it") } + if (result.stdout.split("\n").size > 5) { + println(" ... and more") + } + } + } + + println() + println("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•") + println("โœจ Try it yourself!") + println("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•") + println("Commands you can try:") + println(" ls /owner/repo/branch") + println(" cat /owner/repo/branch/file.md") + println(" cd /owner/repo/branch/directory") + println(" pwd") + println() + println("Popular repositories to explore:") + println(" - /microsoft/vscode/main") + println(" - /facebook/react/main") + println(" - /vuejs/core/main") + println(" - /angular/angular/main") + println(" - /tensorflow/tensorflow/master") + println(" - /kubernetes/kubernetes/master") +} + +private suspend fun executeAndPrint( + shell: ShellFsInterpreter, + command: String, + maxLines: Int? = null +) { + println("$ $command") + val result = shell.execute(command) + + if (result.exitCode == 0) { + val output = if (maxLines != null) { + result.stdout.lines().take(maxLines).joinToString("\n") + } else { + result.stdout + } + println(output) + if (maxLines != null && result.stdout.lines().size > maxLines) { + println("... (${result.stdout.lines().size - maxLines} more lines)") + } + } else { + println("Error: ${result.stderr}") + } +} + +/** + * Repository analysis example + */ +suspend fun analyzeRepositoryExample() { + val backend = GitHubFsBackend() + val shell = ShellFsInterpreter(backend) + + println("๐Ÿ“Š Repository Analysis") + println() + + val repoPath = "/microsoft/vscode/main" + shell.execute("cd $repoPath") + + // Count files by type + val files = shell.execute("ls .").stdout.split("\n") + + val stats = files.groupingBy { file -> + when { + file.endsWith(".md") -> "Markdown" + file.endsWith(".json") -> "JSON" + file.endsWith(".js") || file.endsWith(".ts") -> "Code" + file.endsWith(".yml") || file.endsWith(".yaml") -> "YAML" + else -> "Other" + } + }.eachCount() + + println("File Statistics for $repoPath:") + stats.forEach { (type, count) -> + println(" $type: $count files") + } +} + +/** + * Search for content across repositories + */ +suspend fun searchContentExample(pattern: String) { + val backend = GitHubFsBackend() + val shell = ShellFsInterpreter(backend) + + println("๐Ÿ” Searching for: $pattern") + println() + + val repos = listOf( + "/microsoft/vscode/main", + "/facebook/react/main" + ) + + for (repo in repos) { + println("Searching in $repo...") + + // Read README + val readme = shell.execute("cat $repo/README.md") + if (readme.exitCode == 0 && readme.stdout.contains(pattern, ignoreCase = true)) { + println(" โœ… Found in README.md") + + // Show context + val lines = readme.stdout.lines() + lines.forEachIndexed { index, line -> + if (line.contains(pattern, ignoreCase = true)) { + val start = maxOf(0, index - 1) + val end = minOf(lines.size, index + 2) + println(" Context:") + lines.subList(start, end).forEach { println(" $it") } + } + } + } + println() + } +} + +/** + * Documentation index generator + */ +suspend fun generateDocsIndexExample() { + val backend = GitHubFsBackend() + val shell = ShellFsInterpreter(backend) + + println("๐Ÿ“š Documentation Index Generator") + println() + + val repoPath = "/microsoft/vscode/main" + shell.execute("cd $repoPath") + + // Find all markdown files in root + val files = shell.execute("ls .").stdout.split("\n") + val mdFiles = files.filter { it.endsWith(".md") } + + println("# Documentation Index") + println() + + for (file in mdFiles) { + val content = shell.execute("cat $file") + if (content.exitCode == 0) { + // Extract first heading + val firstHeading = content.stdout.lines() + .firstOrNull { it.startsWith("#") } + ?.trim() + ?: file + + println("- [$firstHeading]($file)") + } + } +} diff --git a/xiuper-fs/src/jvmMain/kotlin/cc/unitmesh/xiuper/fs/http/HttpClientFactory.jvm.kt b/xiuper-fs/src/jvmMain/kotlin/cc/unitmesh/xiuper/fs/http/HttpClientFactory.jvm.kt new file mode 100644 index 0000000000..0cb7eaa060 --- /dev/null +++ b/xiuper-fs/src/jvmMain/kotlin/cc/unitmesh/xiuper/fs/http/HttpClientFactory.jvm.kt @@ -0,0 +1,34 @@ +package cc.unitmesh.xiuper.fs.http + +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.header +import io.ktor.http.HttpHeaders +import io.ktor.http.takeFrom +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json + +actual object HttpClientFactory { + actual fun create(service: RestServiceConfig): HttpClient { + val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + return HttpClient(CIO) { + expectSuccess = false + + install(ContentNegotiation) { + json(json) + } + + defaultRequest { + url.takeFrom(service.baseUrl) + service.defaultHeaders.forEach { (k, v) -> header(k, v) } + header(HttpHeaders.UserAgent, "XiuperFs/1.0") + } + } + } +} diff --git a/xiuper-fs/src/jvmTest/kotlin/cc/unitmesh/xiuper/fs/conformance/DbPosixSubsetConformanceTest.kt b/xiuper-fs/src/jvmTest/kotlin/cc/unitmesh/xiuper/fs/conformance/DbPosixSubsetConformanceTest.kt new file mode 100644 index 0000000000..78b4358a81 --- /dev/null +++ b/xiuper-fs/src/jvmTest/kotlin/cc/unitmesh/xiuper/fs/conformance/DbPosixSubsetConformanceTest.kt @@ -0,0 +1,219 @@ +package cc.unitmesh.xiuper.fs.conformance + +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import cc.unitmesh.xiuper.fs.* +import cc.unitmesh.xiuper.fs.db.DbFsBackend +import cc.unitmesh.xiuper.fs.db.XiuperFsDatabase +import kotlinx.datetime.Clock +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.test.fail + +class DbPosixSubsetConformanceTest { + private data class ConformanceTarget( + val fs: XiuperFileSystem, + val capabilities: BackendCapabilities + ) + + private fun newTarget(): ConformanceTarget { + val driver: SqlDriver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + XiuperFsDatabase.Schema.create(driver) + val database = XiuperFsDatabase(driver) + + database.fsNodeQueries.upsertNode( + path = "/", + isDir = 1L, + content = null, + mtimeEpochMillis = Clock.System.now().toEpochMilliseconds(), + ) + + val backend = DbFsBackend(database) + val capabilities = (backend as? CapabilityAwareBackend)?.capabilities ?: BackendCapabilities() + val fs = XiuperVfs( + mounts = listOf( + Mount(FsPath.of("/"), backend) + ) + ) + return ConformanceTarget(fs, capabilities) + } + + @Test + fun rootAlwaysExistsAndIsDirectory() = runTest { + val (fs) = newTarget() + val st = fs.stat(FsPath.of("/")) + assertTrue(st.isDirectory) + assertEquals("/", st.path.value) + + val entries = fs.list(FsPath.of("/")) + assertEquals(0, entries.size) + } + + @Test + fun listNonexistentIsEnoent() = runTest { + val (fs) = newTarget() + assertFsError(FsErrorCode.ENOENT) { + fs.list(FsPath.of("/missing")) + } + } + + @Test + fun mkdirRequiresParentAndIsNotMkdirP() = runTest { + val (fs, caps) = newTarget() + if (!caps.supportsMkdir) { + assertFsError(FsErrorCode.ENOTSUP) { + fs.mkdir(FsPath.of("/a/b")) + } + return@runTest + } + + assertFsError(FsErrorCode.ENOENT) { + fs.mkdir(FsPath.of("/a/b")) + } + + fs.mkdir(FsPath.of("/a")) + fs.mkdir(FsPath.of("/a/b")) + + val st = fs.stat(FsPath.of("/a/b")) + assertTrue(st.isDirectory) + } + + @Test + fun mkdirExistingIsEexist() = runTest { + val (fs, caps) = newTarget() + if (!caps.supportsMkdir) { + assertFsError(FsErrorCode.ENOTSUP) { + fs.mkdir(FsPath.of("/dir")) + } + return@runTest + } + fs.mkdir(FsPath.of("/dir")) + assertFsError(FsErrorCode.EEXIST) { + fs.mkdir(FsPath.of("/dir")) + } + + assertFsError(FsErrorCode.EEXIST) { + fs.mkdir(FsPath.of("/")) + } + } + + @Test + fun writeRequiresParentDir() = runTest { + val (fs, caps) = newTarget() + assertFsError(FsErrorCode.ENOENT) { + fs.write(FsPath.of("/a/file.txt"), "x".encodeToByteArray()) + } + + if (!caps.supportsMkdir) { + return@runTest + } + + fs.mkdir(FsPath.of("/a")) + assertTrue(fs.write(FsPath.of("/a/file.txt"), "hello".encodeToByteArray()).ok) + + val read = fs.read(FsPath.of("/a/file.txt")).textOrNull() + assertEquals("hello", read) + } + + @Test + fun writeIsCreateOrTruncate() = runTest { + val (fs, caps) = newTarget() + if (!caps.supportsMkdir) return@runTest + fs.mkdir(FsPath.of("/d")) + + fs.write(FsPath.of("/d/f"), "hello".encodeToByteArray()) + fs.write(FsPath.of("/d/f"), "hi".encodeToByteArray()) + + assertEquals("hi", fs.read(FsPath.of("/d/f")).textOrNull()) + val st = fs.stat(FsPath.of("/d/f")) + assertEquals(2, st.size) + assertTrue(!st.isDirectory) + } + + @Test + fun typeErrorsForReadAndList() = runTest { + val (fs, caps) = newTarget() + if (!caps.supportsMkdir) return@runTest + fs.mkdir(FsPath.of("/d")) + fs.write(FsPath.of("/d/f"), "x".encodeToByteArray()) + + assertFsError(FsErrorCode.EISDIR) { + fs.read(FsPath.of("/d")) + } + + assertFsError(FsErrorCode.ENOTDIR) { + fs.list(FsPath.of("/d/f")) + } + } + + @Test + fun deleteFileAndEmptyDirectory() = runTest { + val (fs, caps) = newTarget() + if (!caps.supportsMkdir) return@runTest + if (!caps.supportsDelete) { + assertFsError(FsErrorCode.ENOTSUP) { + fs.delete(FsPath.of("/d")) + } + return@runTest + } + fs.mkdir(FsPath.of("/d")) + fs.write(FsPath.of("/d/f"), "x".encodeToByteArray()) + + fs.delete(FsPath.of("/d/f")) + assertFsError(FsErrorCode.ENOENT) { + fs.read(FsPath.of("/d/f")) + } + + fs.delete(FsPath.of("/d")) + assertFsError(FsErrorCode.ENOENT) { + fs.stat(FsPath.of("/d")) + } + } + + @Test + fun deleteNonEmptyDirectoryIsEnotempty() = runTest { + val (fs, caps) = newTarget() + if (!caps.supportsMkdir) return@runTest + if (!caps.supportsDelete) { + assertFsError(FsErrorCode.ENOTSUP) { + fs.delete(FsPath.of("/d")) + } + return@runTest + } + fs.mkdir(FsPath.of("/d")) + fs.write(FsPath.of("/d/f"), "x".encodeToByteArray()) + + assertFsError(FsErrorCode.ENOTEMPTY) { + fs.delete(FsPath.of("/d")) + } + } + + @Test + fun deleteRootIsEacces() = runTest { + val (fs, caps) = newTarget() + val expected = if (caps.supportsDelete) FsErrorCode.EACCES else FsErrorCode.ENOTSUP + assertFsError(expected) { + fs.delete(FsPath.of("/")) + } + } + + @Test + fun commitIsNoOpAndOk() = runTest { + val (fs) = newTarget() + val r = fs.commit(FsPath.of("/")) + assertTrue(r.ok) + } + + private suspend fun assertFsError(code: FsErrorCode, block: suspend () -> Unit) { + try { + block() + fail("Expected FsException($code)") + } catch (e: FsException) { + assertEquals(code, e.code) + assertNotNull(e.message) + } + } +} diff --git a/xiuper-fs/src/jvmTest/kotlin/cc/unitmesh/xiuper/fs/conformance/RestFsConformanceTest.kt b/xiuper-fs/src/jvmTest/kotlin/cc/unitmesh/xiuper/fs/conformance/RestFsConformanceTest.kt new file mode 100644 index 0000000000..2ec3672c32 --- /dev/null +++ b/xiuper-fs/src/jvmTest/kotlin/cc/unitmesh/xiuper/fs/conformance/RestFsConformanceTest.kt @@ -0,0 +1,29 @@ +package cc.unitmesh.xiuper.fs.conformance + +import cc.unitmesh.xiuper.fs.XiuperFileSystem +import cc.unitmesh.xiuper.fs.http.RestFsBackend +import cc.unitmesh.xiuper.fs.http.RestServiceConfig +import cc.unitmesh.xiuper.fs.http.HttpClientFactory +import kotlin.test.Test +import kotlinx.coroutines.test.runTest + +/** + * Conformance tests for REST filesystem backend. + * Verifies capability-aware behavior: + * - REST backend declares supportsMkdir=false, supportsDelete=false + * - Conformance tests should pass by asserting ENOTSUP for these operations + * + * NOTE: This is a placeholder - real REST conformance requires a mock HTTP server. + * For now, we just verify that RestFsBackend properly declares its capabilities. + */ +class RestFsConformanceTest { + @Test + fun restBackendDeclaresReadOnlyCapabilities() = runTest { + val config = RestServiceConfig(baseUrl = "http://mock.local") + val backend = RestFsBackend(config) + + // REST backend should declare it doesn't support mkdir/delete + assert(!backend.capabilities.supportsMkdir) { "REST backend should not support mkdir" } + assert(!backend.capabilities.supportsDelete) { "REST backend should not support delete" } + } +} diff --git a/xiuper-fs/src/jvmTest/kotlin/cc/unitmesh/xiuper/fs/db/migrations/MigrationTest.kt b/xiuper-fs/src/jvmTest/kotlin/cc/unitmesh/xiuper/fs/db/migrations/MigrationTest.kt new file mode 100644 index 0000000000..f3c9606518 --- /dev/null +++ b/xiuper-fs/src/jvmTest/kotlin/cc/unitmesh/xiuper/fs/db/migrations/MigrationTest.kt @@ -0,0 +1,124 @@ +package cc.unitmesh.xiuper.fs.db.migrations + +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import cc.unitmesh.xiuper.fs.db.XiuperFsDatabase +import cc.unitmesh.xiuper.fs.db.createDatabase +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class MigrationTest { + + @Test + fun freshDatabaseCreatesLatestSchemaAndSetsVersion() { + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + + val database = createDatabase(driver) + + // Verify schema version was set + val version = getUserVersion(driver) + assertEquals(XiuperFsDatabase.Schema.version.toInt(), version) + + // Verify schema was created + val tables = getTableNames(driver) + assertTrue(tables.contains("FsNode"), "FsNode table should exist") + } + + @Test + fun upToDateDatabaseSkipsMigrations() { + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + + // Create initial DB + XiuperFsDatabase.Schema.create(driver) + setUserVersion(driver, XiuperFsDatabase.Schema.version.toInt()) + + // Open again (should be no-op) + val database = createDatabase(driver) + + val version = getUserVersion(driver) + assertEquals(XiuperFsDatabase.Schema.version.toInt(), version) + } + + @Test + fun futureVersionThrowsError() { + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + + XiuperFsDatabase.Schema.create(driver) + val futureVersion = XiuperFsDatabase.Schema.version.toInt() + 100 + setUserVersion(driver, futureVersion) + + val exception = assertFailsWith { + createDatabase(driver) + } + assertTrue(exception.message!!.contains("newer than supported")) + } + + @Test + fun migrationRegistryRejectsDowngrade() { + val exception = assertFailsWith { + MigrationRegistry.path(current = 2, target = 1) + } + assertTrue(exception.message!!.contains("Downgrade")) + } + + @Test + fun migrationRegistryReturnsEmptyForSameVersion() { + val path = MigrationRegistry.path(current = 1, target = 1) + assertTrue(path.isEmpty()) + } + + @Test + fun migrationRegistryThrowsIfNoPathExists() { + // Currently registry has v1โ†’v2; v2โ†’v3 should fail + val exception = assertFailsWith { + MigrationRegistry.path(current = 2, target = 3) + } + assertTrue(exception.message!!.contains("No migration found")) + } + + // Helper functions + + private fun getUserVersion(driver: SqlDriver): Int { + return driver.executeQuery( + identifier = null, + sql = "PRAGMA user_version", + mapper = { cursor -> + if (cursor.next().value) { + QueryResult.Value(cursor.getLong(0)?.toInt() ?: 0) + } else { + QueryResult.Value(0) + } + }, + parameters = 0, + binders = null + ).value + } + + private fun setUserVersion(driver: SqlDriver, version: Int) { + driver.execute( + identifier = null, + sql = "PRAGMA user_version = $version", + parameters = 0, + binders = null + ) + } + + private fun getTableNames(driver: SqlDriver): List { + return driver.executeQuery( + identifier = null, + sql = "SELECT name FROM sqlite_master WHERE type='table'", + mapper = { cursor -> + val names = mutableListOf() + while (cursor.next().value) { + cursor.getString(0)?.let { names.add(it) } + } + QueryResult.Value(names) + }, + parameters = 0, + binders = null + ).value + } +} diff --git a/xiuper-fs/src/jvmTest/kotlin/cc/unitmesh/xiuper/fs/db/migrations/MigrationUpgradeTest.kt b/xiuper-fs/src/jvmTest/kotlin/cc/unitmesh/xiuper/fs/db/migrations/MigrationUpgradeTest.kt new file mode 100644 index 0000000000..237c790e74 --- /dev/null +++ b/xiuper-fs/src/jvmTest/kotlin/cc/unitmesh/xiuper/fs/db/migrations/MigrationUpgradeTest.kt @@ -0,0 +1,145 @@ +package cc.unitmesh.xiuper.fs.db.migrations + +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import cc.unitmesh.xiuper.fs.db.XiuperFsDatabase +import cc.unitmesh.xiuper.fs.db.createDatabase +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Tests for actual migration upgrade paths. + * Verifies schema evolution and data integrity across versions. + */ +class MigrationUpgradeTest { + + @Test + fun migrateFromV1ToV2AddsXattrTable() { + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + + // Create v1 schema manually + driver.execute(null, """ + CREATE TABLE IF NOT EXISTS FsNode ( + path TEXT NOT NULL PRIMARY KEY, + isDir INTEGER NOT NULL, + content BLOB, + mtimeEpochMillis INTEGER NOT NULL + ) + """.trimIndent(), 0, null) + + // Insert some v1 data + driver.execute(null, """ + INSERT INTO FsNode(path, isDir, content, mtimeEpochMillis) + VALUES ('/test', 0, X'68656C6C6F', 1000000) + """.trimIndent(), 0, null) + + // Set version to 1 + driver.execute(null, "PRAGMA user_version = 1", 0, null) + + // Now apply migration to v2 + val database = createDatabase(driver) + + // Verify version updated to 2 (not just target, but specifically v2) + val version = getUserVersion(driver) + assertEquals(2, version, "Schema version should be 2 after migration") + + // Verify FsXattr table exists + val tables = getTableNames(driver) + assertTrue(tables.contains("FsXattr"), "FsXattr table should exist after migration") + + // Verify old data intact + val db = XiuperFsDatabase(driver) + val node = db.fsNodeQueries.selectByPath("/test").executeAsOne() + assertEquals("/test", node.path) + assertEquals(0L, node.isDir) + + // Verify we can insert xattr + driver.execute(null, """ + INSERT INTO FsXattr(path, name, value) + VALUES ('/test', 'user.comment', X'746573742064617461') + """.trimIndent(), 0, null) + + val xattr = driver.executeQuery(null, + "SELECT name, value FROM FsXattr WHERE path = '/test'", + { cursor -> + cursor.next() + val name = cursor.getString(0) + val value = cursor.getBytes(1) + QueryResult.Value(name to value) + }, 0, null).value + + assertEquals("user.comment", xattr.first) + assertTrue(xattr.second != null) + } + + @Test + fun multiStepMigrationPreservesData() { + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + + // Create v1 schema + driver.execute(null, """ + CREATE TABLE IF NOT EXISTS FsNode ( + path TEXT NOT NULL PRIMARY KEY, + isDir INTEGER NOT NULL, + content BLOB, + mtimeEpochMillis INTEGER NOT NULL + ) + """.trimIndent(), 0, null) + + driver.execute(null, """ + INSERT INTO FsNode(path, isDir, content, mtimeEpochMillis) + VALUES ('/', 1, NULL, 900000), + ('/file.txt', 0, X'64617461', 1000000) + """.trimIndent(), 0, null) + + driver.execute(null, "PRAGMA user_version = 1", 0, null) + + // Migrate to latest + createDatabase(driver) + + // Verify all data preserved + val db = XiuperFsDatabase(driver) + val nodes = db.fsNodeQueries.selectAll().executeAsList() + assertEquals(2, nodes.size) + + val root = nodes.find { it.path == "/" } + assertTrue(root != null && root.isDir == 1L) + + val file = nodes.find { it.path == "/file.txt" } + assertTrue(file != null && file.isDir == 0L) + } + + private fun getUserVersion(driver: SqlDriver): Int { + return driver.executeQuery( + identifier = null, + sql = "PRAGMA user_version", + mapper = { cursor -> + if (cursor.next().value) { + QueryResult.Value(cursor.getLong(0)?.toInt() ?: 0) + } else { + QueryResult.Value(0) + } + }, + parameters = 0, + binders = null + ).value + } + + private fun getTableNames(driver: SqlDriver): List { + return driver.executeQuery( + identifier = null, + sql = "SELECT name FROM sqlite_master WHERE type='table'", + mapper = { cursor -> + val names = mutableListOf() + while (cursor.next().value) { + cursor.getString(0)?.let { names.add(it) } + } + QueryResult.Value(names) + }, + parameters = 0, + binders = null + ).value + } +} diff --git a/xiuper-fs/src/jvmTest/kotlin/cc/unitmesh/xiuper/fs/mcp/McpFilesystemServerIntegrationTest.kt b/xiuper-fs/src/jvmTest/kotlin/cc/unitmesh/xiuper/fs/mcp/McpFilesystemServerIntegrationTest.kt new file mode 100644 index 0000000000..161e37753d --- /dev/null +++ b/xiuper-fs/src/jvmTest/kotlin/cc/unitmesh/xiuper/fs/mcp/McpFilesystemServerIntegrationTest.kt @@ -0,0 +1,114 @@ +package cc.unitmesh.xiuper.fs.mcp + +import io.modelcontextprotocol.kotlin.sdk.Implementation +import io.modelcontextprotocol.kotlin.sdk.client.Client +import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.io.asSink +import kotlinx.io.asSource +import kotlinx.io.buffered +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.nio.file.Files +import kotlin.io.path.absolutePathString +import kotlin.io.path.createDirectories +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class McpFilesystemServerIntegrationTest { + + @Test + fun `server-filesystem works with DefaultMcpBackend`() = kotlinx.coroutines.test.runTest { + if (System.getenv("RUN_MCP_INTEGRATION_TESTS") != "true") { + println("Skipping MCP integration test (set RUN_MCP_INTEGRATION_TESTS=true to enable)") + return@runTest + } + + val hasNpx = withContext(Dispatchers.IO) { + runCatching { ProcessBuilder("npx", "--version").start().waitFor() == 0 }.getOrDefault(false) + } + if (!hasNpx) { + println("Skipping MCP integration test (npx not available)") + return@runTest + } + + val tmpDir = Files.createTempDirectory("xiuper-mcp-fs-") + val allowedRoot = tmpDir.resolve("allowed").createDirectories() + val helloFile = allowedRoot.resolve("hello.txt") + helloFile.writeText("hello from mcp") + + val process = withContext(Dispatchers.IO) { + ProcessBuilder( + "npx", + "-y", + "@modelcontextprotocol/server-filesystem", + allowedRoot.absolutePathString(), + ).redirectErrorStream(true).start() + } + + val client = Client(clientInfo = Implementation(name = "XiuperFs-MCP-Test", version = "1.0.0")) + + try { + withTimeout(120_000) { + withContext(Dispatchers.IO) { + val input = process.inputStream.asSource().buffered() + val output = process.outputStream.asSink().buffered() + val transport = StdioClientTransport(input, output) + + client.connect(transport) + + val backend = DefaultMcpBackend(client) + + // Validate tools are discoverable + val toolsDir = backend.list(cc.unitmesh.xiuper.fs.FsPath.of("/tools")) + assertTrue(toolsDir.isNotEmpty(), "Expected MCP server to expose tools") + + // Validate we can execute list_directory + backend.write( + cc.unitmesh.xiuper.fs.FsPath.of("/tools/list_directory/args"), + buildJsonObject { + put("path", allowedRoot.absolutePathString()) + }.toString().encodeToByteArray(), + cc.unitmesh.xiuper.fs.WriteOptions() + ) + val listResult = backend.write( + cc.unitmesh.xiuper.fs.FsPath.of("/tools/list_directory/run"), + ByteArray(0), + cc.unitmesh.xiuper.fs.WriteOptions() + ) + assertTrue(listResult.ok, "list_directory should succeed") + + // Validate we can execute read_file and get content back + backend.write( + cc.unitmesh.xiuper.fs.FsPath.of("/tools/read_file/args"), + buildJsonObject { + put("path", helloFile.absolutePathString()) + }.toString().encodeToByteArray(), + cc.unitmesh.xiuper.fs.WriteOptions() + ) + val readResult = backend.write( + cc.unitmesh.xiuper.fs.FsPath.of("/tools/read_file/run"), + ByteArray(0), + cc.unitmesh.xiuper.fs.WriteOptions() + ) + assertTrue(readResult.ok, "read_file should succeed") + assertNotNull(readResult.message) + } + } + } finally { + runCatching { withContext(Dispatchers.IO) { client.close() } } + runCatching { withContext(Dispatchers.IO) { process.destroy() } } + runCatching { + withContext(Dispatchers.IO) { + if (process.isAlive) process.destroyForcibly() + } + } + } + } +} diff --git a/xiuper-fs/src/jvmTest/kotlin/cc/unitmesh/xiuper/fs/shell/DbFsShellSmokeTest.kt b/xiuper-fs/src/jvmTest/kotlin/cc/unitmesh/xiuper/fs/shell/DbFsShellSmokeTest.kt new file mode 100644 index 0000000000..c438edb93b --- /dev/null +++ b/xiuper-fs/src/jvmTest/kotlin/cc/unitmesh/xiuper/fs/shell/DbFsShellSmokeTest.kt @@ -0,0 +1,53 @@ +package cc.unitmesh.xiuper.fs.shell + +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import cc.unitmesh.xiuper.fs.db.DbFsBackend +import cc.unitmesh.xiuper.fs.db.XiuperFsDatabase +import kotlinx.datetime.Clock +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DbFsShellSmokeTest { + + private fun newDbBackend(): DbFsBackend { + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + XiuperFsDatabase.Schema.create(driver) + val database = XiuperFsDatabase(driver) + + database.fsNodeQueries.upsertNode( + path = "/", + isDir = 1L, + content = null, + mtimeEpochMillis = Clock.System.now().toEpochMilliseconds(), + ) + + return DbFsBackend(database) + } + + @Test + fun `db backend works via shell commands`() = runTest { + val shell = ShellFsInterpreter(newDbBackend()) + + assertEquals(0, shell.execute("mkdir /data").exitCode) + assertEquals(0, shell.execute("echo hello sqlite > /data/hello.txt").exitCode) + + val cat = shell.execute("cat /data/hello.txt") + assertEquals(0, cat.exitCode) + assertEquals("hello sqlite", cat.stdout) + + val ls = shell.execute("ls /data") + assertEquals(0, ls.exitCode) + assertTrue(ls.stdout.contains("hello.txt")) + + assertEquals(0, shell.execute("cp /data/hello.txt /data/copy.txt").exitCode) + assertEquals("hello sqlite", shell.execute("cat /data/copy.txt").stdout) + + assertEquals(0, shell.execute("mv /data/copy.txt /data/moved.txt").exitCode) + assertEquals("hello sqlite", shell.execute("cat /data/moved.txt").stdout) + + assertEquals(0, shell.execute("rm /data/moved.txt").exitCode) + assertEquals(1, shell.execute("cat /data/moved.txt").exitCode) + } +} diff --git a/xiuper-fs/src/wasmJsMain/kotlin/cc/unitmesh/xiuper/fs/db/DatabaseDriverFactory.kt b/xiuper-fs/src/wasmJsMain/kotlin/cc/unitmesh/xiuper/fs/db/DatabaseDriverFactory.kt new file mode 100644 index 0000000000..a70ad54d8c --- /dev/null +++ b/xiuper-fs/src/wasmJsMain/kotlin/cc/unitmesh/xiuper/fs/db/DatabaseDriverFactory.kt @@ -0,0 +1,21 @@ +package cc.unitmesh.xiuper.fs.db + +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.worker.WebWorkerDriver + +actual class DatabaseDriverFactory { + actual fun createDriver(): SqlDriver { + // Requires browser environment; wasmJs tests/build should still compile. + return WebWorkerDriver( + WorkerJs.worker(), + ) + } +} + +private object WorkerJs { + fun worker(): dynamic { + // See @cashapp/sqldelight-sqljs-worker docs; this is a lightweight bridge. + // In actual browser setup, bundler will provide Worker. + return js("new Worker(new URL('@cashapp/sqldelight-sqljs-worker/sqljs.worker.js', import.meta.url), { type: 'module' })") + } +} diff --git a/xiuper-fs/src/wasmJsMain/kotlin/cc/unitmesh/xiuper/fs/http/HttpClientFactory.wasmJs.kt b/xiuper-fs/src/wasmJsMain/kotlin/cc/unitmesh/xiuper/fs/http/HttpClientFactory.wasmJs.kt new file mode 100644 index 0000000000..30c8ac814f --- /dev/null +++ b/xiuper-fs/src/wasmJsMain/kotlin/cc/unitmesh/xiuper/fs/http/HttpClientFactory.wasmJs.kt @@ -0,0 +1,34 @@ +package cc.unitmesh.xiuper.fs.http + +import io.ktor.client.HttpClient +import io.ktor.client.engine.js.Js +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.header +import io.ktor.http.HttpHeaders +import io.ktor.http.takeFrom +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json + +actual object HttpClientFactory { + actual fun create(service: RestServiceConfig): HttpClient { + val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + return HttpClient(Js) { + expectSuccess = false + + install(ContentNegotiation) { + json(json) + } + + defaultRequest { + url.takeFrom(service.baseUrl) + service.defaultHeaders.forEach { (k, v) -> header(k, v) } + header(HttpHeaders.UserAgent, "XiuperFs/1.0") + } + } + } +} diff --git a/xiuper-fs/xiuper-fs-db-migration-design.md b/xiuper-fs/xiuper-fs-db-migration-design.md new file mode 100644 index 0000000000..043f172aee --- /dev/null +++ b/xiuper-fs/xiuper-fs-db-migration-design.md @@ -0,0 +1,261 @@ +# XiuperFS Database Migration Design + +## Overview + +XiuperFS DB backend requires a migration strategy to handle schema evolution across versions, ensuring data integrity and backward/forward compatibility where feasible. + +## Design Principles + +1. **Explicit Versioning**: Use SQLite `PRAGMA user_version` to track current schema version +2. **Additive Migrations**: Prefer adding columns/tables over modifying existing structures +3. **Single-Direction Upgrades**: Focus on forward migrations (v1โ†’v2โ†’v3); downgrades are opt-in +4. **Fail-Safe Initialization**: If user_version = 0, create latest schema directly; else apply incremental migrations +5. **No ORM Magic**: Keep migrations explicit and SQL-based for cross-platform transparency + +## Schema Version Management + +### Version Numbering + +- **Version 1 (Current)**: Initial schema with `FsNode` table (path, isDir, content, mtimeEpochMillis) +- **Future Versions**: Incremental integer (2, 3, ...) per breaking or additive change + +### Version Storage + +```sql +-- Read current version +PRAGMA user_version; + +-- Set version after migration +PRAGMA user_version = ; +``` + +## Migration Architecture + +### File Structure + +``` +xiuper-fs/src/commonMain/kotlin/cc/unitmesh/xiuper/fs/db/ +โ”œโ”€โ”€ DbFsBackend.kt # Main backend (unchanged API) +โ”œโ”€โ”€ DatabaseDriverFactory.kt # Expect/actual driver factories (existing) +โ”œโ”€โ”€ migrations/ +โ”‚ โ”œโ”€โ”€ Migration.kt # Migration interface + registry +โ”‚ โ”œโ”€โ”€ Migration_1_to_2.kt # Example: add xattr support +โ”‚ โ””โ”€โ”€ Migration_2_to_3.kt # Future migrations +โ””โ”€โ”€ XiuperFsDatabase.sq # (generated; we work with FsNode.sq) +``` + +### Migration Interface + +```kotlin +package cc.unitmesh.xiuper.fs.db.migrations + +import app.cash.sqldelight.db.SqlDriver + +/** + * Represents a single migration step from [fromVersion] to [toVersion]. + */ +interface Migration { + val fromVersion: Int + val toVersion: Int + val description: String + + /** + * Apply the migration to [driver]. + * Throws if migration fails; caller rolls back or aborts. + */ + fun migrate(driver: SqlDriver) +} + +/** + * Registry of all migrations, ordered by fromVersion. + */ +object MigrationRegistry { + val all: List = listOf( + // Migration_1_to_2(), + // Migration_2_to_3(), + ) + + /** + * Returns the chain of migrations needed to go from [current] to [target]. + * Throws if no path exists or if current > target (downgrade unsupported by default). + */ + fun path(current: Int, target: Int): List { + if (current > target) { + throw UnsupportedOperationException("Downgrade from $current to $target not supported") + } + if (current == target) return emptyList() + + val chain = mutableListOf() + var v = current + while (v < target) { + val next = all.firstOrNull { it.fromVersion == v } + ?: throw IllegalStateException("No migration from version $v") + if (next.toVersion > target) { + throw IllegalStateException("Migration overshoots target: ${next.toVersion} > $target") + } + chain.add(next) + v = next.toVersion + } + return chain + } +} +``` + +### Example Migration (v1 โ†’ v2: Add Extended Attributes) + +```kotlin +package cc.unitmesh.xiuper.fs.db.migrations + +import app.cash.sqldelight.db.SqlDriver + +/** + * Migration 1โ†’2: Add xattr (extended attributes) table for POSIX xattr emulation. + */ +class Migration_1_to_2 : Migration { + override val fromVersion = 1 + override val toVersion = 2 + override val description = "Add extended attributes table" + + override fun migrate(driver: SqlDriver) { + driver.execute( + identifier = null, + sql = """ + CREATE TABLE IF NOT EXISTS FsXattr ( + path TEXT NOT NULL, + name TEXT NOT NULL, + value BLOB NOT NULL, + PRIMARY KEY (path, name) + ) + """.trimIndent(), + parameters = 0, + binders = null + ) + } +} +``` + +## Migration Process + +### On Database Initialization + +```kotlin +fun createDatabase(driverFactory: DatabaseDriverFactory): XiuperFsDatabase { + val driver = driverFactory.createDriver() + + val currentVersion = getUserVersion(driver) + val targetVersion = XiuperFsDatabase.Schema.version.toInt() + + when { + currentVersion == 0 -> { + // Fresh DB: create latest schema directly + XiuperFsDatabase.Schema.create(driver) + setUserVersion(driver, targetVersion) + } + currentVersion < targetVersion -> { + // Existing DB: apply migrations + val migrations = MigrationRegistry.path(currentVersion, targetVersion) + for (migration in migrations) { + try { + migration.migrate(driver) + } catch (e: Exception) { + throw IllegalStateException( + "Migration failed: ${migration.description} ($currentVersion โ†’ ${migration.toVersion})", + e + ) + } + } + setUserVersion(driver, targetVersion) + } + currentVersion > targetVersion -> { + // Future DB opened by older code: error or warn + throw IllegalStateException( + "Database version $currentVersion is newer than supported $targetVersion. " + + "Please upgrade the application." + ) + } + else -> { + // Already up-to-date + } + } + + return XiuperFsDatabase(driver) +} + +private fun getUserVersion(driver: SqlDriver): Int { + return driver.executeQuery( + identifier = null, + sql = "PRAGMA user_version", + mapper = { cursor -> + if (cursor.next().value) { + QueryResult.Value(cursor.getLong(0)?.toInt() ?: 0) + } else { + QueryResult.Value(0) + } + }, + parameters = 0, + binders = null + ).value +} + +private fun setUserVersion(driver: SqlDriver, version: Int) { + driver.execute( + identifier = null, + sql = "PRAGMA user_version = $version", + parameters = 0, + binders = null + ) +} +``` + +## Testing Strategy + +1. **Upgrade Path Tests** (JVM-only initially): + - Create DB at v1, apply migration, verify v2 schema + data integrity + - Multi-step: v1โ†’v2โ†’v3 +2. **Idempotency Tests**: + - Apply migration twice; second run should be no-op or safe +3. **Rollback Tests** (optional): + - If downgrade support is added, test v2โ†’v1 +4. **Data Preservation**: + - Insert data at v1, migrate to v2, verify data intact + new features work + +## Package Structure Impact + +### Before (current) +``` +cc.unitmesh.xiuper.fs.db/ +โ”œโ”€โ”€ DbFsBackend.kt +โ”œโ”€โ”€ DatabaseDriverFactory.kt +โ””โ”€โ”€ FsNode.sq (sqldelight source) +``` + +### After (with migrations) +``` +cc.unitmesh.xiuper.fs.db/ +โ”œโ”€โ”€ DbFsBackend.kt +โ”œโ”€โ”€ DatabaseDriverFactory.kt +โ”œโ”€โ”€ migrations/ +โ”‚ โ”œโ”€โ”€ Migration.kt +โ”‚ โ”œโ”€โ”€ Migration_1_to_2.kt +โ”‚ โ””โ”€โ”€ (future migrations) +โ””โ”€โ”€ FsNode.sq +``` + +This keeps migrations as a subpackage under `db`, making it clear they're internal to the DB backend and not exposed to the public `xiuper-fs` API. + +## Rollout Plan + +1. **Phase 1** (Current PR): Ship v1 schema as-is; document migration design +2. **Phase 2**: Implement `Migration` interface + registry + `createDatabase` upgrade logic +3. **Phase 3**: Add first real migration (e.g., xattr or file metadata columns) +4. **Phase 4**: Write conformance/migration tests + +## Open Questions + +1. **Downgrade Support**: Do we need it? (Recommendation: No, unless enterprise requirement) +2. **Migration Naming**: File-per-migration vs single registry file? +3. **SQL vs Kotlin DSL**: Keep raw SQL for transparency or use a builder? + +--- + +**Status**: Design approved, ready for implementation in next iteration. diff --git a/xiuper-ui/src/jvmTest/kotlin/cc/unitmesh/xuiper/render/HtmlRendererTest.kt b/xiuper-ui/src/jvmTest/kotlin/cc/unitmesh/xuiper/render/HtmlRendererTest.kt index fbfd064789..af3448b2cd 100644 --- a/xiuper-ui/src/jvmTest/kotlin/cc/unitmesh/xuiper/render/HtmlRendererTest.kt +++ b/xiuper-ui/src/jvmTest/kotlin/cc/unitmesh/xuiper/render/HtmlRendererTest.kt @@ -107,9 +107,9 @@ class HtmlRendererTest { padding = "md", children = listOf(NanoIR.text("Hello")) ) - + val html = renderer.render(ir) - + assertContains(html, "") assertContains(html, "") assertContains(html, "