- fixed bug in compiler (rare)
- added lyng.io.fs (multiplatform) - CLI tools now have access to the filesystem
This commit is contained in:
1 parent
41746f22e5
commit
438e48959e
28 files changed
+2137
-6
No files matched your search
@@ -0,0 +1,473 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
/*
|
||||
* Lyng FS module installer and bindings
|
||||
*/
|
||||
|
||||
package net.sergeych.lyng.io.fs
|
||||
|
||||
import net.sergeych.lyng.ModuleScope
|
||||
import net.sergeych.lyng.Scope
|
||||
import net.sergeych.lyng.obj.*
|
||||
import net.sergeych.lyng.pacman.ImportManager
|
||||
import net.sergeych.lyngio.fs.LyngFS
|
||||
import net.sergeych.lyngio.fs.LyngFs
|
||||
import net.sergeych.lyngio.fs.LyngPath
|
||||
import net.sergeych.lyngio.fs.security.AccessDeniedException
|
||||
import net.sergeych.lyngio.fs.security.FsAccessPolicy
|
||||
import net.sergeych.lyngio.fs.security.LyngFsSecured
|
||||
import okio.Path.Companion.toPath
|
||||
|
||||
/**
|
||||
* Install Lyng module `lyng.io.fs` into the given scope's ImportManager.
|
||||
* Returns true if installed, false if it was already registered in this manager.
|
||||
*/
|
||||
fun createFsModule(policy: FsAccessPolicy, scope: Scope): Boolean =
|
||||
createFsModule(policy, scope.importManager)
|
||||
|
||||
// Alias as requested earlier in discussions
|
||||
fun createFs(policy: FsAccessPolicy, scope: Scope): Boolean = createFsModule(policy, scope)
|
||||
|
||||
/** Same as [createFsModule] but with explicit [ImportManager]. */
|
||||
fun createFsModule(policy: FsAccessPolicy, manager: ImportManager): Boolean {
|
||||
val name = "lyng.io.fs"
|
||||
// Avoid re-registering in this ImportManager
|
||||
if (manager.packageNames.contains(name)) return false
|
||||
|
||||
manager.addPackage(name) { module ->
|
||||
buildFsModule(module, policy)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Alias overload for ImportManager
|
||||
fun createFs(policy: FsAccessPolicy, manager: ImportManager): Boolean = createFsModule(policy, manager)
|
||||
|
||||
// --- Module builder ---
|
||||
|
||||
private suspend fun buildFsModule(module: ModuleScope, policy: FsAccessPolicy) {
|
||||
// Per-module secured FS, captured by all factories and methods
|
||||
val base: LyngFs = LyngFS.system()
|
||||
val secured = LyngFsSecured(base, policy)
|
||||
|
||||
// Path class bound to this module
|
||||
val pathType = object : ObjClass("Path") {
|
||||
override suspend fun callOn(scope: Scope): Obj {
|
||||
val arg = scope.requireOnlyArg<ObjString>()
|
||||
val str = arg.value
|
||||
return ObjPath(this, secured, str.toPath())
|
||||
}
|
||||
}.apply {
|
||||
addFn("name") {
|
||||
val self = thisAs<ObjPath>()
|
||||
self.path.name.toObj()
|
||||
}
|
||||
addFn("parent") {
|
||||
val self = thisAs<ObjPath>()
|
||||
self.path.parent?.let {
|
||||
ObjPath( this@apply, self.secured, it)
|
||||
} ?: ObjNull
|
||||
}
|
||||
addFn("segments") {
|
||||
val self = thisAs<ObjPath>()
|
||||
ObjList(self.path.segments.map { ObjString(it) }.toMutableList())
|
||||
}
|
||||
// exists(): Bool
|
||||
addFn("exists") {
|
||||
fsGuard {
|
||||
val self = this.thisObj as ObjPath
|
||||
(self.secured.exists(self.path)).toObj()
|
||||
}
|
||||
}
|
||||
// isFile(): Bool — cached metadata
|
||||
addFn("isFile") {
|
||||
fsGuard {
|
||||
val self = this.thisObj as ObjPath
|
||||
self.ensureMetadata().let { ObjBool(it.isRegularFile) }
|
||||
}
|
||||
}
|
||||
// isDirectory(): Bool — cached metadata
|
||||
addFn("isDirectory") {
|
||||
fsGuard {
|
||||
val self = this.thisObj as ObjPath
|
||||
self.ensureMetadata().let { ObjBool(it.isDirectory) }
|
||||
}
|
||||
}
|
||||
// size(): Int? — null when unavailable
|
||||
addFn("size") {
|
||||
fsGuard {
|
||||
val self = this.thisObj as ObjPath
|
||||
val m = self.ensureMetadata()
|
||||
m.size?.let { ObjInt(it) } ?: ObjNull
|
||||
}
|
||||
}
|
||||
// createdAt(): Instant? — Lyng Instant, null when unavailable
|
||||
addFn("createdAt") {
|
||||
fsGuard {
|
||||
val self = this.thisObj as ObjPath
|
||||
val m = self.ensureMetadata()
|
||||
m.createdAtMillis?.let { ObjInstant(kotlinx.datetime.Instant.fromEpochMilliseconds(it)) } ?: ObjNull
|
||||
}
|
||||
}
|
||||
// createdAtMillis(): Int? — milliseconds since epoch or null
|
||||
addFn("createdAtMillis") {
|
||||
fsGuard {
|
||||
val self = this.thisObj as ObjPath
|
||||
val m = self.ensureMetadata()
|
||||
m.createdAtMillis?.let { ObjInt(it) } ?: ObjNull
|
||||
}
|
||||
}
|
||||
// modifiedAt(): Instant? — Lyng Instant, null when unavailable
|
||||
addFn("modifiedAt") {
|
||||
fsGuard {
|
||||
val self = this.thisObj as ObjPath
|
||||
val m = self.ensureMetadata()
|
||||
m.modifiedAtMillis?.let { ObjInstant(kotlinx.datetime.Instant.fromEpochMilliseconds(it)) } ?: ObjNull
|
||||
}
|
||||
}
|
||||
// modifiedAtMillis(): Int? — milliseconds since epoch or null
|
||||
addFn("modifiedAtMillis") {
|
||||
fsGuard {
|
||||
val self = this.thisObj as ObjPath
|
||||
val m = self.ensureMetadata()
|
||||
m.modifiedAtMillis?.let { ObjInt(it) } ?: ObjNull
|
||||
}
|
||||
}
|
||||
// list(): List<Path>
|
||||
addFn("list") {
|
||||
fsGuard {
|
||||
val self = this.thisObj as ObjPath
|
||||
val items = self.secured.list(self.path).map { ObjPath(self.objClass, self.secured, it) }
|
||||
ObjList(items.toMutableList())
|
||||
}
|
||||
}
|
||||
// readBytes(): Buffer
|
||||
addFn("readBytes") {
|
||||
fsGuard {
|
||||
val self = this.thisObj as ObjPath
|
||||
val bytes = self.secured.readBytes(self.path)
|
||||
ObjBuffer(bytes.asUByteArray())
|
||||
}
|
||||
}
|
||||
// writeBytes(bytes: Buffer)
|
||||
addFn("writeBytes") {
|
||||
fsGuard {
|
||||
val self = this.thisObj as ObjPath
|
||||
val buf = requiredArg<ObjBuffer>(0)
|
||||
self.secured.writeBytes(self.path, buf.byteArray.asByteArray(), append = false)
|
||||
ObjVoid
|
||||
}
|
||||
}
|
||||
// appendBytes(bytes: Buffer)
|
||||
addFn("appendBytes") {
|
||||
fsGuard {
|
||||
val self = this.thisObj as ObjPath
|
||||
val buf = requiredArg<ObjBuffer>(0)
|
||||
self.secured.writeBytes(self.path, buf.byteArray.asByteArray(), append = true)
|
||||
ObjVoid
|
||||
}
|
||||
}
|
||||
// readUtf8(): String
|
||||
addFn("readUtf8") {
|
||||
fsGuard {
|
||||
val self = this.thisObj as ObjPath
|
||||
self.secured.readUtf8(self.path).toObj()
|
||||
}
|
||||
}
|
||||
// writeUtf8(text: String)
|
||||
addFn("writeUtf8") {
|
||||
fsGuard {
|
||||
val self = this.thisObj as ObjPath
|
||||
val text = requireOnlyArg<ObjString>().value
|
||||
self.secured.writeUtf8(self.path, text, append = false)
|
||||
ObjVoid
|
||||
}
|
||||
}
|
||||
// appendUtf8(text: String)
|
||||
addFn("appendUtf8") {
|
||||
fsGuard {
|
||||
val self = this.thisObj as ObjPath
|
||||
val text = requireOnlyArg<ObjString>().value
|
||||
self.secured.writeUtf8(self.path, text, append = true)
|
||||
ObjVoid
|
||||
}
|
||||
}
|
||||
// metadata(): Map
|
||||
addFn("metadata") {
|
||||
fsGuard {
|
||||
val self = this.thisObj as ObjPath
|
||||
val m = self.secured.metadata(self.path)
|
||||
ObjMap(mutableMapOf(
|
||||
ObjString("isFile") to ObjBool(m.isRegularFile),
|
||||
ObjString("isDirectory") to ObjBool(m.isDirectory),
|
||||
ObjString("size") to (m.size?.toLong() ?: 0L).toObj(),
|
||||
ObjString("createdAtMillis") to ((m.createdAtMillis ?: 0L)).toObj(),
|
||||
ObjString("modifiedAtMillis") to ((m.modifiedAtMillis ?: 0L)).toObj(),
|
||||
ObjString("isSymlink") to ObjBool(m.isSymlink),
|
||||
))
|
||||
}
|
||||
}
|
||||
// mkdirs(mustCreate: Bool=false)
|
||||
addFn("mkdirs") {
|
||||
fsGuard {
|
||||
val self = this.thisObj as ObjPath
|
||||
val mustCreate = args.list.getOrNull(0)?.toBool() ?: false
|
||||
self.secured.createDirectories(self.path, mustCreate)
|
||||
ObjVoid
|
||||
}
|
||||
}
|
||||
// move(to: Path|String, overwrite: Bool=false)
|
||||
addFn("move") {
|
||||
fsGuard {
|
||||
val self = this.thisObj as ObjPath
|
||||
val toPath = parsePathArg(this, self, requiredArg<Obj>(0))
|
||||
val overwrite = args.list.getOrNull(1)?.toBool() ?: false
|
||||
self.secured.move(self.path, toPath, overwrite)
|
||||
ObjVoid
|
||||
}
|
||||
}
|
||||
// delete(mustExist: Bool=false, recursively: Bool=false)
|
||||
addFn("delete") {
|
||||
fsGuard {
|
||||
val self = this.thisObj as ObjPath
|
||||
val mustExist = args.list.getOrNull(0)?.toBool() ?: false
|
||||
val recursively = args.list.getOrNull(1)?.toBool() ?: false
|
||||
self.secured.delete(self.path, mustExist, recursively)
|
||||
ObjVoid
|
||||
}
|
||||
}
|
||||
// copy(to: Path|String, overwrite: Bool=false)
|
||||
addFn("copy") {
|
||||
fsGuard {
|
||||
val self = this.thisObj as ObjPath
|
||||
val toPath = parsePathArg(this, self, requiredArg<Obj>(0))
|
||||
val overwrite = args.list.getOrNull(1)?.toBool() ?: false
|
||||
self.secured.copy(self.path, toPath, overwrite)
|
||||
ObjVoid
|
||||
}
|
||||
}
|
||||
// glob(pattern: String): List<Path>
|
||||
addFn("glob") {
|
||||
fsGuard {
|
||||
val self = this.thisObj as ObjPath
|
||||
val pattern = requireOnlyArg<ObjString>().value
|
||||
val matches = self.secured.glob(self.path, pattern)
|
||||
ObjList(matches.map { ObjPath(self.objClass, self.secured, it) }.toMutableList())
|
||||
}
|
||||
}
|
||||
|
||||
// --- streaming readers (initial version: chunk from whole content, API stable) ---
|
||||
|
||||
// readChunks(size: Int = 65536) -> Iterator<Buffer>
|
||||
addFn("readChunks") {
|
||||
fsGuard {
|
||||
val self = this.thisObj as ObjPath
|
||||
val size = args.list.getOrNull(0)?.toInt() ?: 65536
|
||||
val bytes = self.secured.readBytes(self.path)
|
||||
ObjFsBytesIterator(bytes, size)
|
||||
}
|
||||
}
|
||||
|
||||
// readUtf8Chunks(size: Int = 65536) -> Iterator<String>
|
||||
addFn("readUtf8Chunks") {
|
||||
fsGuard {
|
||||
val self = this.thisObj as ObjPath
|
||||
val size = args.list.getOrNull(0)?.toInt() ?: 65536
|
||||
val text = self.secured.readUtf8(self.path)
|
||||
ObjFsStringChunksIterator(text, size)
|
||||
}
|
||||
}
|
||||
|
||||
// lines() -> Iterator<String>, implemented via readUtf8Chunks
|
||||
addFn("lines") {
|
||||
fsGuard {
|
||||
val chunkIt = thisObj.invokeInstanceMethod(this, "readUtf8Chunks")
|
||||
ObjFsLinesIterator(chunkIt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export into the module scope
|
||||
module.addConst("Path", pathType)
|
||||
// Alias as requested (Path(s) style)
|
||||
module.addConst("Paths", pathType)
|
||||
}
|
||||
|
||||
// --- Helper classes and utilities ---
|
||||
|
||||
private fun parsePathArg(scope: Scope, self: ObjPath, arg: Obj): LyngPath {
|
||||
return when (arg) {
|
||||
is ObjString -> arg.value.toPath()
|
||||
is ObjPath -> arg.path
|
||||
else -> scope.raiseIllegalArgument("expected Path or String argument")
|
||||
}
|
||||
}
|
||||
|
||||
// Map Fs access denials to Lyng runtime exceptions for script-friendly errors
|
||||
private suspend inline fun Scope.fsGuard(crossinline block: suspend () -> Obj): Obj {
|
||||
return try {
|
||||
block()
|
||||
} catch (e: AccessDeniedException) {
|
||||
raiseError(ObjIllegalOperationException(this, e.reasonDetail ?: "access denied"))
|
||||
}
|
||||
}
|
||||
|
||||
/** Kotlin-side instance backing the Lyng class `Path`. */
|
||||
class ObjPath(
|
||||
private val klass: ObjClass,
|
||||
val secured: LyngFs,
|
||||
val path: LyngPath,
|
||||
) : Obj() {
|
||||
// Cache for metadata to avoid repeated FS calls within the same object instance usage
|
||||
private var _metadata: net.sergeych.lyngio.fs.LyngMetadata? = null
|
||||
|
||||
override val objClass: ObjClass get() = klass
|
||||
override fun toString(): String = path.toString()
|
||||
|
||||
suspend fun ensureMetadata(): net.sergeych.lyngio.fs.LyngMetadata {
|
||||
val cached = _metadata
|
||||
if (cached != null) return cached
|
||||
val m = secured.metadata(path)
|
||||
_metadata = m
|
||||
return m
|
||||
}
|
||||
}
|
||||
|
||||
/** Iterator over byte chunks as Buffers. */
|
||||
class ObjFsBytesIterator(
|
||||
private val data: ByteArray,
|
||||
private val chunkSize: Int,
|
||||
) : Obj() {
|
||||
private var pos = 0
|
||||
|
||||
override val objClass: ObjClass = BytesIteratorType
|
||||
|
||||
companion object {
|
||||
val BytesIteratorType = object : ObjClass("BytesIterator", ObjIterator) {
|
||||
init {
|
||||
// make it usable in for-loops
|
||||
addFn("iterator") { thisObj }
|
||||
addFn("hasNext") {
|
||||
val self = thisAs<ObjFsBytesIterator>()
|
||||
(self.pos < self.data.size).toObj()
|
||||
}
|
||||
addFn("next") {
|
||||
val self = thisAs<ObjFsBytesIterator>()
|
||||
if (self.pos >= self.data.size) raiseIllegalState("iterator exhausted")
|
||||
val end = minOf(self.pos + self.chunkSize, self.data.size)
|
||||
val chunk = self.data.copyOfRange(self.pos, end)
|
||||
self.pos = end
|
||||
ObjBuffer(chunk.asUByteArray())
|
||||
}
|
||||
addFn("cancelIteration") {
|
||||
val self = thisAs<ObjFsBytesIterator>()
|
||||
self.pos = self.data.size
|
||||
ObjVoid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Iterator over utf-8 text chunks (character-counted chunks). */
|
||||
class ObjFsStringChunksIterator(
|
||||
private val text: String,
|
||||
private val chunkChars: Int,
|
||||
) : Obj() {
|
||||
private var pos = 0
|
||||
|
||||
override val objClass: ObjClass = StringChunksIteratorType
|
||||
|
||||
companion object {
|
||||
val StringChunksIteratorType = object : ObjClass("StringChunksIterator", ObjIterator) {
|
||||
init {
|
||||
// make it usable in for-loops
|
||||
addFn("iterator") { thisObj }
|
||||
addFn("hasNext") {
|
||||
val self = thisAs<ObjFsStringChunksIterator>()
|
||||
(self.pos < self.text.length).toObj()
|
||||
}
|
||||
addFn("next") {
|
||||
val self = thisAs<ObjFsStringChunksIterator>()
|
||||
if (self.pos >= self.text.length) raiseIllegalState("iterator exhausted")
|
||||
val end = minOf(self.pos + self.chunkChars, self.text.length)
|
||||
val chunk = self.text.substring(self.pos, end)
|
||||
self.pos = end
|
||||
ObjString(chunk)
|
||||
}
|
||||
addFn("cancelIteration") { ObjVoid }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Iterator that yields lines using an underlying chunks iterator. */
|
||||
class ObjFsLinesIterator(
|
||||
private val chunksIterator: Obj,
|
||||
) : Obj() {
|
||||
private var buffer: String = ""
|
||||
private var exhausted = false
|
||||
|
||||
override val objClass: ObjClass = LinesIteratorType
|
||||
|
||||
companion object {
|
||||
val LinesIteratorType = object : ObjClass("LinesIterator", ObjIterator) {
|
||||
init {
|
||||
// make it usable in for-loops
|
||||
addFn("iterator") { thisObj }
|
||||
addFn("hasNext") {
|
||||
val self = thisAs<ObjFsLinesIterator>()
|
||||
self.ensureBufferFilled(this)
|
||||
(self.buffer.isNotEmpty() || !self.exhausted).toObj()
|
||||
}
|
||||
addFn("next") {
|
||||
val self = thisAs<ObjFsLinesIterator>()
|
||||
self.ensureBufferFilled(this)
|
||||
if (self.buffer.isEmpty() && self.exhausted) raiseIllegalState("iterator exhausted")
|
||||
val idx = self.buffer.indexOf('\n')
|
||||
val line = if (idx >= 0) {
|
||||
val l = self.buffer.substring(0, idx)
|
||||
self.buffer = self.buffer.substring(idx + 1)
|
||||
l
|
||||
} else {
|
||||
// last line without trailing newline
|
||||
val l = self.buffer
|
||||
self.buffer = ""
|
||||
self.exhausted = true
|
||||
l
|
||||
}
|
||||
ObjString(line)
|
||||
}
|
||||
addFn("cancelIteration") { ObjVoid }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ensureBufferFilled(scope: Scope) {
|
||||
if (buffer.contains('\n') || exhausted) return
|
||||
// Pull next chunk from the underlying iterator
|
||||
val it = chunksIterator.invokeInstanceMethod(scope, "iterator")
|
||||
val hasNext = it.invokeInstanceMethod(scope, "hasNext").toBool()
|
||||
if (!hasNext) {
|
||||
exhausted = true
|
||||
return
|
||||
}
|
||||
val next = it.invokeInstanceMethod(scope, "next")
|
||||
buffer += next.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyngio
|
||||
|
||||
/**
|
||||
* LyngIO: foundation for uniform file access APIs across JVM, Native and JS.
|
||||
*
|
||||
* This module depends on `:lynglib` and is configured as a Compose Multiplatform
|
||||
* library, but it does not require Compose at runtime. Actual file system APIs
|
||||
* will be added after we agree on the backend choices (Okio, NodeJS FS, browser VFS).
|
||||
*/
|
||||
object LyngIoInfo {
|
||||
val name: String = "LyngIO"
|
||||
val version: String = "0.0.1-SNAPSHOT"
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyngio.fs
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.*
|
||||
|
||||
/**
|
||||
* Async-first FS API (intermediate public surface).
|
||||
* Note: this is an interim step on the way to final exported entities; names/types may evolve,
|
||||
* but these APIs will stay public to ease migration.
|
||||
*/
|
||||
typealias LyngPath = Path
|
||||
|
||||
/** Metadata snapshot for a filesystem node. */
|
||||
data class LyngMetadata(
|
||||
val isRegularFile: Boolean,
|
||||
val isDirectory: Boolean,
|
||||
val size: Long?,
|
||||
val createdAtMillis: Long?,
|
||||
val modifiedAtMillis: Long?,
|
||||
val isSymlink: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Suspend-first, uniform filesystem. Heavy ops must not block the main thread or event loop.
|
||||
*/
|
||||
interface LyngFs {
|
||||
suspend fun exists(path: LyngPath): Boolean
|
||||
suspend fun list(dir: LyngPath): List<LyngPath>
|
||||
suspend fun readBytes(path: LyngPath): ByteArray
|
||||
suspend fun writeBytes(path: LyngPath, data: ByteArray, append: Boolean = false)
|
||||
suspend fun readUtf8(path: LyngPath): String
|
||||
suspend fun writeUtf8(path: LyngPath, text: String, append: Boolean = false)
|
||||
suspend fun metadata(path: LyngPath): LyngMetadata
|
||||
suspend fun createDirectories(dir: LyngPath, mustCreate: Boolean = false)
|
||||
suspend fun move(from: LyngPath, to: LyngPath, overwrite: Boolean = false)
|
||||
suspend fun delete(path: LyngPath, mustExist: Boolean = false, recursively: Boolean = false)
|
||||
|
||||
/** Default implementation: naive read-all + write; backends may override for efficiency. */
|
||||
suspend fun copy(from: LyngPath, to: LyngPath, overwrite: Boolean = false) {
|
||||
if (!overwrite && exists(to)) error("Target exists: $to")
|
||||
val data = readBytes(from)
|
||||
writeBytes(to, data, append = false)
|
||||
}
|
||||
|
||||
/** Glob search starting at [dir], matching [pattern] with `**`, `*`, and `?`. */
|
||||
suspend fun glob(dir: LyngPath, pattern: String): List<LyngPath> {
|
||||
val regex = globToRegex(pattern)
|
||||
val base = dir
|
||||
val out = mutableListOf<LyngPath>()
|
||||
suspend fun walk(d: LyngPath, rel: String) {
|
||||
for (child in list(d)) {
|
||||
val relPath = if (rel.isEmpty()) child.name else "$rel/${child.name}"
|
||||
if (regex.matches(relPath)) out += child
|
||||
if (metadata(child).isDirectory) walk(child, relPath)
|
||||
}
|
||||
}
|
||||
walk(base, "")
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
/** Okio-backed async implementation used on JVM/Android/Native and in-memory browser. */
|
||||
class OkioAsyncFs(private val fs: FileSystem) : LyngFs {
|
||||
override suspend fun exists(path: LyngPath): Boolean = withContext(LyngIoDispatcher) {
|
||||
try { fs.metadataOrNull(path) != null } catch (_: Throwable) { false }
|
||||
}
|
||||
|
||||
override suspend fun list(dir: LyngPath): List<LyngPath> = withContext(LyngIoDispatcher) {
|
||||
fs.list(dir)
|
||||
}
|
||||
|
||||
override suspend fun readBytes(path: LyngPath): ByteArray = withContext(LyngIoDispatcher) {
|
||||
val buffer = Buffer()
|
||||
fs.source(path).buffer().use { it.readAll(buffer) }
|
||||
buffer.readByteArray()
|
||||
}
|
||||
|
||||
override suspend fun writeBytes(path: LyngPath, data: ByteArray, append: Boolean) {
|
||||
withContext(LyngIoDispatcher) {
|
||||
val sink = if (append) fs.appendingSink(path) else fs.sink(path)
|
||||
sink.buffer().use { it.write(data) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun readUtf8(path: LyngPath): String = withContext(LyngIoDispatcher) {
|
||||
val buffer = Buffer()
|
||||
fs.source(path).buffer().use { it.readAll(buffer) }
|
||||
buffer.readUtf8()
|
||||
}
|
||||
|
||||
override suspend fun writeUtf8(path: LyngPath, text: String, append: Boolean) {
|
||||
withContext(LyngIoDispatcher) {
|
||||
val sink = if (append) fs.appendingSink(path) else fs.sink(path)
|
||||
sink.buffer().use { it.writeUtf8(text) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun metadata(path: LyngPath): LyngMetadata = withContext(LyngIoDispatcher) {
|
||||
val m = fs.metadata(path)
|
||||
LyngMetadata(
|
||||
isRegularFile = m.isRegularFile,
|
||||
isDirectory = m.isDirectory,
|
||||
size = m.size,
|
||||
createdAtMillis = m.createdAtMillis,
|
||||
modifiedAtMillis = m.lastModifiedAtMillis,
|
||||
isSymlink = m.symlinkTarget != null,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun createDirectories(dir: LyngPath, mustCreate: Boolean) = withContext(LyngIoDispatcher) {
|
||||
fs.createDirectories(dir, mustCreate)
|
||||
}
|
||||
|
||||
override suspend fun move(from: LyngPath, to: LyngPath, overwrite: Boolean) = withContext(LyngIoDispatcher) {
|
||||
if (overwrite) fs.delete(to, mustExist = false)
|
||||
fs.atomicMove(from, to)
|
||||
}
|
||||
|
||||
override suspend fun delete(path: LyngPath, mustExist: Boolean, recursively: Boolean) = withContext(LyngIoDispatcher) {
|
||||
if (!recursively) {
|
||||
fs.delete(path, mustExist)
|
||||
return@withContext
|
||||
}
|
||||
fun deleteRec(p: Path) {
|
||||
val meta = fs.metadataOrNull(p)
|
||||
if (meta == null) {
|
||||
if (mustExist) throw IllegalStateException("No such file or directory: $p")
|
||||
return
|
||||
}
|
||||
if (meta.isDirectory) for (child in fs.list(p)) deleteRec(child)
|
||||
fs.delete(p, mustExist = false)
|
||||
}
|
||||
deleteRec(path)
|
||||
if (mustExist && fs.metadataOrNull(path) != null) error("Failed to delete: $path")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default system FS selector per platform.
|
||||
* - JVM/Android/Native: Okio `FileSystem.SYSTEM`
|
||||
* - Node: native fs/promises implementation (non-blocking)
|
||||
* - Browser/Wasm: in-memory Okio `FakeFileSystem`
|
||||
*/
|
||||
expect fun platformAsyncFs(): LyngFs
|
||||
expect val LyngIoDispatcher: CoroutineDispatcher
|
||||
|
||||
object LyngFS { // factory holder (interim name)
|
||||
fun system(): LyngFs = platformAsyncFs()
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private fun globToRegex(pattern: String): Regex {
|
||||
// Convert glob with **, *, ? into a Regex that matches relative POSIX paths
|
||||
val sb = StringBuilder()
|
||||
var i = 0
|
||||
sb.append('^')
|
||||
while (i < pattern.length) {
|
||||
when (val c = pattern[i]) {
|
||||
'*' -> {
|
||||
val isDouble = i + 1 < pattern.length && pattern[i + 1] == '*'
|
||||
if (isDouble) {
|
||||
// ** → match across directories
|
||||
// Consume optional following slash
|
||||
val nextIsSlash = i + 2 < pattern.length && (pattern[i + 2] == '/' || pattern[i + 2] == '\\')
|
||||
sb.append(".*")
|
||||
if (nextIsSlash) i += 1 // skip one extra to consume slash later via i+=2 below
|
||||
i += 1
|
||||
} else {
|
||||
// * within a segment (no slash)
|
||||
sb.append("[^/]*")
|
||||
}
|
||||
}
|
||||
'?' -> sb.append("[^/]")
|
||||
'.', '(', ')', '+', '|', '^', '$', '{', '}', '[', ']', '\\' -> sb.append('\\').append(c)
|
||||
'/' , '\\' -> sb.append('/')
|
||||
else -> sb.append(c)
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
sb.append('$')
|
||||
return Regex(sb.toString())
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyngio.fs.security
|
||||
|
||||
import net.sergeych.lyngio.fs.LyngPath
|
||||
|
||||
/**
|
||||
* Primitive filesystem operations for access control decisions.
|
||||
* Keep this sealed hierarchy minimal and extensible.
|
||||
*/
|
||||
sealed interface AccessOp {
|
||||
data class ListDir(val path: LyngPath) : AccessOp
|
||||
data class CreateFile(val path: LyngPath) : AccessOp
|
||||
data class OpenRead(val path: LyngPath) : AccessOp
|
||||
data class OpenWrite(val path: LyngPath) : AccessOp
|
||||
data class OpenAppend(val path: LyngPath) : AccessOp
|
||||
data class Delete(val path: LyngPath) : AccessOp
|
||||
data class Rename(val from: LyngPath, val to: LyngPath) : AccessOp
|
||||
|
||||
/** Update file metadata/attributes (times, permissions, flags, etc.). */
|
||||
data class UpdateAttributes(val path: LyngPath) : AccessOp
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional contextual information for access decisions (principal, tags, attributes).
|
||||
* This is intentionally generic to avoid coupling with specific auth models.
|
||||
*/
|
||||
data class AccessContext(
|
||||
val principal: String? = null,
|
||||
val attributes: Map<String, Any?> = emptyMap(),
|
||||
)
|
||||
|
||||
enum class Decision { Allow, Deny }
|
||||
|
||||
data class AccessDecision(
|
||||
val decision: Decision,
|
||||
val reason: String? = null,
|
||||
) {
|
||||
fun isAllowed(): Boolean = decision == Decision.Allow
|
||||
}
|
||||
|
||||
class AccessDeniedException(
|
||||
val op: AccessOp,
|
||||
val reasonDetail: String? = null,
|
||||
) : IllegalStateException("Access denied for $op" + (reasonDetail?.let { ": $it" } ?: ""))
|
||||
|
||||
/**
|
||||
* Policy interface that decides whether a specific filesystem operation is allowed.
|
||||
*
|
||||
* Note: by convention, updating file attributes should be treated as equivalent to
|
||||
* write permission unless a stricter policy is desired. I.e., policies may implement
|
||||
* [AccessOp.UpdateAttributes] by delegating to [AccessOp.OpenWrite] for the same path.
|
||||
*/
|
||||
interface FsAccessPolicy {
|
||||
suspend fun check(op: AccessOp, ctx: AccessContext = AccessContext()): AccessDecision
|
||||
|
||||
// Convenience helpers
|
||||
suspend fun require(op: AccessOp, ctx: AccessContext = AccessContext()) {
|
||||
val res = check(op, ctx)
|
||||
if (!res.isAllowed()) throw AccessDeniedException(op, res.reason)
|
||||
}
|
||||
|
||||
suspend fun canList(path: LyngPath, ctx: AccessContext = AccessContext()) =
|
||||
check(AccessOp.ListDir(path), ctx).isAllowed()
|
||||
|
||||
suspend fun canCreateFile(path: LyngPath, ctx: AccessContext = AccessContext()) =
|
||||
check(AccessOp.CreateFile(path), ctx).isAllowed()
|
||||
|
||||
suspend fun canOpenRead(path: LyngPath, ctx: AccessContext = AccessContext()) =
|
||||
check(AccessOp.OpenRead(path), ctx).isAllowed()
|
||||
|
||||
suspend fun canOpenWrite(path: LyngPath, ctx: AccessContext = AccessContext()) =
|
||||
check(AccessOp.OpenWrite(path), ctx).isAllowed()
|
||||
|
||||
suspend fun canOpenAppend(path: LyngPath, ctx: AccessContext = AccessContext()) =
|
||||
check(AccessOp.OpenAppend(path), ctx).isAllowed()
|
||||
|
||||
suspend fun canDelete(path: LyngPath, ctx: AccessContext = AccessContext()) =
|
||||
check(AccessOp.Delete(path), ctx).isAllowed()
|
||||
|
||||
suspend fun canRename(from: LyngPath, to: LyngPath, ctx: AccessContext = AccessContext()) =
|
||||
check(AccessOp.Rename(from, to), ctx).isAllowed()
|
||||
|
||||
/**
|
||||
* Updating file attributes defaults to the same access level as opening for write
|
||||
* in typical policies. Policies may override that to be stricter/looser.
|
||||
*/
|
||||
suspend fun canUpdateAttributes(path: LyngPath, ctx: AccessContext = AccessContext()) =
|
||||
check(AccessOp.UpdateAttributes(path), ctx).isAllowed()
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyngio.fs.security
|
||||
|
||||
import net.sergeych.lyngio.fs.LyngFs
|
||||
import net.sergeych.lyngio.fs.LyngMetadata
|
||||
import net.sergeych.lyngio.fs.LyngPath
|
||||
|
||||
/**
|
||||
* Decorator that applies an [FsAccessPolicy] to a delegate [LyngFs].
|
||||
* Composite operations are checked as a set of primitive [AccessOp]s.
|
||||
*
|
||||
* Note: attribute update operations are covered by [AccessOp.UpdateAttributes] which
|
||||
* by convention defaults to write permission in policy implementations.
|
||||
*/
|
||||
class LyngFsSecured(
|
||||
private val delegate: LyngFs,
|
||||
private val policy: FsAccessPolicy,
|
||||
private val ctx: AccessContext = AccessContext(),
|
||||
) : LyngFs {
|
||||
|
||||
override suspend fun exists(path: LyngPath): Boolean {
|
||||
// Existence check is safe; do not guard to allow probing. Policies may tighten later.
|
||||
return delegate.exists(path)
|
||||
}
|
||||
|
||||
override suspend fun list(dir: LyngPath): List<LyngPath> {
|
||||
policy.require(AccessOp.ListDir(dir), ctx)
|
||||
return delegate.list(dir)
|
||||
}
|
||||
|
||||
override suspend fun readBytes(path: LyngPath): ByteArray {
|
||||
policy.require(AccessOp.OpenRead(path), ctx)
|
||||
return delegate.readBytes(path)
|
||||
}
|
||||
|
||||
override suspend fun writeBytes(path: LyngPath, data: ByteArray, append: Boolean) {
|
||||
if (append) {
|
||||
policy.require(AccessOp.OpenAppend(path), ctx)
|
||||
} else {
|
||||
policy.require(AccessOp.OpenWrite(path), ctx)
|
||||
}
|
||||
// Also require create-file to cover cases when the file does not exist yet.
|
||||
policy.require(AccessOp.CreateFile(path), ctx)
|
||||
return delegate.writeBytes(path, data, append)
|
||||
}
|
||||
|
||||
override suspend fun readUtf8(path: LyngPath): String {
|
||||
policy.require(AccessOp.OpenRead(path), ctx)
|
||||
return delegate.readUtf8(path)
|
||||
}
|
||||
|
||||
override suspend fun writeUtf8(path: LyngPath, text: String, append: Boolean) {
|
||||
if (append) {
|
||||
policy.require(AccessOp.OpenAppend(path), ctx)
|
||||
} else {
|
||||
policy.require(AccessOp.OpenWrite(path), ctx)
|
||||
}
|
||||
policy.require(AccessOp.CreateFile(path), ctx)
|
||||
return delegate.writeUtf8(path, text, append)
|
||||
}
|
||||
|
||||
override suspend fun metadata(path: LyngPath): LyngMetadata {
|
||||
// Not specified in v1; treat as read access for now (can be revised later).
|
||||
// policy.require(AccessOp.OpenRead(path), ctx)
|
||||
return delegate.metadata(path)
|
||||
}
|
||||
|
||||
override suspend fun createDirectories(dir: LyngPath, mustCreate: Boolean) {
|
||||
// Model directory creation using CreateFile on the path to be created.
|
||||
policy.require(AccessOp.CreateFile(dir), ctx)
|
||||
return delegate.createDirectories(dir, mustCreate)
|
||||
}
|
||||
|
||||
override suspend fun move(from: LyngPath, to: LyngPath, overwrite: Boolean) {
|
||||
// Prefer Rename primitive; also check target deletion if overwrite.
|
||||
policy.require(AccessOp.Rename(from, to), ctx)
|
||||
if (overwrite) policy.require(AccessOp.Delete(to), ctx)
|
||||
return delegate.move(from, to, overwrite)
|
||||
}
|
||||
|
||||
override suspend fun delete(path: LyngPath, mustExist: Boolean, recursively: Boolean) {
|
||||
policy.require(AccessOp.Delete(path), ctx)
|
||||
return delegate.delete(path, mustExist, recursively)
|
||||
}
|
||||
|
||||
override suspend fun copy(from: LyngPath, to: LyngPath, overwrite: Boolean) {
|
||||
// Composite checks: read from source, create+write to dest, optional delete target first.
|
||||
policy.require(AccessOp.OpenRead(from), ctx)
|
||||
if (overwrite) policy.require(AccessOp.Delete(to), ctx)
|
||||
policy.require(AccessOp.CreateFile(to), ctx)
|
||||
policy.require(AccessOp.OpenWrite(to), ctx)
|
||||
return delegate.copy(from, to, overwrite)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyngio.fs.security
|
||||
|
||||
/**
|
||||
* Minimal policy that allows every operation. Useful as a default or for tests.
|
||||
*/
|
||||
object PermitAllAccessPolicy : FsAccessPolicy {
|
||||
private val allow = AccessDecision(Decision.Allow)
|
||||
|
||||
override suspend fun check(op: AccessOp, ctx: AccessContext): AccessDecision = allow
|
||||
}
|
||||
Reference in new issue
Block a user