You've already forked Arcturus-Morningstar-Extended
mirror of
https://github.com/duckietm/Arcturus-Morningstar-Extended.git
synced 2026-06-20 07:26:18 +00:00
feat(furnidata): split-tier write to winning tier with path-traversal guard
This commit is contained in:
@@ -3,6 +3,8 @@ package com.eu.habbo.habbohotel.items;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.*;
|
import java.nio.file.*;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
@@ -14,6 +16,13 @@ import java.util.regex.Pattern;
|
|||||||
* the classname). Edit-only: refuses classnames absent from the furnidata.
|
* the classname). Edit-only: refuses classnames absent from the furnidata.
|
||||||
*/
|
*/
|
||||||
public class FurnidataWriter {
|
public class FurnidataWriter {
|
||||||
|
|
||||||
|
/** Default tier names in override order (later = higher priority, wins on conflict). */
|
||||||
|
private static final List<String> DEFAULT_TIERS = Arrays.asList("core", "custom", "seasonal");
|
||||||
|
|
||||||
|
/** Manifest filenames tried in order (json5 first, plain json second). */
|
||||||
|
private static final List<String> MANIFEST_NAMES = Arrays.asList("manifest.json5", "manifest.json");
|
||||||
|
|
||||||
private final Path source; // file (single) or base dir (split-tier)
|
private final Path source; // file (single) or base dir (split-tier)
|
||||||
private final boolean directory; // true => split-tier
|
private final boolean directory; // true => split-tier
|
||||||
private final long maxBytes;
|
private final long maxBytes;
|
||||||
@@ -135,11 +144,77 @@ public class FurnidataWriter {
|
|||||||
return b.toString();
|
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.
|
||||||
|
*
|
||||||
|
* <p>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<Path> splitTierFilesInOrder() throws IOException {
|
private List<Path> splitTierFilesInOrder() throws IOException {
|
||||||
// Mirrors FurnidataReader split-tier resolution at a coarse level: the manifest order.
|
Path base = source.toAbsolutePath().normalize();
|
||||||
// For the plan we reuse the reader's defaults; the concrete enumeration is implemented
|
List<String> tiers = manifestList(base, "tiers", DEFAULT_TIERS);
|
||||||
// in Task 4 alongside the split-tier test. Single-file path does not call this.
|
List<Path> result = new ArrayList<>();
|
||||||
throw new UnsupportedOperationException("implemented in Task 4");
|
|
||||||
|
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<String> manifestList(Path dir, String key, List<String> 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<String> 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 {
|
private void backup(Path target) throws IOException {
|
||||||
|
|||||||
@@ -11,6 +11,18 @@ class FurnidataWriterTest {
|
|||||||
" { \"id\": 1, \"classname\": \"01_caterhead\", \"name\": \"old name\", \"description\": \"old desc\" }\n" +
|
" { \"id\": 1, \"classname\": \"01_caterhead\", \"name\": \"old name\", \"description\": \"old desc\" }\n" +
|
||||||
"] }, \"wallitemtypes\": { \"furnitype\": [] } }";
|
"] }, \"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
|
@Test
|
||||||
void writesNameAndDescriptionByClassnameSingleFile() throws Exception {
|
void writesNameAndDescriptionByClassnameSingleFile() throws Exception {
|
||||||
Path dir = Files.createTempDirectory("fd");
|
Path dir = Files.createTempDirectory("fd");
|
||||||
@@ -37,4 +49,82 @@ class FurnidataWriterTest {
|
|||||||
FurnidataWriter w = new FurnidataWriter(file, false, 64L * 1024 * 1024, 10);
|
FurnidataWriter w = new FurnidataWriter(file, false, 64L * 1024 * 1024, 10);
|
||||||
assertFalse(w.write("does_not_exist", "x", "y"));
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user