Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ ShaderAPI disables its client integration when Iris is missing.

Connect to a server implementing the ShaderAPI protocol. The client requests the server's shader pack and asks for approval before downloading and applying it. Acceptance is remembered by server group.

Offers must provide a 40-digit SHA-1 hash; downloaded and cached packs are checked against it. Rejection is also remembered by server group. To change a saved decision, remove that group's entry from `config/shaderapi.json` while the game is closed.

A server-side implementation for Paper is available in [ShaderAPI-Paper](https://github.com/Liams-Services/ShaderAPI-Paper). Client support for 26.3 does not establish compatibility of that separate server plugin.

## Building
Expand Down
8 changes: 8 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ dependencies {

}

fabricApi {
configureTests {
createSourceSet = true
modId = "shaderapi_test"
enableGameTests = false
}
}

tasks.processResources {
inputs.property("version", project.version)
inputs.property("minecraft_version", project.property("minecraft_version"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,33 @@ import net.minecraft.client.Minecraft
import net.minecraft.client.gui.screens.ConfirmScreen
import net.minecraft.network.chat.Component
import java.io.File
import java.io.OutputStream
import java.net.HttpURLConnection
import java.net.URI
import java.security.DigestInputStream
import java.security.MessageDigest
import java.util.*
import kotlin.reflect.jvm.jvmName

class ShaderReceivePayloadHandler : ClientPlayNetworking.PlayPayloadHandler<ShaderReceivePayload> {
override fun receive(payload: ShaderReceivePayload, context: ClientPlayNetworking.Context) {
logger.info("ShaderReceivePayload received")
val shaderUrl: String = payload.shaderUrl ?: return
val shaderHash: String = payload.hash ?: return
val serverGroup: String = payload.serverGroup.ifEmpty { "global" }
val decision = ConfigManager.getShaderPackAcceptState(serverGroup)

if (testForServerGroupAlwaysAccept(serverGroup)) {
if (decision == ShaderPackAcceptState.DENY) {
sendShaderStatusResponse(ShaderStatusResponse.DENIED)
return
}

if (!shaderHash.matches(Regex("[0-9a-fA-F]{40}"))) {
logger.warn("Shader pack hash is not a SHA-1 digest")
sendShaderStatusResponse(ShaderStatusResponse.DOWNLOAD_FAILED)
return
}

if (decision == ShaderPackAcceptState.ACCEPT) {
downloadAndApplyShaderPack(shaderUrl, shaderHash)
return
}
Expand All @@ -45,21 +59,25 @@ class ShaderReceivePayloadHandler : ClientPlayNetworking.PlayPayloadHandler<Shad
}
}

private fun testForServerGroupAlwaysAccept(serverGroup: String): Boolean {
return ConfigManager.getShaderPackAcceptState(serverGroup) == ShaderPackAcceptState.ACCEPT
}

private fun testForShaderPack(hash: String): Boolean {
try {
val file = File(Minecraft.getInstance().gameDirectory, "downloads/$hash.zip")
return file.exists()
if (!file.isFile) return false
if (hashMatches(file, hash)) return true
logger.warn("Cached shader pack has an incorrect SHA-1 digest")
file.delete()
} catch (exception: Exception) {
exception.printStackTrace()
sendShaderStatusResponse(exception::class.jvmName.uppercase())
logger.warn("Could not validate cached shader pack", exception)
}
return false
}

private fun hashMatches(file: File, hash: String): Boolean {
val digest = MessageDigest.getInstance("SHA-1")
DigestInputStream(file.inputStream(), digest).use { it.copyTo(OutputStream.nullOutputStream()) }
return HexFormat.of().formatHex(digest.digest()).equals(hash, ignoreCase = true)
}

private fun downloadAndApplyShaderPack(url: String, shaderHash: String) {

if (testForShaderPack(shaderHash)) {
Expand Down Expand Up @@ -100,30 +118,32 @@ class ShaderReceivePayloadHandler : ClientPlayNetworking.PlayPayloadHandler<Shad
}

private fun downloadShaderPack(url: String, hash: String): File? {
// Implementiere den Download des Shaderpacks von der URL und speichere es lokal ab
// Gib die Datei zurück, wenn der Download erfolgreich war, andernfalls null
var tmpFile: File? = null
var connection: HttpURLConnection? = null
try {
val minecraftClient = Minecraft.getInstance()
val tempUUID = UUID.randomUUID()
val tmpFile = File(minecraftClient.gameDirectory, "downloads/$tempUUID")
val urlConnection = URI(url).toURL().openConnection() as HttpURLConnection
urlConnection.connect()
urlConnection.inputStream.use { inputStream ->
val directory = File(minecraftClient.gameDirectory, "downloads")
if (!directory.isDirectory && !directory.mkdirs()) return null
tmpFile = File.createTempFile("shaderapi-", ".tmp", directory)
connection = URI(url).toURL().openConnection() as HttpURLConnection
connection.connectTimeout = 10_000
connection.readTimeout = 30_000
connection.inputStream.use { inputStream ->
tmpFile.outputStream().use { outputStream ->
inputStream.copyTo(outputStream)
}
}

val renamed = tmpFile.renameTo(File(minecraftClient.gameDirectory, "downloads/$hash.zip"))
return if (renamed) {
File(minecraftClient.gameDirectory, "downloads/$hash.zip")
} else {
sendShaderStatusResponse(ShaderStatusResponse.IO_EXCEPTION)
null
if (!hashMatches(tmpFile, hash)) {
logger.warn("Downloaded shader pack has an incorrect SHA-1 digest")
return null
}
val destination = File(directory, "$hash.zip")
return if (tmpFile.renameTo(destination)) destination else null
} catch (exception: Exception) {
exception.printStackTrace()
sendShaderStatusResponse(exception::class.jvmName.uppercase())
logger.warn("Could not download shader pack", exception)
} finally {
connection?.disconnect()
tmpFile?.delete()
}
return null
}
Expand Down Expand Up @@ -171,4 +191,4 @@ private object IrisBridge {
fun reload() {
reloadMethod.invoke(null)
}
}
}
114 changes: 114 additions & 0 deletions src/gametest/java/com/liamxsage/shaderapi/ShaderConsentClientTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package com.liamxsage.shaderapi;

import com.liamxsage.shaderapi.client.ShaderReceivePayloadHandler;
import com.liamxsage.shaderapi.client.config.ConfigManager;
import com.liamxsage.shaderapi.client.config.ShaderPackAcceptState;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import com.sun.net.httpserver.HttpServer;
import net.minecraft.client.gui.screens.ConfirmScreen;
import java.net.InetSocketAddress;
import java.nio.file.Files;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

public final class ShaderConsentClientTest implements FabricClientGameTest {
@Override
public void runTest(ClientGameTestContext context) {
var status = new AtomicReference<String>();
var statuses = new CopyOnWriteArrayList<String>();
ClientPlayNetworking.registerGlobalReceiver(ShaderReceivePayload.ID, new ShaderReceivePayloadHandler());
ServerPlayNetworking.registerGlobalReceiver(ShaderStatusResponsePayload.ID,
(payload, ignored) -> { statuses.add(payload.statusResponse()); status.set(payload.statusResponse()); });
try (var world = context.worldBuilder().create()) {
context.runOnClient(client -> ConfigManager.INSTANCE.addServerGroup("denied-test", ShaderPackAcceptState.DENY));
world.getServer().runOnServer(server -> ServerPlayNetworking.send(world.getConnection().getServerPlayer(),
new ShaderReceivePayload("https://example.com/pack.zip", "0000000000000000000000000000000000000000", "denied-test")));
context.waitFor(client -> status.get() != null || client.gui.screen() instanceof ConfirmScreen, 200);
if (!"DENIED".equals(status.get())) throw new AssertionError("A saved rejection must send DENIED without prompting");
context.runOnClient(client -> {
if (client.gui.screen() instanceof ConfirmScreen) throw new AssertionError("Saved rejection prompted again");
});
statuses.clear();
world.getServer().runOnServer(server -> ServerPlayNetworking.send(world.getConnection().getServerPlayer(),
new ShaderReceivePayload("https://example.com/pack.zip", "../../unsafe", "denied-test")));
context.waitFor(client -> !statuses.isEmpty(), 200);
if (!statuses.equals(List.of("DENIED")))
throw new AssertionError("A saved rejection must remain DENIED even for an invalid offer: " + statuses);
statuses.clear();
context.runOnClient(client -> ConfigManager.INSTANCE.addServerGroup("accepted-test", ShaderPackAcceptState.ACCEPT));
world.getServer().runOnServer(server -> ServerPlayNetworking.send(world.getConnection().getServerPlayer(),
new ShaderReceivePayload("http://127.0.0.1:1/missing.zip", "0000000000000000000000000000000000000000", "accepted-test")));
context.waitFor(client -> statuses.contains("DOWNLOAD_FAILED"), 200);
context.waitTicks(20);
if (!statuses.equals(List.of("DOWNLOAD_FAILED")))
throw new AssertionError("A failed download must send one DOWNLOAD_FAILED status: " + statuses);

try {
var name = "shaderapi-escape-" + UUID.randomUUID();
var gameDir = context.computeOnClient(client -> client.gameDirectory.toPath());
var outside = gameDir.resolve("downloads/../../" + name + ".zip").normalize();
var wrongHash = "1111111111111111111111111111111111111111";
var cached = gameDir.resolve("downloads/" + wrongHash + ".zip");
var validHash = "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3";
var validCache = gameDir.resolve("downloads/" + validHash + ".zip");
if (Files.exists(outside) || Files.exists(cached) || Files.exists(validCache))
throw new AssertionError("Test destination already exists");
var http = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
var requests = new AtomicInteger();
http.createContext("/pack", exchange -> {
requests.incrementAndGet();
byte[] data = "test".getBytes(java.nio.charset.StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, data.length);
try (var body = exchange.getResponseBody()) { body.write(data); }
});
http.start();
try {
statuses.clear();
var url = "http://127.0.0.1:" + http.getAddress().getPort() + "/pack";
world.getServer().runOnServer(server -> ServerPlayNetworking.send(world.getConnection().getServerPlayer(),
new ShaderReceivePayload(url, "../../" + name, "accepted-test")));
context.waitFor(client -> !statuses.isEmpty(), 200);
context.waitTicks(10);
if (Files.exists(outside) || !statuses.equals(List.of("DOWNLOAD_FAILED")))
throw new AssertionError("An unsafe hash must not write outside the download directory: " + statuses);

statuses.clear();
world.getServer().runOnServer(server -> ServerPlayNetworking.send(world.getConnection().getServerPlayer(),
new ShaderReceivePayload(url, wrongHash, "accepted-test")));
context.waitFor(client -> !statuses.isEmpty(), 200);
context.waitTicks(10);
if (Files.exists(cached) || !statuses.equals(List.of("DOWNLOAD_FAILED")))
throw new AssertionError("A mismatched download must not be cached or applied: " + statuses);

statuses.clear();
world.getServer().runOnServer(server -> ServerPlayNetworking.send(world.getConnection().getServerPlayer(),
new ShaderReceivePayload(url, validHash, "accepted-test")));
context.waitFor(client -> !statuses.isEmpty(), 200);
if (requests.get() != 2 || !Files.readString(validCache).equals("test") || !statuses.equals(List.of("APPLY_FAILED")))
throw new AssertionError("A matching download must be cached before Iris is invoked: " + statuses);

Files.writeString(validCache, "corrupt");
statuses.clear();
world.getServer().runOnServer(server -> ServerPlayNetworking.send(world.getConnection().getServerPlayer(),
new ShaderReceivePayload(url, validHash, "accepted-test")));
context.waitFor(client -> !statuses.isEmpty(), 200);
if (requests.get() != 3 || !Files.readString(validCache).equals("test") || !statuses.equals(List.of("APPLY_FAILED")))
throw new AssertionError("A corrupt cached file must be replaced by a verified download: " + statuses);
} finally {
http.stop(0);
Files.deleteIfExists(outside);
Files.deleteIfExists(cached);
Files.deleteIfExists(validCache);
}
} catch (java.io.IOException e) {
throw new AssertionError("Could not run download fixture", e);
}
}
}
}
9 changes: 9 additions & 0 deletions src/gametest/resources/fabric.mod.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"schemaVersion": 1,
"id": "shaderapi_test",
"version": "1.0.0",
"environment": "client",
"entrypoints": {
"fabric-client-gametest": ["com.liamxsage.shaderapi.ShaderConsentClientTest"]
}
}
Loading