From 86498b6b4c91ffe3b0a549288782e4f584fb43e1 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 4 Jun 2026 20:50:22 +0200 Subject: [PATCH] feat(items): FurnidataReader (single + split JSON5, path-guard, size-cap, fail-safe) --- .../habbohotel/items/FurnidataReader.java | 164 ++++++++++++++++++ .../habbohotel/items/FurnidataReaderTest.java | 74 ++++++++ 2 files changed, 238 insertions(+) create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataReader.java create mode 100644 Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataReaderTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataReader.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataReader.java new file mode 100644 index 00000000..d3b753d8 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataReader.java @@ -0,0 +1,164 @@ +package com.eu.habbo.habbohotel.items; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Neutral furnidata reader. Supports a single JSON/JSON5 file or a split-tier + * directory ({@code core/custom/seasonal} with {@code manifest.json(5)}). + * Never throws: any IO/parse error yields an empty list (the caller decides the + * fallback). All resolved paths are guarded against escaping the base dir. + */ +public class FurnidataReader { + + private static final Logger LOGGER = LoggerFactory.getLogger(FurnidataReader.class); + private static final List DEFAULT_TIERS = Arrays.asList("core", "custom", "seasonal"); + private static final List MANIFEST_NAMES = Arrays.asList("manifest.json5", "manifest.json"); + private static final List SECTIONS = Arrays.asList("roomitemtypes", "wallitemtypes"); + + private final Path source; + private final long maxBytes; + + public FurnidataReader(Path source, long maxBytes) { + this.source = source; + this.maxBytes = maxBytes; + } + + public List read() { + List out = new ArrayList<>(); + try { + if (this.source == null || !Files.exists(this.source)) return out; + + if (Files.isDirectory(this.source)) { + readSplitDir(this.source, out); + } else { + String content = readJson5Capped(this.source); + if (content != null) { + parseRoot(JsonParser.parseString(content).getAsJsonObject(), out); + } + } + } catch (Exception e) { + LOGGER.warn("FurnidataReader failed to read {} — returning empty", this.source, e); + return new ArrayList<>(); + } + return out; + } + + private void readSplitDir(Path base, List out) { + List tiers = readManifestList(base, "tiers", DEFAULT_TIERS); + Path baseNorm = base.toAbsolutePath().normalize(); + + for (String tier : tiers) { + Path tierDir = base.resolve(tier); + if (!isInside(baseNorm, tierDir) || !Files.isDirectory(tierDir)) continue; + + for (String fileName : readManifestList(tierDir, "files", List.of())) { + Path file = tierDir.resolve(fileName); + if (!isInside(baseNorm, file)) { + LOGGER.warn("FurnidataReader: ignoring out-of-base file {}", file); + continue; + } + if (!Files.exists(file)) continue; + try { + String content = readJson5Capped(file); + if (content != null) parseRoot(JsonParser.parseString(content).getAsJsonObject(), out); + } catch (Exception e) { + LOGGER.warn("FurnidataReader: failed to parse {}", file, e); + } + } + } + } + + private List readManifestList(Path dir, String key, List fallback) { + for (String name : MANIFEST_NAMES) { + Path m = dir.resolve(name); + if (!Files.exists(m)) continue; + try { + JsonObject obj = JsonParser.parseString(readJson5Capped(m)).getAsJsonObject(); + if (obj.has(key) && obj.get(key).isJsonArray()) { + List list = new ArrayList<>(); + for (JsonElement el : obj.getAsJsonArray(key)) list.add(el.getAsString()); + if (!list.isEmpty()) return list; + } + } catch (Exception e) { + LOGGER.warn("FurnidataReader: bad manifest {}", m, e); + } + } + return fallback; + } + + private void parseRoot(JsonObject root, List out) { + for (String section : SECTIONS) { + if (!root.has(section)) continue; + JsonObject sectionObj = root.getAsJsonObject(section); + if (!sectionObj.has("furnitype")) continue; + FurnitureType type = section.equals("roomitemtypes") ? FurnitureType.FLOOR : FurnitureType.WALL; + JsonArray types = sectionObj.getAsJsonArray("furnitype"); + for (JsonElement el : types) { + JsonObject o = el.getAsJsonObject(); + if (!o.has("id") || !o.has("classname")) continue; + out.add(new FurnidataEntry( + o.get("id").getAsInt(), + o.get("classname").getAsString(), + type, + o.has("name") ? o.get("name").getAsString() : "", + o.has("description") ? o.get("description").getAsString() : "" + )); + } + } + } + + /** Returns the JSON5-stripped content, or null if the file exceeds the byte cap. */ + private String readJson5Capped(Path path) throws Exception { + long size = Files.size(path); + if (size > this.maxBytes) { + LOGGER.warn("FurnidataReader: {} is {} bytes, over cap {} — refusing", path, size, this.maxBytes); + return null; + } + return stripJson5(Files.readString(path, StandardCharsets.UTF_8)); + } + + private static boolean isInside(Path baseNorm, Path candidate) { + return candidate.toAbsolutePath().normalize().startsWith(baseNorm); + } + + /** Strip // and block comments and trailing commas so Gson can parse JSON5. */ + static String stripJson5(String content) { + if (content == null || content.isEmpty()) return content; + StringBuilder out = new StringBuilder(content.length()); + int i = 0, len = content.length(); + boolean inString = false, escape = false; + char stringChar = 0; + while (i < len) { + char c = content.charAt(i); + if (inString) { + out.append(c); + if (escape) escape = false; + else if (c == '\\') escape = true; + else if (c == stringChar) inString = false; + i++; + continue; + } + if (c == '"' || c == '\'') { inString = true; stringChar = c; out.append(c); i++; continue; } + if (c == '/' && i + 1 < len) { + char next = content.charAt(i + 1); + if (next == '/') { int eol = content.indexOf('\n', i + 2); if (eol < 0) break; i = eol; continue; } + if (next == '*') { int end = content.indexOf("*/", i + 2); if (end < 0) break; i = end + 2; continue; } + } + out.append(c); + i++; + } + return out.toString().replaceAll(",(\\s*[}\\]])", "$1"); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataReaderTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataReaderTest.java new file mode 100644 index 00000000..a15e2c19 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataReaderTest.java @@ -0,0 +1,74 @@ +package com.eu.habbo.habbohotel.items; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class FurnidataReaderTest { + + private static final String SINGLE = """ + { + // a comment + "roomitemtypes": { "furnitype": [ + { "id": 10, "classname": "chair_norja", "name": "Chair", "description": "Sit", "xdim": 1, "ydim": 1 }, + ]}, + "wallitemtypes": { "furnitype": [ + { "id": 20, "classname": "poster_5", "name": "Poster", "description": "Wall" } + ]} + } + """; + + @Test + void parsesSingleFileFloorAndWall(@TempDir Path dir) throws Exception { + Path file = dir.resolve("FurnitureData.json"); + Files.writeString(file, SINGLE); + + List entries = new FurnidataReader(file, 64 * 1024 * 1024).read(); + + assertEquals(2, entries.size()); + FurnidataEntry floor = entries.stream().filter(e -> e.id() == 10).findFirst().orElseThrow(); + assertEquals("chair_norja", floor.classname()); + assertEquals(FurnitureType.FLOOR, floor.type()); + assertEquals("Chair", floor.name()); + FurnidataEntry wall = entries.stream().filter(e -> e.id() == 20).findFirst().orElseThrow(); + assertEquals(FurnitureType.WALL, wall.type()); + } + + @Test + void rejectsFileOverSizeCap(@TempDir Path dir) throws Exception { + Path file = dir.resolve("FurnitureData.json"); + Files.writeString(file, SINGLE); + List entries = new FurnidataReader(file, 8 /* bytes */).read(); + assertTrue(entries.isEmpty(), "oversized file must be refused, returning empty"); + } + + @Test + void missingSourceReturnsEmptyNeverThrows(@TempDir Path dir) { + Path missing = dir.resolve("does-not-exist.json"); + assertDoesNotThrow(() -> { + assertTrue(new FurnidataReader(missing, 64 * 1024 * 1024).read().isEmpty()); + }); + } + + @Test + void splitDirRejectsTraversalFiles(@TempDir Path dir) throws Exception { + Path secret = dir.resolve("secret.json"); + Files.writeString(secret, "{ \"roomitemtypes\": { \"furnitype\": [ { \"id\": 99, \"classname\": \"x\", \"name\": \"LEAK\", \"description\": \"\" } ] } }"); + + Path base = dir.resolve("furnidata"); + Path core = base.resolve("core"); + Files.createDirectories(core); + Files.writeString(base.resolve("manifest.json"), "{ \"tiers\": [ \"core\" ] }"); + Files.writeString(core.resolve("manifest.json"), "{ \"files\": [ \"../../secret.json\" ] }"); + + List entries = new FurnidataReader(base, 64 * 1024 * 1024).read(); + + assertTrue(entries.stream().noneMatch(e -> e.id() == 99), + "traversal file outside the base dir must be ignored"); + } +}