diff --git a/README.md b/README.md new file mode 100644 index 0000000..3566ec6 --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# permits-lib + +Permits Lib provides a declarative way to describe Bukkit/Paper permission hierarchies using a Kotlin +DSL. Once a tree is built you can hand it to `PermissionRegistry` (or the higher-level +`MutationSession`) and the library will register/unregister the relevant `org.bukkit.permissions.Permission` +instances and keep `PermissionAttachment`s in sync. + +## Usage + +```kotlin +class ExamplePlugin : JavaPlugin() { + private val permits = PermitsLib.session(this) + + override fun onEnable() { + val tree = permissionTree("example") { + node("command") { + description = "Access to all example commands" + defaultValue = PermissionDefault.OP + + node("reload") { + description = "Allows /example reload (permission example.command.reload)" + } + + // Link to a helper node defined elsewhere under the command branch: + child("helper") + + // Link to a permission outside the current branch by using the absolute helper: + childAbsolute("tools.repair") + + node("cooldown") { + description = "Allows /example cooldown tweaks" + wildcard = false // opt-out if you do not want example.command.* to include it + } + } + + node("command.helper") { + description = "Allows /example helper (referenced via child(\"helper\"))" + } + + node("tools.repair") { + description = "Allows /example tools repair (linked with childAbsolute)" + } + } + + permits.applyTree(tree) + + // The tree above materializes as permissions such as: + // example.command, example.command.reload, example.command.helper, example.command.cooldown, + // example.tools.repair, + // plus the auto-generated example.command.* wildcard that references every child (visible if you + // export to plugin.yml or inspect Bukkit's /permissions output). + + configureRuntimePermissions() + } + + fun grantHelper(player: Player) { + permits.attachments.grant(player, PermissionId.of("example.command.reload")) + } + + private fun configureRuntimePermissions() { + // Later in runtime you can mutate the previously applied structure without rebuilding it: + permits.edit("example") { + // Update an existing node and link it to new children + node("command") { + description = "Admins for every command path" + node("debug") { + description = "Allows /example debug" + defaultValue = PermissionDefault.OP + } + } + // Remove deprecated permissions entirely + remove("command.cooldown") + } + } +} +``` + +### Concepts + +- **Permission tree** – immutable graph of `PermissionNode`s. Nodes specify description, default value, + boolean children map, optional tags, and the `wildcard` flag (enabled by default) that makes the library + create/update `namespace.path.*` aggregate permissions automatically. +- **DSL** – `permissionTree("namespace") { ... }` ensures consistent prefixes and validation (no cycles). +- **Nested nodes** – `node("command") { node("reload") { ... } }` automatically produces + `namespace.command` and `namespace.command.reload` plus wires the parent/child relationship so you don't + have to repeat the full id. +- **Flexible references** – `child("reload")`, `node("command") { node("reload") { ... } }`, or + even `node("command.reload")` inside `edit` all resolve to the same node; children are auto-created on + first reference but you can demand explicit nodes by adding a `node` block later, and you can unlink + specific children via `node("command") { removeChild("cooldown") }` without deleting the underlying node. + Nested `child(...)` calls are relative to the current node by default, while `childAbsolute(...)` lets you + point at any fully-qualified permission ID within the namespace. +- **PermissionRegistry** – calculates a diff between snapshots and performs the minimum additions, + removals, or updates via Bukkit's `PluginManager`. +- **Wildcards** – with `wildcard = true`, the generated `namespace.command.*` child always exists and stays + in sync so granting `example.command.*` automatically grants every nested node; set it to `false` to opt + out for specific permissions. +- **Mutable edits** – `permits.edit { ... }` clones the currently registered tree, lets you mutate nodes + imperatively, re-validates, and only pushes the structural diff to Bukkit. +- **AttachmentSynchronizer** – manages identity-based `PermissionAttachment`s and exposes high-level + helpers (`grant`, `revoke`, `applyPatch`). +- **MutationSession** – ties everything together for plugins that just want to push new trees and manage + attachments without worrying about the lower-level services. diff --git a/src/main/kotlin/net/hareworks/permits_lib/App.kt b/src/main/kotlin/net/hareworks/permits_lib/App.kt index fd0bf1d..35a5711 100644 --- a/src/main/kotlin/net/hareworks/permits_lib/App.kt +++ b/src/main/kotlin/net/hareworks/permits_lib/App.kt @@ -1,14 +1,44 @@ -package net.hareworks.permits_lib; +package net.hareworks.permits_lib +import net.hareworks.permits_lib.bukkit.MutationSession +import net.hareworks.permits_lib.dsl.permissionTree +import org.bukkit.permissions.PermissionDefault import org.bukkit.plugin.java.JavaPlugin -public class App : JavaPlugin() { - companion object { - lateinit var instance: App - private set - } +class App : JavaPlugin() { + companion object { + lateinit var instance: App + private set + } - override fun onEnable() { - instance = this - } + private lateinit var session: MutationSession + + override fun onEnable() { + instance = this + session = MutationSession.create(this) + + val baseTree = permissionTree("permits-lib") { + node("command") { + description = "Allows execution of all permits-lib commands." + defaultValue = PermissionDefault.OP + + node("reload") { + description = "Allows /permitslib reload." + } + } + } + + session.applyTree(baseTree) + + // Example of mutating the existing tree without rebuilding everything. + session.edit { + node("command") { + node("debug") { + description = "Allows /permitslib debug utilities." + defaultValue = PermissionDefault.OP + wildcard = false + } + } + } + } } diff --git a/src/main/kotlin/net/hareworks/permits_lib/PermitsLib.kt b/src/main/kotlin/net/hareworks/permits_lib/PermitsLib.kt index 874da7f..3929885 100644 --- a/src/main/kotlin/net/hareworks/permits_lib/PermitsLib.kt +++ b/src/main/kotlin/net/hareworks/permits_lib/PermitsLib.kt @@ -1 +1,8 @@ -package net.hareworks.permits_lib; +package net.hareworks.permits_lib + +import net.hareworks.permits_lib.bukkit.MutationSession +import org.bukkit.plugin.java.JavaPlugin + +object PermitsLib { + fun session(plugin: JavaPlugin): MutationSession = MutationSession.create(plugin) +} diff --git a/src/main/kotlin/net/hareworks/permits_lib/bukkit/AttachmentPatch.kt b/src/main/kotlin/net/hareworks/permits_lib/bukkit/AttachmentPatch.kt new file mode 100644 index 0000000..9f9048a --- /dev/null +++ b/src/main/kotlin/net/hareworks/permits_lib/bukkit/AttachmentPatch.kt @@ -0,0 +1,15 @@ +package net.hareworks.permits_lib.bukkit + +import net.hareworks.permits_lib.domain.PermissionId + +/** + * Describes a set of attachment changes to be applied to a [Permissible]. + * `true`/`false` represent forced grant/deny, while `null` removes the override. + */ +data class AttachmentPatch( + val changes: Map +) { + companion object { + val EMPTY = AttachmentPatch(emptyMap()) + } +} diff --git a/src/main/kotlin/net/hareworks/permits_lib/bukkit/AttachmentSynchronizer.kt b/src/main/kotlin/net/hareworks/permits_lib/bukkit/AttachmentSynchronizer.kt new file mode 100644 index 0000000..16954ef --- /dev/null +++ b/src/main/kotlin/net/hareworks/permits_lib/bukkit/AttachmentSynchronizer.kt @@ -0,0 +1,69 @@ +package net.hareworks.permits_lib.bukkit + +import java.util.IdentityHashMap +import net.hareworks.permits_lib.domain.PermissionId +import net.hareworks.permits_lib.util.ThreadChecks +import org.bukkit.permissions.PermissionAttachment +import org.bukkit.permissions.Permissible +import org.bukkit.plugin.java.JavaPlugin + +/** + * Manages [PermissionAttachment] instances per [Permissible], applying patches and cleaning up once no + * overrides remain. + */ +class AttachmentSynchronizer( + private val plugin: JavaPlugin +) { + private data class AttachmentHandle( + val attachment: PermissionAttachment, + val overrides: MutableMap = linkedMapOf() + ) + + private val handles = IdentityHashMap() + + fun applyPatch(permissible: Permissible, patch: AttachmentPatch) { + ThreadChecks.ensurePrimaryThread("AttachmentSynchronizer.applyPatch") + if (patch.changes.isEmpty()) return + val handle = ensureHandle(permissible) + patch.changes.forEach { (id, value) -> + if (value == null) { + handle.overrides.remove(id) + handle.attachment.unsetPermission(id.value) + } else { + handle.overrides[id] = value + handle.attachment.setPermission(id.value, value) + } + } + if (handle.overrides.isEmpty()) { + release(permissible) + } + } + + fun grant(permissible: Permissible, permission: PermissionId, value: Boolean = true) { + applyPatch(permissible, AttachmentPatch(mapOf(permission to value))) + } + + fun revoke(permissible: Permissible, permission: PermissionId) { + applyPatch(permissible, AttachmentPatch(mapOf(permission to null))) + } + + fun clear(permissible: Permissible) { + ThreadChecks.ensurePrimaryThread("AttachmentSynchronizer.clear") + handles.remove(permissible)?.attachment?.remove() + } + + fun clearAll() { + ThreadChecks.ensurePrimaryThread("AttachmentSynchronizer.clearAll") + handles.values.forEach { it.attachment.remove() } + handles.clear() + } + + private fun ensureHandle(permissible: Permissible): AttachmentHandle = + handles[permissible] ?: AttachmentHandle(permissible.addAttachment(plugin)).also { + handles[permissible] = it + } + + private fun release(permissible: Permissible) { + handles.remove(permissible)?.attachment?.remove() + } +} diff --git a/src/main/kotlin/net/hareworks/permits_lib/bukkit/MutationSession.kt b/src/main/kotlin/net/hareworks/permits_lib/bukkit/MutationSession.kt new file mode 100644 index 0000000..ffb21ef --- /dev/null +++ b/src/main/kotlin/net/hareworks/permits_lib/bukkit/MutationSession.kt @@ -0,0 +1,73 @@ +package net.hareworks.permits_lib.bukkit + +import net.hareworks.permits_lib.domain.MutablePermissionTree +import net.hareworks.permits_lib.domain.PermissionTree +import net.hareworks.permits_lib.domain.TreeDiff + +/** + * High-level façade that ties the registry and attachment synchronizer together. + */ +class MutationSession( + private val registry: PermissionRegistry, + val attachments: AttachmentSynchronizer +) { + private var tree: PermissionTree? = null + private var diff: TreeDiff? = null + + fun applyTree(next: PermissionTree): TreeDiff { + val computed = registry.applyTree(next) + tree = next + diff = computed + return computed + } + + /** + * Mutates the currently applied tree (must exist) and immediately applies the resulting diff to the + * Bukkit registry. + */ + fun edit(block: MutablePermissionTree.() -> Unit): TreeDiff { + val base = tree ?: error("No permission tree applied yet. Call applyTree or edit(namespace) first.") + return editInternal(MutablePermissionTree.from(base), block) + } + + /** + * Mutates the existing tree or creates a fresh one for the provided [namespace] when none was applied + * before. + */ + fun edit(namespace: String, block: MutablePermissionTree.() -> Unit): TreeDiff { + val mutable = tree?.let { + require(it.namespace == namespace) { + "Existing tree namespace '${it.namespace}' differs from requested '$namespace'." + } + MutablePermissionTree.from(it) + } ?: MutablePermissionTree.create(namespace) + return editInternal(mutable, block) + } + + private fun editInternal( + mutable: MutablePermissionTree, + block: MutablePermissionTree.() -> Unit + ): TreeDiff { + mutable.block() + val next = mutable.build() + return applyTree(next) + } + + fun clearAll() { + registry.clear() + attachments.clearAll() + tree = null + diff = null + } + + fun currentTree(): PermissionTree? = tree + fun lastDiff(): TreeDiff? = diff + + companion object { + fun create(plugin: org.bukkit.plugin.java.JavaPlugin): MutationSession = + MutationSession( + registry = PermissionRegistry(plugin), + attachments = AttachmentSynchronizer(plugin) + ) + } +} diff --git a/src/main/kotlin/net/hareworks/permits_lib/bukkit/PermissionRegistry.kt b/src/main/kotlin/net/hareworks/permits_lib/bukkit/PermissionRegistry.kt new file mode 100644 index 0000000..b6da936 --- /dev/null +++ b/src/main/kotlin/net/hareworks/permits_lib/bukkit/PermissionRegistry.kt @@ -0,0 +1,65 @@ +package net.hareworks.permits_lib.bukkit + +import net.hareworks.permits_lib.domain.PermissionNode +import net.hareworks.permits_lib.domain.PermissionTree +import net.hareworks.permits_lib.domain.TreeDiff +import net.hareworks.permits_lib.domain.TreeDiffer +import net.hareworks.permits_lib.domain.TreeSnapshot +import net.hareworks.permits_lib.util.ThreadChecks +import org.bukkit.permissions.Permission +import org.bukkit.plugin.PluginManager +import org.bukkit.plugin.java.JavaPlugin + +/** + * Registers permission nodes against Bukkit's [PluginManager], keeping track of previous state so that + * only diffs are applied back to the server. + */ +class PermissionRegistry( + private val plugin: JavaPlugin, + private val pluginManager: PluginManager = plugin.server.pluginManager +) { + private var snapshot: TreeSnapshot? = null + + fun applyTree(tree: PermissionTree): TreeDiff { + ThreadChecks.ensurePrimaryThread("PermissionRegistry.applyTree") + + val nextSnapshot = tree.toSnapshot() + val diff = TreeDiffer.diff(snapshot, nextSnapshot) + if (!diff.hasChanges) { + return diff + } + + diff.removed.forEach { removeNode(it) } + diff.added.forEach { registerNode(it) } + diff.updated.forEach { updateNode(it) } + + snapshot = nextSnapshot + return diff + } + + fun clear() { + ThreadChecks.ensurePrimaryThread("PermissionRegistry.clear") + snapshot?.nodes?.values?.forEach { removeNode(it) } + snapshot = null + } + + private fun removeNode(node: PermissionNode) { + pluginManager.getPermission(node.id.value)?.let { permission -> + pluginManager.removePermission(permission) + permission.recalculatePermissibles() + } + } + + private fun registerNode(node: PermissionNode) { + val permission = Permission(node.id.value, node.description, node.defaultValue) + permission.children.clear() + permission.children.putAll(node.children.mapKeys { it.key.value }) + pluginManager.addPermission(permission) + permission.recalculatePermissibles() + } + + private fun updateNode(updated: TreeDiff.UpdatedNode) { + removeNode(updated.before) + registerNode(updated.after) + } +} diff --git a/src/main/kotlin/net/hareworks/permits_lib/domain/MutablePermissionTree.kt b/src/main/kotlin/net/hareworks/permits_lib/domain/MutablePermissionTree.kt new file mode 100644 index 0000000..ca0f95b --- /dev/null +++ b/src/main/kotlin/net/hareworks/permits_lib/domain/MutablePermissionTree.kt @@ -0,0 +1,108 @@ +package net.hareworks.permits_lib.domain + +import org.bukkit.permissions.PermissionDefault + +/** + * Imperative view over a permission tree that lets callers mutate nodes directly. Once the desired + * modifications are complete, call [build] to obtain an immutable [PermissionTree]. + */ +class MutablePermissionTree internal constructor( + private val namespace: String, + private val drafts: MutableMap +) { + fun node(id: String, block: MutableNode.() -> Unit = {}): MutableNode { + val permissionId = PermissionId.of(qualify(id)) + val draft = drafts.getOrPut(permissionId) { PermissionNodeDraft(permissionId) } + return MutableNode(permissionId, draft).apply(block) + } + + fun remove(id: String) { + val permissionId = PermissionId.of(qualify(id)) + drafts.remove(permissionId) + drafts.values.forEach { it.children.remove(permissionId) } + } + + fun contains(id: String): Boolean = drafts.containsKey(PermissionId.of(qualify(id))) + + fun build(): PermissionTree { + val nodes = drafts.mapValues { it.value.toNode() } + return PermissionTree.from(namespace, nodes) + } + + private fun qualify(id: String): String = + if (id.startsWith(namespace)) id else "$namespace.$id" + + private fun qualifyRelative(parent: PermissionId, childSegment: String): String { + val normalized = childSegment.trim().lowercase().trimStart('.') + require(normalized.isNotEmpty()) { "Child id must not be blank." } + return if (normalized.startsWith(namespace)) { + normalized + } else { + "${parent.value}.$normalized" + } + } + + inner class MutableNode internal constructor( + val id: PermissionId, + private val draft: PermissionNodeDraft + ) { + var description: String? + get() = draft.description + set(value) { + draft.description = value?.trim() + } + + var defaultValue: PermissionDefault + get() = draft.defaultValue + set(value) { + draft.defaultValue = value + } + + var wildcard: Boolean + get() = draft.wildcard + set(value) { + draft.wildcard = value + } + + val tags: MutableSet + get() = draft.tags + + fun tag(value: String) { + if (value.isNotBlank()) { + draft.tags += value.trim() + } + } + + fun child(id: String, value: Boolean = true) { + val permissionId = PermissionId.of(qualifyRelative(this.id, id)) + draft.children[permissionId] = value + } + + fun childAbsolute(id: String, value: Boolean = true) { + val permissionId = PermissionId.of(qualify(id)) + draft.children[permissionId] = value + } + + fun node(id: String, block: MutableNode.() -> Unit = {}) { + val permissionId = PermissionId.of(qualifyRelative(this.id, id)) + draft.children[permissionId] = true + val childDraft = drafts.getOrPut(permissionId) { PermissionNodeDraft(permissionId) } + MutableNode(permissionId, childDraft).apply(block) + } + + fun removeChild(id: String) { + draft.children.remove(PermissionId.of(qualify(id))) + } + } + + companion object { + fun create(namespace: String): MutablePermissionTree = + MutablePermissionTree(namespace.trim().lowercase(), linkedMapOf()) + + fun from(tree: PermissionTree): MutablePermissionTree = + MutablePermissionTree( + namespace = tree.namespace, + drafts = tree.nodes.mapValues { PermissionNodeDraft.from(it.value) }.toMutableMap() + ) + } +} diff --git a/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionId.kt b/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionId.kt new file mode 100644 index 0000000..40e318d --- /dev/null +++ b/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionId.kt @@ -0,0 +1,26 @@ +package net.hareworks.permits_lib.domain + +/** + * Value object that represents a normalized Bukkit permission node identifier. + * + * The constructor is private to ensure every instance passes through [of] where we enforce the + * naming constraints (lowercase alphanumeric with dots/dashes/underscores) and drop leading/trailing + * whitespace. + */ +@JvmInline +value class PermissionId private constructor(val value: String) { + override fun toString(): String = value + + companion object { + private val VALID_PATTERN = Regex("""^[a-z0-9_.-]+$""") + + fun of(raw: String): PermissionId { + val normalized = raw.trim().lowercase() + require(normalized.isNotEmpty()) { "Permission id must not be blank." } + require(VALID_PATTERN.matches(normalized)) { + "Permission id '$raw' must match ${VALID_PATTERN.pattern}" + } + return PermissionId(normalized) + } + } +} diff --git a/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionNode.kt b/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionNode.kt new file mode 100644 index 0000000..1dbcaf7 --- /dev/null +++ b/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionNode.kt @@ -0,0 +1,22 @@ +package net.hareworks.permits_lib.domain + +import org.bukkit.permissions.PermissionDefault + +/** + * Immutable description of a permission node in the tree. + * + * The [children] boolean flag behaves like Bukkit's `Permission.children` where `true` propagates a + * grant while `false` explicitly revokes. + */ +data class PermissionNode( + val id: PermissionId, + val description: String? = null, + val defaultValue: PermissionDefault = PermissionDefault.FALSE, + val children: Map = emptyMap(), + val tags: Set = emptySet(), + val wildcard: Boolean = true +) { + init { + require(children.keys.none { it == id }) { "Permission node cannot be a child of itself." } + } +} diff --git a/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionNodeDraft.kt b/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionNodeDraft.kt new file mode 100644 index 0000000..7b84625 --- /dev/null +++ b/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionNodeDraft.kt @@ -0,0 +1,34 @@ +package net.hareworks.permits_lib.domain + +import org.bukkit.permissions.PermissionDefault + +internal data class PermissionNodeDraft( + val id: PermissionId, + var description: String? = null, + var defaultValue: PermissionDefault = PermissionDefault.FALSE, + val children: MutableMap = linkedMapOf(), + val tags: MutableSet = linkedSetOf(), + var wildcard: Boolean = true +) { + fun toNode(): PermissionNode = + PermissionNode( + id = id, + description = description, + defaultValue = defaultValue, + children = children.toMap(), + tags = tags.toSet(), + wildcard = wildcard + ) + + companion object { + fun from(node: PermissionNode): PermissionNodeDraft = + PermissionNodeDraft( + id = node.id, + description = node.description, + defaultValue = node.defaultValue, + children = node.children.toMutableMap(), + tags = node.tags.toMutableSet(), + wildcard = node.wildcard + ) + } +} diff --git a/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionTree.kt b/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionTree.kt new file mode 100644 index 0000000..d088627 --- /dev/null +++ b/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionTree.kt @@ -0,0 +1,29 @@ +package net.hareworks.permits_lib.domain + +/** + * Immutable aggregate of permission nodes. + */ +class PermissionTree internal constructor( + val namespace: String, + internal val nodes: Map +) { + init { + require(namespace.isNotBlank()) { "Permission namespace must not be blank." } + } + + val size: Int get() = nodes.size + + operator fun get(id: PermissionId): PermissionNode? = nodes[id] + + fun toSnapshot(): TreeSnapshot = TreeSnapshot(nodes) + + companion object { + fun empty(namespace: String): PermissionTree = PermissionTree(namespace, emptyMap()) + + fun from(namespace: String, rawNodes: Map): PermissionTree { + val augmented = WildcardAugmentor.apply(rawNodes) + PermissionTreeValidator.validate(augmented) + return PermissionTree(namespace, augmented) + } + } +} diff --git a/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionTreeValidator.kt b/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionTreeValidator.kt new file mode 100644 index 0000000..134393b --- /dev/null +++ b/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionTreeValidator.kt @@ -0,0 +1,32 @@ +package net.hareworks.permits_lib.domain + +internal object PermissionTreeValidator { + fun validate(nodes: Map) { + checkForCycles(nodes) + } + + private fun checkForCycles(nodes: Map) { + val visiting = mutableSetOf() + val visited = mutableSetOf() + + fun dfs(id: PermissionId) { + if (!visiting.add(id)) { + error("Detected cycle that includes permission '${id.value}'") + } + val node = nodes[id] ?: return + for (child in node.children.keys) { + if (child !in visited) { + dfs(child) + } + } + visiting.remove(id) + visited.add(id) + } + + nodes.keys.forEach { id -> + if (id !in visited) { + dfs(id) + } + } + } +} diff --git a/src/main/kotlin/net/hareworks/permits_lib/domain/TreeDiff.kt b/src/main/kotlin/net/hareworks/permits_lib/domain/TreeDiff.kt new file mode 100644 index 0000000..8a49b28 --- /dev/null +++ b/src/main/kotlin/net/hareworks/permits_lib/domain/TreeDiff.kt @@ -0,0 +1,12 @@ +package net.hareworks.permits_lib.domain + +data class TreeDiff( + val added: List, + val removed: List, + val updated: List +) { + val hasChanges: Boolean + get() = added.isNotEmpty() || removed.isNotEmpty() || updated.isNotEmpty() + + data class UpdatedNode(val before: PermissionNode, val after: PermissionNode) +} diff --git a/src/main/kotlin/net/hareworks/permits_lib/domain/TreeDiffer.kt b/src/main/kotlin/net/hareworks/permits_lib/domain/TreeDiffer.kt new file mode 100644 index 0000000..4eb7259 --- /dev/null +++ b/src/main/kotlin/net/hareworks/permits_lib/domain/TreeDiffer.kt @@ -0,0 +1,29 @@ +package net.hareworks.permits_lib.domain + +object TreeDiffer { + fun diff(previous: TreeSnapshot?, next: TreeSnapshot): TreeDiff { + val prevNodes = previous?.nodes.orEmpty() + val nextNodes = next.nodes + + val added = mutableListOf() + val removed = mutableListOf() + val updated = mutableListOf() + + val allKeys = (prevNodes.keys + nextNodes.keys).toSet() + for (key in allKeys) { + val before = prevNodes[key] + val after = nextNodes[key] + when { + before == null && after != null -> added += after + before != null && after == null -> removed += before + before != null && after != null && before != after -> updated += TreeDiff.UpdatedNode(before, after) + } + } + + return TreeDiff( + added = added.sortedBy { it.id.value }, + removed = removed.sortedBy { it.id.value }, + updated = updated.sortedBy { it.after.id.value } + ) + } +} diff --git a/src/main/kotlin/net/hareworks/permits_lib/domain/TreeSnapshot.kt b/src/main/kotlin/net/hareworks/permits_lib/domain/TreeSnapshot.kt new file mode 100644 index 0000000..a121f46 --- /dev/null +++ b/src/main/kotlin/net/hareworks/permits_lib/domain/TreeSnapshot.kt @@ -0,0 +1,36 @@ +package net.hareworks.permits_lib.domain + +import java.security.MessageDigest + +/** + * Snapshot of a tree at a specific point in time. Holds a deterministic digest useful for caching. + */ +class TreeSnapshot internal constructor( + internal val nodes: Map +) { + val digest: String = computeDigest(nodes) + + companion object { + val EMPTY = TreeSnapshot(emptyMap()) + + private fun computeDigest(nodes: Map): String { + val digest = MessageDigest.getInstance("SHA-256") + nodes.entries + .sortedBy { it.key.value } + .forEach { (id, node) -> + digest.update(id.value.toByteArray()) + digest.update(node.description.orEmpty().toByteArray()) + digest.update(node.defaultValue.name.toByteArray()) + node.children.toSortedMap(compareBy { it.value }).forEach { (childId, flag) -> + digest.update(childId.value.toByteArray()) + digest.update(if (flag) 1 else 0) + } + node.tags.sorted().forEach { tag -> + digest.update(tag.toByteArray()) + } + digest.update(if (node.wildcard) 1 else 0) + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + } +} diff --git a/src/main/kotlin/net/hareworks/permits_lib/domain/WildcardAugmentor.kt b/src/main/kotlin/net/hareworks/permits_lib/domain/WildcardAugmentor.kt new file mode 100644 index 0000000..4844ea1 --- /dev/null +++ b/src/main/kotlin/net/hareworks/permits_lib/domain/WildcardAugmentor.kt @@ -0,0 +1,46 @@ +package net.hareworks.permits_lib.domain + +import org.bukkit.permissions.PermissionDefault + +internal object WildcardAugmentor { + fun apply(nodes: Map): Map { + if (nodes.isEmpty()) return nodes + val result = nodes.toMutableMap() + + nodes.values.forEach { node -> + if (!node.wildcard) return@forEach + if (node.id.value.endsWith(".*")) return@forEach + + val wildcardId = parentWildcardId(node.id) ?: return@forEach + val existing = result[wildcardId] + val updatedChildren = (existing?.children ?: emptyMap()).toMutableMap() + val alreadyPresent = updatedChildren[node.id] == true + if (!alreadyPresent) { + updatedChildren[node.id] = true + } + + if (existing == null) { + result[wildcardId] = PermissionNode( + id = wildcardId, + description = "Wildcard for ${wildcardId.value}", + defaultValue = node.defaultValue, + children = updatedChildren, + tags = setOf("wildcard"), + wildcard = false + ) + } else if (!alreadyPresent) { + result[wildcardId] = existing.copy(children = updatedChildren) + } + } + + return result + } + + private fun parentWildcardId(id: PermissionId): PermissionId? { + val value = id.value + val lastDot = value.lastIndexOf('.') + if (lastDot <= 0) return null + val parent = value.substring(0, lastDot) + return PermissionId.of("$parent.*") + } +} diff --git a/src/main/kotlin/net/hareworks/permits_lib/dsl/PermissionDsl.kt b/src/main/kotlin/net/hareworks/permits_lib/dsl/PermissionDsl.kt new file mode 100644 index 0000000..d83cc95 --- /dev/null +++ b/src/main/kotlin/net/hareworks/permits_lib/dsl/PermissionDsl.kt @@ -0,0 +1,4 @@ +package net.hareworks.permits_lib.dsl + +@DslMarker +annotation class PermissionDsl diff --git a/src/main/kotlin/net/hareworks/permits_lib/dsl/PermissionNodeBuilder.kt b/src/main/kotlin/net/hareworks/permits_lib/dsl/PermissionNodeBuilder.kt new file mode 100644 index 0000000..52a4455 --- /dev/null +++ b/src/main/kotlin/net/hareworks/permits_lib/dsl/PermissionNodeBuilder.kt @@ -0,0 +1,60 @@ +package net.hareworks.permits_lib.dsl + +import net.hareworks.permits_lib.domain.PermissionId +import net.hareworks.permits_lib.domain.PermissionNodeDraft +import org.bukkit.permissions.PermissionDefault + +@PermissionDsl +class PermissionNodeBuilder internal constructor( + private val treeBuilder: PermissionTreeBuilder, + private val draft: PermissionNodeDraft +) { + var description: String? + get() = draft.description + set(value) { + draft.description = value?.trim() + } + + var defaultValue: PermissionDefault + get() = draft.defaultValue + set(value) { + draft.defaultValue = value + } + + var wildcard: Boolean + get() = draft.wildcard + set(value) { + draft.wildcard = value + } + + fun tag(value: String) { + if (value.isNotBlank()) { + draft.tags += value.trim() + } + } + + fun child(id: String, value: Boolean = true) { + treeBuilder.childRelative(draft, id, value) + } + + fun child(id: PermissionId, value: Boolean = true) { + treeBuilder.childAbsolute(draft, id.value, value) + } + + fun childAbsolute(id: String, value: Boolean = true) { + treeBuilder.childAbsolute(draft, id, value) + } + + /** + * Declares a nested node whose id is derived from the current node: + * + * ``` + * node("command") { + * node("reload") { ... } // -> namespace.command.reload + * } + * ``` + */ + fun node(id: String, block: PermissionNodeBuilder.() -> Unit = {}) { + treeBuilder.nestedNode(draft, id, block) + } +} diff --git a/src/main/kotlin/net/hareworks/permits_lib/dsl/PermissionTreeBuilder.kt b/src/main/kotlin/net/hareworks/permits_lib/dsl/PermissionTreeBuilder.kt new file mode 100644 index 0000000..2ae73e5 --- /dev/null +++ b/src/main/kotlin/net/hareworks/permits_lib/dsl/PermissionTreeBuilder.kt @@ -0,0 +1,70 @@ +package net.hareworks.permits_lib.dsl + +import net.hareworks.permits_lib.domain.PermissionId +import net.hareworks.permits_lib.domain.PermissionNodeDraft +import net.hareworks.permits_lib.domain.PermissionTree + +@PermissionDsl +class PermissionTreeBuilder internal constructor( + private val namespace: String +) { + private val drafts = linkedMapOf() + + fun node(id: String, block: PermissionNodeBuilder.() -> Unit = {}) { + val permissionId = PermissionId.of(qualify(id)) + val draft = drafts.getOrPut(permissionId) { PermissionNodeDraft(permissionId) } + PermissionNodeBuilder(this, draft).apply(block) + } + + internal fun qualify(id: String): String = + if (id.startsWith(namespace)) id else "$namespace.$id" + + internal fun child( + parent: PermissionNodeDraft, + id: String, + value: Boolean, + relative: Boolean + ) { + val permissionId = PermissionId.of(if (relative) qualifyRelative(parent.id, id) else qualify(id)) + parent.children[permissionId] = value + } + + internal fun childRelative( + parent: PermissionNodeDraft, + id: String, + value: Boolean + ) = child(parent, id, value, relative = true) + + internal fun childAbsolute( + parent: PermissionNodeDraft, + id: String, + value: Boolean + ) = child(parent, id, value, relative = false) + + internal fun nestedNode( + parent: PermissionNodeDraft, + id: String, + block: PermissionNodeBuilder.() -> Unit + ) { + val permissionId = PermissionId.of(qualifyRelative(parent.id, id)) + parent.children[permissionId] = true + val draft = drafts.getOrPut(permissionId) { PermissionNodeDraft(permissionId) } + PermissionNodeBuilder(this, draft).apply(block) + } + + fun build(): PermissionTree = + PermissionTree.from(namespace, drafts.mapValues { it.value.toNode() }) + + private fun qualifyRelative(parent: PermissionId, childSegment: String): String { + val normalized = childSegment.trim().lowercase().trimStart('.') + require(normalized.isNotEmpty()) { "Child id must not be blank." } + return if (normalized.startsWith(namespace)) { + normalized + } else { + "${parent.value}.$normalized" + } + } +} + +fun permissionTree(namespace: String, block: PermissionTreeBuilder.() -> Unit): PermissionTree = + PermissionTreeBuilder(namespace.trim().lowercase()).apply(block).build() diff --git a/src/main/kotlin/net/hareworks/permits_lib/util/ThreadChecks.kt b/src/main/kotlin/net/hareworks/permits_lib/util/ThreadChecks.kt new file mode 100644 index 0000000..b2f380a --- /dev/null +++ b/src/main/kotlin/net/hareworks/permits_lib/util/ThreadChecks.kt @@ -0,0 +1,11 @@ +package net.hareworks.permits_lib.util + +import org.bukkit.Bukkit + +internal object ThreadChecks { + fun ensurePrimaryThread(action: String) { + check(Bukkit.isPrimaryThread()) { + "$action must be invoked from the primary server thread." + } + } +}