- fixed bug in compiler (rare)

- added lyng.io.fs (multiplatform)
- CLI tools now have access to the filesystem
This commit is contained in:
sergeych committed 2025-11-29 00:51:01 +01:00
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
}