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..4f78c48048 --- /dev/null +++ b/worldedit-fabric/src/main/java/com/sk89q/worldedit/fabric/internal/FabricPermissionsProvider.java @@ -0,0 +1,111 @@ +/* + * 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.nio.charset.StandardCharsets; +import java.util.HexFormat; +import java.util.Locale; +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 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); + } + + Boolean result = player.checkPermission(node); + return result != null ? result : fallback.hasPermission(player, 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); + } + fallback.registerPermission(permission); + } + + private static @Nullable PermissionNode createNode(String permission) { + 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; + } + + // 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) + ); + } + + 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 { + encoded.append('_'); + encoded.append(HexFormat.of().toHexDigits(rawByte)); + } + } + return encoded.toString(); + } +} 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": "*" 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..4626df729c --- /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_5fv1_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.")); + } +}