diff --git a/build.gradle b/build.gradle index bb5de08..7d8f64a 100644 --- a/build.gradle +++ b/build.gradle @@ -22,7 +22,7 @@ sourceSets.main.resources { } repositories { - // Add here additional repositories if required by some of the dependencies below. + mavenCentral() } base { @@ -49,6 +49,12 @@ neoForge { systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id } + client2 { + client() + programArguments.addAll '--username', 'Tester2' + systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id + } + server { server() programArgument '--nogui' @@ -108,27 +114,22 @@ configurations { } dependencies { - // Example optional mod dependency with JEI - // The JEI API is declared for compile time use, while the full JEI artifact is used at runtime - // compileOnly "mezz.jei:jei-${mc_version}-common-api:${jei_version}" - // compileOnly "mezz.jei:jei-${mc_version}-neoforge-api:${jei_version}" - // We add the full version to localRuntime, not runtimeOnly, so that we do not publish a dependency on it - // localRuntime "mezz.jei:jei-${mc_version}-neoforge:${jei_version}" - - // Example mod dependency using a mod jar from ./libs with a flat dir repository - // This maps to ./libs/coolmod-${mc_version}-${coolmod_version}.jar - // The group id is ignored when searching -- in this case, it is "blank" - // implementation "blank:coolmod-${mc_version}:${coolmod_version}" - - // Example mod dependency using a file as dependency - // implementation files("libs/coolmod-${mc_version}-${coolmod_version}.jar") - - // Example project dependency using a sister or child project: - // implementation project(":myproject") - - // For more info: - // http://www.gradle.org/docs/current/userguide/artifact_dependencies_tutorial.html - // http://www.gradle.org/docs/current/userguide/dependency_management.html + // Security tests: JUnit 5 + Jazzer (coverage-guided fuzz harnesses). These never run + // in the mod at runtime; they're only on the test classpath. + testImplementation platform('org.junit:junit-bom:5.11.3') + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'com.code-intelligence:jazzer-junit:0.24.0' +} + +// Regression tests run with `./gradlew test`. Jazzer @FuzzTest methods run for `maxDuration` +// each by default (30s). To run a longer fuzzing session on a specific harness: +// ./gradlew test --tests "com.razz.dfashion.security.SafePngReaderSecurityTest.fuzzDecode" +tasks.named('test', Test) { + useJUnitPlatform() + // Jazzer loads its instrumentation agent at runtime; Java 21+ requires this flag + // or the JVM warns and Jazzer may fail to attach. + jvmArgs '-XX:+EnableDynamicAgentLoading' } // This block of code expands all declared replace properties in the specified resource targets. diff --git a/src/main/java/com/razz/dfashion/DecoFashionClient.java b/src/main/java/com/razz/dfashion/DecoFashionClient.java index 5fd2e6a..71b4e7f 100644 --- a/src/main/java/com/razz/dfashion/DecoFashionClient.java +++ b/src/main/java/com/razz/dfashion/DecoFashionClient.java @@ -7,11 +7,13 @@ import com.razz.dfashion.bbmodel.BbOutlinerNode; import com.razz.dfashion.bbmodel.Bbmodel; import com.razz.dfashion.bbmodel.BbmodelBaker; import com.razz.dfashion.block.ClosetRegistry; +import com.razz.dfashion.client.ClientSharedCosmeticCache; import com.razz.dfashion.client.ClientSkinCache; import com.razz.dfashion.client.ClosetModelCache; import com.razz.dfashion.client.ClosetRenderer; import com.razz.dfashion.client.CosmeticCache; import com.razz.dfashion.client.CosmeticRenderLayer; +import com.razz.dfashion.client.SafeImageLoader; import com.razz.dfashion.cosmetic.CosmeticCatalog; import com.razz.dfashion.cosmetic.CosmeticDefinition; import com.razz.dfashion.cosmetic.UserCosmeticLoader; @@ -38,7 +40,6 @@ import net.neoforged.neoforge.client.event.AddClientReloadListenersEvent; import net.neoforged.neoforge.client.event.EntityRenderersEvent; import java.io.IOException; -import java.io.InputStream; import java.io.Reader; import java.nio.file.Files; import java.util.HashMap; @@ -50,6 +51,11 @@ import java.util.Optional; @EventBusSubscriber(modid = DecoFashion.MODID, value = Dist.CLIENT) public class DecoFashionClient { + /** Upper bound on a user-authored .bbmodel we'll even attempt to parse. Real cosmetics + * are well under 1 MB; 16 MB is slack for giant authored pieces while still blocking + * a malicious GB-scale file from being slurped into memory via {@code Files.newBufferedReader}. */ + private static final long MAX_BBMODEL_FILE_BYTES = 16L * 1024 * 1024; + @SubscribeEvent static void onClientSetup(FMLClientSetupEvent event) { DecoFashion.LOGGER.info("DecoFashion client setup complete"); @@ -94,6 +100,7 @@ public class DecoFashionClient { // Pre-register every skin PNG that's already on disk so the wardrobe grid shows // the player's full history without waiting on attachment sync or re-uploads. ClientSkinCache.scanDisk(); + ClientSharedCosmeticCache.scanDisk(); } /** @@ -114,25 +121,38 @@ public class DecoFashionClient { for (UserCosmeticLoader.Entry entry : entries) { Bbmodel model = modelCache.get(entry.modelFile()); if (model == null) { + try { + long size = Files.size(entry.modelFile()); + if (size > MAX_BBMODEL_FILE_BYTES) { + DecoFashion.LOGGER.error("User cosmetic {}: bbmodel too large ({} bytes); skipping {}", + entry.id(), size, entry.modelFile()); + continue; + } + } catch (IOException ex) { + DecoFashion.LOGGER.error("User cosmetic {}: size check failed for {}", + entry.id(), entry.modelFile(), ex); + continue; + } try (Reader reader = Files.newBufferedReader(entry.modelFile())) { model = BbModelParser.parse(reader); modelCache.put(entry.modelFile(), model); } catch (Exception ex) { - DecoFashion.LOGGER.error("User cosmetic {}: failed to parse {}", - entry.id(), entry.modelFile(), ex); + DecoFashion.LOGGER.error("User cosmetic {}: failed to parse {} ({})", + entry.id(), entry.modelFile(), ex.getMessage()); continue; } } - try (InputStream in = Files.newInputStream(entry.textureFile())) { - NativeImage image = NativeImage.read(in); - Identifier texId = entry.def().texture(); - tm.register(texId, new DynamicTexture(() -> "decofashion user " + texId, image)); + NativeImage image; + try { + image = SafeImageLoader.loadNativeImage(entry.textureFile()); } catch (IOException ex) { - DecoFashion.LOGGER.error("User cosmetic {}: failed to load texture {}", - entry.id(), entry.textureFile(), ex); + DecoFashion.LOGGER.error("User cosmetic {}: rejected texture {} ({})", + entry.id(), entry.textureFile(), ex.getMessage()); continue; } + Identifier texId = entry.def().texture(); + tm.register(texId, new DynamicTexture(() -> "decofashion user " + texId, image)); Map parts = BbmodelBaker.bake( model, model.resolutionWidth(), model.resolutionHeight(), true); diff --git a/src/main/java/com/razz/dfashion/bbmodel/BbModelParser.java b/src/main/java/com/razz/dfashion/bbmodel/BbModelParser.java index 73b174e..2d1bcd2 100644 --- a/src/main/java/com/razz/dfashion/bbmodel/BbModelParser.java +++ b/src/main/java/com/razz/dfashion/bbmodel/BbModelParser.java @@ -7,15 +7,47 @@ import org.joml.Vector3f; import java.io.Reader; import java.lang.reflect.Type; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Set; +/** + * Parses .bbmodel JSON (format_version 5.0+) into an internal geometry record. Callers + * must treat the input as untrusted: the parser enforces finite-float checks, coordinate + * range caps (to prevent NaN-by-overflow in the renderer's matrix math), resolution caps + * (to align with the texture parser's pixel budget), duplicate-uuid rejection, and outliner + * reference resolution. Element/group/locator counts are intentionally uncapped — the real + * DoS bound is the file-size cap at the caller. + */ public class BbModelParser { + + // Caps are deliberately minimal. The primary DoS defense is the file-size cap enforced + // upstream (see DecoFashionClient.MAX_BBMODEL_FILE_BYTES): a 16 MB JSON can't hide a + // billion cubes. Element/group/locator counts and outliner node totals are therefore + // NOT capped here — a complex authored cosmetic should parse without complaint. + // + // What we DO cap: + // - resolution: matches SafePngReader's MAX_DIM so UVs stay in the same pixel budget. + // - coordinates: must be finite AND within a range that won't NaN the renderer's + // matrix math (real bbmodel content lives in [-256, 256]; 1e4 is huge slack). + // - outliner depth: purely a stack-overflow guard for the recursive validator. Real + // content never breaks 20 levels; 256 is 40× slack. If legit authoring ever hits + // this, the fix is to rewrite the walk iteratively, not to raise the cap. + public static final int MAX_RESOLUTION = 4096; + public static final int MAX_OUTLINER_DEPTH = 256; + public static final float MAX_COORD = 1e4f; + private static final Gson GSON = new GsonBuilder() .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) .registerTypeAdapter(Vector3f.class, new Vector3fDeserializer()) .registerTypeAdapter(BbOutlinerNode.class, new BbOutlinerNode.BbOutlinerNodeDeserializer()) .create(); + public static final class BadBbmodelException extends JsonParseException { + public BadBbmodelException(String message) { super(message); } + } + public static Bbmodel parse(Reader in) { JsonObject root = JsonParser.parseReader(in).getAsJsonObject(); @@ -36,7 +68,8 @@ public class BbModelParser { List cubes = new ArrayList<>(); List locators = new ArrayList<>(); - for (JsonElement e : root.getAsJsonArray("elements")) { + JsonArray elementsArr = root.has("elements") ? root.getAsJsonArray("elements") : new JsonArray(); + for (JsonElement e : elementsArr) { JsonObject obj = e.getAsJsonObject(); String type = obj.has("type") ? obj.get("type").getAsString() : "cube"; switch (type) { @@ -46,17 +79,113 @@ public class BbModelParser { } } - List groups = GSON.fromJson( - root.getAsJsonArray("groups"), - new TypeToken>(){}.getType() - ); + JsonArray groupsArr = root.has("groups") ? root.getAsJsonArray("groups") : new JsonArray(); + List groups = GSON.fromJson(groupsArr, new TypeToken>(){}.getType()); + + JsonArray outlinerArr = root.has("outliner") ? root.getAsJsonArray("outliner") : new JsonArray(); + List outliner = GSON.fromJson(outlinerArr, new TypeToken>(){}.getType()); + + Bbmodel model = new Bbmodel(resW, resH, cubes, locators, groups, outliner); + validate(model); + return model; + } + + /** Runs all structural invariants over a {@link Bbmodel}. Exposed so wire-side codecs + * can re-validate after deserialization; see {@code BbmodelCodec}. */ + public static void validate(Bbmodel m) { + requireRange("resolution.width", m.resolutionWidth(), 1, MAX_RESOLUTION); + requireRange("resolution.height", m.resolutionHeight(), 1, MAX_RESOLUTION); + + // Unified id space — outliner ElementRefs can point to either a cube or a locator. + Set elementIds = new HashSet<>(); + for (BbCube c : m.elements()) { + requireNonNull("element.uuid", c.uuid()); + validateVec("element.from", c.from()); + validateVec("element.to", c.to()); + validateVec("element.origin", c.origin()); + validateVec("element.rotation", c.rotation()); + requireFinite("element.inflate", c.inflate()); + if (c.faces() != null) { + for (Map.Entry e : c.faces().entrySet()) { + BbFace f = e.getValue(); + if (f != null && f.uv() != null) { + for (float uv : f.uv()) requireFinite("face.uv", uv); + } + } + } + if (!elementIds.add(c.uuid())) throw new BadBbmodelException("duplicate element uuid " + c.uuid()); + } + for (BbLocator loc : m.locators()) { + requireNonNull("locator.uuid", loc.uuid()); + validateVec("locator.position", loc.position()); + validateVec("locator.rotation", loc.rotation()); + if (!elementIds.add(loc.uuid())) throw new BadBbmodelException("duplicate locator uuid " + loc.uuid()); + } + + Set groupIds = new HashSet<>(); + for (BbGroup g : m.groups()) { + requireNonNull("group.uuid", g.uuid()); + validateVec("group.origin", g.origin()); + validateVec("group.rotation", g.rotation()); + if (!groupIds.add(g.uuid())) throw new BadBbmodelException("duplicate group uuid " + g.uuid()); + } + + for (BbOutlinerNode node : m.outliner()) { + validateOutlinerNode(node, 0, elementIds, groupIds); + } + } + + private static void validateOutlinerNode( + BbOutlinerNode node, int depth, + Set elementIds, Set groupIds + ) { + // Stack-overflow guard only; not a content limit. See class-level comment. + if (depth > MAX_OUTLINER_DEPTH) { + throw new BadBbmodelException("outliner nesting exceeded stack-safety bound (>" + MAX_OUTLINER_DEPTH + ")"); + } + switch (node) { + case BbOutlinerNode.ElementRef er -> { + requireNonNull("outliner.element.uuid", er.uuid()); + if (!elementIds.contains(er.uuid())) { + throw new BadBbmodelException("outliner references unknown element " + er.uuid()); + } + } + case BbOutlinerNode.GroupRef gr -> { + requireNonNull("outliner.group.uuid", gr.uuid()); + if (!groupIds.contains(gr.uuid())) { + throw new BadBbmodelException("outliner references unknown group " + gr.uuid()); + } + if (gr.children() != null) { + for (BbOutlinerNode child : gr.children()) { + validateOutlinerNode(child, depth + 1, elementIds, groupIds); + } + } + } + } + } + + private static void requireRange(String label, int v, int min, int max) { + if (v < min || v > max) { + throw new BadBbmodelException(label + " out of range [" + min + "," + max + "]: " + v); + } + } - List outliner = GSON.fromJson( - root.getAsJsonArray("outliner"), - new TypeToken>(){}.getType() - ); + private static void requireNonNull(String label, String s) { + if (s == null) throw new BadBbmodelException(label + " is null"); + } + + private static void validateVec(String label, Vector3f v) { + if (v == null) return; + requireFinite(label + ".x", v.x); + requireFinite(label + ".y", v.y); + requireFinite(label + ".z", v.z); + if (Math.abs(v.x) > MAX_COORD || Math.abs(v.y) > MAX_COORD || Math.abs(v.z) > MAX_COORD) { + throw new BadBbmodelException(label + " out of range: (" + v.x + "," + v.y + "," + v.z + ")"); + } + } - return new Bbmodel(resW, resH, cubes, locators, groups, outliner); + private static void requireFinite(String label, float v) { + if (!Float.isFinite(v)) throw new BadBbmodelException(label + " is not finite: " + v); } @@ -72,4 +201,4 @@ public class BbModelParser { return new Vector3f(x, y, z); } } -} +} \ No newline at end of file diff --git a/src/main/java/com/razz/dfashion/client/ClientCommands.java b/src/main/java/com/razz/dfashion/client/ClientCommands.java index f283bb2..40ed94d 100644 --- a/src/main/java/com/razz/dfashion/client/ClientCommands.java +++ b/src/main/java/com/razz/dfashion/client/ClientCommands.java @@ -5,9 +5,16 @@ import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.context.CommandContext; import com.razz.dfashion.DecoFashion; import com.razz.dfashion.DecoFashionClient; +import com.razz.dfashion.cosmetic.CosmeticAttachments; +import com.razz.dfashion.cosmetic.share.CosmeticLibrary; +import com.razz.dfashion.cosmetic.share.CosmeticLibraryEntry; +import com.razz.dfashion.cosmetic.share.SharedCosmeticCache; +import com.razz.dfashion.cosmetic.share.packet.AssignSharedCosmetic; +import com.razz.dfashion.cosmetic.share.packet.DeleteCosmetic; import com.razz.dfashion.skin.SkinModel; import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.Commands; import net.minecraft.network.chat.Component; @@ -16,8 +23,12 @@ import net.neoforged.bus.api.SubscribeEvent; import net.neoforged.fml.common.EventBusSubscriber; import net.neoforged.neoforge.client.event.RegisterClientCommandsEvent; +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.Locale; @EventBusSubscriber(modid = DecoFashion.MODID, value = Dist.CLIENT) public final class ClientCommands { @@ -33,6 +44,29 @@ public final class ClientCommands { .executes(ctx -> uploadSkin(ctx, SkinModel.WIDE)) ) ) + .then(Commands.literal("share_cosmetic") + .then(Commands.argument("folder", StringArgumentType.greedyString()) + .executes(ClientCommands::shareCosmetic) + ) + ) + .then(Commands.literal("list_shared").executes(ClientCommands::listShared)) + .then(Commands.literal("equip_shared") + .then(Commands.argument("slot", StringArgumentType.word()) + .then(Commands.argument("hash", StringArgumentType.word()) + .executes(ClientCommands::equipShared) + ) + ) + ) + .then(Commands.literal("unequip_shared") + .then(Commands.argument("slot", StringArgumentType.word()) + .executes(ClientCommands::unequipShared) + ) + ) + .then(Commands.literal("delete_shared") + .then(Commands.argument("hash", StringArgumentType.word()) + .executes(ClientCommands::deleteShared) + ) + ) ); } @@ -61,5 +95,107 @@ public final class ClientCommands { return 1; } + private static int shareCosmetic(CommandContext ctx) { + String raw = StringArgumentType.getString(ctx, "folder").trim(); + if ((raw.startsWith("\"") && raw.endsWith("\"")) || (raw.startsWith("'") && raw.endsWith("'"))) { + raw = raw.substring(1, raw.length() - 1); + } + Path folder; + try { + folder = Paths.get(raw); + } catch (Exception ex) { + ctx.getSource().sendFailure(Component.literal("Invalid path: " + raw)); + return 0; + } + if (!Files.isDirectory(folder)) { + ctx.getSource().sendFailure(Component.literal("Not a directory: " + folder)); + return 0; + } + + Path bbmodelFile = null; + Path textureFile = null; + try (DirectoryStream stream = Files.newDirectoryStream(folder)) { + for (Path f : stream) { + if (!Files.isRegularFile(f)) continue; + String name = f.getFileName().toString().toLowerCase(Locale.ROOT); + if (bbmodelFile == null && name.endsWith(".bbmodel")) bbmodelFile = f; + else if (textureFile == null && name.endsWith(".png")) textureFile = f; + } + } catch (IOException ex) { + ctx.getSource().sendFailure(Component.literal("Scan failed: " + ex.getMessage())); + return 0; + } + if (bbmodelFile == null) { + ctx.getSource().sendFailure(Component.literal("No .bbmodel found in " + folder)); + return 0; + } + if (textureFile == null) { + ctx.getSource().sendFailure(Component.literal("No .png found in " + folder)); + return 0; + } + + String displayName = folder.getFileName().toString(); + String msg = ClientSharedCosmeticUploader.upload(bbmodelFile, textureFile, displayName); + ctx.getSource().sendSuccess(() -> Component.literal(msg), false); + return 1; + } + + private static int listShared(CommandContext ctx) { + LocalPlayer player = Minecraft.getInstance().player; + if (player == null) { + ctx.getSource().sendFailure(Component.literal("Not in-world")); + return 0; + } + CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get()); + if (lib.entries().isEmpty()) { + ctx.getSource().sendSuccess(() -> Component.literal("Shared library is empty"), false); + return 1; + } + for (CosmeticLibraryEntry e : lib.entries()) { + ctx.getSource().sendSuccess(() -> Component.literal( + " " + e.hash().substring(0, 8) + "… " + e.displayName()), false); + } + return 1; + } + + private static int equipShared(CommandContext ctx) { + String slot = StringArgumentType.getString(ctx, "slot"); + String hash = StringArgumentType.getString(ctx, "hash"); + if (!SharedCosmeticCache.isValidHash(hash)) { + ctx.getSource().sendFailure(Component.literal("Invalid hash")); + return 0; + } + ClientSharedCosmeticCache.sendToServer(new AssignSharedCosmetic(slot, hash)); + ctx.getSource().sendSuccess( + () -> Component.literal("Equipped " + hash.substring(0, 8) + "… in '" + slot + "'"), + false); + return 1; + } + + private static int unequipShared(CommandContext ctx) { + String slot = StringArgumentType.getString(ctx, "slot"); + // Empty hash = clear slot (server-side convention in AssignSharedCosmetic handler). + ClientSharedCosmeticCache.sendToServer(new AssignSharedCosmetic(slot, "")); + ctx.getSource().sendSuccess( + () -> Component.literal("Unequipped '" + slot + "'"), false); + return 1; + } + + private static int deleteShared(CommandContext ctx) { + String hash = StringArgumentType.getString(ctx, "hash"); + if (!SharedCosmeticCache.isValidHash(hash)) { + ctx.getSource().sendFailure(Component.literal("Invalid hash")); + return 0; + } + // Two-phase delete: tell the server to remove from the library, then release the + // local blob + texture. Server is authoritative but the local cache is what the + // render layer pulls from until the library attachment sync round-trips. + ClientSharedCosmeticCache.sendToServer(new DeleteCosmetic(hash)); + ClientSharedCosmeticCache.delete(hash); + ctx.getSource().sendSuccess( + () -> Component.literal("Deleted " + hash.substring(0, 8) + "…"), false); + return 1; + } + private ClientCommands() {} } diff --git a/src/main/java/com/razz/dfashion/client/ClientSharedCosmeticCache.java b/src/main/java/com/razz/dfashion/client/ClientSharedCosmeticCache.java new file mode 100644 index 0000000..165fa46 --- /dev/null +++ b/src/main/java/com/razz/dfashion/client/ClientSharedCosmeticCache.java @@ -0,0 +1,353 @@ +package com.razz.dfashion.client; + +import com.mojang.blaze3d.platform.NativeImage; +import com.razz.dfashion.DecoFashion; +import com.razz.dfashion.bbmodel.Bbmodel; +import com.razz.dfashion.bbmodel.BbmodelBaker; +import com.razz.dfashion.cosmetic.share.BbmodelCodec; +import com.razz.dfashion.cosmetic.share.CosmeticLibrary; +import com.razz.dfashion.cosmetic.CosmeticAttachments; +import com.razz.dfashion.cosmetic.share.SharedCosmeticCache; +import com.razz.dfashion.cosmetic.share.packet.RequestCosmetic; +import com.razz.dfashion.skin.SkinCache; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import net.minecraft.client.Minecraft; +import net.minecraft.client.model.geom.ModelPart; +import net.minecraft.client.multiplayer.ClientPacketListener; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.renderer.texture.DynamicTexture; +import net.minecraft.client.renderer.texture.TextureManager; +import net.minecraft.network.protocol.common.ServerboundCustomPayloadPacket; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; +import net.neoforged.fml.loading.FMLPaths; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.Set; + +/** + * Client-side store for shared cosmetics. Holds content-addressed {@code .dfcos} + * blobs under {@code /decofashion/shared_cosmetics_cache/.dfcos}, baked + * {@code CosmeticCache.Baked} entries keyed by hash, and an in-flight download map for + * streaming reception. + * + *

Symmetric to {@link ClientSkinCache} but with an extra bbmodel-decode step. At no + * point on this path does any PNG decoder or JSON parser see wire bytes: + *

    + *
  • The bbmodel binary is decoded via {@link BbmodelCodec} — bounded and re-validated.
  • + *
  • The deflated RGBA is inflated under a {@code w*h*4} cap.
  • + *
  • The {@link NativeImage} is built pixel-by-pixel via {@code setPixelABGR}; STB is + * never invoked with untrusted bytes.
  • + *
+ */ +public final class ClientSharedCosmeticCache { + + private static final String SUBDIR = "decofashion/shared_cosmetics_cache"; + + private static final Map BAKED = new ConcurrentHashMap<>(); + private static final Map IN_FLIGHT = new ConcurrentHashMap<>(); + private static final Set PENDING_REQUESTS = ConcurrentHashMap.newKeySet(); + + private ClientSharedCosmeticCache() {} + + private static final class Download { + int expectedTotal; + int nextIndex; + int bbmodelLen; + int width; + int height; + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + } + + private static Path root() { + return FMLPaths.GAMEDIR.get().resolve(SUBDIR); + } + + private static Path fileFor(String hash) { + return root().resolve(hash + SharedCosmeticCache.FILE_EXT); + } + + public static Identifier textureIdFor(String hash) { + return Identifier.fromNamespaceAndPath(DecoFashion.MODID, "shared_cosmetics/" + hash); + } + + public static CosmeticCache.Baked getBaked(String hash) { + return BAKED.get(hash); + } + + public static boolean hasOnDisk(String hash) { + return SharedCosmeticCache.isValidHash(hash) && Files.isRegularFile(fileFor(hash)); + } + + /** + * If we don't have a baked entry and haven't already asked the server, send one + * {@link RequestCosmetic}. Deduped so the render layer can call every frame without + * spamming. Pure in-memory dispatch — no disk I/O on the render thread. + */ + public static void requestIfMissing(String hash) { + if (!SharedCosmeticCache.isValidHash(hash)) return; + if (BAKED.containsKey(hash)) return; + if (IN_FLIGHT.containsKey(hash)) return; + if (!PENDING_REQUESTS.add(hash)) return; + IN_FLIGHT.put(hash, new Download()); + sendToServer(new RequestCosmetic(hash)); + } + + /** + * Consume one server→client chunk. Every field is validated against the first-chunk + * state; mismatches clear in-flight state. + */ + public static void onChunk(String hash, int index, int total, int bbmodelLen, + int width, int height, byte[] data) { + if (!SharedCosmeticCache.isValidHash(hash)) { + DecoFashion.LOGGER.warn("shared cosmetic chunk: bad hash"); + return; + } + if (total <= 0 || index < 0 || index >= total) { + DecoFashion.LOGGER.warn("shared cosmetic {}: bad chunk {}/{}", hash, index, total); + IN_FLIGHT.remove(hash); + return; + } + if (bbmodelLen <= 0 || bbmodelLen > SharedCosmeticCache.MAX_BBMODEL_BIN_BYTES) { + DecoFashion.LOGGER.warn("shared cosmetic {}: bad bbmodelLen {}", hash, bbmodelLen); + IN_FLIGHT.remove(hash); + return; + } + if (width <= 0 || height <= 0 + || width > SharedCosmeticCache.MAX_DIM + || height > SharedCosmeticCache.MAX_DIM) { + DecoFashion.LOGGER.warn("shared cosmetic {}: bad dims {}x{}", hash, width, height); + IN_FLIGHT.remove(hash); + return; + } + if (data == null || data.length > SharedCosmeticCache.MAX_CHUNK_BYTES) { + DecoFashion.LOGGER.warn("shared cosmetic {}: chunk too large", hash); + IN_FLIGHT.remove(hash); + return; + } + + Download d = IN_FLIGHT.get(hash); + if (d == null) { + d = new Download(); + IN_FLIGHT.put(hash, d); + } + if (index == 0) { + d.expectedTotal = total; + d.nextIndex = 0; + d.bbmodelLen = bbmodelLen; + d.width = width; + d.height = height; + d.buffer.reset(); + } else if (d.expectedTotal != total || d.nextIndex != index + || d.bbmodelLen != bbmodelLen || d.width != width || d.height != height) { + DecoFashion.LOGGER.warn("shared cosmetic {}: chunk mismatch at {}/{}", hash, index, total); + IN_FLIGHT.remove(hash); + return; + } + + int maxTotal = SharedCosmeticCache.MAX_BBMODEL_BIN_BYTES + SharedCosmeticCache.MAX_DEFLATED_BYTES; + if (d.buffer.size() + data.length > maxTotal) { + DecoFashion.LOGGER.warn("shared cosmetic {}: exceeds total cap", hash); + IN_FLIGHT.remove(hash); + return; + } + try { + d.buffer.write(data); + } catch (IOException ex) { + IN_FLIGHT.remove(hash); + return; + } + d.nextIndex = index + 1; + if (index + 1 < total) return; + + IN_FLIGHT.remove(hash); + PENDING_REQUESTS.remove(hash); + finalizeDownload(hash, d); + } + + private static void finalizeDownload(String hash, Download d) { + byte[] payload = d.buffer.toByteArray(); + if (payload.length < d.bbmodelLen) { + DecoFashion.LOGGER.warn("shared cosmetic {}: payload shorter than bbmodelLen", hash); + return; + } + + byte[] bbmodelBin = new byte[d.bbmodelLen]; + System.arraycopy(payload, 0, bbmodelBin, 0, d.bbmodelLen); + byte[] deflatedRgba = new byte[payload.length - d.bbmodelLen]; + System.arraycopy(payload, d.bbmodelLen, deflatedRgba, 0, deflatedRgba.length); + + // Bake first; only write the blob to disk after it validates. Writing first would + // leave a rejected blob lingering on disk, where scanDisk would keep re-hydrating it + // and failing forever. + if (!bakeAndRegister(hash, bbmodelBin, d.width, d.height, deflatedRgba)) { + return; + } + byte[] blob = SharedCosmeticCache.buildBlob(bbmodelBin, d.width, d.height, deflatedRgba); + try { + Files.createDirectories(root()); + Files.write(fileFor(hash), blob); + } catch (IOException ex) { + DecoFashion.LOGGER.warn("shared cosmetic {}: disk write failed ({})", hash, ex.getMessage()); + } + } + + /** + * Load a cached blob back into the baked cache without a round-trip. If bake fails, + * the on-disk file is deleted so a subsequent {@link #scanDisk} doesn't keep re-trying + * a permanently-broken blob. + */ + public static void rehydrateFromDisk(String hash) throws IOException { + if (!SharedCosmeticCache.isValidHash(hash)) throw new IOException("invalid hash"); + byte[] blob = Files.readAllBytes(fileFor(hash)); + SharedCosmeticCache.BlobView view = SharedCosmeticCache.readBlob(blob); + if (view == null) { + Files.deleteIfExists(fileFor(hash)); + throw new IOException("blob corrupt"); + } + boolean ok = bakeAndRegister(hash, view.bbmodelBinary(), view.width(), view.height(), view.deflatedRgba()); + if (!ok) { + Files.deleteIfExists(fileFor(hash)); + throw new IOException("bake rejected"); + } + } + + /** + * Decode bbmodel, inflate RGBA, build {@link NativeImage}, register with + * {@link TextureManager}, bake the ModelPart tree, and publish into the render cache. + */ + /** + * Returns {@code true} iff the blob fully decoded and was registered. Callers use the + * return value to gate disk persistence — a failed bake must never leave a blob on + * disk that subsequent {@code scanDisk} calls would keep re-hydrating and failing. + */ + private static boolean bakeAndRegister(String hash, byte[] bbmodelBin, + int width, int height, byte[] deflatedRgba) { + Bbmodel bbmodel; + ByteBuf in = Unpooled.wrappedBuffer(bbmodelBin); + try { + bbmodel = BbmodelCodec.CODEC.decode(in); + if (in.readableBytes() != 0) { + DecoFashion.LOGGER.warn("shared cosmetic {}: trailing bytes after bbmodel decode", hash); + return false; + } + } catch (RuntimeException ex) { + DecoFashion.LOGGER.warn("shared cosmetic {}: bbmodel rejected ({})", hash, ex.getMessage()); + return false; + } finally { + in.release(); + } + + int expectedRaw; + try { + expectedRaw = SkinCache.rgbaByteCount(width, height); + } catch (IllegalArgumentException ex) { + DecoFashion.LOGGER.warn("shared cosmetic {}: {}", hash, ex.getMessage()); + return false; + } + byte[] rgba; + try { + rgba = SkinCache.inflateBounded(deflatedRgba, expectedRaw); + } catch (IOException ex) { + DecoFashion.LOGGER.warn("shared cosmetic {}: inflate failed ({})", hash, ex.getMessage()); + return false; + } + + NativeImage img = new NativeImage(width, height, false); + int src = 0; + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int r = rgba[src++] & 0xFF; + int g = rgba[src++] & 0xFF; + int b = rgba[src++] & 0xFF; + int a = rgba[src++] & 0xFF; + img.setPixelABGR(x, y, r | (g << 8) | (b << 16) | (a << 24)); + } + } + TextureManager tm = Minecraft.getInstance().getTextureManager(); + Identifier texId = textureIdFor(hash); + tm.register(texId, new DynamicTexture(() -> "decofashion shared " + hash, img)); + + Map parts = BbmodelBaker.bake( + bbmodel, bbmodel.resolutionWidth(), bbmodel.resolutionHeight(), true); + BAKED.put(hash, new CosmeticCache.Baked(parts, texId)); + DecoFashion.LOGGER.info("shared cosmetic ready: hash={} dims={}x{} parts={}", + hash, width, height, parts.keySet()); + return true; + } + + /** + * Drop a cached shared cosmetic from this client — releases its texture, forgets the + * baked entry, removes the local cache file. The server's copy is untouched. + */ + public static boolean delete(String hash) { + if (!SharedCosmeticCache.isValidHash(hash)) return false; + CosmeticCache.Baked removed = BAKED.remove(hash); + boolean changed = removed != null; + if (removed != null) { + try { + Minecraft.getInstance().getTextureManager().release(removed.texture()); + } catch (Throwable t) { + DecoFashion.LOGGER.warn("shared cosmetic {}: texture release failed ({})", hash, t.getMessage()); + } + } + try { + if (Files.deleteIfExists(fileFor(hash))) changed = true; + } catch (IOException ex) { + DecoFashion.LOGGER.warn("shared cosmetic {}: file delete failed ({})", hash, ex.getMessage()); + } + return changed; + } + + /** + * Preload every cached blob into the baked map so shared cosmetics render without a + * round-trip after a restart. Run at client setup (equivalent to {@code ClientSkinCache.scanDisk}). + */ + public static void scanDisk() { + Path dir = root(); + if (!Files.isDirectory(dir)) return; + int loaded = 0; + int expectedNameLen = 64 + SharedCosmeticCache.FILE_EXT.length(); + try (java.nio.file.DirectoryStream stream = + Files.newDirectoryStream(dir, "*" + SharedCosmeticCache.FILE_EXT)) { + for (Path p : stream) { + String name = p.getFileName().toString(); + if (name.length() != expectedNameLen) continue; + String hash = name.substring(0, 64); + if (!SharedCosmeticCache.isValidHash(hash)) continue; + if (BAKED.containsKey(hash)) continue; + try { + rehydrateFromDisk(hash); + loaded++; + } catch (Exception ex) { + DecoFashion.LOGGER.warn("shared cache: failed to load {} ({})", + hash, ex.getMessage()); + } + } + } catch (IOException ex) { + DecoFashion.LOGGER.warn("shared cache scan failed: {}", ex.getMessage()); + } + if (loaded > 0) DecoFashion.LOGGER.info("shared cache: loaded {} cosmetic(s) from disk", loaded); + } + + /** Server-authoritative dedupe check — is this hash already in the local player's library? */ + public static boolean libraryContains(String hash) { + LocalPlayer player = Minecraft.getInstance().player; + if (player == null) return false; + CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get()); + return lib.contains(hash); + } + + public static void sendToServer(CustomPacketPayload payload) { + ClientPacketListener conn = Minecraft.getInstance().getConnection(); + if (conn == null) return; + conn.send(new ServerboundCustomPayloadPacket(payload)); + } +} \ No newline at end of file diff --git a/src/main/java/com/razz/dfashion/client/ClientSharedCosmeticUploader.java b/src/main/java/com/razz/dfashion/client/ClientSharedCosmeticUploader.java new file mode 100644 index 0000000..800fcec --- /dev/null +++ b/src/main/java/com/razz/dfashion/client/ClientSharedCosmeticUploader.java @@ -0,0 +1,177 @@ +package com.razz.dfashion.client; + +import com.razz.dfashion.DecoFashion; +import com.razz.dfashion.bbmodel.BbModelParser; +import com.razz.dfashion.bbmodel.Bbmodel; +import com.razz.dfashion.cosmetic.share.BbmodelCodec; +import com.razz.dfashion.cosmetic.share.SharedCosmeticCache; +import com.razz.dfashion.cosmetic.share.packet.UploadCosmeticChunk; +import com.razz.dfashion.skin.SafePngReader; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientPacketListener; +import net.minecraft.network.protocol.common.ServerboundCustomPayloadPacket; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.Reader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.NoSuchAlgorithmException; +import java.util.zip.Deflater; + +/** + * Author-side upload pipeline for shared cosmetics. Runs only on the uploading client. + * + *

Security-critical: this is the only place in the entire system where + * {@link BbModelParser} and {@link SafePngReader} see untrusted bytes. Everything + * downstream (server, receivers) operates on the validated binary produced here and on + * already-deflated RGBA. The output of this file is what every other client will + * eventually decode — if this file doesn't reject something, nothing else will either. + * + *

Anti-DoS: before sending, the server-authoritative {@link + * ClientSharedCosmeticCache#libraryContains} check short-circuits uploads that the + * server already has in this player's library. Re-uploads of identical content are + * therefore zero-bandwidth after the first success. + */ +public final class ClientSharedCosmeticUploader { + + private ClientSharedCosmeticUploader() {} + + public static String upload(Path bbmodelFile, Path textureFile, String displayName) { + String result = doUpload(bbmodelFile, textureFile, displayName); + DecoFashion.LOGGER.info("Shared cosmetic upload: {}", result); + return result; + } + + private static String doUpload(Path bbmodelFile, Path textureFile, String displayName) { + if (!Files.isRegularFile(bbmodelFile)) return "Not a file: " + bbmodelFile; + if (!Files.isRegularFile(textureFile)) return "Not a file: " + textureFile; + + long bbSize; + long texSize; + try { + bbSize = Files.size(bbmodelFile); + texSize = Files.size(textureFile); + } catch (IOException ex) { + return "Size check failed: " + ex.getMessage(); + } + if (bbSize > 16L * 1024 * 1024) return "bbmodel too large: " + bbSize; + if (texSize > SafeImageLoader.MAX_PNG_FILE_BYTES) return "PNG too large: " + texSize; + + // 1. Parse bbmodel via BbModelParser (validates everything). + Bbmodel bbmodel; + try (Reader reader = Files.newBufferedReader(bbmodelFile)) { + bbmodel = BbModelParser.parse(reader); + } catch (Exception ex) { + return "bbmodel rejected: " + ex.getMessage(); + } + + // 2. Decode PNG via SafePngReader (memory-safe, RGBA only). + SafePngReader.Image decoded; + try { + byte[] pngBytes = Files.readAllBytes(textureFile); + decoded = SafePngReader.decode(pngBytes); + } catch (IOException ex) { + return "PNG rejected: " + ex.getMessage(); + } + + // 3. Encode bbmodel canonically via BbmodelCodec. This is the form that will cross + // the wire, be stored, hashed, and eventually decoded by every receiver. + byte[] canonicalBbmodelBin; + ByteBuf out = Unpooled.buffer(1024); + try { + BbmodelCodec.CODEC.encode(out, bbmodel); + if (out.readableBytes() > SharedCosmeticCache.MAX_BBMODEL_BIN_BYTES) { + return "canonical bbmodel too large: " + out.readableBytes(); + } + canonicalBbmodelBin = new byte[out.readableBytes()]; + out.readBytes(canonicalBbmodelBin); + } finally { + out.release(); + } + + // 4. Compute content hash — must match what the server will compute. + String hash; + try { + hash = SharedCosmeticCache.hashContent(canonicalBbmodelBin, + decoded.width, decoded.height, decoded.rgba); + } catch (NoSuchAlgorithmException ex) { + return "SHA-256 unavailable"; + } + + // 5. Anti-DoS: if the server already has this hash in our library, skip the wire. + if (ClientSharedCosmeticCache.libraryContains(hash)) { + return "Already in library (" + hash.substring(0, 8) + "…); skipped"; + } + + // 6. Deflate raw RGBA. + byte[] deflated = deflate(decoded.rgba); + if (deflated.length > SharedCosmeticCache.MAX_DEFLATED_BYTES) { + return "RGBA deflated too large: " + deflated.length; + } + + ClientPacketListener conn = Minecraft.getInstance().getConnection(); + if (conn == null) return "Not connected"; + + // 7. Assemble wire payload = bbmodel binary || deflated RGBA. Chunk and send. + int bbmodelLen = canonicalBbmodelBin.length; + int payloadLen = bbmodelLen + deflated.length; + int chunkSize = SharedCosmeticCache.MAX_CHUNK_BYTES; + int total = Math.max(1, (payloadLen + chunkSize - 1) / chunkSize); + + for (int i = 0; i < total; i++) { + int off = i * chunkSize; + int len = Math.min(chunkSize, payloadLen - off); + byte[] slice = sliceConcat(canonicalBbmodelBin, deflated, off, len); + conn.send(new ServerboundCustomPayloadPacket( + new UploadCosmeticChunk( + i, total, bbmodelLen, + decoded.width, decoded.height, + slice + ))); + } + + return "Uploading " + decoded.width + "x" + decoded.height + " (" + + payloadLen + " bytes, " + total + " chunks, hash " + + hash.substring(0, 8) + "…) [" + displayName + "]"; + } + + /** Slice bytes from {@code a ‖ b} into a new array of length {@code len} starting at {@code off}. */ + private static byte[] sliceConcat(byte[] a, byte[] b, int off, int len) { + byte[] out = new byte[len]; + int dst = 0; + if (off < a.length) { + int take = Math.min(len, a.length - off); + System.arraycopy(a, off, out, dst, take); + dst += take; + len -= take; + off += take; + } + if (len > 0) { + int bOff = off - a.length; + System.arraycopy(b, bOff, out, dst, len); + } + return out; + } + + private static byte[] deflate(byte[] raw) { + Deflater def = new Deflater(Deflater.BEST_COMPRESSION); + try { + def.setInput(raw); + def.finish(); + ByteArrayOutputStream out = new ByteArrayOutputStream(raw.length / 2 + 64); + byte[] buf = new byte[64 * 1024]; + while (!def.finished()) { + int n = def.deflate(buf); + if (n == 0) break; + out.write(buf, 0, n); + } + return out.toByteArray(); + } finally { + def.end(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/razz/dfashion/client/ClientSkinCache.java b/src/main/java/com/razz/dfashion/client/ClientSkinCache.java index 4181e41..4cf18d2 100644 --- a/src/main/java/com/razz/dfashion/client/ClientSkinCache.java +++ b/src/main/java/com/razz/dfashion/client/ClientSkinCache.java @@ -2,6 +2,7 @@ package com.razz.dfashion.client; import com.mojang.blaze3d.platform.NativeImage; import com.razz.dfashion.DecoFashion; +import com.razz.dfashion.skin.SafePngReader; import com.razz.dfashion.skin.SkinCache; import com.razz.dfashion.skin.SkinModel; import com.razz.dfashion.skin.packet.UploadSkinChunk; @@ -16,17 +17,24 @@ import net.neoforged.fml.loading.FMLPaths; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.zip.Deflater; /** - * Client-side skin store. Writes received PNGs to {@code /decofashion/skins_cache/.png}, - * registers them as {@link DynamicTexture}s under synthetic {@code decofashion:skins/} identifiers, - * and tracks in-flight downloads keyed by hash. + * Client-side skin store. Holds pixel blobs under + * {@code /decofashion/skins_cache/.bin} (4-byte {@code (u16 w, u16 h)} header + * + deflated RGBA body), and registers decoded images as {@link DynamicTexture}s under + * synthetic {@code decofashion:skins/} identifiers. + * + *

The only PNG parser in the pipeline is {@link SafePngReader} on the uploading client. + * Inbound data from the server is already raw deflated RGBA — we inflate under a bounded + * cap, fill a {@link NativeImage} directly via {@code setPixelABGR}, and never invoke STB. */ public final class ClientSkinCache { @@ -38,6 +46,8 @@ public final class ClientSkinCache { private static final class Download { int expectedTotal; int nextIndex; + int width; + int height; ByteArrayOutputStream buffer = new ByteArrayOutputStream(); } @@ -48,14 +58,13 @@ public final class ClientSkinCache { } private static Path fileFor(String hash) { - return root().resolve(hash + ".png"); + return root().resolve(hash + SkinCache.FILE_EXT); } public static Identifier textureIdFor(String hash) { return Identifier.fromNamespaceAndPath(DecoFashion.MODID, "skins/" + hash); } - /** Already have a {@link DynamicTexture} registered for this hash? */ public static boolean hasRegistered(String hash) { return REGISTERED.containsKey(hash); } @@ -64,21 +73,35 @@ public final class ClientSkinCache { return Files.isRegularFile(fileFor(hash)); } - /** Load from disk + register if we have the PNG cached but haven't built a texture yet. */ + /** Load from disk + register if we have the blob cached but haven't built a texture yet. */ public static Identifier ensureLoadedFromDisk(String hash) { + if (!SkinCache.isValidHash(hash)) return null; Identifier existing = REGISTERED.get(hash); if (existing != null) return existing; if (!hasOnDisk(hash)) return null; - try (InputStream in = Files.newInputStream(fileFor(hash))) { - return register(hash, in); + try { + byte[] blob = Files.readAllBytes(fileFor(hash)); + int[] dims = SkinCache.readHeader(blob); + if (dims == null) { + DecoFashion.LOGGER.error("Skin cache: {} bad magic/header; ignoring", hash); + return null; + } + int w = dims[0], h = dims[1]; + if (w <= 0 || h <= 0 || w > SkinCache.MAX_DIM || h > SkinCache.MAX_DIM) { + DecoFashion.LOGGER.error("Skin cache: {} has bad dims {}x{}", hash, w, h); + return null; + } + byte[] deflated = new byte[blob.length - SkinCache.HEADER_SIZE]; + System.arraycopy(blob, SkinCache.HEADER_SIZE, deflated, 0, deflated.length); + byte[] rgba = SkinCache.inflateBounded(deflated, SkinCache.rgbaByteCount(w, h)); + return registerFromPixels(hash, w, h, rgba); } catch (IOException ex) { DecoFashion.LOGGER.error("Skin cache: failed reading {}", hash, ex); return null; } } - /** Begin a chunked download for this hash. Later {@link #onChunk} calls reassemble. */ public static boolean isAwaiting(String hash) { return IN_FLIGHT.containsKey(hash); } @@ -88,11 +111,18 @@ public final class ClientSkinCache { } /** - * Consume one inbound {@code SkinChunk}. When the last chunk lands, writes to disk, - * registers a {@link DynamicTexture}, and returns its synthetic {@link Identifier}. - * Returns {@code null} while more chunks are expected (or on error). + * Consume one inbound deflated-pixel chunk. When the last chunk lands, inflates with a + * bounded cap, writes the blob to disk, builds a {@link NativeImage} directly from pixels + * (no STB), and returns the synthetic {@link Identifier}. Returns {@code null} while more + * chunks are expected or on error. */ - public static Identifier onChunk(String hash, int index, int total, byte[] data) { + public static Identifier onChunk(String hash, int index, int total, int width, int height, byte[] data) { + if (width <= 0 || height <= 0 || width > SkinCache.MAX_DIM || height > SkinCache.MAX_DIM) { + DecoFashion.LOGGER.warn("Skin download {}: bad dims {}x{}", hash, width, height); + IN_FLIGHT.remove(hash); + return null; + } + Download d = IN_FLIGHT.get(hash); if (d == null) { d = new Download(); @@ -101,15 +131,17 @@ public final class ClientSkinCache { if (index == 0) { d.expectedTotal = total; d.nextIndex = 0; + d.width = width; + d.height = height; d.buffer.reset(); - } else if (d.expectedTotal != total || d.nextIndex != index) { - DecoFashion.LOGGER.warn("Skin download {}: out-of-order chunk {}/{} (expected {}/{})", - hash, index, total, d.nextIndex, d.expectedTotal); + } else if (d.expectedTotal != total || d.nextIndex != index + || d.width != width || d.height != height) { + DecoFashion.LOGGER.warn("Skin download {}: chunk mismatch at {}/{}", hash, index, total); IN_FLIGHT.remove(hash); return null; } - if (d.buffer.size() + data.length > SkinCache.MAX_BYTES) { - DecoFashion.LOGGER.warn("Skin download {}: exceeds size cap", hash); + if (d.buffer.size() + data.length > SkinCache.MAX_DEFLATED_BYTES) { + DecoFashion.LOGGER.warn("Skin download {}: exceeds deflated cap", hash); IN_FLIGHT.remove(hash); return null; } @@ -124,28 +156,63 @@ public final class ClientSkinCache { if (index + 1 < total) return null; IN_FLIGHT.remove(hash); - byte[] png = d.buffer.toByteArray(); + byte[] deflated = d.buffer.toByteArray(); + byte[] rgba; try { - Files.createDirectories(root()); - Files.write(fileFor(hash), png); + rgba = SkinCache.inflateBounded(deflated, SkinCache.rgbaByteCount(d.width, d.height)); + } catch (IllegalArgumentException ex) { + DecoFashion.LOGGER.warn("Skin download {}: {}", hash, ex.getMessage()); + return null; } catch (IOException ex) { - DecoFashion.LOGGER.error("Skin download {}: disk write failed", hash, ex); + DecoFashion.LOGGER.warn("Skin download {}: inflate failed ({})", hash, ex.getMessage()); + return null; } - try (InputStream in = new java.io.ByteArrayInputStream(png)) { - return register(hash, in); + + try { + Files.createDirectories(root()); + Files.write(fileFor(hash), SkinCache.buildBlob(d.width, d.height, deflated)); } catch (IOException ex) { - DecoFashion.LOGGER.error("Skin download {}: image decode failed", hash, ex); - return null; + DecoFashion.LOGGER.error("Skin download {}: disk write failed", hash, ex); } + + return registerFromPixels(hash, d.width, d.height, rgba); } - private static Identifier register(String hash, InputStream in) throws IOException { - NativeImage img = NativeImage.read(in); + /** + * Build a {@link NativeImage} directly from RGBA bytes and register it. No PNG decoder + * is involved — {@code NativeImage} is allocated in RGBA format and each pixel is written + * via {@code setPixelABGR}, where the int is packed so the native {@code memPutInt} lays + * down bytes in the order {@code [R, G, B, A]} (little-endian platforms). + */ + private static Identifier registerFromPixels(String hash, int width, int height, byte[] rgba) { + int expected; + try { + expected = SkinCache.rgbaByteCount(width, height); + } catch (IllegalArgumentException ex) { + DecoFashion.LOGGER.error("Skin {}: {}", hash, ex.getMessage()); + return null; + } + if (rgba.length != expected) { + DecoFashion.LOGGER.error("Skin {}: pixel buffer length {} != {}*{}*4", + hash, rgba.length, width, height); + return null; + } + NativeImage img = new NativeImage(width, height, false); + int src = 0; + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int r = rgba[src++] & 0xFF; + int g = rgba[src++] & 0xFF; + int b = rgba[src++] & 0xFF; + int a = rgba[src++] & 0xFF; + img.setPixelABGR(x, y, r | (g << 8) | (b << 16) | (a << 24)); + } + } TextureManager tm = Minecraft.getInstance().getTextureManager(); Identifier id = textureIdFor(hash); tm.register(id, new DynamicTexture(() -> "decofashion skin " + hash, img)); REGISTERED.put(hash, id); - DecoFashion.LOGGER.info("Skin registered: hash={} id={}", hash, id); + DecoFashion.LOGGER.info("Skin registered: hash={} id={} dims={}x{}", hash, id, width, height); return id; } @@ -154,21 +221,22 @@ public final class ClientSkinCache { } /** - * Register every cached PNG under {@code /decofashion/skins_cache/}. Runs on - * client setup and wardrobe open so previously-uploaded skins (which persist as files) - * reappear in the grid without waiting for an attachment to name them. - * Filename must be a 64-char lowercase hex hash with {@code .png} suffix to be accepted. + * Register every cached blob under {@code /decofashion/skins_cache/}. Runs on + * client setup and wardrobe open so previously-uploaded skins reappear in the grid + * without waiting for an attachment to name them. Filename must be a 64-char lowercase + * hex hash with {@code .bin} suffix. */ public static void scanDisk() { Path dir = root(); if (!Files.isDirectory(dir)) return; int loaded = 0; - try (java.nio.file.DirectoryStream stream = Files.newDirectoryStream(dir, "*.png")) { - for (Path png : stream) { - String name = png.getFileName().toString(); - if (name.length() != 68) continue; // 64 hex + ".png" + int expectedNameLen = 64 + SkinCache.FILE_EXT.length(); + try (java.nio.file.DirectoryStream stream = Files.newDirectoryStream(dir, "*" + SkinCache.FILE_EXT)) { + for (Path bin : stream) { + String name = bin.getFileName().toString(); + if (name.length() != expectedNameLen) continue; String hash = name.substring(0, 64); - if (!hash.matches("[0-9a-f]+")) continue; + if (!SkinCache.isValidHash(hash)) continue; if (REGISTERED.containsKey(hash)) continue; if (ensureLoadedFromDisk(hash) != null) loaded++; } @@ -180,10 +248,10 @@ public final class ClientSkinCache { /** * Drop a cached skin from this client — releases the texture, forgets the hash→id map - * entry, and removes the on-disk PNG. The server's copy is untouched (other players - * may still be using it). Returns true if anything was removed. + * entry, and removes the on-disk blob. The server's copy is untouched. */ public static boolean delete(String hash) { + if (!SkinCache.isValidHash(hash)) return false; Identifier id = REGISTERED.remove(hash); boolean changed = id != null; if (id != null) { @@ -203,67 +271,114 @@ public final class ClientSkinCache { } /** - * Read a PNG from disk, validate, chunk it, and ship the chunks upstream via - * {@link UploadSkinChunk}. Also caches the PNG locally (same hash the server will compute) - * so the wardrobe grid can show it immediately without waiting for the server round-trip. - * Returns a short user-facing status string for display. + * Read a PNG from disk, decode it with {@link SafePngReader} (pure Java, no STB), + * deflate the raw RGBA, chunk it, and ship chunks upstream as {@link UploadSkinChunk}s. + * Also caches locally under the same hash the server will compute so the wardrobe shows + * it immediately. Returns a short user-facing status string. */ public static String uploadFromFile(Path file, SkinModel model) { + String result = doUpload(file, model); + DecoFashion.LOGGER.info("Skin upload: {}", result); + return result; + } + + private static String doUpload(Path file, SkinModel model) { if (!Files.isRegularFile(file)) return "Not a file: " + file; byte[] png; try { + long size = Files.size(file); + if (size > SafeImageLoader.MAX_PNG_FILE_BYTES) { + return "PNG file too large: " + size; + } png = Files.readAllBytes(file); } catch (IOException ex) { return "Read failed: " + ex.getMessage(); } - if (png.length > SkinCache.MAX_BYTES) { - return "Too large: " + png.length + " > " + SkinCache.MAX_BYTES; + + SafePngReader.Image decoded; + try { + decoded = SafePngReader.decode(png); + } catch (IOException ex) { + return "Rejected: " + ex.getMessage(); } - if (png.length < 8 - || png[0] != (byte) 0x89 || png[1] != 'P' || png[2] != 'N' || png[3] != 'G') { - return "Not a PNG"; + + byte[] deflated = deflate(decoded.rgba); + if (deflated.length > SkinCache.MAX_DEFLATED_BYTES) { + return "Too large after compression: " + deflated.length; } ClientPacketListener conn = Minecraft.getInstance().getConnection(); if (conn == null) return "Not connected"; - // Local-cache first so the grid picks it up without waiting for the server echo. - String localHash = null; + String localHash; + try { + localHash = sha256HexOfPixels(decoded.width, decoded.height, decoded.rgba); + } catch (NoSuchAlgorithmException ex) { + return "SHA-256 unavailable"; + } + try { - localHash = sha256Hex(png); Files.createDirectories(root()); - if (!hasOnDisk(localHash)) Files.write(fileFor(localHash), png); - ensureLoadedFromDisk(localHash); - } catch (Exception ex) { + if (!hasOnDisk(localHash)) { + Files.write(fileFor(localHash), SkinCache.buildBlob(decoded.width, decoded.height, deflated)); + } + if (!REGISTERED.containsKey(localHash)) { + registerFromPixels(localHash, decoded.width, decoded.height, decoded.rgba); + } + } catch (IOException ex) { DecoFashion.LOGGER.warn("Local skin cache on upload failed: {}", ex.getMessage()); } int chunk = SkinCache.MAX_CHUNK_BYTES; - int total = Math.max(1, (png.length + chunk - 1) / chunk); + int total = Math.max(1, (deflated.length + chunk - 1) / chunk); for (int i = 0; i < total; i++) { int off = i * chunk; - int len = Math.min(chunk, png.length - off); + int len = Math.min(chunk, deflated.length - off); byte[] slice = new byte[len]; - System.arraycopy(png, off, slice, 0, len); - conn.send(new ServerboundCustomPayloadPacket(new UploadSkinChunk(i, total, model, slice))); + System.arraycopy(deflated, off, slice, 0, len); + conn.send(new ServerboundCustomPayloadPacket( + new UploadSkinChunk(i, total, decoded.width, decoded.height, model, slice))); + } + return "Uploading " + decoded.width + "x" + decoded.height + " (" + + deflated.length + " bytes, " + total + " chunks, hash " + + localHash.substring(0, 8) + ")"; + } + + private static byte[] deflate(byte[] raw) { + Deflater def = new Deflater(Deflater.BEST_COMPRESSION); + try { + def.setInput(raw); + def.finish(); + ByteArrayOutputStream out = new ByteArrayOutputStream(raw.length / 2 + 64); + byte[] buf = new byte[64 * 1024]; + while (!def.finished()) { + int n = def.deflate(buf); + if (n == 0) break; + out.write(buf, 0, n); + } + return out.toByteArray(); + } finally { + def.end(); } - return "Uploading " + png.length + " bytes in " + total + " chunks" - + (localHash != null ? " (hash " + localHash.substring(0, 8) + ")" : ""); } - private static String sha256Hex(byte[] in) throws java.security.NoSuchAlgorithmException { - java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); - byte[] out = md.digest(in); + private static String sha256HexOfPixels(int width, int height, byte[] rgba) throws NoSuchAlgorithmException { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update((byte) ((width >>> 8) & 0xFF)); + md.update((byte) (width & 0xFF)); + md.update((byte) ((height >>> 8) & 0xFF)); + md.update((byte) (height & 0xFF)); + md.update(rgba); + byte[] out = md.digest(); StringBuilder sb = new StringBuilder(out.length * 2); for (byte b : out) sb.append(String.format("%02x", b)); return sb.toString(); } - /** Shortcut: send any server-bound payload from the client. */ public static void sendToServer(net.minecraft.network.protocol.common.custom.CustomPacketPayload payload) { ClientPacketListener conn = Minecraft.getInstance().getConnection(); if (conn == null) return; conn.send(new ServerboundCustomPayloadPacket(payload)); } -} +} \ No newline at end of file diff --git a/src/main/java/com/razz/dfashion/client/ClosetRenderer.java b/src/main/java/com/razz/dfashion/client/ClosetRenderer.java index 8310491..5232170 100644 --- a/src/main/java/com/razz/dfashion/client/ClosetRenderer.java +++ b/src/main/java/com/razz/dfashion/client/ClosetRenderer.java @@ -61,7 +61,7 @@ public class ClosetRenderer implements BlockEntityRenderer> RENDER_OVERRIDES = + public static final Map> RENDER_OVERRIDES = Collections.synchronizedMap(new IdentityHashMap<>()); public CosmeticRenderLayer(RenderLayerParent parent) { @@ -44,14 +45,16 @@ public class CosmeticRenderLayer extends RenderLayer cache = CosmeticCache.cosmetics; - if (cache.isEmpty()) return; Minecraft mc = Minecraft.getInstance(); // GUI preview override takes precedence: the wardrobe Screen sets this to display // a specific cosmetic in a mini-player box without touching the live attachment. - Map equipped = RENDER_OVERRIDES.get(state); + Map equipped = RENDER_OVERRIDES.get(state); if (equipped == null) { if (mc.level == null) return; Entity entity = mc.level.getEntity(state.id); @@ -62,9 +65,9 @@ public class CosmeticRenderLayer extends RenderLayer entry : cosmetic.parts().entrySet()) { ModelPart bone = findBone(model, entry.getKey()); @@ -78,7 +81,7 @@ public class CosmeticRenderLayer extends RenderLayer localCache + ) { + return switch (ref) { + case CosmeticRef.Local l -> localCache.get(l.id()); + case CosmeticRef.Shared s -> { + CosmeticCache.Baked baked = ClientSharedCosmeticCache.getBaked(s.hash()); + if (baked == null) ClientSharedCosmeticCache.requestIfMissing(s.hash()); + yield baked; + } + }; + } + private static ModelPart findBone(PlayerModel model, String rawName) { String name = normalize(rawName); ModelPart bone = switch (name) { diff --git a/src/main/java/com/razz/dfashion/client/SafeImageLoader.java b/src/main/java/com/razz/dfashion/client/SafeImageLoader.java new file mode 100644 index 0000000..9cfd746 --- /dev/null +++ b/src/main/java/com/razz/dfashion/client/SafeImageLoader.java @@ -0,0 +1,47 @@ +package com.razz.dfashion.client; + +import com.mojang.blaze3d.platform.NativeImage; +import com.razz.dfashion.skin.SafePngReader; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * The only sanctioned bridge from a user-supplied PNG file on disk to a {@link NativeImage}. + * + *

Runs the PNG through {@link SafePngReader} — pure Java, no STB, 8-bit RGBA only, hard + * dim cap, IHDR/IDAT/IEND whitelist, CRC-checked, zero trailing bytes — then builds the + * {@code NativeImage} by writing pixels directly with {@code setPixelABGR}. {@code NativeImage.read} + * (and therefore STB) is never invoked with untrusted bytes. Mirrors the pattern already used on + * the skin download path in {@link ClientSkinCache}. + */ +public final class SafeImageLoader { + + /** Hard cap on a PNG file we'll even read off disk. A 4096² 8-bit-RGBA PNG is ≤ ~67 MB + * uncompressed; real authored content is well under 20 MB. */ + public static final long MAX_PNG_FILE_BYTES = 64L * 1024 * 1024; + + private SafeImageLoader() {} + + public static NativeImage loadNativeImage(Path file) throws IOException { + long size = Files.size(file); + if (size > MAX_PNG_FILE_BYTES) { + throw new IOException("PNG file too large: " + size + " > " + MAX_PNG_FILE_BYTES); + } + byte[] png = Files.readAllBytes(file); + SafePngReader.Image decoded = SafePngReader.decode(png); + NativeImage img = new NativeImage(decoded.width, decoded.height, false); + int src = 0; + for (int y = 0; y < decoded.height; y++) { + for (int x = 0; x < decoded.width; x++) { + int r = decoded.rgba[src++] & 0xFF; + int g = decoded.rgba[src++] & 0xFF; + int b = decoded.rgba[src++] & 0xFF; + int a = decoded.rgba[src++] & 0xFF; + img.setPixelABGR(x, y, r | (g << 8) | (b << 16) | (a << 24)); + } + } + return img; + } +} \ No newline at end of file diff --git a/src/main/java/com/razz/dfashion/client/screen/ClosetScreen.java b/src/main/java/com/razz/dfashion/client/screen/ClosetScreen.java index 08ef227..85898ae 100644 --- a/src/main/java/com/razz/dfashion/client/screen/ClosetScreen.java +++ b/src/main/java/com/razz/dfashion/client/screen/ClosetScreen.java @@ -1,16 +1,28 @@ package com.razz.dfashion.client.screen; import com.razz.dfashion.block.ClosetBlockEntity; +import com.razz.dfashion.client.ClientSharedCosmeticCache; +import com.razz.dfashion.client.ClientSharedCosmeticUploader; import com.razz.dfashion.client.ClientSkinCache; import com.razz.dfashion.client.CosmeticCache; import com.razz.dfashion.client.CosmeticRenderLayer; import com.razz.dfashion.client.SkinInfoOverride; import com.razz.dfashion.cosmetic.CosmeticAttachments; import com.razz.dfashion.cosmetic.CosmeticDefinition; +import com.razz.dfashion.cosmetic.CosmeticRef; +import com.razz.dfashion.cosmetic.share.BoneExtraction; +import com.razz.dfashion.cosmetic.share.CosmeticLibrary; +import com.razz.dfashion.cosmetic.share.CosmeticLibraryEntry; +import com.razz.dfashion.cosmetic.share.packet.AssignSharedCosmetic; +import com.razz.dfashion.cosmetic.share.packet.DeleteCosmetic; import com.razz.dfashion.skin.SkinAttachments; import com.razz.dfashion.skin.SkinData; +import com.razz.dfashion.skin.SkinLibrary; +import com.razz.dfashion.skin.SkinLibraryEntry; import com.razz.dfashion.skin.SkinModel; import com.razz.dfashion.skin.packet.AssignSkin; +import com.razz.dfashion.skin.packet.DeleteSkin; +import com.razz.dfashion.skin.packet.RequestSkin; import net.minecraft.core.ClientAsset; import net.minecraft.world.entity.player.PlayerModelType; @@ -21,6 +33,8 @@ import net.minecraft.client.CameraType; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.StringWidget; import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.player.LocalPlayer; import net.minecraft.client.renderer.entity.EntityRenderDispatcher; @@ -45,19 +59,37 @@ import org.joml.Quaternionf; import org.joml.Vector3f; import org.lwjgl.glfw.GLFW; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; public class ClosetScreen extends Screen { // Tab vocabulary — matches category memory. "skin" is a pseudo-category that drives the // per-player skin override UI (upload/reset) rather than the usual cosmetic row. + // "shared" is a pseudo-category for the shared-cosmetic library (vertical scroll grid). private static final String[] CATEGORIES = { - "hat", "head", "torso", "arms", "legs", "wrist", "feet", "wings", "particle", "skin" + "hat", "head", "torso", "arms", "legs", "wrist", "feet", "wings", "particle", "skin", "shared" }; private static final String SKIN_TAB = "skin"; + private static final String SHARED_TAB = "shared"; + + /** Fallback equip category used when a shared cosmetic has no canonical bones. */ + private static final String FALLBACK_SHARED_CATEGORY = "particle"; + + /** + * Bone-based filters on the shared tab. {@code null} = "All"; others are canonical + * bone group names used by {@link BoneExtraction}. Labels are user-facing ({@code Torso} + * rather than the canonical {@code Body}). + */ + private static final String[] SHARED_FILTER_KEYS = { null, "Head", "Body", "Arm", "Leg" }; + private static final String[] SHARED_FILTER_LABELS = { "All", "Head", "Torso", "Arms", "Legs" }; // Per-tick rotation rate while an arrow is held (20 tps → 120°/sec). private static final float ROTATE_PER_TICK = 6f; @@ -76,6 +108,7 @@ public class ClosetScreen extends Screen { private static final int COSMETIC_INNER_PADDING = 3; private static final int COSMETIC_BORDER_COLOR = 0xFFFFFFFF; // normal frame around each preview private static final int COSMETIC_BORDER_EQUIPPED_COLOR = 0xFF55FF55; // green frame when this item is currently worn + private static final int COSMETIC_BORDER_SHARED_COLOR = 0xFFFFB060; // amber frame — entry came from the user's shared library private static final float COSMETIC_PREVIEW_SIZE = 11f; // entity render scale — small enough to fit cosmetics that extend the silhouette private static final float COSMETIC_PREVIEW_YAW = -20f; // degrees — angled for better cosmetic visibility @@ -117,6 +150,40 @@ public class ClosetScreen extends Screen { private double dragLastX = 0; private boolean isDraggingRow = false; + // ---- Share-cosmetic form state ---- + // When showingShareForm is true, init() replaces the normal tabs/grid/buttons with a + // modal form. Widget refs are nullable because they're rebuilt on every widget refresh + // (Screen.rebuildWidgets clears children and re-runs init). + private boolean showingShareForm = false; + private @Nullable EditBox shareTextureField; + private @Nullable EditBox shareModelField; + private @Nullable EditBox shareNameField; + private @Nullable StringWidget shareStatusLabel; + private String shareStatusMessage = ""; + + /** UX cap on displayName shown in the field. Wire codec caps at 128; 64 is user-friendly. */ + private static final int SHARE_NAME_MAX_LEN = 64; + /** OS path-length slack. Real paths rarely exceed 4 KB; inputs past this are suspect. */ + private static final int SHARE_PATH_MAX_LEN = 4096; + + // ---- Shared-library tab state ---- + /** {@code null} = no filter (show all); else one of {@link #SHARED_FILTER_KEYS}. */ + private @Nullable String sharedBoneFilter = null; + private int sharedVerticalScroll = 0; + private final Map sharedButtonHashes = new HashMap<>(); + private int lastKnownSharedCount = -1; + + /** Filter bar Y (below the tab row). */ + private static final int SHARED_FILTER_Y = TAB_Y + TAB_H + 10; + private static final int SHARED_FILTER_H = 20; + private static final int SHARED_FILTER_BTN_W = 50; + private static final int SHARED_FILTER_BTN_GAP = 4; + + /** Top of the vertical-scroll grid on the Shared tab (below the filter bar). */ + private static final int SHARED_GRID_TOP = SHARED_FILTER_Y + SHARED_FILTER_H + 10; + /** Height reserved for bottom buttons; the grid stops above this. */ + private static final int SHARED_GRID_BOTTOM_PAD = 50; + public ClosetScreen(@Nullable ClosetBlockEntity closet) { super(Component.literal("Wardrobe")); this.closet = closet; @@ -147,6 +214,11 @@ public class ClosetScreen extends Screen { ClientSkinCache.scanDisk(); } + if (showingShareForm) { + buildShareForm(); + return; + } + buildTabs(); buildBottomControls(); selectCategory(selectedCategory != null ? selectedCategory : CATEGORIES[0]); @@ -155,7 +227,7 @@ public class ClosetScreen extends Screen { private void buildTabs() { int x = TAB_X_START; for (String cat : CATEGORIES) { - String label = cat.substring(0, 1).toUpperCase(); + String label = tabLabel(cat); addRenderableWidget(Button.builder( Component.literal(label), b -> selectCategory(cat) @@ -164,6 +236,14 @@ public class ClosetScreen extends Screen { } } + /** Single-char tab labels; {@code shared} overridden to "L" so it doesn't collide with {@code skin}. */ + private static String tabLabel(String cat) { + return switch (cat) { + case SHARED_TAB -> "L"; + default -> cat.substring(0, 1).toUpperCase(); + }; + } + private void buildBottomControls() { int centerX = this.width / 2; int y = this.height - 40; @@ -190,6 +270,14 @@ public class ClosetScreen extends Screen { rebuildCosmeticRow(); // re-apply visibility to the cosmetic buttons } ).bounds(this.width - 70, y, 60, 20).build()); + + // Open the shared-cosmetic upload form. All validation + parsing happens in the + // uploader's pipeline (SafePngReader + BbModelParser + BbmodelCodec); this button + // just swaps the closet UI into the form's widgets. + addRenderableWidget(Button.builder( + Component.literal("Share"), + b -> openShareForm() + ).bounds(this.width - 140, y, 60, 20).build()); } private void selectCategory(String cat) { @@ -202,16 +290,22 @@ public class ClosetScreen extends Screen { private void rebuildCosmeticRow() { for (Button btn : cosmeticButtons) removeWidget(btn); cosmeticButtons.clear(); + sharedButtonHashes.clear(); if (selectedCategory == null) return; if (SKIN_TAB.equals(selectedCategory)) { buildSkinTabRow(); return; } + if (SHARED_TAB.equals(selectedCategory)) { + buildSharedTabRow(); + return; + } int x = COSMETIC_ROW_LEFT - cosmeticScroll; int y = this.height - COSMETIC_ROW_BOTTOM_MARGIN; + // Authored cosmetics for this category. for (Map.Entry entry : CosmeticCache.catalog.entrySet()) { CosmeticDefinition def = entry.getValue(); if (!selectedCategory.equals(def.category())) continue; @@ -234,6 +328,32 @@ public class ClosetScreen extends Screen { addRenderableWidget(btn); x += COSMETIC_SIZE + COSMETIC_SPACING; } + + // Unified browse: append shared-library entries whose bones map to this category. + // Same box size + stride as authored — they scroll together in the same horizontal + // row. Visual distinguisher is a different border color in the preview renderer. + LocalPlayer player = Minecraft.getInstance().player; + CosmeticLibrary lib = player != null + ? player.getData(CosmeticAttachments.SHARED_LIBRARY.get()) + : CosmeticLibrary.EMPTY; + + for (CosmeticLibraryEntry libEntry : lib.entries()) { + if (!BoneExtraction.categoriesFor(libEntry.bones()).contains(selectedCategory)) continue; + + // Pull from server if the blob hasn't materialized locally yet. + ClientSharedCosmeticCache.requestIfMissing(libEntry.hash()); + + Button btn = Button.builder( + Component.empty(), + b -> equipShared(libEntry) + ).bounds(x, y, COSMETIC_SIZE, COSMETIC_SIZE).build(); + btn.visible = showPreviews && (x >= COSMETIC_ROW_LEFT) && (x < this.width); + btn.active = btn.visible; + cosmeticButtons.add(btn); + addRenderableWidget(btn); + sharedButtonHashes.put(btn, libEntry.hash()); + x += COSMETIC_SIZE + COSMETIC_SPACING; + } } // Skin tab layout — action buttons stack vertically on the left, grid runs right. @@ -295,10 +415,22 @@ public class ClosetScreen extends Screen { cosmeticButtons.add(reset); // Grid row starts past the action column. Boxes honor the Toggle button; action - // column above stays visible either way. + // column above stays visible either way. Source of truth is the per-player server + // library attachment — not the local blob cache — so other players' uploads never + // appear here even when their blobs happen to be on disk. int boxX = SKIN_GRID_START_X - cosmeticScroll; - for (Map.Entry entry : ClientSkinCache.snapshotRegistered().entrySet()) { - final String hash = entry.getKey(); + LocalPlayer localPlayer = Minecraft.getInstance().player; + SkinLibrary library = localPlayer != null + ? localPlayer.getData(SkinAttachments.LIBRARY.get()) + : SkinLibrary.EMPTY; + for (SkinLibraryEntry libEntry : library.entries()) { + final String hash = libEntry.hash(); + // Pull bytes from the server if this client hasn't seen them yet. + if (ClientSkinCache.ensureLoadedFromDisk(hash) == null + && !ClientSkinCache.isAwaiting(hash)) { + ClientSkinCache.markRequested(hash); + ClientSkinCache.sendToServer(new RequestSkin(hash)); + } boolean onScreen = (boxX >= SKIN_GRID_START_X) && (boxX < this.width); boolean show = showPreviews && onScreen; @@ -328,6 +460,187 @@ public class ClosetScreen extends Screen { } } + // ---- Shared-library tab (vertical scroll grid) ---- + + /** + * Shared tab layout: horizontal filter row at top (All / Head / Torso / Arms / Legs), + * wrapping grid below using full width. Clicking a filter narrows the grid to entries + * whose bone set includes the filter's bone group. Entries with only {@link + * BoneExtraction#OTHER} bones show only under "All". + */ + private void buildSharedTabRow() { + sharedButtonHashes.clear(); + + // Filter bar — one button per filter option; active filter is dimmed so the player + // can tell which one is applied without hover state. + int fx = COSMETIC_ROW_LEFT; + for (int i = 0; i < SHARED_FILTER_KEYS.length; i++) { + final String key = SHARED_FILTER_KEYS[i]; + Button filterBtn = Button.builder( + Component.literal(SHARED_FILTER_LABELS[i]), + b -> setSharedBoneFilter(key) + ).bounds(fx, SHARED_FILTER_Y, SHARED_FILTER_BTN_W, SHARED_FILTER_H).build(); + filterBtn.active = !java.util.Objects.equals(sharedBoneFilter, key); + addRenderableWidget(filterBtn); + cosmeticButtons.add(filterBtn); + fx += SHARED_FILTER_BTN_W + SHARED_FILTER_BTN_GAP; + } + + // Grid area — full-width now that the slot selector is gone. + int gridY = SHARED_GRID_TOP; + int gridLeft = COSMETIC_ROW_LEFT; + int gridRight = this.width - 10; + int gridBottom = this.height - SHARED_GRID_BOTTOM_PAD; + int stride = COSMETIC_SIZE + COSMETIC_SPACING; + int boxesPerRow = Math.max(1, (gridRight - gridLeft + COSMETIC_SPACING) / stride); + + LocalPlayer player = Minecraft.getInstance().player; + CosmeticLibrary lib = player != null + ? player.getData(CosmeticAttachments.SHARED_LIBRARY.get()) + : CosmeticLibrary.EMPTY; + + int idx = 0; + for (CosmeticLibraryEntry libEntry : lib.entries()) { + if (!entryMatchesSharedFilter(libEntry)) continue; + String hash = libEntry.hash(); + // Pull from server if the blob hasn't materialized locally yet. + ClientSharedCosmeticCache.requestIfMissing(hash); + + int row = idx / boxesPerRow; + int col = idx % boxesPerRow; + int boxX = gridLeft + col * stride; + int boxY = gridY + row * stride - sharedVerticalScroll; + idx++; + + boolean onScreen = boxY + COSMETIC_SIZE > gridY && boxY < gridBottom; + boolean show = showPreviews && onScreen; + + Button pick = Button.builder( + Component.empty(), + b -> equipShared(libEntry) + ).bounds(boxX, boxY, COSMETIC_SIZE, COSMETIC_SIZE).build(); + pick.visible = show; + pick.active = show; + cosmeticButtons.add(pick); + addRenderableWidget(pick); + sharedButtonHashes.put(pick, hash); + + Button del = Button.builder( + Component.literal("x"), + b -> deleteShared(hash) + ).bounds(boxX + COSMETIC_SIZE - 12, boxY - 12, 12, 12).build(); + del.visible = show; + del.active = show; + cosmeticButtons.add(del); + addRenderableWidget(del); + } + } + + private void setSharedBoneFilter(@Nullable String filter) { + sharedBoneFilter = filter; + sharedVerticalScroll = 0; + rebuildCosmeticRow(); + } + + /** + * Does an entry's bone set include the currently-selected filter's group? Maps + * filter keys to canonical bones: {@code "Head"} → just {@code Head}; + * {@code "Body"} → {@code Body}; {@code "Arm"} → either left/right arm; + * {@code "Leg"} → either left/right leg. {@code null} filter always matches. + */ + private boolean entryMatchesSharedFilter(CosmeticLibraryEntry entry) { + if (sharedBoneFilter == null) return true; + List bones = entry.bones(); + return switch (sharedBoneFilter) { + case "Head" -> bones.contains(BoneExtraction.HEAD); + case "Body" -> bones.contains(BoneExtraction.BODY); + case "Arm" -> bones.contains(BoneExtraction.LEFT_ARM) || bones.contains(BoneExtraction.RIGHT_ARM); + case "Leg" -> bones.contains(BoneExtraction.LEFT_LEG) || bones.contains(BoneExtraction.RIGHT_LEG); + default -> false; + }; + } + + /** + * Derive the equip category from the cosmetic's primary bone and toggle equip in + * that slot — matches the authored path's click-to-unequip behavior. An empty hash + * on the wire tells the server to clear the slot (see + * {@link com.razz.dfashion.cosmetic.share.CosmeticShareNetwork#onAssignShared}). + */ + private void equipShared(CosmeticLibraryEntry entry) { + String category = BoneExtraction.primaryCategory(entry.bones()); + if (category == null) category = FALLBACK_SHARED_CATEGORY; + + LocalPlayer player = Minecraft.getInstance().player; + Map currentEquipped = player != null + ? player.getData(CosmeticAttachments.EQUIPPED.get()) + : Map.of(); + CosmeticRef current = currentEquipped.get(category); + boolean alreadyEquipped = current instanceof CosmeticRef.Shared s + && entry.hash().equals(s.hash()); + + String hashOnWire = alreadyEquipped ? "" : entry.hash(); + ClientSharedCosmeticCache.sendToServer(new AssignSharedCosmetic(category, hashOnWire)); + } + + /** + * Look up the library entry for a given content hash. O(N) over library entries — + * fine since {@link CosmeticLibrary#MAX_ENTRIES} caps this at a small number. + */ + private @Nullable CosmeticLibraryEntry findSharedEntry(String hash) { + LocalPlayer player = Minecraft.getInstance().player; + if (player == null) return null; + CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get()); + for (CosmeticLibraryEntry e : lib.entries()) { + if (e.hash().equals(hash)) return e; + } + return null; + } + + /** Same derivation used by {@link #equipShared}, looked up by hash — for preview rendering. */ + private String sharedCategoryForHash(String hash) { + CosmeticLibraryEntry entry = findSharedEntry(hash); + if (entry == null) return FALLBACK_SHARED_CATEGORY; + String cat = BoneExtraction.primaryCategory(entry.bones()); + return cat != null ? cat : FALLBACK_SHARED_CATEGORY; + } + + private void deleteShared(String hash) { + // Server drops the hash from this player's library; the local call releases the + // baked model + texture and deletes the local blob cache file. + ClientSharedCosmeticCache.sendToServer(new DeleteCosmetic(hash)); + ClientSharedCosmeticCache.delete(hash); + rebuildCosmeticRow(); + } + + /** Total vertical extent of the shared grid, counting only entries that pass the filter. */ + private int sharedGridContentHeight() { + LocalPlayer player = Minecraft.getInstance().player; + if (player == null) return 0; + CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get()); + int stride = COSMETIC_SIZE + COSMETIC_SPACING; + int gridLeft = COSMETIC_ROW_LEFT; + int gridRight = this.width - 10; + int boxesPerRow = Math.max(1, (gridRight - gridLeft + COSMETIC_SPACING) / stride); + int visible = 0; + for (CosmeticLibraryEntry e : lib.entries()) { + if (entryMatchesSharedFilter(e)) visible++; + } + int rows = (visible + boxesPerRow - 1) / boxesPerRow; + return rows * stride; + } + + private void clampSharedScroll() { + int viewport = (this.height - SHARED_GRID_BOTTOM_PAD) - SHARED_GRID_TOP; + int max = Math.max(0, sharedGridContentHeight() - viewport); + if (sharedVerticalScroll < 0) sharedVerticalScroll = 0; + if (sharedVerticalScroll > max) sharedVerticalScroll = max; + } + + private static String titleCaseAscii(String s) { + if (s.isEmpty()) return s; + return Character.toUpperCase(s.charAt(0)) + s.substring(1); + } + /** * Apply a cached skin locally + tell the server. Also syncs {@link SkinInfoOverride} * immediately so the dispatcher picks the right renderer this frame, not next tick. @@ -341,22 +654,24 @@ public class ClosetScreen extends Screen { } private void deleteCachedSkin(String hash) { + // Tell the server to drop it from this player's library (and reset their active + // SkinData if they were wearing it). The server broadcasts nothing to other + // players beyond the SkinData reset — other players' libraries aren't touched. + ClientSkinCache.sendToServer(new DeleteSkin(hash)); ClientSkinCache.delete(hash); - // If the player was wearing this skin, reset both sides of the sync: - // - send AssignSkin("") so other clients + the server record the reset - // - setData(EMPTY) locally so the SkinInfoOverride tick doesn't re-request the - // bytes before the server sync round-trips back (which would re-cache the - // skin we just deleted and require a second click to actually get rid of it). + LocalPlayer player = Minecraft.getInstance().player; if (player != null) { + SkinLibrary lib = player.getData(SkinAttachments.LIBRARY.get()); + player.setData(SkinAttachments.LIBRARY.get(), lib.remove(hash)); + SkinData current = player.getData(SkinAttachments.DATA.get()); if (current != null && hash.equals(current.hash())) { player.setData(SkinAttachments.DATA.get(), SkinData.EMPTY); - ClientSkinCache.sendToServer(new AssignSkin("", SkinModel.WIDE)); SkinInfoOverride.syncAll(); } } - lastKnownSkinCount = -1; // force the skin-count poll to re-pick up the change + lastKnownSkinCount = -1; rebuildCosmeticRow(); } @@ -394,14 +709,244 @@ public class ClosetScreen extends Screen { } } + // ---- Shared-cosmetic upload form ---- + + private void openShareForm() { + showingShareForm = true; + rebuildWidgets(); + } + + private void closeShareForm() { + showingShareForm = false; + shareTextureField = null; + shareModelField = null; + shareNameField = null; + shareStatusLabel = null; + shareStatusMessage = ""; + rebuildWidgets(); + } + + /** + * Build the modal form. EditBox contents are plain user text — every byte is re-validated + * by {@link #submitShareForm} before anything reaches the uploader, and the uploader + * itself does the real sanitization (file-size gates, {@code SafePngReader}, + * {@code BbModelParser}, {@code BbmodelCodec}). This UI layer just provides friendly + * early rejection so the user sees a clear error before the upload attempt. + */ + private void buildShareForm() { + int panelW = Math.min(360, this.width - 40); + int cx = this.width / 2; + int x0 = cx - panelW / 2; + int fieldW = panelW - 20; + int fieldH = 20; + int rowGap = 10; + int y = this.height / 2 - 90; + + StringWidget title = new StringWidget( + x0, y, panelW, 15, + Component.literal("Share Cosmetic"), + this.font + ); + addRenderableWidget(title); + y += 22; + + // Texture path row: EditBox + Browse. + int browseW = 60; + shareTextureField = new EditBox(this.font, x0 + 10, y, fieldW - browseW - 5, fieldH, + Component.literal("Texture path")); + shareTextureField.setMaxLength(SHARE_PATH_MAX_LEN); + shareTextureField.setHint(Component.literal("Path to .png")); + addRenderableWidget(shareTextureField); + addRenderableWidget(Button.builder( + Component.literal("Browse"), + b -> pickShareTexturePath() + ).bounds(x0 + 10 + fieldW - browseW, y, browseW, fieldH).build()); + y += fieldH + rowGap; + + // Model path row: EditBox + Browse. + shareModelField = new EditBox(this.font, x0 + 10, y, fieldW - browseW - 5, fieldH, + Component.literal("Model path")); + shareModelField.setMaxLength(SHARE_PATH_MAX_LEN); + shareModelField.setHint(Component.literal("Path to .bbmodel")); + addRenderableWidget(shareModelField); + addRenderableWidget(Button.builder( + Component.literal("Browse"), + b -> pickShareModelPath() + ).bounds(x0 + 10 + fieldW - browseW, y, browseW, fieldH).build()); + y += fieldH + rowGap; + + // Name (optional). + shareNameField = new EditBox(this.font, x0 + 10, y, fieldW, fieldH, + Component.literal("Display name")); + shareNameField.setMaxLength(SHARE_NAME_MAX_LEN); + shareNameField.setHint(Component.literal("Name (optional — defaults to texture filename)")); + addRenderableWidget(shareNameField); + y += fieldH + rowGap; + + // Status line (updated after submit). + shareStatusLabel = new StringWidget( + x0 + 10, y, fieldW, 15, + Component.literal(shareStatusMessage), + this.font + ); + addRenderableWidget(shareStatusLabel); + y += 22; + + // Submit / Cancel. + int btnW = (fieldW - 10) / 2; + addRenderableWidget(Button.builder( + Component.literal("Submit"), + b -> submitShareForm() + ).bounds(x0 + 10, y, btnW, fieldH).build()); + addRenderableWidget(Button.builder( + Component.literal("Cancel"), + b -> closeShareForm() + ).bounds(x0 + 10 + btnW + 10, y, btnW, fieldH).build()); + } + + private void pickShareTexturePath() { + try (MemoryStack stack = MemoryStack.stackPush()) { + PointerBuffer filters = stack.mallocPointer(1); + filters.put(stack.UTF8("*.png")); + filters.flip(); + String path = TinyFileDialogs.tinyfd_openFileDialog( + "Select texture PNG", "", filters, "PNG images", false); + if (path != null && !path.isEmpty() && shareTextureField != null) { + shareTextureField.setValue(path); + } + } + } + + private void pickShareModelPath() { + try (MemoryStack stack = MemoryStack.stackPush()) { + PointerBuffer filters = stack.mallocPointer(1); + filters.put(stack.UTF8("*.bbmodel")); + filters.flip(); + String path = TinyFileDialogs.tinyfd_openFileDialog( + "Select .bbmodel", "", filters, "bbmodel files", false); + if (path != null && !path.isEmpty() && shareModelField != null) { + shareModelField.setValue(path); + } + } + } + + /** + * Sanitize every field, reject obvious bad input early with clear messages, and then + * hand off to {@link ClientSharedCosmeticUploader} which runs the actual security + * pipeline (file-size gates, {@code SafePngReader}, {@code BbModelParser}, + * {@code BbmodelCodec}, library-dedup check, chunking). + */ + private void submitShareForm() { + if (shareTextureField == null || shareModelField == null || shareNameField == null) return; + + String texturePath = sanitizeSharePath(shareTextureField.getValue()); + String modelPath = sanitizeSharePath(shareModelField.getValue()); + String name = sanitizeShareName(shareNameField.getValue()); + + if (texturePath.isEmpty()) { setShareStatus("Texture path required"); return; } + if (modelPath.isEmpty()) { setShareStatus("Model path required"); return; } + + Path texture; + Path model; + try { + texture = Paths.get(texturePath); + model = Paths.get(modelPath); + } catch (InvalidPathException ex) { + setShareStatus("Invalid path: " + ex.getMessage()); + return; + } + if (!Files.isRegularFile(texture)) { setShareStatus("Texture file not found"); return; } + if (!Files.isRegularFile(model)) { setShareStatus("Model file not found"); return; } + + String texLower = texturePath.toLowerCase(Locale.ROOT); + String mdlLower = modelPath.toLowerCase(Locale.ROOT); + if (!texLower.endsWith(".png")) { setShareStatus("Texture must be .png"); return; } + if (!mdlLower.endsWith(".bbmodel")) { setShareStatus("Model must be .bbmodel"); return; } + + if (name.isEmpty()) name = titleCaseFilenameStem(texture.getFileName().toString()); + + String result = ClientSharedCosmeticUploader.upload(model, texture, name); + setShareStatus(result); + + // Clear the fields on successful submission so a second upload starts fresh. + // The uploader's status string is the only signal of outcome — "Uploading " means + // chunks went to the server, "Already in library" means dedup short-circuited + // (still a valid state, nothing to retry). Failures keep the fields populated so + // the user can correct and resubmit without re-entering paths. + if (result != null + && (result.startsWith("Uploading ") || result.startsWith("Already in library"))) { + shareTextureField.setValue(""); + shareModelField.setValue(""); + shareNameField.setValue(""); + } + } + + private void setShareStatus(String msg) { + shareStatusMessage = msg == null ? "" : msg; + if (shareStatusLabel != null) shareStatusLabel.setMessage(Component.literal(shareStatusMessage)); + } + + /** Trim, strip matched wrapping quotes, reject null bytes and absurdly long inputs. */ + private static String sanitizeSharePath(String raw) { + if (raw == null) return ""; + String s = raw.trim(); + if (s.length() >= 2 + && ((s.charAt(0) == '"' && s.charAt(s.length() - 1) == '"') + || (s.charAt(0) == '\'' && s.charAt(s.length() - 1) == '\''))) { + s = s.substring(1, s.length() - 1).trim(); + } + if (s.indexOf('\0') >= 0) return ""; + if (s.length() > SHARE_PATH_MAX_LEN) return ""; + return s; + } + + /** Trim, drop control characters, cap length. Null byte / DEL / C0 all stripped. */ + private static String sanitizeShareName(String raw) { + if (raw == null) return ""; + String s = raw.trim(); + StringBuilder sb = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c >= 0x20 && c != 0x7F) sb.append(c); + } + String out = sb.toString(); + if (out.length() > SHARE_NAME_MAX_LEN) out = out.substring(0, SHARE_NAME_MAX_LEN); + return out; + } + + /** "red_hat.png" → "Red Hat". Used when the user leaves the name field blank. */ + private static String titleCaseFilenameStem(String filename) { + int dot = filename.lastIndexOf('.'); + String stem = dot < 0 ? filename : filename.substring(0, dot); + StringBuilder out = new StringBuilder(stem.length()); + boolean capitalize = true; + for (int i = 0; i < stem.length(); i++) { + char c = stem.charAt(i); + if (c == '_' || c == '-' || c == ' ') { + out.append(' '); + capitalize = true; + } else if (capitalize) { + out.append(Character.toUpperCase(c)); + capitalize = false; + } else { + out.append(c); + } + } + return out.toString(); + } + private void equip(String category, Identifier cosmeticId) { Minecraft mc = Minecraft.getInstance(); LocalPlayer player = mc.player; if (player == null || player.connection == null) return; // Toggle: if this cosmetic is already equipped in that category, unequip. - Map currentEquipped = player.getData(CosmeticAttachments.EQUIPPED.get()); - String cmd = cosmeticId.equals(currentEquipped.get(category)) + Map currentEquipped = + player.getData(CosmeticAttachments.EQUIPPED.get()); + com.razz.dfashion.cosmetic.CosmeticRef current = currentEquipped.get(category); + boolean alreadyEquipped = current instanceof com.razz.dfashion.cosmetic.CosmeticRef.Local l + && l.id().equals(cosmeticId); + String cmd = alreadyEquipped ? "decofashion unequip " + category : "decofashion equip " + category + " " + cosmeticId; player.connection.sendUnattendedCommand(cmd, this); @@ -423,6 +968,16 @@ public class ClosetScreen extends Screen { @Override public boolean mouseScrolled(double mx, double my, double deltaX, double deltaY) { + // Shared tab uses the full right-side region as a vertical scroll area. + if (SHARED_TAB.equals(selectedCategory) + && my >= SHARED_GRID_TOP + && my <= this.height - SHARED_GRID_BOTTOM_PAD + && mx >= SKIN_GRID_START_X) { + sharedVerticalScroll -= (int) (deltaY * 20); + clampSharedScroll(); + rebuildCosmeticRow(); + return true; + } if (my > this.height - COSMETIC_ROW_BOTTOM_MARGIN - 10 && my < this.height - COSMETIC_ROW_BOTTOM_MARGIN + COSMETIC_SIZE + 10) { cosmeticScroll -= (int) (deltaY * 20); @@ -445,12 +1000,20 @@ public class ClosetScreen extends Screen { // covers uploads (which add a new entry asynchronously after the server echo arrives) // and any new skin received from another player. if (SKIN_TAB.equals(selectedCategory)) { - int count = ClientSkinCache.snapshotRegistered().size(); + int count = player.getData(SkinAttachments.LIBRARY.get()).entries().size(); if (count != lastKnownSkinCount) { lastKnownSkinCount = count; rebuildCosmeticRow(); } } + if (SHARED_TAB.equals(selectedCategory)) { + int count = player.getData(CosmeticAttachments.SHARED_LIBRARY.get()).entries().size(); + if (count != lastKnownSharedCount) { + lastKnownSharedCount = count; + clampSharedScroll(); + rebuildCosmeticRow(); + } + } long handle = mc.getWindow().handle(); boolean leftDown = GLFW.glfwGetMouseButton(handle, GLFW.GLFW_MOUSE_BUTTON_LEFT) == GLFW.GLFW_PRESS; @@ -481,8 +1044,11 @@ public class ClosetScreen extends Screen { String hit = findClickedEquippedCategory(player, mx, my); if (hit != null) { selectCategory(hit); - Identifier current = player.getData(CosmeticAttachments.EQUIPPED.get()).get(hit); - if (current != null) scrollToCosmetic(hit, current); + com.razz.dfashion.cosmetic.CosmeticRef ref = + player.getData(CosmeticAttachments.EQUIPPED.get()).get(hit); + if (ref instanceof com.razz.dfashion.cosmetic.CosmeticRef.Local l) { + scrollToCosmetic(hit, l.id()); + } } } } @@ -541,7 +1107,8 @@ public class ClosetScreen extends Screen { * camera-local space (subtract {@code camera.position()}). */ private @Nullable String findClickedEquippedCategory(LocalPlayer player, double clickX, double clickY) { - Map equipped = player.getData(CosmeticAttachments.EQUIPPED.get()); + Map equipped = + player.getData(CosmeticAttachments.EQUIPPED.get()); if (equipped.isEmpty()) return null; Camera camera = Minecraft.getInstance().gameRenderer.getMainCamera(); @@ -554,7 +1121,7 @@ public class ClosetScreen extends Screen { String best = null; double bestDistSq = PLAYER_CLICK_THRESHOLD_PX * PLAYER_CLICK_THRESHOLD_PX; - for (Map.Entry entry : equipped.entrySet()) { + for (Map.Entry entry : equipped.entrySet()) { String category = entry.getKey(); Vec3 bonePos = approximateBoneWorldPos(player, category); @@ -603,9 +1170,10 @@ public class ClosetScreen extends Screen { int count; int rowLeft; if (SKIN_TAB.equals(selectedCategory)) { - // Skin grid: rows come from the client skin cache, and they start past the - // vertical action column, not at COSMETIC_ROW_LEFT. - count = ClientSkinCache.snapshotRegistered().size(); + // Skin grid: rows come from the server-authoritative player library (not the + // client's content-addressed blob pool), so only this player's 5 slots count. + LocalPlayer p = Minecraft.getInstance().player; + count = p != null ? p.getData(SkinAttachments.LIBRARY.get()).entries().size() : 0; rowLeft = SKIN_GRID_START_X; } else { count = 0; @@ -666,11 +1234,18 @@ public class ClosetScreen extends Screen { renderSkinPreviews(graphics, player); return; } + if (SHARED_TAB.equals(selectedCategory)) { + renderSharedPreviews(graphics, player); + return; + } EntityRenderDispatcher dispatcher = mc.getEntityRenderDispatcher(); EntityRenderer renderer = dispatcher.getRenderer(player); - Map liveEquipped = player.getData(CosmeticAttachments.EQUIPPED.get()); + Map liveEquipped = + player.getData(CosmeticAttachments.EQUIPPED.get()); + // Authored loop first. Shared entries appended to this category tab render via + // renderSharedPreviews below — they're not matched by the catalog iteration. int idx = 0; for (Map.Entry entry : CosmeticCache.catalog.entrySet()) { CosmeticDefinition def = entry.getValue(); @@ -688,7 +1263,9 @@ public class ClosetScreen extends Screen { // Skip off-screen or encroaching-on-tabs buttons (match btn.visible logic). if (fx0 < COSMETIC_ROW_LEFT || fx0 >= this.width) continue; - boolean isEquipped = entry.getKey().equals(liveEquipped.get(def.category())); + com.razz.dfashion.cosmetic.CosmeticRef liveRef = liveEquipped.get(def.category()); + boolean isEquipped = liveRef instanceof com.razz.dfashion.cosmetic.CosmeticRef.Local l + && entry.getKey().equals(l.id()); int borderColor = isEquipped ? COSMETIC_BORDER_EQUIPPED_COLOR : COSMETIC_BORDER_COLOR; // Frame (green if currently equipped). @@ -715,7 +1292,7 @@ public class ClosetScreen extends Screen { // Register the override — layer will read this instead of the live player's // equipped attachment when rendering this specific render state. CosmeticRenderLayer.RENDER_OVERRIDES.put(avatar, - Map.of(def.category(), entry.getKey())); + Map.of(def.category(), new com.razz.dfashion.cosmetic.CosmeticRef.Local(entry.getKey()))); Vector3f translation = new Vector3f(0f, avatar.boundingBoxHeight / 2f + 0.0625f, 0f); Quaternionf rotation = new Quaternionf().rotateZ((float) Math.PI); @@ -724,6 +1301,80 @@ public class ClosetScreen extends Screen { graphics.entity(avatar, COSMETIC_PREVIEW_SIZE, translation, rotation, xRotation, x0, y0, x1, y1); } + + // Shared entries that `rebuildCosmeticRow` appended to this category tab render + // via the shared preview path — they're not in the catalog. + renderSharedPreviews(graphics, player); + } + + /** + * 3D-mini-player preview per shared-library entry. Per-box we inject a + * {@link CosmeticRef.Shared} override so the cosmetic renders in the category derived + * from the entry's bones. Boxes that are scrolled off the grid are skipped. Green frame + * when equipped; amber frame for un-equipped shared entries so they're distinguishable + * from authored ones (white frame). + */ + private void renderSharedPreviews(GuiGraphicsExtractor graphics, LocalPlayer player) { + if (sharedButtonHashes.isEmpty()) return; + + Minecraft mc = Minecraft.getInstance(); + EntityRenderDispatcher dispatcher = mc.getEntityRenderDispatcher(); + EntityRenderer renderer = dispatcher.getRenderer(player); + Map liveEquipped = player.getData(CosmeticAttachments.EQUIPPED.get()); + + // Vertical clip only matters on the shared tab (vertical scroll grid). + // On authored category tabs the shared buttons live in the bottom row alongside + // authored ones, so we use the full-screen range and rely on the button's visible + // flag + horizontal clip. + boolean onSharedTab = SHARED_TAB.equals(selectedCategory); + int gridTop = onSharedTab ? SHARED_GRID_TOP : 0; + int gridBottom = onSharedTab ? (this.height - SHARED_GRID_BOTTOM_PAD) : this.height; + + for (Map.Entry e : sharedButtonHashes.entrySet()) { + Button btn = e.getKey(); + String hash = e.getValue(); + if (!btn.visible) continue; + + int fx0 = btn.getX(); + int fy0 = btn.getY(); + int fx1 = fx0 + btn.getWidth(); + int fy1 = fy0 + btn.getHeight(); + // Clip against vertical-scroll viewport. + if (fy1 <= gridTop || fy0 >= gridBottom) continue; + if (fx0 < COSMETIC_ROW_LEFT || fx0 >= this.width) continue; + + // Baked entry may not have arrived yet (requestIfMissing sent above). + if (ClientSharedCosmeticCache.getBaked(hash) == null) continue; + + String previewCategory = sharedCategoryForHash(hash); + CosmeticRef liveRef = liveEquipped.get(previewCategory); + boolean isEquipped = liveRef instanceof CosmeticRef.Shared s && hash.equals(s.hash()); + graphics.outline(fx0, fy0, fx1 - fx0, fy1 - fy0, + isEquipped ? COSMETIC_BORDER_EQUIPPED_COLOR : COSMETIC_BORDER_SHARED_COLOR); + + int x0 = fx0 + COSMETIC_INNER_PADDING; + int y0 = fy0 + COSMETIC_INNER_PADDING; + int x1 = fx1 - COSMETIC_INNER_PADDING; + int y1 = fy1 - COSMETIC_INNER_PADDING; + + EntityRenderState state = renderer.createRenderState(player, 1.0F); + if (!(state instanceof AvatarRenderState avatar)) continue; + + avatar.shadowPieces.clear(); + avatar.outlineColor = 0; + avatar.bodyRot = 180f + COSMETIC_PREVIEW_YAW; + avatar.yRot = COSMETIC_PREVIEW_YAW; + avatar.xRot = 0f; + + CosmeticRenderLayer.RENDER_OVERRIDES.put(avatar, + Map.of(previewCategory, new CosmeticRef.Shared(hash))); + + Vector3f translation = new Vector3f(0f, avatar.boundingBoxHeight / 2f + 0.0625f, 0f); + Quaternionf rotation = new Quaternionf().rotateZ((float) Math.PI); + Quaternionf xRotation = new Quaternionf(); + graphics.entity(avatar, COSMETIC_PREVIEW_SIZE, translation, rotation, xRotation, + x0, y0, x1, y1); + } } /** diff --git a/src/main/java/com/razz/dfashion/cosmetic/CosmeticAttachments.java b/src/main/java/com/razz/dfashion/cosmetic/CosmeticAttachments.java index 1db615e..0784af9 100644 --- a/src/main/java/com/razz/dfashion/cosmetic/CosmeticAttachments.java +++ b/src/main/java/com/razz/dfashion/cosmetic/CosmeticAttachments.java @@ -2,11 +2,12 @@ package com.razz.dfashion.cosmetic; import com.mojang.serialization.Codec; import com.razz.dfashion.DecoFashion; +import com.razz.dfashion.cosmetic.share.CosmeticLibrary; import net.minecraft.network.RegistryFriendlyByteBuf; import net.minecraft.network.codec.ByteBufCodecs; import net.minecraft.network.codec.StreamCodec; -import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerPlayer; import net.neoforged.neoforge.attachment.AttachmentType; import net.neoforged.neoforge.registries.DeferredHolder; import net.neoforged.neoforge.registries.DeferredRegister; @@ -20,22 +21,40 @@ public final class CosmeticAttachments { public static final DeferredRegister> ATTACHMENT_TYPES = DeferredRegister.create(NeoForgeRegistries.ATTACHMENT_TYPES, DecoFashion.MODID); - private static final Codec> MAP_CODEC = - Codec.unboundedMap(Codec.STRING, Identifier.CODEC); + private static final Codec> MAP_CODEC = + Codec.unboundedMap(Codec.STRING, CosmeticRef.CODEC); - private static final StreamCodec> STREAM_CODEC = - ByteBufCodecs.map(HashMap::new, ByteBufCodecs.STRING_UTF8, Identifier.STREAM_CODEC); + private static final StreamCodec> STREAM_CODEC = + ByteBufCodecs.map(HashMap::new, ByteBufCodecs.stringUtf8(64), CosmeticRef.STREAM_CODEC); - /** Per-player map of category -> equipped cosmetic id. */ - public static final DeferredHolder, AttachmentType>> EQUIPPED = + /** Per-player map of category -> equipped cosmetic ref. A ref is either a + * {@link CosmeticRef.Local} (built-in / user-folder cosmetic) or a + * {@link CosmeticRef.Shared} (server-stored blob by content hash). */ + public static final DeferredHolder, AttachmentType>> EQUIPPED = ATTACHMENT_TYPES.register( "equipped_cosmetics", - () -> AttachmentType.>builder(() -> new HashMap<>()) + () -> AttachmentType.>builder(() -> new HashMap<>()) .serialize(MAP_CODEC.fieldOf("equipped")) .sync(STREAM_CODEC) .copyOnDeath() .build() ); + /** Personal library of shared-cosmetic hashes. Synced only to the owning player + * so nobody can enumerate another player's uploaded cosmetics. */ + public static final DeferredHolder, AttachmentType> SHARED_LIBRARY = + ATTACHMENT_TYPES.register( + "shared_cosmetic_library", + () -> AttachmentType.builder(() -> CosmeticLibrary.EMPTY) + .serialize(CosmeticLibrary.CODEC.fieldOf("shared_library")) + .sync( + (holder, to) -> holder instanceof ServerPlayer sp + && sp.getUUID().equals(to.getUUID()), + CosmeticLibrary.STREAM_CODEC + ) + .copyOnDeath() + .build() + ); + private CosmeticAttachments() {} } diff --git a/src/main/java/com/razz/dfashion/cosmetic/CosmeticCommands.java b/src/main/java/com/razz/dfashion/cosmetic/CosmeticCommands.java index bd28a31..458ebaa 100644 --- a/src/main/java/com/razz/dfashion/cosmetic/CosmeticCommands.java +++ b/src/main/java/com/razz/dfashion/cosmetic/CosmeticCommands.java @@ -48,8 +48,8 @@ public final class CosmeticCommands { String idStr = StringArgumentType.getString(ctx, "id"); Identifier id = Identifier.parse(idStr); - Map equipped = new HashMap<>(player.getData(CosmeticAttachments.EQUIPPED.get())); - equipped.put(category, id); + Map equipped = new HashMap<>(player.getData(CosmeticAttachments.EQUIPPED.get())); + equipped.put(category, new CosmeticRef.Local(id)); player.setData(CosmeticAttachments.EQUIPPED.get(), equipped); ctx.getSource().sendSuccess( @@ -67,13 +67,13 @@ public final class CosmeticCommands { } String category = StringArgumentType.getString(ctx, "category"); - Map equipped = new HashMap<>(player.getData(CosmeticAttachments.EQUIPPED.get())); - Identifier removed = equipped.remove(category); + Map equipped = new HashMap<>(player.getData(CosmeticAttachments.EQUIPPED.get())); + CosmeticRef removed = equipped.remove(category); player.setData(CosmeticAttachments.EQUIPPED.get(), equipped); ctx.getSource().sendSuccess( () -> Component.literal(removed != null - ? "Unequipped " + removed + " from '" + category + "'" + ? "Unequipped " + formatRef(removed) + " from '" + category + "'" : "Nothing was equipped in '" + category + "'"), false ); @@ -86,16 +86,23 @@ public final class CosmeticCommands { ctx.getSource().sendFailure(Component.literal("Must be run by a player")); return 0; } - Map equipped = player.getData(CosmeticAttachments.EQUIPPED.get()); + Map equipped = player.getData(CosmeticAttachments.EQUIPPED.get()); if (equipped.isEmpty()) { ctx.getSource().sendSuccess(() -> Component.literal("No cosmetics equipped"), false); } else { - equipped.forEach((cat, id) -> - ctx.getSource().sendSuccess(() -> Component.literal(cat + " = " + id), false) + equipped.forEach((cat, ref) -> + ctx.getSource().sendSuccess(() -> Component.literal(cat + " = " + formatRef(ref)), false) ); } return 1; } + private static String formatRef(CosmeticRef ref) { + return switch (ref) { + case CosmeticRef.Local l -> l.id().toString(); + case CosmeticRef.Shared s -> "shared:" + s.hash().substring(0, 8) + "…"; + }; + } + private CosmeticCommands() {} } diff --git a/src/main/java/com/razz/dfashion/cosmetic/CosmeticRef.java b/src/main/java/com/razz/dfashion/cosmetic/CosmeticRef.java new file mode 100644 index 0000000..c719453 --- /dev/null +++ b/src/main/java/com/razz/dfashion/cosmetic/CosmeticRef.java @@ -0,0 +1,81 @@ +package com.razz.dfashion.cosmetic; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import com.razz.dfashion.cosmetic.share.SharedCosmeticCache; + +import io.netty.buffer.ByteBuf; +import io.netty.handler.codec.DecoderException; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.resources.Identifier; + +/** + * One of two ways an equipped cosmetic slot can reference its content: + *

    + *
  • {@link Local} — a built-in or user-folder cosmetic addressed by {@link Identifier}. + * Resolved against {@code CosmeticCache.cosmetics}.
  • + *
  • {@link Shared} — a server-side blob addressed by its 64-char content hash. + * Resolved against {@code ClientSharedCosmeticCache}.
  • + *
+ * + *

Wire codec tags the variant with a single byte (0 = local, 1 = shared) and caps each + * inner string length. Invalid shared hashes are rejected via + * {@link SharedCosmeticCache#isValidHash} on decode. + */ +public sealed interface CosmeticRef permits CosmeticRef.Local, CosmeticRef.Shared { + + record Local(Identifier id) implements CosmeticRef {} + record Shared(String hash) implements CosmeticRef {} + + byte TAG_LOCAL = 0; + byte TAG_SHARED = 1; + + Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( + Codec.BOOL.fieldOf("shared").forGetter(r -> r instanceof Shared), + Codec.STRING.fieldOf("value").forGetter(r -> switch (r) { + case Local l -> l.id().toString(); + case Shared s -> s.hash(); + }) + ).apply(inst, (shared, value) -> shared + ? new Shared(value) + : new Local(Identifier.parse(value)))); + + StreamCodec STREAM_CODEC = new StreamCodec<>() { + private final StreamCodec LOCAL_STR = ByteBufCodecs.stringUtf8(256); + private final StreamCodec SHARED_STR = ByteBufCodecs.stringUtf8(64); + + @Override public void encode(ByteBuf buf, CosmeticRef ref) { + switch (ref) { + case Local l -> { + buf.writeByte(TAG_LOCAL); + LOCAL_STR.encode(buf, l.id().toString()); + } + case Shared s -> { + buf.writeByte(TAG_SHARED); + SHARED_STR.encode(buf, s.hash()); + } + } + } + + @Override public CosmeticRef decode(ByteBuf buf) { + byte tag = buf.readByte(); + return switch (tag) { + case TAG_LOCAL -> { + String s = LOCAL_STR.decode(buf); + Identifier id = Identifier.tryParse(s); + if (id == null) throw new DecoderException("bad Identifier in CosmeticRef.Local: " + s); + yield new Local(id); + } + case TAG_SHARED -> { + String hash = SHARED_STR.decode(buf); + if (!SharedCosmeticCache.isValidHash(hash)) { + throw new DecoderException("invalid hash in CosmeticRef.Shared"); + } + yield new Shared(hash); + } + default -> throw new DecoderException("bad CosmeticRef tag " + tag); + }; + } + }; +} \ No newline at end of file diff --git a/src/main/java/com/razz/dfashion/cosmetic/share/BbmodelCodec.java b/src/main/java/com/razz/dfashion/cosmetic/share/BbmodelCodec.java new file mode 100644 index 0000000..5fe5a65 --- /dev/null +++ b/src/main/java/com/razz/dfashion/cosmetic/share/BbmodelCodec.java @@ -0,0 +1,300 @@ +package com.razz.dfashion.cosmetic.share; + +import com.razz.dfashion.bbmodel.BbCube; +import com.razz.dfashion.bbmodel.BbFace; +import com.razz.dfashion.bbmodel.BbGroup; +import com.razz.dfashion.bbmodel.BbLocator; +import com.razz.dfashion.bbmodel.BbModelParser; +import com.razz.dfashion.bbmodel.BbOutlinerNode; +import com.razz.dfashion.bbmodel.Bbmodel; + +import io.netty.buffer.ByteBuf; +import io.netty.handler.codec.DecoderException; +import net.minecraft.network.VarInt; +import net.minecraft.network.codec.StreamCodec; +import org.joml.Vector3f; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Binary wire format for a {@link Bbmodel}. The author's client parses their local JSON + * with {@link BbModelParser} (the only JSON parser anywhere in the mod's untrusted-bytes + * path), encodes the vetted record via this codec, and everything downstream — server, + * receivers — only ever decodes via this codec. GSON is never fed wire bytes. + * + *

Defense layers: + *

    + *
  • Every integer length is VAR_INT and bounded: strings ≤ {@value #MAX_STRING_LEN} chars, + * lists ≤ {@value #MAX_LIST_ELEMENTS} elements, face UVs ≤ {@value #MAX_UV_LEN} floats. + * A malicious {@code VAR_INT = 2^30} is rejected before any allocation.
  • + *
  • Outliner recursion is bounded by {@link BbModelParser#MAX_OUTLINER_DEPTH} because + * {@link BbModelParser#validate} re-runs on decoded output.
  • + *
  • After decoding, the full semantic validator fires again. Two separate code paths + * (codec + validator) must both miss a bug for anything malformed to reach the baker.
  • + *
+ */ +public final class BbmodelCodec { + + public static final int MAX_STRING_LEN = 256; + /** Per-list element cap. Bounds up-front allocation before ByteBuf consumption catches up. */ + public static final int MAX_LIST_ELEMENTS = 65536; + /** Per-face UV length cap. Real bbmodel faces use 4 floats; 32 is generous slack. */ + public static final int MAX_UV_LEN = 32; + + public static final StreamCodec CODEC = new StreamCodec<>() { + @Override public void encode(ByteBuf buf, Bbmodel m) { writeBbmodel(buf, m); } + @Override public Bbmodel decode(ByteBuf buf) { + Bbmodel m = readBbmodel(buf); + try { + BbModelParser.validate(m); + } catch (RuntimeException ex) { + throw new DecoderException("bbmodel failed wire-side validation: " + ex.getMessage(), ex); + } + return m; + } + }; + + private BbmodelCodec() {} + + // ---- Bbmodel ---- + + private static void writeBbmodel(ByteBuf buf, Bbmodel m) { + VarInt.write(buf, m.resolutionWidth()); + VarInt.write(buf, m.resolutionHeight()); + writeList(buf, m.elements(), BbmodelCodec::writeCube); + writeList(buf, m.locators(), BbmodelCodec::writeLocator); + writeList(buf, m.groups(), BbmodelCodec::writeGroup); + writeList(buf, m.outliner(), BbmodelCodec::writeOutlinerNode); + } + + private static Bbmodel readBbmodel(ByteBuf buf) { + int w = VarInt.read(buf); + int h = VarInt.read(buf); + List elements = readList(buf, BbmodelCodec::readCube); + List locators = readList(buf, BbmodelCodec::readLocator); + List groups = readList(buf, BbmodelCodec::readGroup); + List outliner = readList(buf, BbmodelCodec::readOutlinerNode); + return new Bbmodel(w, h, elements, locators, groups, outliner); + } + + // ---- BbCube ---- + + private static void writeCube(ByteBuf buf, BbCube c) { + writeString(buf, c.uuid()); + writeString(buf, c.name()); + writeVec3f(buf, c.from()); + writeVec3f(buf, c.to()); + writeVec3f(buf, c.origin()); + writeVec3f(buf, c.rotation()); + buf.writeFloat(c.inflate()); + Map faces = c.faces() != null ? c.faces() : Map.of(); + if (faces.size() > MAX_LIST_ELEMENTS) { + throw new DecoderException("face map too large: " + faces.size()); + } + // Canonical order so authors on different JVMs / Map implementations produce identical + // binary and therefore identical content hashes. Breaks dedup if this ever changes. + List> sortedFaces = new ArrayList<>(faces.entrySet()); + sortedFaces.sort(Map.Entry.comparingByKey()); + VarInt.write(buf, sortedFaces.size()); + for (Map.Entry e : sortedFaces) { + writeString(buf, e.getKey()); + writeFace(buf, e.getValue()); + } + } + + private static BbCube readCube(ByteBuf buf) { + String uuid = readString(buf); + String name = readString(buf); + Vector3f from = readVec3f(buf); + Vector3f to = readVec3f(buf); + Vector3f origin = readVec3f(buf); + Vector3f rotation = readVec3f(buf); + float inflate = buf.readFloat(); + int fcount = VarInt.read(buf); + if (fcount < 0 || fcount > MAX_LIST_ELEMENTS) { + throw new DecoderException("face count out of range: " + fcount); + } + Map faces = new HashMap<>(Math.min(fcount, 16)); + for (int i = 0; i < fcount; i++) { + String key = readString(buf); + faces.put(key, readFace(buf)); + } + return new BbCube(uuid, name, from, to, inflate, origin, rotation, faces); + } + + // ---- BbFace ---- + + private static void writeFace(ByteBuf buf, BbFace f) { + float[] uv = f.uv() != null ? f.uv() : new float[0]; + if (uv.length > MAX_UV_LEN) throw new DecoderException("uv length too large: " + uv.length); + VarInt.write(buf, uv.length); + for (float v : uv) buf.writeFloat(v); + Integer texture = f.texture(); + if (texture == null) { + buf.writeByte(0); + } else { + buf.writeByte(1); + VarInt.write(buf, texture); + } + VarInt.write(buf, f.rotation()); + } + + private static BbFace readFace(ByteBuf buf) { + int n = VarInt.read(buf); + if (n < 0 || n > MAX_UV_LEN) throw new DecoderException("uv length out of range: " + n); + float[] uv = new float[n]; + for (int i = 0; i < n; i++) uv[i] = buf.readFloat(); + byte flag = buf.readByte(); + Integer texture; + if (flag == 0) texture = null; + else if (flag == 1) texture = VarInt.read(buf); + else throw new DecoderException("bad texture flag " + flag); + int rotation = VarInt.read(buf); + return new BbFace(uv, texture, rotation); + } + + // ---- BbLocator / BbGroup ---- + + private static void writeLocator(ByteBuf buf, BbLocator loc) { + writeString(buf, loc.uuid()); + writeString(buf, loc.name()); + writeVec3f(buf, loc.position()); + writeVec3f(buf, loc.rotation()); + } + + private static BbLocator readLocator(ByteBuf buf) { + return new BbLocator(readString(buf), readString(buf), readVec3f(buf), readVec3f(buf)); + } + + private static void writeGroup(ByteBuf buf, BbGroup g) { + writeString(buf, g.uuid()); + writeString(buf, g.name()); + writeVec3f(buf, g.origin()); + writeVec3f(buf, g.rotation()); + } + + private static BbGroup readGroup(ByteBuf buf) { + return new BbGroup(readString(buf), readString(buf), readVec3f(buf), readVec3f(buf)); + } + + // ---- BbOutlinerNode (recursive) ---- + + private static final byte TAG_ELEMENT = 0; + private static final byte TAG_GROUP = 1; + + private static void writeOutlinerNode(ByteBuf buf, BbOutlinerNode node) { + switch (node) { + case BbOutlinerNode.ElementRef er -> { + buf.writeByte(TAG_ELEMENT); + writeString(buf, er.uuid()); + } + case BbOutlinerNode.GroupRef gr -> { + buf.writeByte(TAG_GROUP); + writeString(buf, gr.uuid()); + writeList(buf, gr.children(), BbmodelCodec::writeOutlinerNode); + } + } + } + + private static BbOutlinerNode readOutlinerNode(ByteBuf buf) { + return readOutlinerNode(buf, 0); + } + + /** + * Depth-tracked recursive decode. Caps at {@link BbModelParser#MAX_OUTLINER_DEPTH} + * during decode because the semantic validator in {@link BbModelParser#validate} + * only runs after full construction — a malicious payload with unbounded nesting would + * otherwise blow the thread's stack before validation ever got a chance to fire. + * One GroupRef nesting = ~2 wire bytes, so 16 MB of payload is more than enough to + * overflow a typical ~512 KB JVM stack without this cap. + */ + private static BbOutlinerNode readOutlinerNode(ByteBuf buf, int depth) { + if (depth > BbModelParser.MAX_OUTLINER_DEPTH) { + throw new DecoderException( + "outliner nesting exceeds decode-time depth cap (" + BbModelParser.MAX_OUTLINER_DEPTH + ")"); + } + byte tag = buf.readByte(); + return switch (tag) { + case TAG_ELEMENT -> new BbOutlinerNode.ElementRef(readString(buf)); + case TAG_GROUP -> { + String uuid = readString(buf); + int n = VarInt.read(buf); + if (n < 0 || n > MAX_LIST_ELEMENTS) { + throw new DecoderException("outliner children count out of range: " + n); + } + List children = new ArrayList<>(Math.min(n, 1024)); + for (int i = 0; i < n; i++) { + children.add(readOutlinerNode(buf, depth + 1)); + } + yield new BbOutlinerNode.GroupRef(uuid, children); + } + default -> throw new DecoderException("bad outliner node tag " + tag); + }; + } + + // ---- primitives ---- + + private static void writeVec3f(ByteBuf buf, Vector3f v) { + Vector3f safe = v != null ? v : new Vector3f(); + buf.writeFloat(safe.x); + buf.writeFloat(safe.y); + buf.writeFloat(safe.z); + } + + private static Vector3f readVec3f(ByteBuf buf) { + return new Vector3f(buf.readFloat(), buf.readFloat(), buf.readFloat()); + } + + private static void writeString(ByteBuf buf, String s) { + if (s == null) s = ""; + byte[] bytes = s.getBytes(StandardCharsets.UTF_8); + if (bytes.length > MAX_STRING_LEN * 4) { + throw new DecoderException("string too long on encode: " + bytes.length); + } + VarInt.write(buf, bytes.length); + buf.writeBytes(bytes); + } + + private static String readString(ByteBuf buf) { + int n = VarInt.read(buf); + // UTF-8 string of N chars can take up to 4N bytes; cap the byte length at 4× the char cap. + if (n < 0 || n > MAX_STRING_LEN * 4) { + throw new DecoderException("string byte length out of range: " + n); + } + byte[] bytes = new byte[n]; + buf.readBytes(bytes); + String s = new String(bytes, StandardCharsets.UTF_8); + if (s.length() > MAX_STRING_LEN) { + throw new DecoderException("string too long after decode: " + s.length()); + } + return s; + } + + // ---- list helpers ---- + + @FunctionalInterface private interface Writer { void write(ByteBuf buf, T t); } + @FunctionalInterface private interface Reader { T read(ByteBuf buf); } + + private static void writeList(ByteBuf buf, List list, Writer w) { + List safe = list != null ? list : List.of(); + if (safe.size() > MAX_LIST_ELEMENTS) { + throw new DecoderException("list too large: " + safe.size()); + } + VarInt.write(buf, safe.size()); + for (T t : safe) w.write(buf, t); + } + + private static List readList(ByteBuf buf, Reader r) { + int n = VarInt.read(buf); + if (n < 0 || n > MAX_LIST_ELEMENTS) { + throw new DecoderException("list length out of range: " + n); + } + List out = new ArrayList<>(Math.min(n, 1024)); + for (int i = 0; i < n; i++) out.add(r.read(buf)); + return out; + } +} \ No newline at end of file diff --git a/src/main/java/com/razz/dfashion/cosmetic/share/BoneExtraction.java b/src/main/java/com/razz/dfashion/cosmetic/share/BoneExtraction.java new file mode 100644 index 0000000..6e72554 --- /dev/null +++ b/src/main/java/com/razz/dfashion/cosmetic/share/BoneExtraction.java @@ -0,0 +1,189 @@ +package com.razz.dfashion.cosmetic.share; + +import com.razz.dfashion.bbmodel.BbGroup; +import com.razz.dfashion.bbmodel.BbOutlinerNode; +import com.razz.dfashion.bbmodel.Bbmodel; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Extract the top-level bone set from a parsed bbmodel, and map bones to wardrobe + * categories for filtering + auto-equip. + * + *

Per the "bone parenting" convention: the top-level {@code GroupRef}s in + * {@link Bbmodel#outliner()} are the cosmetic's attachment bones — their names match + * player-model bones like {@code Head}, {@code Body}, {@code LeftArm}, etc. We resolve + * each top-level group ref to its {@link BbGroup}, take the group's name, and normalize + * to a canonical key. The bone set is stored on the cosmetic library entry and drives: + *

    + *
  1. wardrobe filtering — a cosmetic with {@code Head} shows on hat / head tabs,
  2. + *
  3. auto-equip — {@link #primaryCategory} picks the default slot when the user + * clicks equip without choosing a category.
  4. + *
+ */ +public final class BoneExtraction { + + private BoneExtraction() {} + + /** Canonical player-bone keys. Matches vanilla {@code HumanoidModel} part names. */ + public static final String HEAD = "Head"; + public static final String BODY = "Body"; + public static final String LEFT_ARM = "LeftArm"; + public static final String RIGHT_ARM = "RightArm"; + public static final String LEFT_LEG = "LeftLeg"; + public static final String RIGHT_LEG = "RightLeg"; + /** Unrecognized bbmodel group name — cosmetic is still renderable but filters won't match it. */ + public static final String OTHER = "Other"; + + /** + * Walk {@code bbmodel.outliner()} and return canonical bone names ordered by how + * much geometry (recursive element count) each bone contains — most first. Ties + * break by the priority order body → head → arms → legs for determinism. + * + *

This lets auto-equip pick the dominant bone as the cosmetic's primary + * category: a hat with 10 cubes on {@code Head} and 1 stray on {@code Body} still + * equips as a hat; a full outfit with 20 cubes on {@code Body} and 6 each on arms/ + * legs still equips as torso. The author doesn't need a slot field. + * + *

{@link #OTHER} bones contribute no count and aren't stored — they'd never + * resolve to a category anyway. + */ + public static List fromBbmodel(Bbmodel bbmodel) { + Map groupsByUuid = new HashMap<>(bbmodel.groups().size()); + for (BbGroup g : bbmodel.groups()) { + groupsByUuid.put(g.uuid(), g); + } + + // Accumulate canonical-bone → recursive element count. Top-level groups with + // the same canonical name (e.g. two "arm_l" typos) merge their counts. Empty + // bones (rigged groups with no geometry) are discarded — common authoring + // pattern is to rig all six player bones and drop cubes into just one. + Map counts = new LinkedHashMap<>(); + for (BbOutlinerNode node : bbmodel.outliner()) { + if (!(node instanceof BbOutlinerNode.GroupRef gr)) continue; + BbGroup g = groupsByUuid.get(gr.uuid()); + if (g == null) continue; + String bone = canonical(g.name()); + if (OTHER.equals(bone)) continue; + int n = countElementsRecursive(gr); + if (n <= 0) continue; + counts.merge(bone, n, Integer::sum); + } + + List> sorted = new ArrayList<>(counts.entrySet()); + sorted.sort((a, b) -> { + int byCount = Integer.compare(b.getValue(), a.getValue()); // count desc + if (byCount != 0) return byCount; + return Integer.compare(priority(a.getKey()), priority(b.getKey())); // priority asc + }); + + List result = new ArrayList<>(sorted.size()); + for (Map.Entry e : sorted) result.add(e.getKey()); + return result; + } + + /** Recursive element (cube) count under a group, following nested groups. */ + private static int countElementsRecursive(BbOutlinerNode.GroupRef groupRef) { + int count = 0; + for (BbOutlinerNode child : groupRef.children()) { + if (child instanceof BbOutlinerNode.ElementRef) { + count++; + } else if (child instanceof BbOutlinerNode.GroupRef cgr) { + count += countElementsRecursive(cgr); + } + } + return count; + } + + /** Tie-break priority for equal geometry counts (lower index wins). */ + private static int priority(String bone) { + return switch (bone) { + case BODY -> 0; + case HEAD -> 1; + case LEFT_ARM -> 2; + case RIGHT_ARM -> 3; + case LEFT_LEG -> 4; + case RIGHT_LEG -> 5; + default -> Integer.MAX_VALUE; + }; + } + + /** + * Case-insensitive name normalization with common aliases ({@code torso} → {@code Body}, + * {@code arml} → {@code LeftArm}, etc.). Returns {@link #OTHER} for anything we don't + * recognize — the cosmetic still bakes and renders, it just won't appear in bone-filtered + * views. + */ + public static String canonical(String name) { + if (name == null) return OTHER; + String n = name.trim().toLowerCase().replace("_", "").replace(" ", "").replace("-", ""); + return switch (n) { + case "head" -> HEAD; + case "body", "torso", "chest" -> BODY; + case "leftarm", "arml", "armleft", "larm" -> LEFT_ARM; + case "rightarm", "armr", "armright", "rarm" -> RIGHT_ARM; + case "leftleg", "legl", "legleft", "lleg" -> LEFT_LEG; + case "rightleg", "legr", "legright", "rleg" -> RIGHT_LEG; + default -> OTHER; + }; + } + + /** + * Which wardrobe categories should show a cosmetic with these bones? Head bone → + * both {@code hat} and {@code head}; body → {@code torso} and {@code wings}; arm → + * {@code arms} and {@code wrist}; leg → {@code legs} and {@code feet}. Cosmetics with + * only {@link #OTHER} bones fall into no category and won't appear in filtered views. + */ + public static Set categoriesFor(List bones) { + Set cats = new HashSet<>(); + if (bones == null) return cats; + for (String bone : bones) { + switch (bone) { + case HEAD -> { + cats.add("hat"); + cats.add("head"); + } + case BODY -> { + cats.add("torso"); + cats.add("wings"); + } + case LEFT_ARM, RIGHT_ARM -> { + cats.add("arms"); + cats.add("wrist"); + } + case LEFT_LEG, RIGHT_LEG -> { + cats.add("legs"); + cats.add("feet"); + } + default -> { /* OTHER — no category */ } + } + } + return cats; + } + + /** + * Pick the default equip category for a cosmetic with these bones. {@link #fromBbmodel} + * orders bones by geometry count (most first), so the first bone is the cosmetic's + * dominant attachment — that's the slot it lands in. + * + *

Returns {@code null} if no canonical bone is present; the caller should fall + * back to a sentinel category (e.g. {@code "particle"}) so the cosmetic remains + * equippable. + */ + public static String primaryCategory(List bones) { + if (bones == null || bones.isEmpty()) return null; + return switch (bones.get(0)) { + case BODY -> "torso"; + case HEAD -> "hat"; + case LEFT_ARM, RIGHT_ARM -> "arms"; + case LEFT_LEG, RIGHT_LEG -> "legs"; + default -> null; + }; + } +} diff --git a/src/main/java/com/razz/dfashion/cosmetic/share/CosmeticLibrary.java b/src/main/java/com/razz/dfashion/cosmetic/share/CosmeticLibrary.java new file mode 100644 index 0000000..efcbdec --- /dev/null +++ b/src/main/java/com/razz/dfashion/cosmetic/share/CosmeticLibrary.java @@ -0,0 +1,59 @@ +package com.razz.dfashion.cosmetic.share; + +import com.mojang.serialization.Codec; + +import io.netty.buffer.ByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; + +import java.util.ArrayList; +import java.util.List; + +/** + * A player's personal shared-cosmetic library — a bounded list of {@link CosmeticLibraryEntry}s. + * + *

Persisted on the player via an attachment and synced only to the owning client (like + * {@code SkinLibrary}). Other players never receive anyone else's library. Each entry's + * {@code hash} resolves to a {@code .dfcos} blob in {@code SharedCosmeticCache}. + */ +public record CosmeticLibrary(List entries) { + + /** Per-player cap. Cosmetics have multiple slots and benefit from more variants than skins. */ + public static final int MAX_ENTRIES = 15; + + public static final CosmeticLibrary EMPTY = new CosmeticLibrary(List.of()); + + public static final Codec CODEC = + CosmeticLibraryEntry.CODEC.listOf().xmap(CosmeticLibrary::new, CosmeticLibrary::entries); + + public static final StreamCodec STREAM_CODEC = + CosmeticLibraryEntry.STREAM_CODEC + .apply(ByteBufCodecs.list()) + .map(CosmeticLibrary::new, CosmeticLibrary::entries); + + public boolean contains(String hash) { + for (CosmeticLibraryEntry e : entries) { + if (e.hash().equals(hash)) return true; + } + return false; + } + + public boolean isFull() { + return entries.size() >= MAX_ENTRIES; + } + + public CosmeticLibrary add(CosmeticLibraryEntry entry) { + List out = new ArrayList<>(entries.size() + 1); + out.addAll(entries); + out.add(entry); + return new CosmeticLibrary(List.copyOf(out)); + } + + public CosmeticLibrary remove(String hash) { + List out = new ArrayList<>(entries.size()); + for (CosmeticLibraryEntry e : entries) { + if (!e.hash().equals(hash)) out.add(e); + } + return new CosmeticLibrary(List.copyOf(out)); + } +} \ No newline at end of file diff --git a/src/main/java/com/razz/dfashion/cosmetic/share/CosmeticLibraryEntry.java b/src/main/java/com/razz/dfashion/cosmetic/share/CosmeticLibraryEntry.java new file mode 100644 index 0000000..e0414bb --- /dev/null +++ b/src/main/java/com/razz/dfashion/cosmetic/share/CosmeticLibraryEntry.java @@ -0,0 +1,51 @@ +package com.razz.dfashion.cosmetic.share; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; + +import io.netty.buffer.ByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; + +import java.util.List; + +/** + * One entry in a player's personal shared-cosmetic library. {@code hash} points at a + * {@code .dfcos} blob the server stores under {@code /decofashion/cosmetics/.dfcos}. + * {@code displayName} is a user-visible label; {@code uploadedAt} is epoch millis so the + * wardrobe can sort without touching the filesystem. {@code bones} is the canonical set + * of player bones the cosmetic parents to — drives wardrobe filtering and auto-equip + * category, extracted from the bbmodel at upload finalization via {@link BoneExtraction}. + */ +public record CosmeticLibraryEntry( + String hash, + String displayName, + long uploadedAt, + List bones +) { + + /** Cap on bone list size. Real cosmetics declare ≤ 6 top-level bones; 16 is slack. */ + public static final int MAX_BONES = 16; + /** Cap on a single bone key's length. Canonical keys are ≤ 8 chars; 32 is slack. */ + public static final int MAX_BONE_LEN = 32; + + public CosmeticLibraryEntry { + bones = bones == null ? List.of() : List.copyOf(bones); + } + + public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( + Codec.STRING.fieldOf("hash").forGetter(CosmeticLibraryEntry::hash), + Codec.STRING.optionalFieldOf("name", "").forGetter(CosmeticLibraryEntry::displayName), + Codec.LONG.optionalFieldOf("uploadedAt", 0L).forGetter(CosmeticLibraryEntry::uploadedAt), + Codec.STRING.listOf().optionalFieldOf("bones", List.of()).forGetter(CosmeticLibraryEntry::bones) + ).apply(inst, CosmeticLibraryEntry::new)); + + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.stringUtf8(64), CosmeticLibraryEntry::hash, + ByteBufCodecs.stringUtf8(128), CosmeticLibraryEntry::displayName, + ByteBufCodecs.VAR_LONG, CosmeticLibraryEntry::uploadedAt, + ByteBufCodecs.stringUtf8(MAX_BONE_LEN).apply(ByteBufCodecs.list(MAX_BONES)), + CosmeticLibraryEntry::bones, + CosmeticLibraryEntry::new + ); +} diff --git a/src/main/java/com/razz/dfashion/cosmetic/share/CosmeticShareNetwork.java b/src/main/java/com/razz/dfashion/cosmetic/share/CosmeticShareNetwork.java new file mode 100644 index 0000000..7eb9278 --- /dev/null +++ b/src/main/java/com/razz/dfashion/cosmetic/share/CosmeticShareNetwork.java @@ -0,0 +1,357 @@ +package com.razz.dfashion.cosmetic.share; + +import com.razz.dfashion.DecoFashion; +import com.razz.dfashion.bbmodel.Bbmodel; +import com.razz.dfashion.cosmetic.CosmeticAttachments; +import com.razz.dfashion.cosmetic.CosmeticRef; +import com.razz.dfashion.cosmetic.share.packet.AssignSharedCosmetic; +import com.razz.dfashion.cosmetic.share.packet.CosmeticChunk; +import com.razz.dfashion.cosmetic.share.packet.DeleteCosmetic; +import com.razz.dfashion.cosmetic.share.packet.RequestCosmetic; +import com.razz.dfashion.cosmetic.share.packet.UploadCosmeticChunk; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import net.minecraft.network.protocol.common.ClientboundCustomPayloadPacket; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.event.entity.player.PlayerEvent; +import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent; +import net.neoforged.neoforge.network.handling.IPayloadContext; +import net.neoforged.neoforge.network.registration.PayloadRegistrar; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Wires up the shared-cosmetic payloads. + * + *

Server side is authoritative on everything: hash validity, library membership, + * content decoding, and the final content hash. Clients never supply a hash on upload — + * the server computes it from the decoded content. Every inbound path validates the hash + * against {@link SharedCosmeticCache#isValidHash} before any filesystem access. + */ +@EventBusSubscriber(modid = DecoFashion.MODID) +public final class CosmeticShareNetwork { + + /** Bumped on wire-breaking changes. Separate from the skin channel's version. */ + private static final String VERSION = "1"; + + private CosmeticShareNetwork() {} + + @SubscribeEvent + static void onRegister(RegisterPayloadHandlersEvent event) { + PayloadRegistrar registrar = event.registrar(VERSION); + + registrar.playToServer( + UploadCosmeticChunk.TYPE, UploadCosmeticChunk.STREAM_CODEC, + CosmeticShareNetwork::onUploadChunk + ); + registrar.playToServer( + RequestCosmetic.TYPE, RequestCosmetic.STREAM_CODEC, + CosmeticShareNetwork::onRequestCosmetic + ); + registrar.playToServer( + DeleteCosmetic.TYPE, DeleteCosmetic.STREAM_CODEC, + CosmeticShareNetwork::onDeleteCosmetic + ); + registrar.playToServer( + AssignSharedCosmetic.TYPE, AssignSharedCosmetic.STREAM_CODEC, + CosmeticShareNetwork::onAssignShared + ); + registrar.playToClient( + CosmeticChunk.TYPE, CosmeticChunk.STREAM_CODEC, + CosmeticShareNetwork::onCosmeticChunk + ); + } + + /** Clear any in-flight upload state when a player disconnects so buffers don't leak. */ + @SubscribeEvent + static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) { + if (event.getEntity() instanceof ServerPlayer sp) { + SharedCosmeticCache.cancelInFlight(sp.getUUID()); + } + } + + /** + * Re-extract bones for every library entry on login. Cheap — a handful of blob + * reads + bbmodel decodes, capped at {@link CosmeticLibrary#MAX_ENTRIES} — and + * self-healing: any entry persisted under older/buggier bone-extraction logic gets + * corrected automatically. If re-extraction fails (blob missing, decode error) the + * entry is left untouched. + */ + @SubscribeEvent + static void onPlayerLoggedIn(PlayerEvent.PlayerLoggedInEvent event) { + if (!(event.getEntity() instanceof ServerPlayer player)) return; + MinecraftServer server = player.level().getServer(); + if (server == null) return; + + CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get()); + if (lib.entries().isEmpty()) return; + + boolean anyChanged = false; + List migrated = new ArrayList<>(lib.entries().size()); + for (CosmeticLibraryEntry entry : lib.entries()) { + List extracted = extractBonesFromStoredBlob(server, entry.hash()); + if (extracted == null) { + migrated.add(entry); // read/decode failed — leave untouched + continue; + } + if (extracted.equals(entry.bones())) { + migrated.add(entry); // already correct + continue; + } + migrated.add(new CosmeticLibraryEntry( + entry.hash(), entry.displayName(), entry.uploadedAt(), extracted)); + anyChanged = true; + DecoFashion.LOGGER.info("Cosmetic bones refreshed: player={} hash={} old={} new={}", + player.getUUID(), entry.hash(), entry.bones(), extracted); + } + CosmeticLibrary finalLib = anyChanged ? new CosmeticLibrary(migrated) : lib; + if (anyChanged) { + player.setData(CosmeticAttachments.SHARED_LIBRARY.get(), finalLib); + } + + // Second audit: drop any CosmeticRef.Shared in EQUIPPED whose hash isn't in the + // current library. Catches zombies from older client/server builds where a delete + // removed the library entry but left the equipped slot pointing at the dead hash. + Map equipped = player.getData(CosmeticAttachments.EQUIPPED.get()); + Map cleaned = null; + for (Map.Entry e : equipped.entrySet()) { + if (!(e.getValue() instanceof CosmeticRef.Shared s)) continue; + if (!finalLib.contains(s.hash())) { + if (cleaned == null) cleaned = new HashMap<>(equipped); + cleaned.remove(e.getKey()); + DecoFashion.LOGGER.info("Cosmetic equipped-orphan cleared: player={} slot={} hash={}", + player.getUUID(), e.getKey(), s.hash()); + } + } + if (cleaned != null) { + player.setData(CosmeticAttachments.EQUIPPED.get(), cleaned); + } + } + + /** + * Read a stored {@code .dfcos} blob, decode its bbmodel section, and extract the + * top-level bone set. Returns {@code null} on any read/decode failure so the caller + * leaves the entry unchanged. + */ + private static List extractBonesFromStoredBlob(MinecraftServer server, String hash) { + if (!SharedCosmeticCache.has(server, hash)) return null; + byte[] blob; + try { + blob = SharedCosmeticCache.read(server, hash); + } catch (IOException ex) { + DecoFashion.LOGGER.warn("Cosmetic bones backfill: read failed for {}: {}", hash, ex.getMessage()); + return null; + } + SharedCosmeticCache.BlobView view = SharedCosmeticCache.readBlob(blob); + if (view == null) { + DecoFashion.LOGGER.warn("Cosmetic bones backfill: blob corrupt for {}", hash); + return null; + } + ByteBuf in = Unpooled.wrappedBuffer(view.bbmodelBinary()); + try { + Bbmodel bbmodel = BbmodelCodec.CODEC.decode(in); + return BoneExtraction.fromBbmodel(bbmodel); + } catch (RuntimeException ex) { + DecoFashion.LOGGER.warn("Cosmetic bones backfill: decode failed for {}: {}", hash, ex.getMessage()); + return null; + } finally { + in.release(); + } + } + + // ---- server-side handlers ---- + + private static void onUploadChunk(UploadCosmeticChunk msg, IPayloadContext ctx) { + ctx.enqueueWork(() -> { + if (!(ctx.player() instanceof ServerPlayer player)) return; + MinecraftServer server = player.level().getServer(); + if (server == null) return; + + SharedCosmeticCache.Finalized finalized = SharedCosmeticCache.acceptChunk( + server, player.getUUID(), + msg.index(), msg.total(), + msg.bbmodelLen(), + msg.width(), msg.height(), + msg.data() + ); + if (finalized == null) return; + + CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get()); + if (!lib.contains(finalized.hash())) { + int n = lib.entries().size() + 1; + CosmeticLibraryEntry entry = new CosmeticLibraryEntry( + finalized.hash(), + "Cosmetic " + n, + System.currentTimeMillis(), + finalized.bones() + ); + player.setData(CosmeticAttachments.SHARED_LIBRARY.get(), lib.add(entry)); + } + }); + } + + private static void onRequestCosmetic(RequestCosmetic msg, IPayloadContext ctx) { + ctx.enqueueWork(() -> { + if (!(ctx.player() instanceof ServerPlayer player)) return; + MinecraftServer server = player.level().getServer(); + if (server == null) return; + if (!SharedCosmeticCache.isValidHash(msg.hash())) { + DecoFashion.LOGGER.warn("Cosmetic request from {}: invalid hash rejected", player.getUUID()); + return; + } + if (!SharedCosmeticCache.has(server, msg.hash())) { + DecoFashion.LOGGER.warn("Cosmetic request from {}: unknown hash {}", player.getUUID(), msg.hash()); + return; + } + + byte[] blob; + try { + blob = SharedCosmeticCache.read(server, msg.hash()); + } catch (IOException ex) { + DecoFashion.LOGGER.error("Cosmetic request {}: read failed", msg.hash(), ex); + return; + } + + SharedCosmeticCache.BlobView view = SharedCosmeticCache.readBlob(blob); + if (view == null) { + DecoFashion.LOGGER.error("Cosmetic request {}: stored blob corrupt", msg.hash()); + return; + } + + // Wire payload (per-chunk) is [bbmodel binary][deflated RGBA], same shape as upload. + int bbmodelLen = view.bbmodelBinary().length; + int deflatedLen = view.deflatedRgba().length; + int payloadLen = bbmodelLen + deflatedLen; + int chunkSize = SharedCosmeticCache.MAX_CHUNK_BYTES; + int total = Math.max(1, (payloadLen + chunkSize - 1) / chunkSize); + + for (int i = 0; i < total; i++) { + int off = i * chunkSize; + int len = Math.min(chunkSize, payloadLen - off); + byte[] slice = new byte[len]; + // Copy from (bbmodelBinary || deflatedRgba) virtual buffer without concatenating. + copySlice(view.bbmodelBinary(), view.deflatedRgba(), off, slice); + player.connection.send(new ClientboundCustomPayloadPacket( + new CosmeticChunk( + msg.hash(), + i, total, bbmodelLen, + view.width(), view.height(), + slice + ))); + } + }); + } + + private static void onAssignShared(AssignSharedCosmetic msg, IPayloadContext ctx) { + ctx.enqueueWork(() -> { + if (!(ctx.player() instanceof ServerPlayer player)) return; + String slot = msg.slot(); + if (slot == null || slot.isEmpty() || slot.length() > 64) { + DecoFashion.LOGGER.warn("AssignSharedCosmetic from {}: bad slot", player.getUUID()); + return; + } + + Map equipped = new HashMap<>(player.getData(CosmeticAttachments.EQUIPPED.get())); + + // Empty hash = clear this slot. + if (msg.hash().isEmpty()) { + equipped.remove(slot); + player.setData(CosmeticAttachments.EQUIPPED.get(), equipped); + return; + } + + if (!SharedCosmeticCache.isValidHash(msg.hash())) { + DecoFashion.LOGGER.warn("AssignSharedCosmetic from {}: invalid hash", player.getUUID()); + return; + } + + CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get()); + if (!lib.contains(msg.hash())) { + DecoFashion.LOGGER.warn("AssignSharedCosmetic from {}: hash {} not in caller's library", + player.getUUID(), msg.hash()); + return; + } + + equipped.put(slot, new CosmeticRef.Shared(msg.hash())); + player.setData(CosmeticAttachments.EQUIPPED.get(), equipped); + }); + } + + private static void onDeleteCosmetic(DeleteCosmetic msg, IPayloadContext ctx) { + ctx.enqueueWork(() -> { + if (!(ctx.player() instanceof ServerPlayer player)) return; + if (!SharedCosmeticCache.isValidHash(msg.hash())) { + DecoFashion.LOGGER.warn("DeleteCosmetic from {}: invalid hash rejected", player.getUUID()); + return; + } + CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get()); + if (!lib.contains(msg.hash())) return; + player.setData(CosmeticAttachments.SHARED_LIBRARY.get(), lib.remove(msg.hash())); + + // Also unequip any slots still pointing at this hash — otherwise the render + // layer's requestIfMissing re-hydrates the blob from the server and the player + // keeps wearing a cosmetic they just removed. + Map equipped = player.getData(CosmeticAttachments.EQUIPPED.get()); + Map updated = null; + for (Map.Entry e : equipped.entrySet()) { + if (e.getValue() instanceof CosmeticRef.Shared s && msg.hash().equals(s.hash())) { + if (updated == null) updated = new HashMap<>(equipped); + updated.remove(e.getKey()); + } + } + if (updated != null) { + player.setData(CosmeticAttachments.EQUIPPED.get(), updated); + } + + DecoFashion.LOGGER.info("Cosmetic delete: player={} hash={} unequipped={}", + player.getUUID(), msg.hash(), updated != null); + }); + } + + private static void onCosmeticChunk(CosmeticChunk msg, IPayloadContext ctx) { + ctx.enqueueWork(() -> ClientReceiver.accept(msg)); + } + + /** + * Client-only trampoline. Kept separate so the server-side handler routing can be + * dist-gated without pulling in any client-only classes on dedicated servers. + * {@link com.razz.dfashion.client.ClientSharedCosmeticCache#onChunk} re-validates + * every field — this trampoline just delegates. + */ + private static final class ClientReceiver { + static void accept(CosmeticChunk msg) { + if (net.neoforged.fml.loading.FMLEnvironment.getDist() != Dist.CLIENT) return; + com.razz.dfashion.client.ClientSharedCosmeticCache.onChunk( + msg.hash(), msg.index(), msg.total(), + msg.bbmodelLen(), msg.width(), msg.height(), + msg.data() + ); + } + } + + /** Slice bytes from the virtual concatenation of {@code a ‖ b} into {@code out}, avoiding a full copy. */ + private static void copySlice(byte[] a, byte[] b, int off, byte[] out) { + int dst = 0; + int len = out.length; + if (off < a.length) { + int take = Math.min(len, a.length - off); + System.arraycopy(a, off, out, dst, take); + dst += take; + len -= take; + off += take; + } + if (len > 0) { + int bOff = off - a.length; + System.arraycopy(b, bOff, out, dst, len); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/razz/dfashion/cosmetic/share/SharedCosmeticCache.java b/src/main/java/com/razz/dfashion/cosmetic/share/SharedCosmeticCache.java new file mode 100644 index 0000000..0b8bce4 --- /dev/null +++ b/src/main/java/com/razz/dfashion/cosmetic/share/SharedCosmeticCache.java @@ -0,0 +1,448 @@ +package com.razz.dfashion.cosmetic.share; + +import com.razz.dfashion.DecoFashion; +import com.razz.dfashion.bbmodel.Bbmodel; +import com.razz.dfashion.cosmetic.CosmeticAttachments; +import com.razz.dfashion.skin.SkinCache; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Server-side shared-cosmetic store. Holds content-addressed {@code .dfcos} blobs + * under {@code /decofashion/cosmetics/.dfcos}. + * + *

Blob layout (big-endian everywhere): + *

+ *   [4 bytes]  magic "DFCO"
+ *   [1 byte]   version (currently 1)
+ *   [4 bytes]  u32 bbmodel-binary length
+ *   [N bytes]  bbmodel binary (per {@link BbmodelCodec})
+ *   [2 bytes]  u16 texture width
+ *   [2 bytes]  u16 texture height
+ *   [M bytes]  deflated RGBA pixels
+ * 
+ * + *

The server never parses JSON or PNG on this path. Uploads arrive as already-encoded + * bbmodel-binary + already-decoded-and-deflated RGBA from the authoring client. Every + * read path validates the magic header before trusting a byte. + */ +public final class SharedCosmeticCache { + + public static final String FILE_EXT = ".dfcos"; + public static final byte[] MAGIC = { 'D', 'F', 'C', 'O' }; + public static final byte VERSION = 1; + + /** magic (4) + version (1) + bbmodelLen (4) + w (2) + h (2) = 13, plus the bbmodel body. */ + public static final int HEADER_FIXED_SIZE = 13; + + /** Cap on the bbmodel binary section of a single blob. Matches the local file cap for + * .bbmodel JSON; binary is usually ~1/3 the size of the source JSON, so this is slack. */ + public static final int MAX_BBMODEL_BIN_BYTES = 16 * 1024 * 1024; + + /** Re-exports texture caps from the skin pipeline — same pixel-budget math applies here. */ + public static final int MAX_RAW_BYTES = SkinCache.MAX_RAW_BYTES; + public static final int MAX_DEFLATED_BYTES = SkinCache.MAX_DEFLATED_BYTES; + public static final int MAX_CHUNK_BYTES = SkinCache.MAX_CHUNK_BYTES; + public static final int MAX_DIM = SkinCache.MAX_DIM; + + private static final String SUBDIR = "decofashion/cosmetics"; + + private static final Map IN_FLIGHT = new ConcurrentHashMap<>(); + + private SharedCosmeticCache() {} + + /** Parsed view of a {@code .dfcos} blob. */ + public record BlobView(byte[] bbmodelBinary, int width, int height, byte[] deflatedRgba) {} + + /** Result of a successful upload — the canonical hash, dims, the canonicalized + * bbmodel binary as stored on disk, and the extracted bone set for wardrobe + * filtering + auto-equip category selection. */ + public record Finalized( + String hash, + int width, + int height, + byte[] canonicalBbmodelBinary, + List bones + ) {} + + /** Per-player upload assembly state. Cleared on error, on finalize, or on explicit cancel. */ + private static final class Assembly { + int expectedTotal; + int nextIndex; + int bbmodelLen; + int width; + int height; + ByteArrayOutputStream buffer; + } + + private static Path root(MinecraftServer server) { + return server.getServerDirectory().resolve(SUBDIR); + } + + /** Same 64-char lowercase-hex rule as skins — rejects {@code ".."}, slashes, null bytes. */ + public static boolean isValidHash(String hash) { + if (hash == null || hash.length() != 64) return false; + for (int i = 0; i < 64; i++) { + char c = hash.charAt(i); + boolean ok = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); + if (!ok) return false; + } + return true; + } + + public static Path fileFor(MinecraftServer server, String hash) { + return root(server).resolve(hash + FILE_EXT); + } + + public static boolean has(MinecraftServer server, String hash) { + return isValidHash(hash) && Files.isRegularFile(fileFor(server, hash)); + } + + public static byte[] read(MinecraftServer server, String hash) throws IOException { + if (!isValidHash(hash)) throw new IOException("invalid hash"); + return Files.readAllBytes(fileFor(server, hash)); + } + + /** + * Validate magic + parse header off a blob. Returns a {@link BlobView} on success, + * {@code null} if the blob is short, has bad magic, or claims lengths that overflow + * the buffer. No trust is extended to the body sections until this passes. + */ + public static BlobView readBlob(byte[] blob) { + if (blob == null || blob.length < HEADER_FIXED_SIZE) return null; + for (int i = 0; i < MAGIC.length; i++) { + if (blob[i] != MAGIC[i]) return null; + } + if (blob[4] != VERSION) return null; + + int bbmodelLen = readU32(blob, 5); + if (bbmodelLen < 0 || bbmodelLen > MAX_BBMODEL_BIN_BYTES) return null; + + int widthOff = 9 + bbmodelLen; + if (widthOff + 4 > blob.length) return null; + + int w = ((blob[widthOff] & 0xFF) << 8) | (blob[widthOff + 1] & 0xFF); + int h = ((blob[widthOff + 2] & 0xFF) << 8) | (blob[widthOff + 3] & 0xFF); + if (w <= 0 || h <= 0 || w > MAX_DIM || h > MAX_DIM) return null; + + byte[] bbmodel = new byte[bbmodelLen]; + System.arraycopy(blob, 9, bbmodel, 0, bbmodelLen); + + int deflatedStart = widthOff + 4; + int deflatedLen = blob.length - deflatedStart; + if (deflatedLen < 0 || deflatedLen > MAX_DEFLATED_BYTES) return null; + byte[] deflated = new byte[deflatedLen]; + System.arraycopy(blob, deflatedStart, deflated, 0, deflatedLen); + + return new BlobView(bbmodel, w, h, deflated); + } + + /** Assemble a wire-format blob. Reverse of {@link #readBlob}. */ + public static byte[] buildBlob(byte[] bbmodelBinary, int width, int height, byte[] deflatedRgba) { + if (bbmodelBinary.length > MAX_BBMODEL_BIN_BYTES) { + throw new IllegalArgumentException("bbmodel binary too large: " + bbmodelBinary.length); + } + if (deflatedRgba.length > MAX_DEFLATED_BYTES) { + throw new IllegalArgumentException("deflated RGBA too large: " + deflatedRgba.length); + } + if (width <= 0 || height <= 0 || width > MAX_DIM || height > MAX_DIM) { + throw new IllegalArgumentException("bad dims " + width + "x" + height); + } + int size = HEADER_FIXED_SIZE + bbmodelBinary.length + deflatedRgba.length; + byte[] out = new byte[size]; + System.arraycopy(MAGIC, 0, out, 0, 4); + out[4] = VERSION; + writeU32(out, 5, bbmodelBinary.length); + System.arraycopy(bbmodelBinary, 0, out, 9, bbmodelBinary.length); + int widthOff = 9 + bbmodelBinary.length; + out[widthOff] = (byte) ((width >>> 8) & 0xFF); + out[widthOff + 1] = (byte) (width & 0xFF); + out[widthOff + 2] = (byte) ((height >>> 8) & 0xFF); + out[widthOff + 3] = (byte) (height & 0xFF); + System.arraycopy(deflatedRgba, 0, out, widthOff + 4, deflatedRgba.length); + return out; + } + + /** + * Hash over {@code [bbmodelBinary || u16 w || u16 h || raw RGBA]}. Canonical across + * deflate levels so identical content dedups regardless of who encoded it. Pass raw + * (inflated) pixels, not the deflate stream. + */ + public static String hashContent(byte[] bbmodelBinary, int width, int height, byte[] rawRgba) + throws NoSuchAlgorithmException { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update(bbmodelBinary); + md.update((byte) ((width >>> 8) & 0xFF)); + md.update((byte) (width & 0xFF)); + md.update((byte) ((height >>> 8) & 0xFF)); + md.update((byte) (height & 0xFF)); + md.update(rawRgba); + byte[] digest = md.digest(); + StringBuilder sb = new StringBuilder(64); + for (byte b : digest) sb.append(String.format("%02x", b)); + return sb.toString(); + } + + /** + * Write a finalized blob to disk. Idempotent: if the hash already exists, no-op. + * Caller is responsible for having already content-hashed and validated the inputs. + */ + public static void writeIfAbsent(MinecraftServer server, String hash, byte[] blob) throws IOException { + if (!isValidHash(hash)) throw new IOException("invalid hash"); + Files.createDirectories(root(server)); + Path target = fileFor(server, hash); + if (!Files.isRegularFile(target)) { + Files.write(target, blob); + } + } + + /** + * Accept one chunk of an upload from the player. Returns the {@link Finalized} result + * once the last chunk lands, or {@code null} while more chunks are expected or if the + * upload was rejected. In-flight state is cleared on any error. + * + *

Security posture per field: + *

    + *
  • {@code index}/{@code total}: in range, sequential, no skipping.
  • + *
  • {@code bbmodelLen}: bounded by {@link #MAX_BBMODEL_BIN_BYTES}; must match chunk 0.
  • + *
  • {@code width}/{@code height}: bounded by {@link #MAX_DIM}; must match chunk 0.
  • + *
  • {@code data.length}: bounded by {@link #MAX_CHUNK_BYTES} (redundant with the wire + * codec cap, kept as defense in depth).
  • + *
  • Cumulative accumulated bytes: bounded by + * {@link #MAX_BBMODEL_BIN_BYTES} + {@link #MAX_DEFLATED_BYTES}.
  • + *
+ * + *

On the final chunk the payload is split at {@code bbmodelLen}, the bbmodel half is + * decoded via {@link BbmodelCodec#CODEC} (which re-runs the full validator) and then + * re-encoded canonically so dedup is stable across authoring clients. The RGBA + * half is inflated under a cap set to {@code w*h*4}. The content hash is computed from + * the canonical bbmodel + dims + raw RGBA and is authoritative — any hash the client + * might separately claim is ignored. + */ + public static Finalized acceptChunk( + MinecraftServer server, UUID playerId, + int index, int total, int bbmodelLen, + int width, int height, byte[] data + ) { + if (total <= 0 || index < 0 || index >= total) { + DecoFashion.LOGGER.warn("Cosmetic upload from {}: invalid chunk {}/{}", playerId, index, total); + IN_FLIGHT.remove(playerId); + return null; + } + if (bbmodelLen <= 0 || bbmodelLen > MAX_BBMODEL_BIN_BYTES) { + DecoFashion.LOGGER.warn("Cosmetic upload from {}: bad bbmodelLen {}", playerId, bbmodelLen); + IN_FLIGHT.remove(playerId); + return null; + } + if (width <= 0 || height <= 0 || width > MAX_DIM || height > MAX_DIM) { + DecoFashion.LOGGER.warn("Cosmetic upload from {}: bad dims {}x{}", playerId, width, height); + IN_FLIGHT.remove(playerId); + return null; + } + if (data == null || data.length > MAX_CHUNK_BYTES) { + DecoFashion.LOGGER.warn("Cosmetic upload from {}: chunk too large ({})", + playerId, data == null ? -1 : data.length); + IN_FLIGHT.remove(playerId); + return null; + } + + Assembly asm; + if (index == 0) { + asm = new Assembly(); + asm.expectedTotal = total; + asm.nextIndex = 0; + asm.bbmodelLen = bbmodelLen; + asm.width = width; + asm.height = height; + asm.buffer = new ByteArrayOutputStream(Math.min( + total * MAX_CHUNK_BYTES, + MAX_BBMODEL_BIN_BYTES + MAX_DEFLATED_BYTES)); + IN_FLIGHT.put(playerId, asm); + } else { + asm = IN_FLIGHT.get(playerId); + if (asm == null || asm.expectedTotal != total || asm.nextIndex != index + || asm.bbmodelLen != bbmodelLen + || asm.width != width || asm.height != height) { + DecoFashion.LOGGER.warn("Cosmetic upload from {}: chunk mismatch at {}/{}", + playerId, index, total); + IN_FLIGHT.remove(playerId); + return null; + } + } + + int maxTotal = MAX_BBMODEL_BIN_BYTES + MAX_DEFLATED_BYTES; + if (asm.buffer.size() + data.length > maxTotal) { + DecoFashion.LOGGER.warn("Cosmetic upload from {}: total cap exceeded", playerId); + IN_FLIGHT.remove(playerId); + return null; + } + try { + asm.buffer.write(data); + } catch (IOException ex) { + DecoFashion.LOGGER.error("Cosmetic upload from {}: buffer write failed", playerId, ex); + IN_FLIGHT.remove(playerId); + return null; + } + asm.nextIndex = index + 1; + + if (index + 1 < total) return null; + + IN_FLIGHT.remove(playerId); + return finalizeUpload(server, playerId, asm); + } + + public static void cancelInFlight(UUID playerId) { + IN_FLIGHT.remove(playerId); + } + + private static Finalized finalizeUpload(MinecraftServer server, UUID playerId, Assembly asm) { + byte[] payload = asm.buffer.toByteArray(); + if (payload.length < asm.bbmodelLen) { + DecoFashion.LOGGER.warn("Cosmetic upload from {}: payload {} shorter than declared bbmodel {}", + playerId, payload.length, asm.bbmodelLen); + return null; + } + + byte[] bbmodelBinReceived = new byte[asm.bbmodelLen]; + System.arraycopy(payload, 0, bbmodelBinReceived, 0, asm.bbmodelLen); + byte[] deflatedRgba = new byte[payload.length - asm.bbmodelLen]; + System.arraycopy(payload, asm.bbmodelLen, deflatedRgba, 0, deflatedRgba.length); + + // Decode + validate (BbmodelCodec re-runs BbModelParser.validate internally). + Bbmodel bbmodel; + ByteBuf in = Unpooled.wrappedBuffer(bbmodelBinReceived); + try { + bbmodel = BbmodelCodec.CODEC.decode(in); + if (in.readableBytes() != 0) { + DecoFashion.LOGGER.warn("Cosmetic upload from {}: {} trailing bytes after bbmodel decode", + playerId, in.readableBytes()); + return null; + } + } catch (RuntimeException ex) { + DecoFashion.LOGGER.warn("Cosmetic upload from {}: bbmodel rejected ({})", + playerId, ex.getMessage()); + return null; + } finally { + in.release(); + } + + // Canonical re-encode so dedup is stable across authoring clients. + byte[] canonicalBbmodelBin; + ByteBuf out = Unpooled.buffer(Math.max(64, bbmodelBinReceived.length)); + try { + BbmodelCodec.CODEC.encode(out, bbmodel); + if (out.readableBytes() > MAX_BBMODEL_BIN_BYTES) { + DecoFashion.LOGGER.warn("Cosmetic upload from {}: canonical bbmodel too large ({})", + playerId, out.readableBytes()); + return null; + } + canonicalBbmodelBin = new byte[out.readableBytes()]; + out.readBytes(canonicalBbmodelBin); + } finally { + out.release(); + } + + int expectedRaw; + try { + expectedRaw = SkinCache.rgbaByteCount(asm.width, asm.height); + } catch (IllegalArgumentException ex) { + DecoFashion.LOGGER.warn("Cosmetic upload from {}: {}", playerId, ex.getMessage()); + return null; + } + byte[] rgba; + try { + rgba = SkinCache.inflateBounded(deflatedRgba, expectedRaw); + } catch (IOException ex) { + DecoFashion.LOGGER.warn("Cosmetic upload from {}: inflate failed ({})", + playerId, ex.getMessage()); + return null; + } + + String hash; + try { + hash = hashContent(canonicalBbmodelBin, asm.width, asm.height, rgba); + } catch (NoSuchAlgorithmException ex) { + DecoFashion.LOGGER.error("SHA-256 unavailable", ex); + return null; + } + + // Library-size gate — duplicates are free, new entries respect MAX_ENTRIES. + ServerPlayer player = server.getPlayerList().getPlayer(playerId); + if (player == null) { + DecoFashion.LOGGER.warn("Cosmetic upload from {}: player disconnected before finalize", playerId); + return null; + } + CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get()); + if (!lib.contains(hash) && lib.isFull()) { + DecoFashion.LOGGER.warn("Cosmetic upload from {}: library full ({}); rejecting {}", + playerId, CosmeticLibrary.MAX_ENTRIES, hash); + return null; + } + + byte[] blob = buildBlob(canonicalBbmodelBin, asm.width, asm.height, deflatedRgba); + try { + writeIfAbsent(server, hash, blob); + } catch (IOException ex) { + DecoFashion.LOGGER.error("Cosmetic upload from {}: disk write failed for {}", + playerId, hash, ex); + return null; + } + + List bones = BoneExtraction.fromBbmodel(bbmodel); + + DecoFashion.LOGGER.info("Cosmetic upload finalized: player={} hash={} dims={}x{} bbmodel={}B deflated={}B bones={}", + playerId, hash, asm.width, asm.height, canonicalBbmodelBin.length, deflatedRgba.length, bones); + return new Finalized(hash, asm.width, asm.height, canonicalBbmodelBin, bones); + } + + /** Enumerate stored hashes with their on-disk sizes — useful for admin tooling. */ + public static Map listCached(MinecraftServer server) { + Map out = new HashMap<>(); + Path dir = root(server); + if (!Files.isDirectory(dir)) return out; + try (var stream = Files.newDirectoryStream(dir, "*" + FILE_EXT)) { + for (Path p : stream) { + String name = p.getFileName().toString(); + String hash = name.substring(0, name.length() - FILE_EXT.length()); + if (!isValidHash(hash)) continue; + try { + out.put(hash, Files.size(p)); + } catch (IOException ignored) {} + } + } catch (IOException ex) { + DecoFashion.LOGGER.warn("SharedCosmeticCache.listCached failed: {}", ex.getMessage()); + } + return out; + } + + // ---- helpers ---- + + private static int readU32(byte[] b, int p) { + return ((b[p] & 0xFF) << 24) + | ((b[p + 1] & 0xFF) << 16) + | ((b[p + 2] & 0xFF) << 8) + | (b[p + 3] & 0xFF); + } + + private static void writeU32(byte[] b, int p, int v) { + b[p] = (byte) ((v >>> 24) & 0xFF); + b[p + 1] = (byte) ((v >>> 16) & 0xFF); + b[p + 2] = (byte) ((v >>> 8) & 0xFF); + b[p + 3] = (byte) (v & 0xFF); + } +} \ No newline at end of file diff --git a/src/main/java/com/razz/dfashion/cosmetic/share/packet/AssignSharedCosmetic.java b/src/main/java/com/razz/dfashion/cosmetic/share/packet/AssignSharedCosmetic.java new file mode 100644 index 0000000..5d77afb --- /dev/null +++ b/src/main/java/com/razz/dfashion/cosmetic/share/packet/AssignSharedCosmetic.java @@ -0,0 +1,33 @@ +package com.razz.dfashion.cosmetic.share.packet; + +import com.razz.dfashion.DecoFashion; + +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +/** + * Client → server. Equip a shared-library cosmetic in {@code slot}. Server validates: + * hash shape, hash membership in the caller's own library, and then writes a + * {@code CosmeticRef.Shared} into the player's EQUIPPED attachment. An empty hash + * unsets the slot. + */ +public record AssignSharedCosmetic(String slot, String hash) implements CustomPacketPayload { + + public static final Type TYPE = + new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "assign_shared_cosmetic")); + + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + ByteBufCodecs.stringUtf8(64), AssignSharedCosmetic::slot, + ByteBufCodecs.stringUtf8(64), AssignSharedCosmetic::hash, + AssignSharedCosmetic::new + ); + + @Override + public Type type() { + return TYPE; + } +} \ No newline at end of file diff --git a/src/main/java/com/razz/dfashion/cosmetic/share/packet/CosmeticChunk.java b/src/main/java/com/razz/dfashion/cosmetic/share/packet/CosmeticChunk.java new file mode 100644 index 0000000..d764f42 --- /dev/null +++ b/src/main/java/com/razz/dfashion/cosmetic/share/packet/CosmeticChunk.java @@ -0,0 +1,49 @@ +package com.razz.dfashion.cosmetic.share.packet; + +import com.razz.dfashion.DecoFashion; +import com.razz.dfashion.cosmetic.share.SharedCosmeticCache; + +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +/** + * Server → client. One chunk of a stored shared cosmetic being streamed down in response + * to a {@link RequestCosmetic}. Shape mirrors {@link UploadCosmeticChunk} with an extra + * {@code hash} field so the receiving client can route to the right in-flight download. + * + *

Same wire-safety properties as the upload path: no PNG, no JSON. Just + * {@code [bbmodel binary][deflated RGBA]} with dims metadata per chunk. + */ +public record CosmeticChunk( + String hash, + int index, + int total, + int bbmodelLen, + int width, + int height, + byte[] data +) implements CustomPacketPayload { + + public static final Type TYPE = + new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "cosmetic_chunk")); + + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + ByteBufCodecs.stringUtf8(64), CosmeticChunk::hash, + ByteBufCodecs.VAR_INT, CosmeticChunk::index, + ByteBufCodecs.VAR_INT, CosmeticChunk::total, + ByteBufCodecs.VAR_INT, CosmeticChunk::bbmodelLen, + ByteBufCodecs.VAR_INT, CosmeticChunk::width, + ByteBufCodecs.VAR_INT, CosmeticChunk::height, + ByteBufCodecs.byteArray(SharedCosmeticCache.MAX_CHUNK_BYTES),CosmeticChunk::data, + CosmeticChunk::new + ); + + @Override + public Type type() { + return TYPE; + } +} \ No newline at end of file diff --git a/src/main/java/com/razz/dfashion/cosmetic/share/packet/DeleteCosmetic.java b/src/main/java/com/razz/dfashion/cosmetic/share/packet/DeleteCosmetic.java new file mode 100644 index 0000000..4a284fb --- /dev/null +++ b/src/main/java/com/razz/dfashion/cosmetic/share/packet/DeleteCosmetic.java @@ -0,0 +1,31 @@ +package com.razz.dfashion.cosmetic.share.packet; + +import com.razz.dfashion.DecoFashion; + +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +/** + * Client → server. Remove {@code hash} from the caller's personal shared-cosmetic library. + * Only affects the caller's own library — cannot reach into other players' libraries. + * Unknown hashes are silently ignored (idempotent). + */ +public record DeleteCosmetic(String hash) implements CustomPacketPayload { + + public static final Type TYPE = + new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "delete_cosmetic")); + + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + ByteBufCodecs.stringUtf8(64), DeleteCosmetic::hash, + DeleteCosmetic::new + ); + + @Override + public Type type() { + return TYPE; + } +} \ No newline at end of file diff --git a/src/main/java/com/razz/dfashion/cosmetic/share/packet/RequestCosmetic.java b/src/main/java/com/razz/dfashion/cosmetic/share/packet/RequestCosmetic.java new file mode 100644 index 0000000..e061bd5 --- /dev/null +++ b/src/main/java/com/razz/dfashion/cosmetic/share/packet/RequestCosmetic.java @@ -0,0 +1,31 @@ +package com.razz.dfashion.cosmetic.share.packet; + +import com.razz.dfashion.DecoFashion; + +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +/** + * Client → server. Asks the server to stream back the blob for {@code hash}. Server + * rejects on invalid or unknown hashes — the hash is validated via + * {@code SharedCosmeticCache.isValidHash} before any filesystem access. + */ +public record RequestCosmetic(String hash) implements CustomPacketPayload { + + public static final Type TYPE = + new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "request_cosmetic")); + + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + ByteBufCodecs.stringUtf8(64), RequestCosmetic::hash, + RequestCosmetic::new + ); + + @Override + public Type type() { + return TYPE; + } +} \ No newline at end of file diff --git a/src/main/java/com/razz/dfashion/cosmetic/share/packet/UploadCosmeticChunk.java b/src/main/java/com/razz/dfashion/cosmetic/share/packet/UploadCosmeticChunk.java new file mode 100644 index 0000000..5f1e03c --- /dev/null +++ b/src/main/java/com/razz/dfashion/cosmetic/share/packet/UploadCosmeticChunk.java @@ -0,0 +1,49 @@ +package com.razz.dfashion.cosmetic.share.packet; + +import com.razz.dfashion.DecoFashion; +import com.razz.dfashion.cosmetic.share.SharedCosmeticCache; + +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +/** + * Client → server. One chunk of a shared-cosmetic upload. The payload bytes are the + * concatenation {@code [bbmodel binary][deflated RGBA]}; the authoring client knows both + * halves up-front so {@code bbmodelLen}, {@code width}, and {@code height} are repeated + * on every chunk for defense-in-depth mismatch detection on the server. + * + *

No PNG container and no JSON ever crosses this wire. The bbmodel binary is produced + * by {@link com.razz.dfashion.cosmetic.share.BbmodelCodec} on the author's machine; the + * RGBA has already been decoded by {@code SafePngReader} and deflated locally. + */ +public record UploadCosmeticChunk( + int index, + int total, + int bbmodelLen, + int width, + int height, + byte[] data +) implements CustomPacketPayload { + + public static final Type TYPE = + new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "upload_cosmetic_chunk")); + + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + ByteBufCodecs.VAR_INT, UploadCosmeticChunk::index, + ByteBufCodecs.VAR_INT, UploadCosmeticChunk::total, + ByteBufCodecs.VAR_INT, UploadCosmeticChunk::bbmodelLen, + ByteBufCodecs.VAR_INT, UploadCosmeticChunk::width, + ByteBufCodecs.VAR_INT, UploadCosmeticChunk::height, + ByteBufCodecs.byteArray(SharedCosmeticCache.MAX_CHUNK_BYTES),UploadCosmeticChunk::data, + UploadCosmeticChunk::new + ); + + @Override + public Type type() { + return TYPE; + } +} \ No newline at end of file diff --git a/src/main/java/com/razz/dfashion/skin/SafePngReader.java b/src/main/java/com/razz/dfashion/skin/SafePngReader.java new file mode 100644 index 0000000..5002aa4 --- /dev/null +++ b/src/main/java/com/razz/dfashion/skin/SafePngReader.java @@ -0,0 +1,517 @@ +package com.razz.dfashion.skin; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.zip.CRC32; +import java.util.zip.DataFormatException; +import java.util.zip.Inflater; + +/** + * Memory-safe, pure-Java PNG decoder restricted to 8-bit color-type-6 (RGBA) images. + * + *

Verifies the PNG magic, parses IHDR, enforces a hard dimension cap, whitelists only + * {@code IHDR/IDAT/IEND} chunks, checks every CRC-32, rejects any trailing bytes past IEND, + * inflates the IDAT stream with a cumulative output cap equal to the declared pixel budget, + * and applies the standard PNG unfilter (None/Sub/Up/Average/Paeth). + * + *

This is the only PNG parser on the sync path — called on the uploading client to turn + * a user-supplied {@code .png} into raw RGBA pixels that then travel over the wire. STB + * (via {@code NativeImage.read}) is never invoked with untrusted bytes. The rejected-chunk + * whitelist, CRC check, and trailing-byte rule together eliminate pdvzip-style polyglot + * PNG/ZIP/JAR payloads: any byte that isn't part of a valid {@code IHDR/IDAT/IEND} chunk + * triggers a {@link BadPngException}. + * + *

Zero dependencies beyond {@code java.util.zip}. + */ +public final class SafePngReader { + + public static final int MAX_DIM = 4096; + + /** Cap on a single chunk's declared length (128 MB). Defensive; real chunks are tiny. */ + private static final int MAX_CHUNK_BYTES = 1 << 27; + + private static final byte[] PNG_SIGNATURE = + { (byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }; + + private static final int TYPE_IHDR = 0x49484452; + private static final int TYPE_IDAT = 0x49444154; + private static final int TYPE_IEND = 0x49454E44; + private static final int TYPE_PLTE = 0x504C5445; + private static final int TYPE_tRNS = 0x74524E53; + + private static final int COLOR_GRAYSCALE = 0; + private static final int COLOR_RGB = 2; + private static final int COLOR_PALETTE = 3; + private static final int COLOR_GRAYSCALE_ALPHA = 4; + private static final int COLOR_RGBA = 6; + + public static final class Image { + public final int width; + public final int height; + /** Tightly packed RGBA, length == width * height * 4. */ + public final byte[] rgba; + + public Image(int width, int height, byte[] rgba) { + this.width = width; + this.height = height; + this.rgba = rgba; + } + } + + public static final class BadPngException extends IOException { + public BadPngException(String message) { super(message); } + } + + private SafePngReader() {} + + public static Image decode(byte[] src) throws IOException { + if (src == null || src.length < PNG_SIGNATURE.length) { + throw new BadPngException("too short"); + } + for (int i = 0; i < PNG_SIGNATURE.length; i++) { + if (src[i] != PNG_SIGNATURE[i]) throw new BadPngException("bad PNG signature"); + } + + int width = -1, height = -1, bitDepth = 0, colorType = -1; + byte[] palette = null; // PLTE: length is multiple of 3, up to 768 bytes (256 × RGB) + byte[] trnsAlpha = null; // tRNS: up to paletteEntries bytes (1 alpha per index) + boolean sawIHDR = false, sawIEND = false, sawIDAT = false; + ByteArrayOutputStream idat = new ByteArrayOutputStream(); + CRC32 crc = new CRC32(); + + int p = PNG_SIGNATURE.length; + while (p < src.length) { + if (p + 8 > src.length) throw new BadPngException("truncated chunk header"); + + int len = readU32(src, p); + int type = readU32(src, p + 4); + if (len < 0 || len > MAX_CHUNK_BYTES) { + throw new BadPngException("chunk length out of range: " + (len & 0xFFFFFFFFL)); + } + int dataStart = p + 8; + int dataEnd = dataStart + len; + int crcEnd = dataEnd + 4; + if (crcEnd > src.length) throw new BadPngException("truncated chunk body"); + + crc.reset(); + crc.update(src, p + 4, 4 + len); + int actual = (int) crc.getValue(); + int declared = readU32(src, dataEnd); + if (actual != declared) { + throw new BadPngException("bad CRC on chunk " + chunkName(type)); + } + + if (sawIEND) throw new BadPngException("trailing chunk after IEND"); + + switch (type) { + case TYPE_IHDR: { + if (sawIHDR) throw new BadPngException("duplicate IHDR"); + if (len != 13) throw new BadPngException("bad IHDR length"); + width = readU32(src, dataStart); + height = readU32(src, dataStart + 4); + bitDepth = src[dataStart + 8] & 0xFF; + colorType = src[dataStart + 9] & 0xFF; + int compression = src[dataStart + 10] & 0xFF; + int filter = src[dataStart + 11] & 0xFF; + int interlace = src[dataStart + 12] & 0xFF; + if (width <= 0 || height <= 0 || width > MAX_DIM || height > MAX_DIM) { + throw new BadPngException("bad dimensions " + width + "x" + height); + } + // Accept the three color types common in authored skins/cosmetics: + // - Type 2 (RGB, no alpha) depths 8 / 16 — alpha synthesized as 255 + // - Type 3 (palette/indexed) depths 1/2/4/8 — PLTE + optional tRNS + // - Type 6 (RGBA) depths 8 / 16 — native + // Types 0 (grayscale) and 4 (grayscale+alpha) are rejected — rare for + // textures, and supporting them would add parsing for edge cases no + // skin editor actually produces. 16-bit channels are downconverted per + // row during unfilter by keeping the high byte of each 16-bit sample. + if (!isSupportedDepthForColor(colorType, bitDepth)) { + throw new BadPngException( + "unsupported PNG format: color type " + colorType + + " at " + bitDepth + "-bit depth"); + } + if (compression != 0) throw new BadPngException("bad compression method"); + if (filter != 0) throw new BadPngException("bad filter method"); + if (interlace != 0) throw new BadPngException("interlaced PNGs not supported"); + sawIHDR = true; + break; + } + case TYPE_PLTE: { + if (!sawIHDR) throw new BadPngException("PLTE before IHDR"); + if (sawIDAT) throw new BadPngException("PLTE after IDAT"); + if (palette != null) throw new BadPngException("duplicate PLTE"); + if (len == 0 || len > 768 || (len % 3) != 0) { + throw new BadPngException("bad PLTE length: " + len); + } + palette = new byte[len]; + System.arraycopy(src, dataStart, palette, 0, len); + break; + } + case TYPE_tRNS: { + if (!sawIHDR) throw new BadPngException("tRNS before IHDR"); + if (sawIDAT) throw new BadPngException("tRNS after IDAT"); + if (colorType != COLOR_PALETTE) { + // tRNS is also defined for types 0 and 2 (single transparent value), + // but we don't support those color types, so the chunk is nonsense. + throw new BadPngException("tRNS only supported on palette images"); + } + if (palette == null) throw new BadPngException("tRNS before PLTE"); + int paletteEntries = palette.length / 3; + if (len > paletteEntries) { + throw new BadPngException("tRNS longer than palette"); + } + trnsAlpha = new byte[len]; + System.arraycopy(src, dataStart, trnsAlpha, 0, len); + break; + } + case TYPE_IDAT: { + if (!sawIHDR) throw new BadPngException("IDAT before IHDR"); + if (colorType == COLOR_PALETTE && palette == null) { + throw new BadPngException("IDAT before PLTE on palette image"); + } + sawIDAT = true; + idat.write(src, dataStart, len); + break; + } + case TYPE_IEND: { + if (!sawIHDR) throw new BadPngException("IEND before IHDR"); + if (len != 0) throw new BadPngException("IEND must be empty"); + sawIEND = true; + break; + } + default: + // Ancillary chunks (per PNG spec: first letter lowercase, bit 5 of high byte set) + // are safe to skip. Their bytes never leave the uploading client — only pixels do — + // so polyglot/iCCP/trailing-IDAT payloads can't cross the wire regardless. Reject + // only unknown *critical* chunks. + if ((type & 0x20000000) == 0) { + throw new BadPngException("disallowed critical chunk " + chunkName(type)); + } + break; + } + + p = crcEnd; + } + + if (!sawIHDR) throw new BadPngException("missing IHDR"); + if (!sawIEND) throw new BadPngException("missing IEND"); + if (p != src.length) throw new BadPngException("trailing bytes after IEND"); + + if (colorType == COLOR_PALETTE && palette == null) { + throw new BadPngException("palette image missing PLTE"); + } + + // PNG filtering is byte-level and uses ceil(bits-per-pixel / 8), min 1, as the + // stride for Sub/Paeth/Average. Scanline byte count is ceil(width * bits-per-pixel / 8). + int filterBpp = filterBpp(colorType, bitDepth); + long rowBytes = scanlineBytes(width, colorType, bitDepth); + + // width/height are both validated to MAX_DIM above; 64-bit math keeps a future + // MAX_DIM bump from silently overflowing int into a negative allocation. + long rawLenL = (long) height * (1L + rowBytes); + if (rawLenL <= 0 || rawLenL > MAX_RAW_SCANLINE_BYTES) { + throw new BadPngException("raw scanline budget out of range: " + rawLenL); + } + int rawLen = (int) rawLenL; + + byte[] raw = inflateBounded(idat.toByteArray(), rawLen); + byte[] rgba = unfilter(raw, width, height, + filterBpp, (int) rowBytes, + colorType, bitDepth, palette, trnsAlpha); + return new Image(width, height, rgba); + } + + private static boolean isSupportedDepthForColor(int colorType, int bitDepth) { + return switch (colorType) { + case COLOR_GRAYSCALE -> bitDepth == 1 || bitDepth == 2 || bitDepth == 4 + || bitDepth == 8 || bitDepth == 16; + case COLOR_RGB, COLOR_RGBA, COLOR_GRAYSCALE_ALPHA -> bitDepth == 8 || bitDepth == 16; + case COLOR_PALETTE -> bitDepth == 1 || bitDepth == 2 || bitDepth == 4 || bitDepth == 8; + default -> false; + }; + } + + private static int filterBpp(int colorType, int bitDepth) { + // ceil(bits-per-pixel / 8), min 1. Palette and sub-byte grayscale both use 1. + return switch (colorType) { + case COLOR_GRAYSCALE -> Math.max(1, bitDepth / 8); // 1 for 1/2/4/8, 2 for 16 + case COLOR_GRAYSCALE_ALPHA -> 2 * (bitDepth / 8); // 2 or 4 + case COLOR_RGB -> 3 * (bitDepth / 8); // 3 or 6 + case COLOR_RGBA -> 4 * (bitDepth / 8); // 4 or 8 + case COLOR_PALETTE -> 1; + default -> throw new IllegalStateException("unreachable"); + }; + } + + private static long scanlineBytes(int width, int colorType, int bitDepth) { + return switch (colorType) { + case COLOR_GRAYSCALE -> ((long) width * bitDepth + 7L) / 8L; + case COLOR_GRAYSCALE_ALPHA -> (long) width * 2L * (bitDepth / 8); + case COLOR_RGB -> (long) width * 3L * (bitDepth / 8); + case COLOR_RGBA -> (long) width * 4L * (bitDepth / 8); + case COLOR_PALETTE -> ((long) width * bitDepth + 7L) / 8L; + default -> throw new IllegalStateException("unreachable"); + }; + } + + /** {@code MAX_DIM * (1 + MAX_DIM * 8)} — the largest legal {@code rawLen} across both + * supported bit depths (8 = 4 bpp, 16 = 8 bpp). */ + private static final long MAX_RAW_SCANLINE_BYTES = (long) MAX_DIM * (1L + (long) MAX_DIM * 8L); + + private static byte[] inflateBounded(byte[] compressed, int expected) throws IOException { + Inflater inf = new Inflater(); + try { + inf.setInput(compressed); + byte[] out = new byte[expected]; + int written = 0; + byte[] scratch = new byte[64 * 1024]; + while (true) { + int n; + try { + n = inf.inflate(scratch); + } catch (DataFormatException ex) { + throw new BadPngException("zlib decode error: " + ex.getMessage()); + } + if (n == 0) { + if (inf.finished()) break; + if (inf.needsInput() || inf.needsDictionary()) { + throw new BadPngException("zlib stream truncated"); + } + break; + } + if (written + n > expected) { + throw new BadPngException("inflated size exceeds pixel budget"); + } + System.arraycopy(scratch, 0, out, written, n); + written += n; + } + if (written != expected) { + throw new BadPngException("inflated size mismatch: " + written + " != " + expected); + } + return out; + } finally { + inf.end(); + } + } + + /** + * Unfilter each scanline and emit 8-bit RGBA. The PNG filter algorithm is purely + * byte-level and uses {@code filterBpp} (ceil of bits-per-pixel / 8) as the stride + * for Sub/Average/Paeth — it's indifferent to the color interpretation. The per-row + * conversion from raw bytes to 8-bit RGBA is where color type, bit depth, palette, + * and transparency come in. We do that conversion inline against a reusable row + * buffer so the peak allocation is the final RGBA output plus two scanline buffers. + */ + private static byte[] unfilter(byte[] raw, int width, int height, + int filterBpp, int rowBytes, + int colorType, int bitDepth, + byte[] palette, byte[] trnsAlpha) throws IOException { + long outLenL = (long) width * (long) height * 4L; + if (outLenL <= 0 || outLenL > (long) MAX_DIM * (long) MAX_DIM * 4L) { + throw new BadPngException("unfilter output size out of range: " + outLenL); + } + byte[] out = new byte[(int) outLenL]; + byte[] prev = new byte[rowBytes]; + byte[] curr = new byte[rowBytes]; + + int rp = 0; + int outPos = 0; + for (int y = 0; y < height; y++) { + int filter = raw[rp++] & 0xFF; + System.arraycopy(raw, rp, curr, 0, rowBytes); + rp += rowBytes; + + switch (filter) { + case 0: + break; + case 1: + for (int x = filterBpp; x < rowBytes; x++) { + curr[x] = (byte) ((curr[x] & 0xFF) + (curr[x - filterBpp] & 0xFF)); + } + break; + case 2: + for (int x = 0; x < rowBytes; x++) { + curr[x] = (byte) ((curr[x] & 0xFF) + (prev[x] & 0xFF)); + } + break; + case 3: + for (int x = 0; x < rowBytes; x++) { + int left = (x >= filterBpp) ? (curr[x - filterBpp] & 0xFF) : 0; + int up = prev[x] & 0xFF; + curr[x] = (byte) ((curr[x] & 0xFF) + ((left + up) >>> 1)); + } + break; + case 4: + for (int x = 0; x < rowBytes; x++) { + int left = (x >= filterBpp) ? (curr[x - filterBpp] & 0xFF) : 0; + int up = prev[x] & 0xFF; + int upLeft = (x >= filterBpp) ? (prev[x - filterBpp] & 0xFF) : 0; + curr[x] = (byte) ((curr[x] & 0xFF) + paeth(left, up, upLeft)); + } + break; + default: + throw new BadPngException("unknown filter " + filter + " at row " + y); + } + + outPos = emitRowAsRgba(curr, width, colorType, bitDepth, palette, trnsAlpha, out, outPos); + byte[] swap = prev; prev = curr; curr = swap; + } + return out; + } + + /** + * Convert one unfiltered scanline {@code curr} into {@code width} RGBA pixels and + * write them into {@code out} starting at {@code outPos}. Returns the new {@code outPos}. + */ + private static int emitRowAsRgba(byte[] curr, int width, + int colorType, int bitDepth, + byte[] palette, byte[] trnsAlpha, + byte[] out, int outPos) throws IOException { + switch (colorType) { + case COLOR_RGBA -> { + if (bitDepth == 8) { + System.arraycopy(curr, 0, out, outPos, width * 4); + outPos += width * 4; + } else { + // 16-bit: high byte of each 2-byte big-endian sample. + for (int x = 0, src = 0; x < width; x++) { + out[outPos++] = curr[src]; src += 2; + out[outPos++] = curr[src]; src += 2; + out[outPos++] = curr[src]; src += 2; + out[outPos++] = curr[src]; src += 2; + } + } + } + case COLOR_RGB -> { + if (bitDepth == 8) { + for (int x = 0, src = 0; x < width; x++) { + out[outPos++] = curr[src++]; + out[outPos++] = curr[src++]; + out[outPos++] = curr[src++]; + out[outPos++] = (byte) 0xFF; + } + } else { + for (int x = 0, src = 0; x < width; x++) { + out[outPos++] = curr[src]; src += 2; + out[outPos++] = curr[src]; src += 2; + out[outPos++] = curr[src]; src += 2; + out[outPos++] = (byte) 0xFF; + } + } + } + case COLOR_PALETTE -> { + int paletteEntries = palette.length / 3; + int trnsLen = trnsAlpha != null ? trnsAlpha.length : 0; + for (int x = 0; x < width; x++) { + int index = readPackedSample(curr, x, bitDepth); + if (index >= paletteEntries) { + throw new BadPngException( + "palette index " + index + " out of range (" + paletteEntries + ")"); + } + int p = index * 3; + out[outPos++] = palette[p]; + out[outPos++] = palette[p + 1]; + out[outPos++] = palette[p + 2]; + out[outPos++] = (index < trnsLen) ? trnsAlpha[index] : (byte) 0xFF; + } + } + case COLOR_GRAYSCALE -> { + if (bitDepth == 16) { + for (int x = 0, src = 0; x < width; x++) { + byte g = curr[src]; src += 2; // high byte of 16-bit sample + out[outPos++] = g; + out[outPos++] = g; + out[outPos++] = g; + out[outPos++] = (byte) 0xFF; + } + } else if (bitDepth == 8) { + for (int x = 0; x < width; x++) { + byte g = curr[x]; + out[outPos++] = g; + out[outPos++] = g; + out[outPos++] = g; + out[outPos++] = (byte) 0xFF; + } + } else { + // 1/2/4-bit: scale sub-byte value to full 8-bit range via bit replication. + // 1-bit → ×0xFF, 2-bit → ×0x55, 4-bit → ×0x11. Equivalent to + // ((1 << 8) - 1) / ((1 << bitDepth) - 1) — fills with repeated pattern. + int scale = 0xFF / ((1 << bitDepth) - 1); + for (int x = 0; x < width; x++) { + byte g = (byte) (readPackedSample(curr, x, bitDepth) * scale); + out[outPos++] = g; + out[outPos++] = g; + out[outPos++] = g; + out[outPos++] = (byte) 0xFF; + } + } + } + case COLOR_GRAYSCALE_ALPHA -> { + if (bitDepth == 8) { + for (int x = 0, src = 0; x < width; x++) { + byte g = curr[src++]; + byte a = curr[src++]; + out[outPos++] = g; + out[outPos++] = g; + out[outPos++] = g; + out[outPos++] = a; + } + } else { + // 16-bit: high byte of each 2-byte sample. + for (int x = 0, src = 0; x < width; x++) { + byte g = curr[src]; src += 2; + byte a = curr[src]; src += 2; + out[outPos++] = g; + out[outPos++] = g; + out[outPos++] = g; + out[outPos++] = a; + } + } + } + default -> throw new IllegalStateException("unreachable"); + } + return outPos; + } + + /** + * Extract an MSB-first packed sample at pixel {@code x} for bit depths 1/2/4/8. + * Used for both palette indices and sub-byte grayscale. + */ + private static int readPackedSample(byte[] row, int x, int bitDepth) { + if (bitDepth == 8) { + return row[x] & 0xFF; + } + int pixelsPerByte = 8 / bitDepth; + int byteIndex = x / pixelsPerByte; + int withinByte = x % pixelsPerByte; + int shift = (pixelsPerByte - 1 - withinByte) * bitDepth; + int mask = (1 << bitDepth) - 1; + return (row[byteIndex] >> shift) & mask; + } + + private static int paeth(int a, int b, int c) { + int p = a + b - c; + int pa = Math.abs(p - a); + int pb = Math.abs(p - b); + int pc = Math.abs(p - c); + if (pa <= pb && pa <= pc) return a; + if (pb <= pc) return b; + return c; + } + + private static int readU32(byte[] b, int p) { + return ((b[p] & 0xFF) << 24) + | ((b[p + 1] & 0xFF) << 16) + | ((b[p + 2] & 0xFF) << 8) + | (b[p + 3] & 0xFF); + } + + private static String chunkName(int type) { + return new String(new char[] { + (char) ((type >> 24) & 0xFF), + (char) ((type >> 16) & 0xFF), + (char) ((type >> 8) & 0xFF), + (char) (type & 0xFF) + }); + } +} \ No newline at end of file diff --git a/src/main/java/com/razz/dfashion/skin/SkinAttachments.java b/src/main/java/com/razz/dfashion/skin/SkinAttachments.java index 18712b7..01ab9d6 100644 --- a/src/main/java/com/razz/dfashion/skin/SkinAttachments.java +++ b/src/main/java/com/razz/dfashion/skin/SkinAttachments.java @@ -2,6 +2,7 @@ package com.razz.dfashion.skin; import com.razz.dfashion.DecoFashion; +import net.minecraft.server.level.ServerPlayer; import net.neoforged.neoforge.attachment.AttachmentType; import net.neoforged.neoforge.registries.DeferredHolder; import net.neoforged.neoforge.registries.DeferredRegister; @@ -12,6 +13,7 @@ public final class SkinAttachments { public static final DeferredRegister> ATTACHMENT_TYPES = DeferredRegister.create(NeoForgeRegistries.ATTACHMENT_TYPES, DecoFashion.MODID); + /** Currently-equipped skin — visible to all trackers so everyone can render it. */ public static final DeferredHolder, AttachmentType> DATA = ATTACHMENT_TYPES.register( "skin_data", @@ -22,5 +24,21 @@ public final class SkinAttachments { .build() ); + /** Personal library of up to 5 uploaded skins. Synced only to the owning player + * so other clients can't see what's in someone else's wardrobe. */ + public static final DeferredHolder, AttachmentType> LIBRARY = + ATTACHMENT_TYPES.register( + "skin_library", + () -> AttachmentType.builder(() -> SkinLibrary.EMPTY) + .serialize(SkinLibrary.CODEC.fieldOf("library")) + .sync( + (holder, to) -> holder instanceof ServerPlayer sp + && sp.getUUID().equals(to.getUUID()), + SkinLibrary.STREAM_CODEC + ) + .copyOnDeath() + .build() + ); + private SkinAttachments() {} -} +} \ No newline at end of file diff --git a/src/main/java/com/razz/dfashion/skin/SkinCache.java b/src/main/java/com/razz/dfashion/skin/SkinCache.java index ef77b22..211a902 100644 --- a/src/main/java/com/razz/dfashion/skin/SkinCache.java +++ b/src/main/java/com/razz/dfashion/skin/SkinCache.java @@ -3,6 +3,7 @@ package com.razz.dfashion.skin; import com.razz.dfashion.DecoFashion; import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -14,15 +15,44 @@ import java.util.HashMap; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.zip.DataFormatException; +import java.util.zip.Inflater; /** - * Server-side skin store. Keeps PNG bytes on disk under {@code /decofashion/skins/.png}, - * and tracks in-flight multi-chunk uploads per player. + * Server-side skin store. Holds pixel blobs on disk under + * {@code /decofashion/skins/.dfskin} with an 8-byte header + * {@code [4-byte magic "DFSK"][u16 width][u16 height]} followed by the deflated RGBA payload. + * + *

The server never parses PNG. Uploads arrive as already-decoded, deflated RGBA from the + * uploading client (which ran a memory-safe pure-Java parser); the server only reassembles + * chunks, stream-inflates with a cumulative cap to compute the content hash, and writes the + * blob. Dedicated servers are therefore immune to PNG CVEs by construction, and the stored + * bytes aren't a valid PNG/ZIP/JAR under any rename — there is no container. The + * {@code .dfskin} extension has no OS handler, and the magic header lets every read path + * reject anything that isn't our exact format. */ public final class SkinCache { - public static final int MAX_BYTES = 32 * 1024 * 1024; // 32 MB - public static final int MAX_CHUNK_BYTES = 512 * 1024; // 512 KB + /** Hard ceiling on raw RGBA size (4096² × 4 bytes = 64 MB). Per-image cap derives from dims. */ + public static final int MAX_RAW_BYTES = 4096 * 4096 * 4; + + /** Per-wire-packet cap: keeps single packets well under the channel MTU. */ + public static final int MAX_CHUNK_BYTES = 512 * 1024; + + /** Total deflated bytes we'll accept for a single upload/download. */ + public static final int MAX_DEFLATED_BYTES = 32 * 1024 * 1024; + + /** Matches {@link SafePngReader#MAX_DIM}. */ + public static final int MAX_DIM = SafePngReader.MAX_DIM; + + /** Proprietary file-format extension. No OS or tool has a handler for it. */ + public static final String FILE_EXT = ".dfskin"; + + /** "DFSK" in ASCII — prepended to every blob; every read validates it before trusting bytes. */ + public static final byte[] MAGIC = { 'D', 'F', 'S', 'K' }; + + /** Magic (4) + width u16 (2) + height u16 (2) = 8. */ + public static final int HEADER_SIZE = 8; private static final String SUBDIR = "decofashion/skins"; @@ -34,6 +64,8 @@ public final class SkinCache { private static final class Assembly { int expectedTotal; int nextIndex; + int width; + int height; SkinModel model; ByteArrayOutputStream buffer; } @@ -42,26 +74,58 @@ public final class SkinCache { return server.getServerDirectory().resolve(SUBDIR); } + /** + * Whitelist check on hashes coming off the wire. Rejects anything that isn't exactly + * 64 lowercase hex chars — which means {@code ".."}, slashes, null-bytes, and every + * other path-traversal trick can't even reach the filesystem. + */ + public static boolean isValidHash(String hash) { + if (hash == null || hash.length() != 64) return false; + for (int i = 0; i < 64; i++) { + char c = hash.charAt(i); + boolean ok = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); + if (!ok) return false; + } + return true; + } + public static Path fileFor(MinecraftServer server, String hash) { - return root(server).resolve(hash + ".png"); + return root(server).resolve(hash + FILE_EXT); } public static boolean has(MinecraftServer server, String hash) { - return Files.isRegularFile(fileFor(server, hash)); + return isValidHash(hash) && Files.isRegularFile(fileFor(server, hash)); } + /** Read an entire skin blob from disk (header + deflated body). */ public static byte[] read(MinecraftServer server, String hash) throws IOException { + if (!isValidHash(hash)) throw new IOException("invalid hash"); return Files.readAllBytes(fileFor(server, hash)); } /** - * Accept one chunk from a player. Returns the finalized {@link SkinData} with the - * computed hash once the last chunk lands, or {@code null} if more chunks are expected - * (or the upload was rejected — check logs). + * Validate the magic + parse the {@code (u16 w, u16 h)} header off a blob. Returns + * {@code [w, h]} on success, or {@code null} if the magic doesn't match or the blob + * is too short. No trust extended to the deflated body until this passes. + */ + public static int[] readHeader(byte[] blob) { + if (blob == null || blob.length < HEADER_SIZE) return null; + for (int i = 0; i < MAGIC.length; i++) { + if (blob[i] != MAGIC[i]) return null; + } + int w = ((blob[4] & 0xFF) << 8) | (blob[5] & 0xFF); + int h = ((blob[6] & 0xFF) << 8) | (blob[7] & 0xFF); + return new int[] { w, h }; + } + + /** + * Accept one chunk of a deflated-RGBA upload from a player. Returns the finalized + * {@link SkinData} once the last chunk lands, or {@code null} if more chunks are expected + * or the upload was rejected. */ public static SkinData acceptChunk( MinecraftServer server, UUID playerId, - int index, int total, SkinModel model, byte[] data + int index, int total, int width, int height, SkinModel model, byte[] data ) { if (total <= 0 || index < 0 || index >= total) { DecoFashion.LOGGER.warn("Skin upload from {}: invalid chunk {}/{}", playerId, index, total); @@ -74,30 +138,35 @@ public final class SkinCache { IN_FLIGHT.remove(playerId); return null; } + if (width <= 0 || height <= 0 || width > MAX_DIM || height > MAX_DIM) { + DecoFashion.LOGGER.warn("Skin upload from {}: bad dims {}x{}", playerId, width, height); + IN_FLIGHT.remove(playerId); + return null; + } Assembly asm; if (index == 0) { asm = new Assembly(); asm.expectedTotal = total; asm.nextIndex = 0; + asm.width = width; + asm.height = height; asm.model = model; - asm.buffer = new ByteArrayOutputStream(Math.min(total * MAX_CHUNK_BYTES, MAX_BYTES)); + asm.buffer = new ByteArrayOutputStream(Math.min(total * MAX_CHUNK_BYTES, MAX_DEFLATED_BYTES)); IN_FLIGHT.put(playerId, asm); } else { asm = IN_FLIGHT.get(playerId); - if (asm == null || asm.expectedTotal != total || asm.nextIndex != index) { - DecoFashion.LOGGER.warn("Skin upload from {}: out-of-order chunk (got {}/{}, expected {}/{})", - playerId, index, total, - asm == null ? -1 : asm.nextIndex, - asm == null ? -1 : asm.expectedTotal); + if (asm == null || asm.expectedTotal != total || asm.nextIndex != index + || asm.width != width || asm.height != height) { + DecoFashion.LOGGER.warn("Skin upload from {}: chunk mismatch at {}/{}", playerId, index, total); IN_FLIGHT.remove(playerId); return null; } } try { - if (asm.buffer.size() + data.length > MAX_BYTES) { - DecoFashion.LOGGER.warn("Skin upload from {}: exceeds size cap ({})", playerId, MAX_BYTES); + if (asm.buffer.size() + data.length > MAX_DEFLATED_BYTES) { + DecoFashion.LOGGER.warn("Skin upload from {}: exceeds deflated cap", playerId); IN_FLIGHT.remove(playerId); return null; } @@ -113,28 +182,59 @@ public final class SkinCache { IN_FLIGHT.remove(playerId); - byte[] png = asm.buffer.toByteArray(); + byte[] deflated = asm.buffer.toByteArray(); + int expectedRaw; + try { + expectedRaw = rgbaByteCount(asm.width, asm.height); + } catch (IllegalArgumentException ex) { + DecoFashion.LOGGER.warn("Skin upload from {}: {}", playerId, ex.getMessage()); + return null; + } + + byte[] rgba; + try { + rgba = inflateBounded(deflated, expectedRaw); + } catch (IOException ex) { + DecoFashion.LOGGER.warn("Skin upload from {}: inflate failed ({})", playerId, ex.getMessage()); + return null; + } + String hash; try { - hash = sha256Hex(png); + hash = sha256HexOfPixels(asm.width, asm.height, rgba); } catch (NoSuchAlgorithmException ex) { DecoFashion.LOGGER.error("SHA-256 unavailable", ex); return null; } + // Library-size gate: if the player already has 5 skins and this hash isn't one of + // them, reject before touching disk. Prevents a malicious client from filling storage + // by streaming arbitrary distinct skins past the per-player cap. + ServerPlayer player = server.getPlayerList().getPlayer(playerId); + if (player == null) { + DecoFashion.LOGGER.warn("Skin upload from {}: player disconnected before finalize", playerId); + return null; + } + SkinLibrary lib = player.getData(SkinAttachments.LIBRARY.get()); + if (!lib.contains(hash) && lib.isFull()) { + DecoFashion.LOGGER.warn("Skin upload from {}: library full ({}); rejecting {}", + playerId, SkinLibrary.MAX_ENTRIES, hash); + return null; + } + try { Files.createDirectories(root(server)); Path target = fileFor(server, hash); if (!Files.isRegularFile(target)) { - Files.write(target, png); + Files.write(target, buildBlob(asm.width, asm.height, deflated)); } } catch (IOException ex) { DecoFashion.LOGGER.error("Failed writing skin {}", hash, ex); return null; } - DecoFashion.LOGGER.info("Skin upload finalized: player={} hash={} bytes={} model={}", - playerId, hash, png.length, asm.model); + DecoFashion.LOGGER.info("Skin upload finalized: player={} hash={} dims={}x{} deflated={} model={}", + playerId, hash, asm.width, asm.height, deflated.length, asm.model); return new SkinData(hash, asm.model); } @@ -147,10 +247,11 @@ public final class SkinCache { Map out = new HashMap<>(); Path dir = root(server); if (!Files.isDirectory(dir)) return out; - try (var stream = Files.newDirectoryStream(dir, "*.png")) { + try (var stream = Files.newDirectoryStream(dir, "*" + FILE_EXT)) { for (Path p : stream) { String name = p.getFileName().toString(); - String hash = name.substring(0, name.length() - 4); + String hash = name.substring(0, name.length() - FILE_EXT.length()); + if (!isValidHash(hash)) continue; try { out.put(hash, Files.size(p)); } catch (IOException ignored) {} @@ -161,11 +262,84 @@ public final class SkinCache { return out; } - private static String sha256Hex(byte[] in) throws NoSuchAlgorithmException { + /** Build a wire-format blob: magic + (u16 w, u16 h) + deflated pixels. */ + public static byte[] buildBlob(int width, int height, byte[] deflated) { + byte[] blob = new byte[HEADER_SIZE + deflated.length]; + System.arraycopy(MAGIC, 0, blob, 0, MAGIC.length); + blob[4] = (byte) ((width >>> 8) & 0xFF); + blob[5] = (byte) (width & 0xFF); + blob[6] = (byte) ((height >>> 8) & 0xFF); + blob[7] = (byte) (height & 0xFF); + System.arraycopy(deflated, 0, blob, HEADER_SIZE, deflated.length); + return blob; + } + + /** + * Compute {@code width * height * 4} in 64-bit arithmetic, rejecting overflow or + * over-budget sizes. Every pixel-buffer allocation and {@link #inflateBounded} call + * should derive its expected size through this helper so a future MAX_DIM bump can't + * silently overflow {@code int} and produce a negative allocation. + */ + public static int rgbaByteCount(int width, int height) { + long n = (long) width * (long) height * 4L; + if (n <= 0 || n > MAX_RAW_BYTES) { + throw new IllegalArgumentException( + "rgba size out of range: " + n + " (dims " + width + "x" + height + ")"); + } + return (int) n; + } + + public static byte[] inflateBounded(byte[] compressed, int expected) throws IOException { + if (expected <= 0 || expected > MAX_RAW_BYTES) { + throw new IOException("bad expected inflated size: " + expected); + } + Inflater inf = new Inflater(); + try { + inf.setInput(compressed); + byte[] out = new byte[expected]; + int written = 0; + byte[] scratch = new byte[64 * 1024]; + while (true) { + int n; + try { + n = inf.inflate(scratch); + } catch (DataFormatException ex) { + throw new IOException("zlib decode error: " + ex.getMessage()); + } + if (n == 0) { + if (inf.finished()) break; + if (inf.needsInput() || inf.needsDictionary()) { + throw new IOException("zlib stream truncated"); + } + break; + } + if (written + n > expected) { + throw new IOException("inflated size exceeds pixel budget"); + } + System.arraycopy(scratch, 0, out, written, n); + written += n; + } + if (written != expected) { + throw new IOException("inflated size mismatch: " + written + " != " + expected); + } + return out; + } finally { + inf.end(); + } + } + + /** Hash is computed over {@code [u16 w][u16 h][raw RGBA bytes]} so dedup is stable across + * deflate-level differences between clients. */ + static String sha256HexOfPixels(int width, int height, byte[] rgba) throws NoSuchAlgorithmException { MessageDigest md = MessageDigest.getInstance("SHA-256"); - byte[] out = md.digest(in); - StringBuilder sb = new StringBuilder(out.length * 2); - for (byte b : out) sb.append(String.format("%02x", b)); + md.update((byte) ((width >>> 8) & 0xFF)); + md.update((byte) (width & 0xFF)); + md.update((byte) ((height >>> 8) & 0xFF)); + md.update((byte) (height & 0xFF)); + md.update(rgba); + byte[] digest = md.digest(); + StringBuilder sb = new StringBuilder(digest.length * 2); + for (byte b : digest) sb.append(String.format("%02x", b)); return sb.toString(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/razz/dfashion/skin/SkinLibrary.java b/src/main/java/com/razz/dfashion/skin/SkinLibrary.java new file mode 100644 index 0000000..7de0eb3 --- /dev/null +++ b/src/main/java/com/razz/dfashion/skin/SkinLibrary.java @@ -0,0 +1,59 @@ +package com.razz.dfashion.skin; + +import com.mojang.serialization.Codec; + +import io.netty.buffer.ByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; + +import java.util.ArrayList; +import java.util.List; + +/** + * A player's personal skin library — a bounded list of {@link SkinLibraryEntry}s. + * + *

Persisted on the player (via an attachment) and synced only to the owning client. + * The wardrobe UI renders this list for the local player; other players never receive + * anyone else's library. The local {@code ClientSkinCache} still holds a content-addressed + * pool of blobs for every skin this client has ever seen, but the wardrobe no longer + * treats that pool as "mine" — it renders the server-authoritative library instead. + */ +public record SkinLibrary(List entries) { + + public static final int MAX_ENTRIES = 5; + public static final SkinLibrary EMPTY = new SkinLibrary(List.of()); + + public static final Codec CODEC = + SkinLibraryEntry.CODEC.listOf().xmap(SkinLibrary::new, SkinLibrary::entries); + + public static final StreamCodec STREAM_CODEC = + SkinLibraryEntry.STREAM_CODEC + .apply(ByteBufCodecs.list()) + .map(SkinLibrary::new, SkinLibrary::entries); + + public boolean contains(String hash) { + for (SkinLibraryEntry e : entries) { + if (e.hash().equals(hash)) return true; + } + return false; + } + + public boolean isFull() { + return entries.size() >= MAX_ENTRIES; + } + + public SkinLibrary add(SkinLibraryEntry entry) { + List out = new ArrayList<>(entries.size() + 1); + out.addAll(entries); + out.add(entry); + return new SkinLibrary(List.copyOf(out)); + } + + public SkinLibrary remove(String hash) { + List out = new ArrayList<>(entries.size()); + for (SkinLibraryEntry e : entries) { + if (!e.hash().equals(hash)) out.add(e); + } + return new SkinLibrary(List.copyOf(out)); + } +} \ No newline at end of file diff --git a/src/main/java/com/razz/dfashion/skin/SkinLibraryEntry.java b/src/main/java/com/razz/dfashion/skin/SkinLibraryEntry.java new file mode 100644 index 0000000..ea0814d --- /dev/null +++ b/src/main/java/com/razz/dfashion/skin/SkinLibraryEntry.java @@ -0,0 +1,31 @@ +package com.razz.dfashion.skin; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; + +import io.netty.buffer.ByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; + +/** + * One entry in a player's personal skin library. {@code hash} points at a blob the + * server stores (or can fetch) under {@code /decofashion/skins/.bin}. + * {@code displayName} is a user-visible label shown in the wardrobe; {@code uploadedAt} + * is epoch millis at the time of upload, handy for sort-by-recent without needing to + * hit the filesystem. + */ +public record SkinLibraryEntry(String hash, String displayName, long uploadedAt) { + + public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( + Codec.STRING.fieldOf("hash").forGetter(SkinLibraryEntry::hash), + Codec.STRING.optionalFieldOf("name", "").forGetter(SkinLibraryEntry::displayName), + Codec.LONG.optionalFieldOf("uploadedAt", 0L).forGetter(SkinLibraryEntry::uploadedAt) + ).apply(inst, SkinLibraryEntry::new)); + + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.STRING_UTF8, SkinLibraryEntry::hash, + ByteBufCodecs.STRING_UTF8, SkinLibraryEntry::displayName, + ByteBufCodecs.VAR_LONG, SkinLibraryEntry::uploadedAt, + SkinLibraryEntry::new + ); +} \ No newline at end of file diff --git a/src/main/java/com/razz/dfashion/skin/SkinNetwork.java b/src/main/java/com/razz/dfashion/skin/SkinNetwork.java index 410e467..b9bf130 100644 --- a/src/main/java/com/razz/dfashion/skin/SkinNetwork.java +++ b/src/main/java/com/razz/dfashion/skin/SkinNetwork.java @@ -2,6 +2,7 @@ package com.razz.dfashion.skin; import com.razz.dfashion.DecoFashion; import com.razz.dfashion.skin.packet.AssignSkin; +import com.razz.dfashion.skin.packet.DeleteSkin; import com.razz.dfashion.skin.packet.RequestSkin; import com.razz.dfashion.skin.packet.SkinChunk; import com.razz.dfashion.skin.packet.UploadSkinChunk; @@ -12,6 +13,7 @@ import net.minecraft.server.level.ServerPlayer; import net.neoforged.api.distmarker.Dist; import net.neoforged.bus.api.SubscribeEvent; import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.event.entity.player.PlayerEvent; import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent; import net.neoforged.neoforge.network.handling.IPayloadContext; import net.neoforged.neoforge.network.registration.PayloadRegistrar; @@ -21,7 +23,7 @@ import java.io.IOException; @EventBusSubscriber(modid = DecoFashion.MODID) public final class SkinNetwork { - private static final String VERSION = "1"; + private static final String VERSION = "2"; @SubscribeEvent static void onRegister(RegisterPayloadHandlersEvent event) { @@ -36,11 +38,43 @@ public final class SkinNetwork { registrar.playToServer( AssignSkin.TYPE, AssignSkin.STREAM_CODEC, SkinNetwork::onAssignSkin ); + registrar.playToServer( + DeleteSkin.TYPE, DeleteSkin.STREAM_CODEC, SkinNetwork::onDeleteSkin + ); registrar.playToClient( SkinChunk.TYPE, SkinChunk.STREAM_CODEC, SkinNetwork::onSkinChunk ); } + /** Clear any in-flight upload assembly when a player disconnects so their reserved + * buffer (up to {@link SkinCache#MAX_DEFLATED_BYTES}) doesn't leak until GC. Mirrors + * {@link com.razz.dfashion.cosmetic.share.CosmeticShareNetwork}'s handler. */ + @SubscribeEvent + static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) { + if (event.getEntity() instanceof ServerPlayer sp) { + SkinCache.cancelInFlight(sp.getUUID()); + } + } + + private static void onDeleteSkin(DeleteSkin msg, IPayloadContext ctx) { + ctx.enqueueWork(() -> { + if (!(ctx.player() instanceof ServerPlayer player)) return; + if (!SkinCache.isValidHash(msg.hash())) { + DecoFashion.LOGGER.warn("DeleteSkin from {}: invalid hash rejected", player.getUUID()); + return; + } + SkinLibrary lib = player.getData(SkinAttachments.LIBRARY.get()); + if (!lib.contains(msg.hash())) return; + player.setData(SkinAttachments.LIBRARY.get(), lib.remove(msg.hash())); + // If the deleted skin was active, reset to vanilla. + SkinData current = player.getData(SkinAttachments.DATA.get()); + if (current != null && msg.hash().equals(current.hash())) { + player.setData(SkinAttachments.DATA.get(), SkinData.EMPTY); + } + DecoFashion.LOGGER.info("Skin delete: player={} hash={}", player.getUUID(), msg.hash()); + }); + } + private static void onUploadChunk(UploadSkinChunk msg, IPayloadContext ctx) { ctx.enqueueWork(() -> { if (!(ctx.player() instanceof ServerPlayer player)) return; @@ -48,9 +82,22 @@ public final class SkinNetwork { if (server == null) return; SkinData finalized = SkinCache.acceptChunk( - server, player.getUUID(), msg.index(), msg.total(), msg.model(), msg.data() + server, player.getUUID(), + msg.index(), msg.total(), + msg.width(), msg.height(), + msg.model(), msg.data() ); if (finalized != null) { + SkinLibrary lib = player.getData(SkinAttachments.LIBRARY.get()); + if (!lib.contains(finalized.hash())) { + int n = lib.entries().size() + 1; + SkinLibraryEntry entry = new SkinLibraryEntry( + finalized.hash(), + "Skin " + n, + System.currentTimeMillis() + ); + player.setData(SkinAttachments.LIBRARY.get(), lib.add(entry)); + } player.setData(SkinAttachments.DATA.get(), finalized); } }); @@ -66,6 +113,10 @@ public final class SkinNetwork { player.setData(SkinAttachments.DATA.get(), SkinData.EMPTY); return; } + if (!SkinCache.isValidHash(msg.hash())) { + DecoFashion.LOGGER.warn("AssignSkin from {}: invalid hash rejected", player.getUUID()); + return; + } if (!SkinCache.has(server, msg.hash())) { DecoFashion.LOGGER.warn("AssignSkin from {}: unknown hash {}", player.getUUID(), msg.hash()); return; @@ -79,49 +130,65 @@ public final class SkinNetwork { if (!(ctx.player() instanceof ServerPlayer player)) return; MinecraftServer server = player.level().getServer(); if (server == null) return; + if (!SkinCache.isValidHash(msg.hash())) { + DecoFashion.LOGGER.warn("Skin request from {}: invalid hash rejected", player.getUUID()); + return; + } if (!SkinCache.has(server, msg.hash())) { DecoFashion.LOGGER.warn("Skin request from {}: unknown hash {}", player.getUUID(), msg.hash()); return; } - byte[] png; + byte[] blob; try { - png = SkinCache.read(server, msg.hash()); + blob = SkinCache.read(server, msg.hash()); } catch (IOException ex) { DecoFashion.LOGGER.error("Skin request {}: read failed", msg.hash(), ex); return; } + int[] dims = SkinCache.readHeader(blob); + if (dims == null || blob.length <= 4) { + DecoFashion.LOGGER.error("Skin request {}: blob missing header", msg.hash()); + return; + } + int width = dims[0]; + int height = dims[1]; + int bodyLen = blob.length - 4; int chunkSize = SkinCache.MAX_CHUNK_BYTES; - int total = Math.max(1, (png.length + chunkSize - 1) / chunkSize); + int total = Math.max(1, (bodyLen + chunkSize - 1) / chunkSize); for (int i = 0; i < total; i++) { - int off = i * chunkSize; - int len = Math.min(chunkSize, png.length - off); + int off = 4 + i * chunkSize; + int len = Math.min(chunkSize, blob.length - off); byte[] slice = new byte[len]; - System.arraycopy(png, off, slice, 0, len); + System.arraycopy(blob, off, slice, 0, len); player.connection.send(new ClientboundCustomPayloadPacket( - new SkinChunk(msg.hash(), i, total, slice))); + new SkinChunk(msg.hash(), i, total, width, height, slice))); } }); } private static void onSkinChunk(SkinChunk msg, IPayloadContext ctx) { - // Defer client-side handling so we don't pull client classes into common code. ctx.enqueueWork(() -> ClientSkinChunkReceiver.accept(msg)); } /** * Small trampoline to keep the client-only receiver out of the common-side packet router; - * the {@code @OnlyIn(CLIENT)} classes are referenced only through this dist-gated class. + * {@code @OnlyIn(CLIENT)} classes are referenced only through this dist-gated class. */ private static final class ClientSkinChunkReceiver { static void accept(SkinChunk msg) { if (net.neoforged.fml.loading.FMLEnvironment.getDist() != Dist.CLIENT) return; + if (!SkinCache.isValidHash(msg.hash())) { + DecoFashion.LOGGER.warn("Received SkinChunk with invalid hash; ignoring"); + return; + } com.razz.dfashion.client.ClientSkinCache.onChunk( - msg.hash(), msg.index(), msg.total(), msg.data() + msg.hash(), msg.index(), msg.total(), + msg.width(), msg.height(), msg.data() ); } } private SkinNetwork() {} -} +} \ No newline at end of file diff --git a/src/main/java/com/razz/dfashion/skin/packet/AssignSkin.java b/src/main/java/com/razz/dfashion/skin/packet/AssignSkin.java index cd98052..8717ebe 100644 --- a/src/main/java/com/razz/dfashion/skin/packet/AssignSkin.java +++ b/src/main/java/com/razz/dfashion/skin/packet/AssignSkin.java @@ -18,10 +18,13 @@ public record AssignSkin(String hash, SkinModel model) implements CustomPacketPa public static final Type TYPE = new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "assign_skin")); + /** SHA-256 hex is exactly 64 chars; empty string (= "clear skin") also fits. */ + private static final int MAX_HASH_LEN = 64; + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( - ByteBufCodecs.STRING_UTF8, AssignSkin::hash, - SkinModel.STREAM_CODEC, AssignSkin::model, + ByteBufCodecs.stringUtf8(MAX_HASH_LEN), AssignSkin::hash, + SkinModel.STREAM_CODEC, AssignSkin::model, AssignSkin::new ); diff --git a/src/main/java/com/razz/dfashion/skin/packet/DeleteSkin.java b/src/main/java/com/razz/dfashion/skin/packet/DeleteSkin.java new file mode 100644 index 0000000..e624f74 --- /dev/null +++ b/src/main/java/com/razz/dfashion/skin/packet/DeleteSkin.java @@ -0,0 +1,31 @@ +package com.razz.dfashion.skin.packet; + +import com.razz.dfashion.DecoFashion; + +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +/** + * Client → server. Remove {@code hash} from the player's personal skin library. If the + * hash was the currently-equipped skin, the server also resets {@code SkinData} back to + * vanilla. Unknown hashes are silently ignored (idempotent). + */ +public record DeleteSkin(String hash) implements CustomPacketPayload { + + public static final Type TYPE = + new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "delete_skin")); + + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + ByteBufCodecs.stringUtf8(64), DeleteSkin::hash, + DeleteSkin::new + ); + + @Override + public Type type() { + return TYPE; + } +} \ No newline at end of file diff --git a/src/main/java/com/razz/dfashion/skin/packet/RequestSkin.java b/src/main/java/com/razz/dfashion/skin/packet/RequestSkin.java index 01e4281..b166a3d 100644 --- a/src/main/java/com/razz/dfashion/skin/packet/RequestSkin.java +++ b/src/main/java/com/razz/dfashion/skin/packet/RequestSkin.java @@ -19,7 +19,7 @@ public record RequestSkin(String hash) implements CustomPacketPayload { public static final StreamCodec STREAM_CODEC = StreamCodec.composite( - ByteBufCodecs.STRING_UTF8, RequestSkin::hash, + ByteBufCodecs.stringUtf8(64), RequestSkin::hash, RequestSkin::new ); diff --git a/src/main/java/com/razz/dfashion/skin/packet/SkinChunk.java b/src/main/java/com/razz/dfashion/skin/packet/SkinChunk.java index e573b2a..6076447 100644 --- a/src/main/java/com/razz/dfashion/skin/packet/SkinChunk.java +++ b/src/main/java/com/razz/dfashion/skin/packet/SkinChunk.java @@ -1,6 +1,7 @@ package com.razz.dfashion.skin.packet; import com.razz.dfashion.DecoFashion; +import com.razz.dfashion.skin.SkinCache; import net.minecraft.network.FriendlyByteBuf; import net.minecraft.network.codec.ByteBufCodecs; @@ -9,9 +10,14 @@ import net.minecraft.network.protocol.common.custom.CustomPacketPayload; import net.minecraft.resources.Identifier; /** - * Server → client. One chunk of the PNG bytes for a requested skin hash. + * Server → client. One chunk of a stored skin's deflated RGBA pixels, not PNG bytes. + * + *

Mirrors {@link UploadSkinChunk}'s shape in reverse. Receivers reassemble the deflated + * buffer, inflate it under a bounded cumulative output cap (= {@code width*height*4}), and + * build a {@code NativeImage} directly via {@code setPixelABGR} — no STB call anywhere in + * the download path. */ -public record SkinChunk(String hash, int index, int total, byte[] data) +public record SkinChunk(String hash, int index, int total, int width, int height, byte[] data) implements CustomPacketPayload { public static final Type TYPE = @@ -19,10 +25,12 @@ public record SkinChunk(String hash, int index, int total, byte[] data) public static final StreamCodec STREAM_CODEC = StreamCodec.composite( - ByteBufCodecs.STRING_UTF8, SkinChunk::hash, - ByteBufCodecs.VAR_INT, SkinChunk::index, - ByteBufCodecs.VAR_INT, SkinChunk::total, - ByteBufCodecs.BYTE_ARRAY, SkinChunk::data, + ByteBufCodecs.stringUtf8(64), SkinChunk::hash, + ByteBufCodecs.VAR_INT, SkinChunk::index, + ByteBufCodecs.VAR_INT, SkinChunk::total, + ByteBufCodecs.VAR_INT, SkinChunk::width, + ByteBufCodecs.VAR_INT, SkinChunk::height, + ByteBufCodecs.byteArray(SkinCache.MAX_CHUNK_BYTES),SkinChunk::data, SkinChunk::new ); @@ -30,4 +38,4 @@ public record SkinChunk(String hash, int index, int total, byte[] data) public Type type() { return TYPE; } -} +} \ No newline at end of file diff --git a/src/main/java/com/razz/dfashion/skin/packet/UploadSkinChunk.java b/src/main/java/com/razz/dfashion/skin/packet/UploadSkinChunk.java index 2d5e70d..c82cd2f 100644 --- a/src/main/java/com/razz/dfashion/skin/packet/UploadSkinChunk.java +++ b/src/main/java/com/razz/dfashion/skin/packet/UploadSkinChunk.java @@ -1,6 +1,7 @@ package com.razz.dfashion.skin.packet; import com.razz.dfashion.DecoFashion; +import com.razz.dfashion.skin.SkinCache; import com.razz.dfashion.skin.SkinModel; import net.minecraft.network.FriendlyByteBuf; @@ -10,11 +11,16 @@ import net.minecraft.network.protocol.common.custom.CustomPacketPayload; import net.minecraft.resources.Identifier; /** - * Client → server. One chunk of a skin upload. When {@code index + 1 == total}, - * the server finalizes, hashes, writes to cache, and updates the player's SkinData. - * Chunk data is raw PNG bytes; the server reassembles in order. + * Client → server. One chunk of a skin upload as deflated RGBA pixels, not PNG bytes. + * + *

The uploading client parses its source PNG locally with {@code SafePngReader} (pure Java, + * no STB), extracts raw RGBA, deflates it, and streams the deflated buffer in fixed-size chunks. + * {@code w} and {@code h} are repeated on every chunk so reassembly can enforce them before + * touching the payload; server validates that subsequent chunks carry the same dimensions as + * the first. No PNG container ever crosses the wire — polyglot/iCCP/trailing-IDAT attacks + * can't exist when the attack surface is two u16s and a deflate stream. */ -public record UploadSkinChunk(int index, int total, SkinModel model, byte[] data) +public record UploadSkinChunk(int index, int total, int width, int height, SkinModel model, byte[] data) implements CustomPacketPayload { public static final Type TYPE = @@ -22,10 +28,12 @@ public record UploadSkinChunk(int index, int total, SkinModel model, byte[] data public static final StreamCodec STREAM_CODEC = StreamCodec.composite( - ByteBufCodecs.VAR_INT, UploadSkinChunk::index, - ByteBufCodecs.VAR_INT, UploadSkinChunk::total, - SkinModel.STREAM_CODEC, UploadSkinChunk::model, - ByteBufCodecs.BYTE_ARRAY, UploadSkinChunk::data, + ByteBufCodecs.VAR_INT, UploadSkinChunk::index, + ByteBufCodecs.VAR_INT, UploadSkinChunk::total, + ByteBufCodecs.VAR_INT, UploadSkinChunk::width, + ByteBufCodecs.VAR_INT, UploadSkinChunk::height, + SkinModel.STREAM_CODEC, UploadSkinChunk::model, + ByteBufCodecs.byteArray(SkinCache.MAX_CHUNK_BYTES), UploadSkinChunk::data, UploadSkinChunk::new ); @@ -33,4 +41,4 @@ public record UploadSkinChunk(int index, int total, SkinModel model, byte[] data public Type type() { return TYPE; } -} +} \ No newline at end of file diff --git a/src/test/java/com/razz/dfashion/security/BbModelParserSecurityTest.java b/src/test/java/com/razz/dfashion/security/BbModelParserSecurityTest.java new file mode 100644 index 0000000..9980f12 --- /dev/null +++ b/src/test/java/com/razz/dfashion/security/BbModelParserSecurityTest.java @@ -0,0 +1,178 @@ +package com.razz.dfashion.security; + +import com.code_intelligence.jazzer.junit.FuzzTest; +import com.google.gson.JsonParseException; +import com.razz.dfashion.bbmodel.BbModelParser; + +import org.junit.jupiter.api.Test; + +import java.io.StringReader; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Security corpus + fuzz harness for {@link BbModelParser}. The parser runs only on the + * author's local disk, but still handles untrusted JSON (the author could have downloaded + * a malicious .bbmodel), so every rejection rule matters. + * + *

Fuzz inputs are fed as UTF-8 byte arrays. Legitimate rejections are any subtype of + * {@link JsonParseException} (including {@code BadBbmodelException}). A {@link StackOverflowError}, + * {@link NullPointerException}, or any non-JsonParseException is a bug Jazzer should surface. + */ +class BbModelParserSecurityTest { + + @Test + void acceptsMinimalValidModel() { + String json = "{" + + "\"meta\":{\"format_version\":\"5.0\"}," + + "\"resolution\":{\"width\":64,\"height\":64}," + + "\"elements\":[]," + + "\"groups\":[]," + + "\"outliner\":[]" + + "}"; + assertDoesNotThrow(() -> BbModelParser.parse(new StringReader(json))); + } + + @Test + void rejectsOldFormatVersion() { + String json = "{" + + "\"meta\":{\"format_version\":\"4.5\"}," + + "\"resolution\":{\"width\":64,\"height\":64}," + + "\"elements\":[],\"groups\":[],\"outliner\":[]" + + "}"; + assertThrows(JsonParseException.class, () -> BbModelParser.parse(new StringReader(json))); + } + + @Test + void rejectsResolutionOverCap() { + String json = "{" + + "\"meta\":{\"format_version\":\"5.0\"}," + + "\"resolution\":{\"width\":" + (BbModelParser.MAX_RESOLUTION + 1) + ",\"height\":64}," + + "\"elements\":[],\"groups\":[],\"outliner\":[]" + + "}"; + assertThrows(JsonParseException.class, () -> BbModelParser.parse(new StringReader(json))); + } + + @Test + void rejectsNanFloat() { + String json = "{" + + "\"meta\":{\"format_version\":\"5.0\"}," + + "\"resolution\":{\"width\":64,\"height\":64}," + + "\"elements\":[{\"type\":\"cube\",\"uuid\":\"a\",\"name\":\"n\"," + + "\"from\":[\"NaN\",0,0],\"to\":[1,1,1],\"origin\":[0,0,0],\"rotation\":[0,0,0]," + + "\"inflate\":0,\"faces\":{}}]," + + "\"groups\":[],\"outliner\":[\"a\"]" + + "}"; + // GSON parses NaN-as-string into Float.NaN (depending on lenient mode); post-parse + // validation rejects non-finite floats. Either way, a JsonParseException is expected. + assertThrows(JsonParseException.class, () -> BbModelParser.parse(new StringReader(json))); + } + + @Test + void rejectsInfinityFloat() { + String json = "{" + + "\"meta\":{\"format_version\":\"5.0\"}," + + "\"resolution\":{\"width\":64,\"height\":64}," + + "\"elements\":[{\"type\":\"cube\",\"uuid\":\"a\",\"name\":\"n\"," + + "\"from\":[0,0,0],\"to\":[1,1,1],\"origin\":[0,0,0]," + + "\"rotation\":[\"Infinity\",0,0]," + + "\"inflate\":0,\"faces\":{}}]," + + "\"groups\":[],\"outliner\":[\"a\"]" + + "}"; + assertThrows(JsonParseException.class, () -> BbModelParser.parse(new StringReader(json))); + } + + @Test + void rejectsCoordOutOfRange() { + String json = "{" + + "\"meta\":{\"format_version\":\"5.0\"}," + + "\"resolution\":{\"width\":64,\"height\":64}," + + "\"elements\":[{\"type\":\"cube\",\"uuid\":\"a\",\"name\":\"n\"," + + "\"from\":[1e9,0,0],\"to\":[1,1,1],\"origin\":[0,0,0],\"rotation\":[0,0,0]," + + "\"inflate\":0,\"faces\":{}}]," + + "\"groups\":[],\"outliner\":[\"a\"]" + + "}"; + assertThrows(JsonParseException.class, () -> BbModelParser.parse(new StringReader(json))); + } + + @Test + void rejectsDuplicateElementUuid() { + String json = "{" + + "\"meta\":{\"format_version\":\"5.0\"}," + + "\"resolution\":{\"width\":64,\"height\":64}," + + "\"elements\":[" + + " {\"type\":\"cube\",\"uuid\":\"dup\",\"name\":\"n\"," + + " \"from\":[0,0,0],\"to\":[1,1,1],\"origin\":[0,0,0],\"rotation\":[0,0,0],\"inflate\":0,\"faces\":{}}," + + " {\"type\":\"cube\",\"uuid\":\"dup\",\"name\":\"n\"," + + " \"from\":[0,0,0],\"to\":[1,1,1],\"origin\":[0,0,0],\"rotation\":[0,0,0],\"inflate\":0,\"faces\":{}}" + + "]," + + "\"groups\":[],\"outliner\":[\"dup\"]" + + "}"; + assertThrows(JsonParseException.class, () -> BbModelParser.parse(new StringReader(json))); + } + + @Test + void rejectsOutlinerRefToUnknownElement() { + String json = "{" + + "\"meta\":{\"format_version\":\"5.0\"}," + + "\"resolution\":{\"width\":64,\"height\":64}," + + "\"elements\":[]," + + "\"groups\":[]," + + "\"outliner\":[\"ghost-uuid\"]" + + "}"; + assertThrows(JsonParseException.class, () -> BbModelParser.parse(new StringReader(json))); + } + + @Test + void rejectsOutlinerOverDepthCap() { + // Build a chain of groups deeper than MAX_OUTLINER_DEPTH. Each group is a GroupRef + // whose single child is the next GroupRef. + int depth = BbModelParser.MAX_OUTLINER_DEPTH + 5; + StringBuilder groups = new StringBuilder(); + StringBuilder outliner = new StringBuilder(); + for (int i = 0; i < depth; i++) { + if (i > 0) groups.append(','); + groups.append("{\"uuid\":\"g").append(i).append("\",\"name\":\"g\"," + + "\"origin\":[0,0,0],\"rotation\":[0,0,0]}"); + } + // Outliner is a nested object tree referencing g0 → g1 → ... → g(depth-1). + for (int i = 0; i < depth; i++) { + outliner.append("{\"uuid\":\"g").append(i).append("\",\"children\":["); + } + for (int i = 0; i < depth; i++) outliner.append("]}"); + + String json = "{" + + "\"meta\":{\"format_version\":\"5.0\"}," + + "\"resolution\":{\"width\":64,\"height\":64}," + + "\"elements\":[]," + + "\"groups\":[" + groups + "]," + + "\"outliner\":[" + outliner + "]" + + "}"; + assertThrows(JsonParseException.class, () -> BbModelParser.parse(new StringReader(json))); + } + + @Test + void rejectsMalformedJson() { + assertThrows(JsonParseException.class, () -> BbModelParser.parse(new StringReader("{ not json"))); + } + + @Test + void rejectsEmpty() { + assertThrows(Exception.class, () -> BbModelParser.parse(new StringReader(""))); + } + + // ---- fuzz harness ---- + + /** + * Fuzz the JSON parser with random UTF-8. Legit rejection = anything JsonParseException-ish. + * Other exceptions point at missing defense in the parser. + */ + @FuzzTest(maxDuration = "30s") + void fuzzParse(byte[] input) { + try { + BbModelParser.parse(new StringReader(new String(input, java.nio.charset.StandardCharsets.UTF_8))); + } catch (JsonParseException | IllegalStateException | NumberFormatException expected) { + // All legitimate rejection paths from GSON + validator. + } + } +} \ No newline at end of file diff --git a/src/test/java/com/razz/dfashion/security/BbmodelCodecSecurityTest.java b/src/test/java/com/razz/dfashion/security/BbmodelCodecSecurityTest.java new file mode 100644 index 0000000..b6c23dd --- /dev/null +++ b/src/test/java/com/razz/dfashion/security/BbmodelCodecSecurityTest.java @@ -0,0 +1,203 @@ +package com.razz.dfashion.security; + +import com.code_intelligence.jazzer.junit.FuzzTest; +import com.razz.dfashion.bbmodel.BbCube; +import com.razz.dfashion.bbmodel.BbGroup; +import com.razz.dfashion.bbmodel.BbLocator; +import com.razz.dfashion.bbmodel.BbOutlinerNode; +import com.razz.dfashion.bbmodel.Bbmodel; +import com.razz.dfashion.cosmetic.share.BbmodelCodec; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import org.junit.jupiter.api.Test; +import org.joml.Vector3f; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Security corpus + fuzz harness for {@link BbmodelCodec}. This codec is the only path + * from network bytes to a {@link Bbmodel} on server and on receiving clients, so every + * decode-time rejection is load-bearing. + * + *

The fuzz harness feeds random bytes to {@code decode}. The only acceptable outcome + * is a successful decode (arbitrarily unlikely from random bytes) or a + * {@link RuntimeException} subtype used for rejection ({@code DecoderException}, + * {@code IndexOutOfBoundsException} from ByteBuf underflow, {@code JsonParseException} + * from validator). Other exceptions or errors indicate missing defense. + */ +class BbmodelCodecSecurityTest { + + @Test + void roundTripEmptyModel() { + Bbmodel original = new Bbmodel(64, 64, List.of(), List.of(), List.of(), List.of()); + Bbmodel decoded = roundTrip(original); + assertEquals(64, decoded.resolutionWidth()); + assertEquals(64, decoded.resolutionHeight()); + assertTrue(decoded.elements().isEmpty()); + } + + @Test + void roundTripSingleCube() { + BbCube cube = new BbCube( + "uuid-1", "cube-name", + new Vector3f(0, 0, 0), new Vector3f(1, 1, 1), + 0f, + new Vector3f(0, 0, 0), new Vector3f(0, 0, 0), + Map.of() + ); + Bbmodel original = new Bbmodel(64, 64, + List.of(cube), List.of(), List.of(), + List.of(new BbOutlinerNode.ElementRef("uuid-1"))); + Bbmodel decoded = roundTrip(original); + assertEquals(1, decoded.elements().size()); + assertEquals("uuid-1", decoded.elements().get(0).uuid()); + } + + @Test + void roundTripWithGroupsAndLocators() { + BbCube cube = new BbCube("c", "cube", + new Vector3f(0, 0, 0), new Vector3f(1, 1, 1), + 0f, + new Vector3f(0, 0, 0), new Vector3f(0, 0, 0), + Map.of()); + BbLocator loc = new BbLocator("l", "loc", + new Vector3f(1, 2, 3), new Vector3f(0, 0, 0)); + BbGroup grp = new BbGroup("g", "group", + new Vector3f(0, 0, 0), new Vector3f(0, 0, 0)); + Bbmodel original = new Bbmodel(64, 64, + List.of(cube), List.of(loc), List.of(grp), + List.of(new BbOutlinerNode.GroupRef("g", List.of( + new BbOutlinerNode.ElementRef("c"), + new BbOutlinerNode.ElementRef("l") + )))); + Bbmodel decoded = roundTrip(original); + assertEquals(1, decoded.groups().size()); + assertEquals(1, decoded.locators().size()); + } + + @Test + void rejectsDecodeOfEmptyBuf() { + ByteBuf buf = Unpooled.buffer(); + try { + assertThrows(RuntimeException.class, () -> BbmodelCodec.CODEC.decode(buf)); + } finally { + buf.release(); + } + } + + @Test + void rejectsDecodeOfAllZeros() { + // Length-prefixed lists will read zero counts; resolution will be 0x0. Validator + // rejects because resolution < 1. + ByteBuf buf = Unpooled.wrappedBuffer(new byte[64]); + try { + assertThrows(RuntimeException.class, () -> BbmodelCodec.CODEC.decode(buf)); + } finally { + buf.release(); + } + } + + @Test + void rejectsExcessiveListCount() { + // Craft a bbmodel binary whose "elements" VarInt says MAX_LIST_ELEMENTS + 1. + ByteBuf buf = Unpooled.buffer(); + try { + net.minecraft.network.VarInt.write(buf, 64); // width + net.minecraft.network.VarInt.write(buf, 64); // height + net.minecraft.network.VarInt.write(buf, BbmodelCodec.MAX_LIST_ELEMENTS + 1); // elements count + ByteBuf finalBuf = buf; + assertThrows(RuntimeException.class, () -> BbmodelCodec.CODEC.decode(finalBuf)); + } finally { + buf.release(); + } + } + + @Test + void rejectsTrailingBytes() { + // A valid decode is followed by extra bytes — callers MUST verify + // readableBytes() == 0 after decode (we do so in SharedCosmeticCache/uploader). + // Codec itself doesn't enforce that; this test documents the contract. + Bbmodel original = new Bbmodel(64, 64, List.of(), List.of(), List.of(), List.of()); + ByteBuf buf = Unpooled.buffer(); + try { + BbmodelCodec.CODEC.encode(buf, original); + buf.writeBytes(new byte[]{1, 2, 3, 4}); // trailing + BbmodelCodec.CODEC.decode(buf); + assertTrue(buf.readableBytes() > 0, "caller must observe trailing bytes"); + } finally { + buf.release(); + } + } + + @Test + void canonicalEncodeSortsFaceKeys() { + // If we encode the same cube twice but with different HashMap iteration orders + // for faces, the resulting binaries must be identical (canonicalness for dedup). + java.util.Map a = new java.util.LinkedHashMap<>(); + a.put("north", new com.razz.dfashion.bbmodel.BbFace(new float[]{0, 0, 1, 1}, 0, 0)); + a.put("south", new com.razz.dfashion.bbmodel.BbFace(new float[]{0, 0, 1, 1}, 0, 0)); + a.put("east", new com.razz.dfashion.bbmodel.BbFace(new float[]{0, 0, 1, 1}, 0, 0)); + java.util.Map b = new java.util.LinkedHashMap<>(); + b.put("south", new com.razz.dfashion.bbmodel.BbFace(new float[]{0, 0, 1, 1}, 0, 0)); + b.put("east", new com.razz.dfashion.bbmodel.BbFace(new float[]{0, 0, 1, 1}, 0, 0)); + b.put("north", new com.razz.dfashion.bbmodel.BbFace(new float[]{0, 0, 1, 1}, 0, 0)); + + BbCube cubeA = new BbCube("u", "n", + new Vector3f(0, 0, 0), new Vector3f(1, 1, 1), + 0f, + new Vector3f(0, 0, 0), new Vector3f(0, 0, 0), a); + BbCube cubeB = new BbCube("u", "n", + new Vector3f(0, 0, 0), new Vector3f(1, 1, 1), + 0f, + new Vector3f(0, 0, 0), new Vector3f(0, 0, 0), b); + Bbmodel modelA = new Bbmodel(64, 64, List.of(cubeA), List.of(), List.of(), + List.of(new BbOutlinerNode.ElementRef("u"))); + Bbmodel modelB = new Bbmodel(64, 64, List.of(cubeB), List.of(), List.of(), + List.of(new BbOutlinerNode.ElementRef("u"))); + + assertArrayEquals(encode(modelA), encode(modelB), + "Canonical encode must be order-independent over face maps"); + } + + // ---- fuzz harness ---- + + @FuzzTest(maxDuration = "30s") + void fuzzDecode(byte[] input) { + ByteBuf buf = Unpooled.wrappedBuffer(input); + try { + BbmodelCodec.CODEC.decode(buf); + } catch (RuntimeException expected) { + // Every rejection path throws a runtime exception; that's intended. + } finally { + buf.release(); + } + } + + // ---- helpers ---- + + private static Bbmodel roundTrip(Bbmodel m) { + ByteBuf buf = Unpooled.buffer(); + try { + BbmodelCodec.CODEC.encode(buf, m); + return BbmodelCodec.CODEC.decode(buf); + } finally { + buf.release(); + } + } + + private static byte[] encode(Bbmodel m) { + ByteBuf buf = Unpooled.buffer(); + try { + BbmodelCodec.CODEC.encode(buf, m); + byte[] out = new byte[buf.readableBytes()]; + buf.readBytes(out); + return out; + } finally { + buf.release(); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/razz/dfashion/security/ForbiddenApiGuardTest.java b/src/test/java/com/razz/dfashion/security/ForbiddenApiGuardTest.java new file mode 100644 index 0000000..2ac5df6 --- /dev/null +++ b/src/test/java/com/razz/dfashion/security/ForbiddenApiGuardTest.java @@ -0,0 +1,111 @@ +package com.razz.dfashion.security; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Source-level guard against accidental reintroduction of dangerous APIs. + * + *

The mod deliberately avoids calling untrusted-bytes-eating APIs (STB PNG decoder, + * ImageIO, GSON's stream parsers, Java serialization) anywhere outside of a tiny pinned + * allowlist. Every entry in {@link #ALLOWLIST} is a place we've audited and know is safe + * — if you want to add a new call site, either it's a new trusted boundary (update this + * allowlist with a comment explaining why), or you need to route through the existing + * safe path ({@code SafePngReader}, {@code BbmodelCodec}). + * + *

Walking source files textually has false-positive risk (tokens appearing in comments + * or string literals). The fix for false positives is to allowlist the specific file, + * not to weaken the check. + */ +class ForbiddenApiGuardTest { + + /** + * Each key is a forbidden token; each value is the set of filenames (basenames) where + * it's legitimate. Empty set means "forbidden everywhere". + * + *

Maintenance rule: when adding a filename here, also add a one-line comment + * explaining the trust boundary that makes the usage safe. + */ + private static final Map> ALLOWLIST = new LinkedHashMap<>(); + static { + // STB-backed PNG decoder. The only sanctioned way to turn user PNGs into NativeImages + // is SafeImageLoader, which routes through SafePngReader. Both files reference the + // API name in documentation only (verified). + ALLOWLIST.put("NativeImage.read", Set.of("SafePngReader.java", "SafeImageLoader.java")); + + // Java ImageIO — historically has CVEs. Never acceptable. + ALLOWLIST.put("ImageIO.read", Set.of()); + + // Stream-based JSON parser. Only the author's local bbmodel parser legitimately runs + // GSON on untrusted bytes (and validates the result post-parse). + ALLOWLIST.put("JsonParser.parseReader", Set.of("BbModelParser.java")); + + // Java serialization — RCE class on untrusted input. Never acceptable. + ALLOWLIST.put("ObjectInputStream", Set.of()); + + // Direct Gson construction. Prefer GsonBuilder so type adapters and defaults are + // explicit. Default-config Gson is banned everywhere. + ALLOWLIST.put("new Gson(", Set.of()); + + // GsonBuilder instances — allowed at the two trusted boundaries only: + // BbModelParser: author's local disk (validated post-parse). + // CosmeticCatalog: assets/decofashion/cosmetics.json from the resource pack (trusted). + ALLOWLIST.put("new GsonBuilder(", Set.of("BbModelParser.java", "CosmeticCatalog.java")); + } + + /** Source root, resolved from the gradle test task's working directory (project root). */ + private static final Path SRC_MAIN = Paths.get("src/main/java"); + + @Test + void noForbiddenApiOutsideAllowlist() throws IOException { + if (!Files.isDirectory(SRC_MAIN)) { + fail("src/main/java not found at " + SRC_MAIN.toAbsolutePath() + " — wrong working dir?"); + } + + List violations = new ArrayList<>(); + try (Stream files = Files.walk(SRC_MAIN)) { + for (Path p : (Iterable) files.filter(f -> f.toString().endsWith(".java"))::iterator) { + String fileName = p.getFileName().toString(); + String content = Files.readString(p); + for (Map.Entry> entry : ALLOWLIST.entrySet()) { + String token = entry.getKey(); + Set okFiles = entry.getValue(); + if (content.contains(token) && !okFiles.contains(fileName)) { + violations.add(relativeOrName(p) + " uses forbidden token: '" + token + "'"); + } + } + } + } + + if (!violations.isEmpty()) { + StringBuilder sb = new StringBuilder(); + sb.append("Forbidden API usage detected:\n\n"); + for (String v : violations) sb.append(" ").append(v).append('\n'); + sb.append("\nIf the usage is legitimate, add the filename to ALLOWLIST in ") + .append(ForbiddenApiGuardTest.class.getSimpleName()).append(" with a one-line\n") + .append("comment explaining the trust boundary. Otherwise, route through the ") + .append("existing safe path\n(SafeImageLoader, SafePngReader, BbmodelCodec).\n"); + fail(sb.toString()); + } + } + + private static String relativeOrName(Path p) { + try { + return SRC_MAIN.relativize(p).toString(); + } catch (IllegalArgumentException ex) { + return p.getFileName().toString(); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/razz/dfashion/security/SafePngReaderSecurityTest.java b/src/test/java/com/razz/dfashion/security/SafePngReaderSecurityTest.java new file mode 100644 index 0000000..2c663e1 --- /dev/null +++ b/src/test/java/com/razz/dfashion/security/SafePngReaderSecurityTest.java @@ -0,0 +1,300 @@ +package com.razz.dfashion.security; + +import com.code_intelligence.jazzer.junit.FuzzTest; +import com.razz.dfashion.skin.SafePngReader; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.zip.CRC32; +import java.util.zip.Deflater; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Security corpus + fuzz harness for {@link SafePngReader}. + * + *

The regression tests are a hand-curated set of "this MUST reject" inputs — malformed + * PNGs, polyglots, bomb shapes, dimension attacks — so a future refactor can't silently + * stop rejecting them. The fuzz harness feeds the parser random byte[] inputs; the only + * exceptions it's allowed to throw are {@link IOException} (BadPng / truncation). Any + * {@link RuntimeException} or {@link Error} from the parser is a bug Jazzer should find. + * + *

Run regression: {@code ./gradlew test}. Run fuzz: {@code ./gradlew test -PfuzzMinutes=5}. + */ +class SafePngReaderSecurityTest { + + // ---- regression corpus ---- + + @Test + void minimalValidRgbaPngDecodes() throws IOException { + byte[] png = buildMinimalRgbaPng(1, 1); + SafePngReader.Image img = SafePngReader.decode(png); + assertEquals(1, img.width); + assertEquals(1, img.height); + assertEquals(4, img.rgba.length); + } + + @Test + void rejectsEmptyInput() { + assertThrows(IOException.class, () -> SafePngReader.decode(new byte[0])); + } + + @Test + void rejectsNullInput() { + assertThrows(IOException.class, () -> SafePngReader.decode(null)); + } + + @Test + void rejectsWrongMagic() { + byte[] png = buildMinimalRgbaPng(1, 1); + png[0] = 'X'; + assertThrows(IOException.class, () -> SafePngReader.decode(png)); + } + + @Test + void rejectsTruncatedHeader() { + byte[] png = buildMinimalRgbaPng(1, 1); + byte[] truncated = new byte[png.length - 10]; + System.arraycopy(png, 0, truncated, 0, truncated.length); + assertThrows(IOException.class, () -> SafePngReader.decode(truncated)); + } + + @Test + void rejectsBadCrcInIhdr() { + byte[] png = buildMinimalRgbaPng(1, 1); + // IHDR CRC lives at byte offset 8 + 4 + 4 + 13 = 29. Flip the last CRC byte. + png[29] ^= 0x01; + assertThrows(IOException.class, () -> SafePngReader.decode(png)); + } + + @Test + void rejectsColorTypeRgb() { + byte[] png = buildRawIhdrPng(1, 1, 8, /*colorType=*/2); + assertThrows(IOException.class, () -> SafePngReader.decode(png)); + } + + @Test + void rejectsBitDepth16() { + byte[] png = buildRawIhdrPng(1, 1, 16, /*colorType=*/6); + assertThrows(IOException.class, () -> SafePngReader.decode(png)); + } + + @Test + void rejectsInterlaced() { + byte[] png = buildMinimalRgbaPng(1, 1); + // IHDR interlace byte is last of 13 IHDR bytes: offset 8 + 4 + 4 + 12 = 28. + png[28] = 1; + // Fix CRC for the mutated IHDR. + fixIhdrCrc(png); + assertThrows(IOException.class, () -> SafePngReader.decode(png)); + } + + @Test + void rejectsDimensionOverMax() { + byte[] png = buildRawIhdrPng(SafePngReader.MAX_DIM + 1, 1, 8, 6); + assertThrows(IOException.class, () -> SafePngReader.decode(png)); + } + + @Test + void rejectsZeroDimension() { + byte[] png = buildRawIhdrPng(0, 1, 8, 6); + assertThrows(IOException.class, () -> SafePngReader.decode(png)); + } + + @Test + void rejectsTrailingBytesAfterIend() { + byte[] png = buildMinimalRgbaPng(1, 1); + byte[] withTrailer = new byte[png.length + 16]; + System.arraycopy(png, 0, withTrailer, 0, png.length); + // Simulate a polyglot trailer (e.g., ZIP magic tacked on). + withTrailer[png.length] = 'P'; + withTrailer[png.length + 1] = 'K'; + withTrailer[png.length + 2] = 0x03; + withTrailer[png.length + 3] = 0x04; + assertThrows(IOException.class, () -> SafePngReader.decode(withTrailer)); + } + + @Test + void rejectsDuplicateIhdr() throws IOException { + // Build PNG with two IHDR chunks. + byte[] sig = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(sig); + writeChunk(out, "IHDR", ihdrPayload(1, 1, 8, 6)); + writeChunk(out, "IHDR", ihdrPayload(1, 1, 8, 6)); + writeChunk(out, "IDAT", idatPayload(1, 1)); + writeChunk(out, "IEND", new byte[0]); + assertThrows(IOException.class, () -> SafePngReader.decode(out.toByteArray())); + } + + @Test + void rejectsUnknownCriticalChunk() throws IOException { + byte[] sig = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(sig); + writeChunk(out, "IHDR", ihdrPayload(1, 1, 8, 6)); + writeChunk(out, "XXXX", new byte[]{1, 2, 3}); // uppercase = critical, unknown + writeChunk(out, "IDAT", idatPayload(1, 1)); + writeChunk(out, "IEND", new byte[0]); + assertThrows(IOException.class, () -> SafePngReader.decode(out.toByteArray())); + } + + @Test + void acceptsUnknownAncillaryChunk() throws IOException { + // Lowercase first letter = ancillary. Should be silently skipped, not rejected. + byte[] sig = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(sig); + writeChunk(out, "IHDR", ihdrPayload(1, 1, 8, 6)); + writeChunk(out, "tEXt", "Author\0example".getBytes()); // ancillary + writeChunk(out, "IDAT", idatPayload(1, 1)); + writeChunk(out, "IEND", new byte[0]); + SafePngReader.Image img = SafePngReader.decode(out.toByteArray()); + assertEquals(1, img.width); + } + + @Test + void rejectsMissingIend() throws IOException { + byte[] sig = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(sig); + writeChunk(out, "IHDR", ihdrPayload(1, 1, 8, 6)); + writeChunk(out, "IDAT", idatPayload(1, 1)); + assertThrows(IOException.class, () -> SafePngReader.decode(out.toByteArray())); + } + + @Test + void rejectsIdatBeforeIhdr() throws IOException { + byte[] sig = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(sig); + writeChunk(out, "IDAT", idatPayload(1, 1)); + writeChunk(out, "IHDR", ihdrPayload(1, 1, 8, 6)); + writeChunk(out, "IEND", new byte[0]); + assertThrows(IOException.class, () -> SafePngReader.decode(out.toByteArray())); + } + + @Test + void rejectsInflatedOversize() throws IOException { + // Declare a tiny image (1x1 = 4 bytes expected), but stuff IDAT with + // data that inflates to much more. The bounded inflate must reject. + byte[] sig = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(sig); + writeChunk(out, "IHDR", ihdrPayload(1, 1, 8, 6)); + writeChunk(out, "IDAT", idatPayload(16, 16)); // produces way more than 1x1's budget + writeChunk(out, "IEND", new byte[0]); + assertThrows(IOException.class, () -> SafePngReader.decode(out.toByteArray())); + } + + // ---- fuzz harness ---- + + /** + * Feed arbitrary byte arrays to the decoder. Jazzer will flag any exception + * other than {@link IOException}, or any {@link Error}, as a finding. Mutates + * around a corpus of valid PNGs by default. + */ + @FuzzTest(maxDuration = "30s") + void fuzzDecode(byte[] input) { + try { + SafePngReader.decode(input); + } catch (IOException expected) { + // BadPngException, truncation, unsupported-format — all legitimate rejections. + } + } + + // ---- builders ---- + + /** Construct a minimally valid 8-bit RGBA PNG with a solid-red pixel at every position. */ + private static byte[] buildMinimalRgbaPng(int w, int h) { + return buildRawIhdrPng(w, h, 8, 6); + } + + private static byte[] buildRawIhdrPng(int w, int h, int bitDepth, int colorType) { + byte[] sig = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try { + out.write(sig); + writeChunk(out, "IHDR", ihdrPayload(w, h, bitDepth, colorType)); + writeChunk(out, "IDAT", idatPayload(Math.max(w, 1), Math.max(h, 1))); + writeChunk(out, "IEND", new byte[0]); + } catch (IOException ex) { + throw new AssertionError(ex); + } + return out.toByteArray(); + } + + private static byte[] ihdrPayload(int w, int h, int bitDepth, int colorType) { + byte[] p = new byte[13]; + writeU32(p, 0, w); + writeU32(p, 4, h); + p[8] = (byte) bitDepth; + p[9] = (byte) colorType; + p[10] = 0; // compression + p[11] = 0; // filter + p[12] = 0; // interlace + return p; + } + + /** Construct a valid IDAT: per-row filter byte (0 = None) + RGBA pixels, deflated. */ + private static byte[] idatPayload(int w, int h) { + int rowBytes = w * 4; + byte[] raw = new byte[h * (1 + rowBytes)]; + for (int y = 0; y < h; y++) { + int off = y * (1 + rowBytes); + raw[off] = 0; // filter: None + for (int x = 0; x < w; x++) { + int px = off + 1 + x * 4; + raw[px] = (byte) 0xFF; + raw[px + 1] = 0; + raw[px + 2] = 0; + raw[px + 3] = (byte) 0xFF; + } + } + Deflater d = new Deflater(Deflater.BEST_COMPRESSION); + try { + d.setInput(raw); + d.finish(); + byte[] buf = new byte[raw.length * 2 + 64]; + int n = d.deflate(buf); + byte[] out = new byte[n]; + System.arraycopy(buf, 0, out, 0, n); + return out; + } finally { + d.end(); + } + } + + private static void writeChunk(ByteArrayOutputStream out, String type, byte[] data) throws IOException { + byte[] typeBytes = type.getBytes(); + if (typeBytes.length != 4) throw new AssertionError("bad chunk type"); + byte[] lenBytes = new byte[4]; + writeU32(lenBytes, 0, data.length); + out.write(lenBytes); + out.write(typeBytes); + out.write(data); + CRC32 crc = new CRC32(); + crc.update(typeBytes); + crc.update(data); + byte[] crcBytes = new byte[4]; + writeU32(crcBytes, 0, (int) crc.getValue()); + out.write(crcBytes); + } + + /** Recompute and rewrite IHDR's CRC after mutating its payload. */ + private static void fixIhdrCrc(byte[] png) { + // IHDR starts at offset 8 (after magic). Chunk: [len=4][type=4][data=13][crc=4]. + CRC32 crc = new CRC32(); + crc.update(png, 8 + 4, 4 + 13); // type + data + writeU32(png, 8 + 4 + 4 + 13, (int) crc.getValue()); + } + + private static void writeU32(byte[] b, int p, int v) { + b[p] = (byte) ((v >>> 24) & 0xFF); + b[p + 1] = (byte) ((v >>> 16) & 0xFF); + b[p + 2] = (byte) ((v >>> 8) & 0xFF); + b[p + 3] = (byte) (v & 0xFF); + } +} \ No newline at end of file