You've already forked Arcturus-Morningstar-Extended
mirror of
https://github.com/duckietm/Arcturus-Morningstar-Extended.git
synced 2026-06-19 15:06:19 +00:00
feat(items): FurnidataReader (single + split JSON5, path-guard, size-cap, fail-safe)
This commit is contained in:
@@ -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<String> DEFAULT_TIERS = Arrays.asList("core", "custom", "seasonal");
|
||||
private static final List<String> MANIFEST_NAMES = Arrays.asList("manifest.json5", "manifest.json");
|
||||
private static final List<String> 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<FurnidataEntry> read() {
|
||||
List<FurnidataEntry> 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<FurnidataEntry> out) {
|
||||
List<String> 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<String> readManifestList(Path dir, String key, List<String> 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<String> 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<FurnidataEntry> 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");
|
||||
}
|
||||
}
|
||||
@@ -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<FurnidataEntry> 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<FurnidataEntry> 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<FurnidataEntry> 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user