From 5bf1d42cfbc5c0e9abdc915e87653f40d42b449f Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 4 Jun 2026 20:57:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(items):=20FurnitureTextProvider=20?= =?UTF-8?q?=E2=80=94=20volatile=20index,=20sanitize,=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../items/FurnitureTextProvider.java | 68 +++++++++++++++++++ .../items/FurnitureTextProviderTest.java | 61 +++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java create mode 100644 Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnitureTextProviderTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java new file mode 100644 index 00000000..634b8be0 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java @@ -0,0 +1,68 @@ +package com.eu.habbo.habbohotel.items; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * In-memory index of furnidata display names, keyed by the lowercased base + * classname (the {@code *N} colour-variant suffix is stripped). Read lazily by + * {@link Item#getDisplayName()}. Names are sanitized at index time. + * + * Thread-safety: the index is held behind a {@code volatile} reference; readers + * never block; {@link #reindex(List)} builds a fresh map and swaps it atomically. + */ +public class FurnitureTextProvider { + + private static final int MAX_LEN = 256; + + private final boolean enabled; + private volatile Map index = Map.of(); + + public FurnitureTextProvider(boolean enabled) { + this.enabled = enabled; + } + + /** Build a fresh sanitized index from the given entries and swap it in atomically. */ + public void reindex(List entries) { + Map next = new HashMap<>(Math.max(16, entries.size() * 2)); + for (FurnidataEntry e : entries) { + String key = baseKey(e.classname()); + if (key == null) continue; + next.put(key, new FurniText(e.id(), e.type(), sanitize(e.name()), sanitize(e.description()))); + } + this.index = next; // atomic reference swap + } + + /** Returns the sanitized display name for a DB classname, or null if absent/disabled. */ + public String getName(String classname) { + if (!this.enabled) return null; + String key = baseKey(classname); + if (key == null) return null; + FurniText t = this.index.get(key); + return (t != null) ? t.name() : null; + } + + private static String baseKey(String classname) { + if (classname == null) return null; + int star = classname.indexOf('*'); + String base = (star >= 0) ? classname.substring(0, star) : classname; + base = base.trim().toLowerCase(); + return base.isEmpty() ? null : base; + } + + /** Cap length, strip control chars/newlines, neutralize % (placeholder-injection safe). */ + static String sanitize(String value) { + if (value == null) return ""; + StringBuilder sb = new StringBuilder(Math.min(value.length(), MAX_LEN)); + for (int i = 0; i < value.length() && sb.length() < MAX_LEN; i++) { + char c = value.charAt(i); + if (c == '%') { sb.append('%'); continue; } // fullwidth percent — not a placeholder token + if (c == '\n' || c == '\r' || Character.isISOControl(c)) continue; + sb.append(c); + } + return sb.toString(); + } + + private record FurniText(int id, FurnitureType type, String name, String description) {} +} diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnitureTextProviderTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnitureTextProviderTest.java new file mode 100644 index 00000000..5e510071 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnitureTextProviderTest.java @@ -0,0 +1,61 @@ +package com.eu.habbo.habbohotel.items; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class FurnitureTextProviderTest { + + private FurnitureTextProvider provider(boolean enabled, FurnidataEntry... entries) { + FurnitureTextProvider p = new FurnitureTextProvider(enabled); + p.reindex(List.of(entries)); + return p; + } + + @Test + void resolvesNameByClassname() { + FurnitureTextProvider p = provider(true, + new FurnidataEntry(1, "chair_norja", FurnitureType.FLOOR, "Norja Chair", "Sit")); + assertEquals("Norja Chair", p.getName("chair_norja")); + } + + @Test + void matchesBaseClassnameIgnoringColourVariantAndCase() { + FurnitureTextProvider p = provider(true, + new FurnidataEntry(1, "chair_norja*2", FurnitureType.FLOOR, "Norja Chair", "Sit")); + assertEquals("Norja Chair", p.getName("CHAIR_NORJA")); + } + + @Test + void returnsNullWhenClassnameMissing() { + FurnitureTextProvider p = provider(true); + assertNull(p.getName("unknown_thing")); + } + + @Test + void returnsNullWhenDisabled() { + FurnitureTextProvider p = provider(false, + new FurnidataEntry(1, "chair_norja", FurnitureType.FLOOR, "Norja Chair", "Sit")); + assertNull(p.getName("chair_norja")); + } + + @Test + void sanitizesNameCapStripControlAndNeutralizesPercent() { + String evil = "Bad\nName %limit% %user.name%".repeat(20); + FurnitureTextProvider p = provider(true, + new FurnidataEntry(1, "x", FurnitureType.FLOOR, evil, "")); + String name = p.getName("x"); + assertTrue(name.length() <= 256, "must be capped to 256"); + assertFalse(name.chars().anyMatch(Character::isISOControl), "no control chars remain after sanitize"); + assertFalse(name.contains("%"), "ASCII percent neutralized"); + } + + @Test + void nullProviderNameNeverThrows() { + FurnitureTextProvider p = provider(true); + assertDoesNotThrow(() -> p.getName(null)); + assertNull(p.getName(null)); + } +}