feat(items): FurnitureTextProvider — volatile index, sanitize, toggle

This commit is contained in:
simoleo89
2026-06-04 20:57:44 +02:00
parent b162b3f4d8
commit 5bf1d42cfb
2 changed files with 129 additions and 0 deletions
@@ -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<String, FurniText> 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<FurnidataEntry> entries) {
Map<String, FurniText> 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) {}
}
@@ -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));
}
}