From 87a34c1bad9a7aa98554391e74bbbcce1aeb714c Mon Sep 17 00:00:00 2001 From: Maddy Miller Date: Tue, 14 Jul 2026 21:55:17 +1000 Subject: [PATCH 1/3] Proof of Concept Fabric Perms API implementation --- .../internal/FabricPermissionsProvider.java | 80 +++++++++++++++++++ .../fabric/internal/FabricWorldEdit.java | 2 +- .../src/main/resources/fabric.mod.json | 3 +- 3 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 worldedit-fabric/src/main/java/com/sk89q/worldedit/fabric/internal/FabricPermissionsProvider.java diff --git a/worldedit-fabric/src/main/java/com/sk89q/worldedit/fabric/internal/FabricPermissionsProvider.java b/worldedit-fabric/src/main/java/com/sk89q/worldedit/fabric/internal/FabricPermissionsProvider.java new file mode 100644 index 0000000000..83d5d63679 --- /dev/null +++ b/worldedit-fabric/src/main/java/com/sk89q/worldedit/fabric/internal/FabricPermissionsProvider.java @@ -0,0 +1,80 @@ +/* + * WorldEdit, a Minecraft world manipulation toolkit + * Copyright (C) sk89q + * Copyright (C) WorldEdit team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sk89q.worldedit.fabric.internal; + +import com.sk89q.worldedit.coremc.CoreMcPermissionsProvider; +import net.fabricmc.fabric.api.permission.v1.PermissionNode; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerPlayer; +import org.jspecify.annotations.Nullable; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +final class FabricPermissionsProvider implements CoreMcPermissionsProvider { + private final CoreMcPermissionsProvider fallback; + private final Map> registeredPermissions = new ConcurrentHashMap<>(); + + FabricPermissionsProvider(CoreMcPermissionsProvider fallback) { + this.fallback = fallback; + } + + @Override + public boolean hasPermission(ServerPlayer player, String permission) { + PermissionNode node = registeredPermissions.get(permission); + if (node == null) { + node = createNode(permission); + } + + if (node == null) { + // This permission node doesn't meet the stricter requirements of the official Fabric perms API + // so delegate to the fallback in the hopes it can handle it. + return fallback.hasPermission(player, permission); + } + + Boolean result = player.checkPermission(node); + return result != null ? result : fallback.hasPermission(player, permission); + } + + @Override + public void registerPermission(String permission) { + PermissionNode node = createNode(permission); + if (node != null) { + registeredPermissions.putIfAbsent(permission, node); + } + fallback.registerPermission(permission); + } + + private static @Nullable PermissionNode createNode(String permission) { + // Our permission nodes are in a.b.c.d format, Fabric expects modid:b.c.d, so we convert it here. + int namespaceSeparator = permission.indexOf('.'); + if (namespaceSeparator < 1 || namespaceSeparator == permission.length() - 1) { + return null; + } + + Identifier identifier = Identifier.tryBuild( + permission.substring(0, namespaceSeparator), + permission.substring(namespaceSeparator + 1) + ); + // It's theoretically possible some of our perm nodes are _not_ valid Identifier instances, + // if so we return null here and allow a fallback later on. + return identifier != null ? PermissionNode.of(identifier) : null; + } +} diff --git a/worldedit-fabric/src/main/java/com/sk89q/worldedit/fabric/internal/FabricWorldEdit.java b/worldedit-fabric/src/main/java/com/sk89q/worldedit/fabric/internal/FabricWorldEdit.java index 0a16849f99..dbf771d8ad 100644 --- a/worldedit-fabric/src/main/java/com/sk89q/worldedit/fabric/internal/FabricWorldEdit.java +++ b/worldedit-fabric/src/main/java/com/sk89q/worldedit/fabric/internal/FabricWorldEdit.java @@ -132,6 +132,6 @@ protected CoreMcPermissionsProvider createPermissionsProvider(CoreMcPlatform pla LOGGER.warn("Failed to load Fabric permissions provider. Falling back to Minecraft", e); } - return provider; + return new FabricPermissionsProvider(provider); } } diff --git a/worldedit-fabric/src/main/resources/fabric.mod.json b/worldedit-fabric/src/main/resources/fabric.mod.json index b3c5080b90..23d96ac4a8 100644 --- a/worldedit-fabric/src/main/resources/fabric.mod.json +++ b/worldedit-fabric/src/main/resources/fabric.mod.json @@ -37,7 +37,8 @@ "fabric-command-api-v2": "*", "fabric-lifecycle-events-v1": "*", "fabric-events-interaction-v0": "*", - "fabric-networking-api-v1": "*" + "fabric-networking-api-v1": "*", + "fabric-permission-api-v1": "*" }, "suggests": { "fabric-permissions-api-v0": "*" From 34c49b08e2afb6d6baf8432b4f6c65f59be90b3c Mon Sep 17 00:00:00 2001 From: Maddy Miller Date: Wed, 15 Jul 2026 20:00:19 +1000 Subject: [PATCH 2/3] Add a form of URL encoding but for Identifier --- .../internal/FabricPermissionsProvider.java | 44 +++++++++-- .../FabricPermissionsProviderTest.java | 74 +++++++++++++++++++ 2 files changed, 110 insertions(+), 8 deletions(-) create mode 100644 worldedit-fabric/src/test/java/com/sk89q/worldedit/fabric/internal/FabricPermissionsProviderTest.java diff --git a/worldedit-fabric/src/main/java/com/sk89q/worldedit/fabric/internal/FabricPermissionsProvider.java b/worldedit-fabric/src/main/java/com/sk89q/worldedit/fabric/internal/FabricPermissionsProvider.java index 83d5d63679..e9e9af3f35 100644 --- a/worldedit-fabric/src/main/java/com/sk89q/worldedit/fabric/internal/FabricPermissionsProvider.java +++ b/worldedit-fabric/src/main/java/com/sk89q/worldedit/fabric/internal/FabricPermissionsProvider.java @@ -25,6 +25,8 @@ import net.minecraft.server.level.ServerPlayer; import org.jspecify.annotations.Nullable; +import java.nio.charset.StandardCharsets; +import java.util.Locale; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -44,7 +46,7 @@ public boolean hasPermission(ServerPlayer player, String permission) { } if (node == null) { - // This permission node doesn't meet the stricter requirements of the official Fabric perms API + // This permission node doesn't have the namespace/path format expected by the official Fabric perms API, // so delegate to the fallback in the hopes it can handle it. return fallback.hasPermission(player, permission); } @@ -63,18 +65,44 @@ public void registerPermission(String permission) { } private static @Nullable PermissionNode createNode(String permission) { - // Our permission nodes are in a.b.c.d format, Fabric expects modid:b.c.d, so we convert it here. + Identifier identifier = identifierFor(permission); + // It's theoretically possible some of our permission nodes don't have a namespace and path, + // so return null here and allow a fallback later on. + return identifier != null ? PermissionNode.of(identifier) : null; + } + + static @Nullable Identifier identifierFor(String permission) { int namespaceSeparator = permission.indexOf('.'); if (namespaceSeparator < 1 || namespaceSeparator == permission.length() - 1) { return null; } - Identifier identifier = Identifier.tryBuild( - permission.substring(0, namespaceSeparator), - permission.substring(namespaceSeparator + 1) + // Our permission nodes are in a.b.c.d format, Fabric expects modid:b.c.d, so we convert it here. + return Identifier.fromNamespaceAndPath( + encodeIdentifierPart(permission.substring(0, namespaceSeparator), false), + encodeIdentifierPart(permission.substring(namespaceSeparator + 1), true) ); - // It's theoretically possible some of our perm nodes are _not_ valid Identifier instances, - // if so we return null here and allow a fallback later on. - return identifier != null ? PermissionNode.of(identifier) : null; + } + + private static String encodeIdentifierPart(String value, boolean allowSlash) { + // Fabric uses Identifier which has a stricter character set, so escape anything they cannot represent directly. + byte[] bytes = value.toLowerCase(Locale.ROOT).getBytes(StandardCharsets.UTF_8); + StringBuilder encoded = new StringBuilder(bytes.length); + for (byte rawByte : bytes) { + int valueByte = Byte.toUnsignedInt(rawByte); + if ((valueByte >= 'a' && valueByte <= 'z') + || (valueByte >= '0' && valueByte <= '9') + || valueByte == '.' || valueByte == '-' + || (allowSlash && valueByte == '/')) { + encoded.append((char) valueByte); + } else if (valueByte == '_') { + encoded.append("__"); + } else { + encoded.append('_'); + encoded.append(Character.forDigit(valueByte >>> 4, 16)); + encoded.append(Character.forDigit(valueByte & 0x0F, 16)); + } + } + return encoded.toString(); } } diff --git a/worldedit-fabric/src/test/java/com/sk89q/worldedit/fabric/internal/FabricPermissionsProviderTest.java b/worldedit-fabric/src/test/java/com/sk89q/worldedit/fabric/internal/FabricPermissionsProviderTest.java new file mode 100644 index 0000000000..aab9b2fbc7 --- /dev/null +++ b/worldedit-fabric/src/test/java/com/sk89q/worldedit/fabric/internal/FabricPermissionsProviderTest.java @@ -0,0 +1,74 @@ +/* + * WorldEdit, a Minecraft world manipulation toolkit + * Copyright (C) sk89q + * Copyright (C) WorldEdit team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sk89q.worldedit.fabric.internal; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class FabricPermissionsProviderTest { + + @Test + void mapsStandardPermission() { + assertEquals( + "worldedit:region.set", + FabricPermissionsProvider.identifierFor("worldedit.region.set").toString() + ); + } + + @Test + void normalizesCase() { + assertEquals( + "worldedit:scripting.execute.build.js", + FabricPermissionsProvider.identifierFor("WorldEdit.scripting.execute.Build.js").toString() + ); + } + + @Test + void escapesInvalidCharactersAndUnderscores() { + assertEquals( + "worldedit:scripting.execute.my_20script__v1_21", + FabricPermissionsProvider.identifierFor("worldedit.scripting.execute.my script_v1!").toString() + ); + } + + @Test + void escapesUtf8Bytes() { + assertEquals( + "worldedit:scripting.execute.caf_c3_a9", + FabricPermissionsProvider.identifierFor("worldedit.scripting.execute.café").toString() + ); + } + + @Test + void preservesPathSeparators() { + assertEquals( + "worldedit:scripting.execute/tools/build.js", + FabricPermissionsProvider.identifierFor("worldedit.scripting.execute/tools/build.js").toString() + ); + } + + @Test + void rejectsInvalidPermissionNodes() { + assertNull(FabricPermissionsProvider.identifierFor("worldedit")); + assertNull(FabricPermissionsProvider.identifierFor("worldedit.")); + } +} From c172688636c335b97bf5f7c79058b8a587e72143 Mon Sep 17 00:00:00 2001 From: Maddy Miller Date: Sat, 18 Jul 2026 19:06:40 +1000 Subject: [PATCH 3/3] Review feedback --- .../fabric/internal/FabricPermissionsProvider.java | 11 +++++++---- .../internal/FabricPermissionsProviderTest.java | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/worldedit-fabric/src/main/java/com/sk89q/worldedit/fabric/internal/FabricPermissionsProvider.java b/worldedit-fabric/src/main/java/com/sk89q/worldedit/fabric/internal/FabricPermissionsProvider.java index e9e9af3f35..4f78c48048 100644 --- a/worldedit-fabric/src/main/java/com/sk89q/worldedit/fabric/internal/FabricPermissionsProvider.java +++ b/worldedit-fabric/src/main/java/com/sk89q/worldedit/fabric/internal/FabricPermissionsProvider.java @@ -26,6 +26,7 @@ import org.jspecify.annotations.Nullable; import java.nio.charset.StandardCharsets; +import java.util.HexFormat; import java.util.Locale; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -57,6 +58,11 @@ public boolean hasPermission(ServerPlayer player, String permission) { @Override public void registerPermission(String permission) { + if (registeredPermissions.containsKey(permission)) { + // Short-circuit if already registered. + return; + } + PermissionNode node = createNode(permission); if (node != null) { registeredPermissions.putIfAbsent(permission, node); @@ -95,12 +101,9 @@ private static String encodeIdentifierPart(String value, boolean allowSlash) { || valueByte == '.' || valueByte == '-' || (allowSlash && valueByte == '/')) { encoded.append((char) valueByte); - } else if (valueByte == '_') { - encoded.append("__"); } else { encoded.append('_'); - encoded.append(Character.forDigit(valueByte >>> 4, 16)); - encoded.append(Character.forDigit(valueByte & 0x0F, 16)); + encoded.append(HexFormat.of().toHexDigits(rawByte)); } } return encoded.toString(); diff --git a/worldedit-fabric/src/test/java/com/sk89q/worldedit/fabric/internal/FabricPermissionsProviderTest.java b/worldedit-fabric/src/test/java/com/sk89q/worldedit/fabric/internal/FabricPermissionsProviderTest.java index aab9b2fbc7..4626df729c 100644 --- a/worldedit-fabric/src/test/java/com/sk89q/worldedit/fabric/internal/FabricPermissionsProviderTest.java +++ b/worldedit-fabric/src/test/java/com/sk89q/worldedit/fabric/internal/FabricPermissionsProviderTest.java @@ -45,7 +45,7 @@ void normalizesCase() { @Test void escapesInvalidCharactersAndUnderscores() { assertEquals( - "worldedit:scripting.execute.my_20script__v1_21", + "worldedit:scripting.execute.my_20script_5fv1_21", FabricPermissionsProvider.identifierFor("worldedit.scripting.execute.my script_v1!").toString() ); }