From 43c2c2b0f179cc587d2ce7d8d061379d1f12c7d0 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 6 Jun 2026 02:09:23 +0200 Subject: [PATCH] feat(furnidata): split-tier write to winning tier with path-traversal guard --- .../habbohotel/items/FurnidataWriter.java | 83 ++++++++++++++++- .../habbohotel/items/FurnidataWriterTest.java | 90 +++++++++++++++++++ 2 files changed, 169 insertions(+), 4 deletions(-) diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWriter.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWriter.java index e525a14e..fd4f701a 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWriter.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWriter.java @@ -3,6 +3,8 @@ package com.eu.habbo.habbohotel.items; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.*; +import java.util.ArrayList; +import java.util.Arrays; import java.util.Comparator; import java.util.List; import java.util.regex.Matcher; @@ -14,6 +16,13 @@ import java.util.regex.Pattern; * the classname). Edit-only: refuses classnames absent from the furnidata. */ public class FurnidataWriter { + + /** Default tier names in override order (later = higher priority, wins on conflict). */ + private static final List DEFAULT_TIERS = Arrays.asList("core", "custom", "seasonal"); + + /** Manifest filenames tried in order (json5 first, plain json second). */ + private static final List MANIFEST_NAMES = Arrays.asList("manifest.json5", "manifest.json"); + private final Path source; // file (single) or base dir (split-tier) private final boolean directory; // true => split-tier private final long maxBytes; @@ -135,11 +144,77 @@ public class FurnidataWriter { return b.toString(); } + /** + * Enumerate every data file reachable from the split-tier base directory, in + * override order (core → custom → seasonal, or the order declared in the top-level + * {@code manifest.json(5)}). Within each tier the per-tier manifest's {@code files} + * array determines the file order. + * + *

All resolved paths are checked against the normalised base directory via + * {@link #safeResolve}: any entry that would escape the base is silently skipped. + * + * @return ordered list of existing, in-bounds data files (earliest tier first). + */ private List splitTierFilesInOrder() throws IOException { - // Mirrors FurnidataReader split-tier resolution at a coarse level: the manifest order. - // For the plan we reuse the reader's defaults; the concrete enumeration is implemented - // in Task 4 alongside the split-tier test. Single-file path does not call this. - throw new UnsupportedOperationException("implemented in Task 4"); + Path base = source.toAbsolutePath().normalize(); + List tiers = manifestList(base, "tiers", DEFAULT_TIERS); + List result = new ArrayList<>(); + + for (String tier : tiers) { + Path tierDir = safeResolve(base, tier); + if (tierDir == null || !Files.isDirectory(tierDir)) continue; + + for (String fileName : manifestList(tierDir, "files", List.of())) { + Path file = safeResolve(base, tierDir.resolve(fileName).toString()); + if (file == null || !Files.isRegularFile(file)) continue; + result.add(file); + } + } + return result; + } + + /** + * Resolve {@code entry} relative to {@code base} and verify the result stays + * inside {@code base} (path-traversal guard). + * + * @param base the normalised absolute base directory. + * @param entry a path string (may be relative or absolute, may contain {@code ..}). + * @return the normalised absolute path if it is inside {@code base}; {@code null} otherwise. + */ + private static Path safeResolve(Path base, String entry) { + try { + Path resolved = base.resolve(entry).toAbsolutePath().normalize(); + return resolved.startsWith(base) ? resolved : null; + } catch (Exception e) { + return null; + } + } + + /** + * Read the {@code key} string-array from the first manifest file found in {@code dir} + * ({@code manifest.json5} then {@code manifest.json}). Falls back to {@code fallback} + * if no manifest exists or the key is absent/empty. + */ + private List manifestList(Path dir, String key, List fallback) { + for (String name : MANIFEST_NAMES) { + Path m = dir.resolve(name); + if (!Files.exists(m)) continue; + try { + String stripped = FurnidataReader.stripJson5( + Files.readString(m, StandardCharsets.UTF_8)); + com.google.gson.JsonObject obj = + com.google.gson.JsonParser.parseString(stripped).getAsJsonObject(); + if (obj.has(key) && obj.get(key).isJsonArray()) { + List list = new ArrayList<>(); + for (com.google.gson.JsonElement el : obj.getAsJsonArray(key)) + list.add(el.getAsString()); + if (!list.isEmpty()) return list; + } + } catch (Exception ignored) { + // bad manifest → fall through to next candidate / fallback + } + } + return fallback; } private void backup(Path target) throws IOException { diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataWriterTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataWriterTest.java index 16faeb76..bef12c82 100644 --- a/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataWriterTest.java +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataWriterTest.java @@ -11,6 +11,18 @@ class FurnidataWriterTest { " { \"id\": 1, \"classname\": \"01_caterhead\", \"name\": \"old name\", \"description\": \"old desc\" }\n" + "] }, \"wallitemtypes\": { \"furnitype\": [] } }"; + // Tier data: core has the entry with "core name"; custom ALSO has it with "custom old name". + // The writer must pick the custom (winning) tier and leave core untouched. + private static final String CORE_DATA = + "{ \"roomitemtypes\": { \"furnitype\": [\n" + + " { \"id\": 1, \"classname\": \"split_chair\", \"name\": \"core name\", \"description\": \"core desc\" }\n" + + "] }, \"wallitemtypes\": { \"furnitype\": [] } }"; + + private static final String CUSTOM_DATA = + "{ \"roomitemtypes\": { \"furnitype\": [\n" + + " { \"id\": 1, \"classname\": \"split_chair\", \"name\": \"custom old name\", \"description\": \"custom old desc\" }\n" + + "] }, \"wallitemtypes\": { \"furnitype\": [] } }"; + @Test void writesNameAndDescriptionByClassnameSingleFile() throws Exception { Path dir = Files.createTempDirectory("fd"); @@ -37,4 +49,82 @@ class FurnidataWriterTest { FurnidataWriter w = new FurnidataWriter(file, false, 64L * 1024 * 1024, 10); assertFalse(w.write("does_not_exist", "x", "y")); } + + /** + * Split-tier: classname present in both core and custom tiers. + * The writer must update the winning (later) tier — custom — and leave core untouched. + */ + @Test + void splitTierWritesWinningTierLeavesEarlierTierUntouched() throws Exception { + Path base = Files.createTempDirectory("fd-split"); + + // Tier subdirectories + Path coreDir = base.resolve("core"); + Path customDir = base.resolve("custom"); + Files.createDirectories(coreDir); + Files.createDirectories(customDir); + + // Top-level manifest: tiers in override order (core < custom) + Files.writeString(base.resolve("manifest.json"), + "{ \"tiers\": [ \"core\", \"custom\" ] }"); + + // Per-tier manifests listing the data file + Files.writeString(coreDir.resolve("manifest.json"), + "{ \"files\": [ \"furnidata.json\" ] }"); + Files.writeString(customDir.resolve("manifest.json"), + "{ \"files\": [ \"furnidata.json\" ] }"); + + // Data files + Path coreFile = coreDir.resolve("furnidata.json"); + Path customFile = customDir.resolve("furnidata.json"); + Files.writeString(coreFile, CORE_DATA); + Files.writeString(customFile, CUSTOM_DATA); + + FurnidataWriter w = new FurnidataWriter(base, true, 64L * 1024 * 1024, 10); + boolean ok = w.write("split_chair", "New Name", "New desc"); + + assertTrue(ok, "write must succeed for classname present in split-tier layout"); + + // custom (winning tier) must be updated + String customAfter = Files.readString(customFile); + assertTrue(customAfter.contains("\"New Name\""), "winning tier must contain new name"); + assertTrue(customAfter.contains("\"New desc\""), "winning tier must contain new desc"); + assertFalse(customAfter.contains("custom old name"), "old name must be gone from winning tier"); + + // core (earlier tier) must be UNTOUCHED + String coreAfter = Files.readString(coreFile); + assertTrue(coreAfter.contains("core name"), "earlier tier must be left untouched"); + } + + /** + * Split-tier path-traversal guard: a manifest that lists "../escape" as a tier + * must be rejected by safeResolve so the writer cannot reach files outside the base dir. + */ + @Test + void splitTierRejectsTraversalTierInManifest() throws Exception { + Path base = Files.createTempDirectory("fd-traversal"); + + // "Escape" directory sits OUTSIDE base + Path escapeDir = base.getParent().resolve("escape_secret"); + Files.createDirectories(escapeDir); + Files.writeString(escapeDir.resolve("manifest.json"), + "{ \"files\": [ \"secret.json\" ] }"); + Files.writeString(escapeDir.resolve("secret.json"), + "{ \"roomitemtypes\": { \"furnitype\": [\n" + + " { \"id\": 99, \"classname\": \"escape_chair\", \"name\": \"secret old\", \"description\": \"\" }\n" + + "] }, \"wallitemtypes\": { \"furnitype\": [] } }"); + + // Top-level manifest references the escape dir via traversal + Files.writeString(base.resolve("manifest.json"), + "{ \"tiers\": [ \"../escape_secret\" ] }"); + + FurnidataWriter w = new FurnidataWriter(base, true, 64L * 1024 * 1024, 10); + boolean ok = w.write("escape_chair", "Pwned", "desc"); + + assertFalse(ok, "classname reachable only via traversal path must not be found/written"); + + // The secret file must not have been touched + String secretAfter = Files.readString(escapeDir.resolve("secret.json")); + assertTrue(secretAfter.contains("secret old"), "traversal target must be untouched"); + } }