Failed closed on unreadable takeovers

- Modded config reports unparseable files, non-object takeover sections, and invalid takeover entries instead of returning silent defaults
- Takeover reconciliation refuses startup when takeovers are unreadable and the world has an applied takeover state file
- Config writes are refused while takeovers are unreadable so fallback defaults never overwrite the operator's file
This commit is contained in:
Brian Neumann-Fopiano
2026-10-14 20:51:18 -04:00
parent 79e220298f
commit 4244aa4ea7
4 changed files with 253 additions and 37 deletions
@@ -24,8 +24,11 @@ import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.TreeSet;
public final class ModdedModConfig {
private static final Object LOCK = new Object();
@@ -39,17 +42,19 @@ public final class ModdedModConfig {
private final long mainWorldSeed;
private final boolean mainWorldAutoRestart;
private final Map<String, String> dimensionTakeovers;
private final List<String> takeoverProblems;
private ModdedModConfig(String defaultPack, String primaryWorld, boolean routePlayersToPrimaryWorld,
String mainWorldPack, long mainWorldSeed, boolean mainWorldAutoRestart,
Map<String, String> dimensionTakeovers) {
Takeovers takeovers) {
this.defaultPack = defaultPack;
this.primaryWorld = primaryWorld == null ? "" : primaryWorld.trim();
this.routePlayersToPrimaryWorld = routePlayersToPrimaryWorld;
this.mainWorldPack = mainWorldPack == null ? "" : mainWorldPack.trim();
this.mainWorldSeed = mainWorldSeed;
this.mainWorldAutoRestart = mainWorldAutoRestart;
this.dimensionTakeovers = Map.copyOf(dimensionTakeovers);
this.dimensionTakeovers = Map.copyOf(takeovers.entries());
this.takeoverProblems = List.copyOf(takeovers.problems());
}
public static ModdedModConfig get() {
@@ -68,9 +73,9 @@ public final class ModdedModConfig {
public static void setPrimaryWorld(String dimensionId) {
synchronized (LOCK) {
ModdedModConfig current = get();
ModdedModConfig current = writable();
ModdedModConfig updated = new ModdedModConfig(current.defaultPack, dimensionId, current.routePlayersToPrimaryWorld,
current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, current.dimensionTakeovers);
current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, current.takeovers());
instance = updated;
write(configFile(), updated);
}
@@ -78,9 +83,9 @@ public final class ModdedModConfig {
public static void setMainWorld(String packRef, long seed) {
synchronized (LOCK) {
ModdedModConfig current = get();
ModdedModConfig current = writable();
ModdedModConfig updated = new ModdedModConfig(current.defaultPack, current.primaryWorld, current.routePlayersToPrimaryWorld,
packRef == null ? "" : packRef.trim(), seed, current.mainWorldAutoRestart, current.dimensionTakeovers);
packRef == null ? "" : packRef.trim(), seed, current.mainWorldAutoRestart, current.takeovers());
instance = updated;
write(configFile(), updated);
}
@@ -122,16 +127,59 @@ public final class ModdedModConfig {
return dimensionTakeovers;
}
public List<String> takeoverProblems() {
return takeoverProblems;
}
static ModdedModConfig read(Path file) {
ModdedModConfig defaults = defaults();
JSONObject json;
try {
json = new JSONObject(Files.readString(file, StandardCharsets.UTF_8));
} catch (RuntimeException | IOException unreadable) {
ModdedIrisLog.error("Iris modded config at {} is unreadable; using defaults", file, unreadable);
return new ModdedModConfig(defaults.defaultPack, defaults.primaryWorld, defaults.routePlayersToPrimaryWorld,
defaults.mainWorldPack, defaults.mainWorldSeed, defaults.mainWorldAutoRestart,
new Takeovers(Map.of(), List.of("Iris could not read " + file + ": " + unreadable.getMessage())));
}
return new ModdedModConfig(
json.optString("defaultPack", defaults.defaultPack),
json.optString("primaryWorld", defaults.primaryWorld),
json.optBoolean("routePlayersToPrimaryWorld", defaults.routePlayersToPrimaryWorld),
json.optString("mainWorldPack", defaults.mainWorldPack),
json.optLong("mainWorldSeed", defaults.mainWorldSeed),
json.optBoolean("mainWorldAutoRestart", defaults.mainWorldAutoRestart),
readDimensionTakeovers(json, file));
}
private Takeovers takeovers() {
return new Takeovers(dimensionTakeovers, takeoverProblems);
}
private static ModdedModConfig writable() {
ModdedModConfig current = get();
if (!current.takeoverProblems.isEmpty()) {
throw new IllegalStateException("Iris will not rewrite " + configFile()
+ " while its dimension takeovers are unreadable: " + String.join("; ", current.takeoverProblems)
+ ". Fix the file and restart first.");
}
return current;
}
private static ModdedModConfig defaults() {
return new ModdedModConfig("overworld", "", true, "", 0L, false, new Takeovers(Map.of(), List.of()));
}
private static boolean updateDimensionTakeover(String levelKey, String packRef) {
synchronized (LOCK) {
ModdedModConfig current = get();
ModdedModConfig current = writable();
Map<String, String> takeovers = new TreeMap<>(current.dimensionTakeovers);
String previous = packRef == null ? takeovers.remove(levelKey) : takeovers.put(levelKey, packRef);
if (packRef == null ? previous == null : packRef.equals(previous)) {
return false;
}
ModdedModConfig updated = new ModdedModConfig(current.defaultPack, current.primaryWorld, current.routePlayersToPrimaryWorld,
current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, takeovers);
current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, new Takeovers(takeovers, List.of()));
instance = updated;
write(configFile(), updated);
return true;
@@ -144,41 +192,41 @@ public final class ModdedModConfig {
private static ModdedModConfig load() {
Path file = configFile();
ModdedModConfig defaults = new ModdedModConfig("overworld", "", true, "", 0L, false, Map.of());
if (!Files.isRegularFile(file)) {
ModdedModConfig defaults = defaults();
write(file, defaults);
return defaults;
}
try {
JSONObject json = new JSONObject(Files.readString(file, StandardCharsets.UTF_8));
return new ModdedModConfig(
json.optString("defaultPack", defaults.defaultPack),
json.optString("primaryWorld", defaults.primaryWorld),
json.optBoolean("routePlayersToPrimaryWorld", defaults.routePlayersToPrimaryWorld),
json.optString("mainWorldPack", defaults.mainWorldPack),
json.optLong("mainWorldSeed", defaults.mainWorldSeed),
json.optBoolean("mainWorldAutoRestart", defaults.mainWorldAutoRestart),
readDimensionTakeovers(json.optJSONObject(DIMENSION_TAKEOVERS), file));
} catch (RuntimeException | IOException e) {
ModdedIrisLog.error("Iris modded config at {} is invalid; using defaults", file, e);
return defaults;
}
return read(file);
}
private static Map<String, String> readDimensionTakeovers(JSONObject json, Path file) {
if (json == null) {
return Map.of();
private static Takeovers readDimensionTakeovers(JSONObject json, Path file) {
if (!json.has(DIMENSION_TAKEOVERS) || json.isNull(DIMENSION_TAKEOVERS)) {
return new Takeovers(Map.of(), List.of());
}
JSONObject section = json.optJSONObject(DIMENSION_TAKEOVERS);
if (section == null) {
return new Takeovers(Map.of(), List.of("Iris could not read " + DIMENSION_TAKEOVERS + " in " + file
+ ": it must be an object mapping dimension keys to pack references"));
}
Map<String, String> takeovers = new TreeMap<>();
for (String levelKey : json.keySet()) {
List<String> problems = new ArrayList<>();
for (String levelKey : new TreeSet<>(section.keySet())) {
Object value = section.opt(levelKey);
try {
ModdedDimensionTakeover takeover = ModdedDimensionTakeover.parse(levelKey, json.optString(levelKey, ""));
if (!(value instanceof String packRef)) {
throw new IllegalArgumentException("the pack reference must be a string");
}
ModdedDimensionTakeover takeover = ModdedDimensionTakeover.parse(levelKey, packRef);
takeovers.put(takeover.levelKey(), takeover.packRef());
} catch (IllegalArgumentException invalid) {
ModdedIrisLog.error("Iris ignored dimension takeover '{}' in {}: {}", levelKey, file, invalid.getMessage());
problems.add("Iris could not read dimension takeover '" + levelKey + "' in " + file + ": " + invalid.getMessage());
}
}
return takeovers;
for (String problem : problems) {
ModdedIrisLog.error(problem);
}
return new Takeovers(takeovers, problems);
}
private static void write(Path file, ModdedModConfig config) {
@@ -201,4 +249,7 @@ public final class ModdedModConfig {
ModdedIrisLog.error("Iris failed to write modded config at {}", file, e);
}
}
private record Takeovers(Map<String, String> entries, List<String> problems) {
}
}
@@ -44,7 +44,8 @@ public final class ModdedTakeoverReconciler {
public static void reconcileEarly() {
try {
Map<String, String> configured = ModdedModConfig.get().dimensionTakeovers();
ModdedModConfig config = ModdedModConfig.get();
Map<String, String> configured = config.dimensionTakeovers();
Path worldRoot = MainWorldService.configuredWorldRootIfPresent();
if (worldRoot == null) {
if (!configured.isEmpty()) {
@@ -54,7 +55,7 @@ public final class ModdedTakeoverReconciler {
return;
}
Path recoveryParent = ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen");
Result result = reconcile(worldRoot, configured, recoveryParent);
Result result = reconcile(worldRoot, configured, config.takeoverProblems(), recoveryParent);
if (result.changedLevels().isEmpty()) {
return;
}
@@ -85,7 +86,9 @@ public final class ModdedTakeoverReconciler {
}
}
static Result reconcile(Path worldRoot, Map<String, String> configured, Path recoveryParent) throws IOException {
static Result reconcile(Path worldRoot, Map<String, String> configured, List<String> configProblems,
Path recoveryParent) throws IOException {
requireReadableConfig(worldRoot, configProblems);
Map<String, String> applied = readApplied(worldRoot);
Set<String> changed = ModdedDimensionTakeovers.changedLevels(applied, configured);
if (changed.isEmpty()) {
@@ -104,6 +107,19 @@ public final class ModdedTakeoverReconciler {
return new Result(changed, moved.isEmpty() ? null : recovery);
}
private static void requireReadableConfig(Path worldRoot, List<String> configProblems) throws IOException {
if (configProblems.isEmpty()) {
return;
}
Path state = worldRoot.resolve(STATE_FILE);
if (!Files.exists(state)) {
return;
}
throw new IOException("Iris refused to reconcile dimension takeovers because the configured takeovers are unreadable while "
+ state + " records applied takeovers: " + String.join("; ", configProblems)
+ ". Fix the modded config so every takeover entry is valid; Iris never treats an unreadable takeover as removed.");
}
static Map<String, String> readApplied(Path worldRoot) throws IOException {
Path state = worldRoot.resolve(STATE_FILE);
if (!Files.isRegularFile(state)) {
@@ -0,0 +1,95 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.modded;
import org.junit.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class ModdedModConfigTest {
@Test
public void validTakeoversLoadWithoutProblems() throws IOException {
Path file = config("{\"dimensionTakeovers\": {\"aether:the_aether\": \"overworld:sky\"}}");
try {
ModdedModConfig config = ModdedModConfig.read(file);
assertEquals(Map.of("aether:the_aether", "overworld:sky"), config.dimensionTakeovers());
assertEquals(List.of(), config.takeoverProblems());
} finally {
ModdedTakeoverReconcilerTest.deleteTree(file.getParent());
}
}
@Test
public void unparseableConfigReportsAProblemNamingTheFile() throws IOException {
Path file = config("{\"dimensionTakeovers\": {\"aether:the_aether\": ");
try {
ModdedModConfig config = ModdedModConfig.read(file);
assertEquals(Map.of(), config.dimensionTakeovers());
assertEquals(1, config.takeoverProblems().size());
assertTrue(config.takeoverProblems().getFirst(), config.takeoverProblems().getFirst().contains(file.toString()));
} finally {
ModdedTakeoverReconcilerTest.deleteTree(file.getParent());
}
}
@Test
public void invalidTakeoverEntryIsReportedInsteadOfSilentlyDropped() throws IOException {
Path file = config("{\"dimensionTakeovers\": {\"aether:the_aether\": \"overworld\", \"Broken Key\": \"overworld\"}}");
try {
ModdedModConfig config = ModdedModConfig.read(file);
assertEquals(Map.of("aether:the_aether", "overworld"), config.dimensionTakeovers());
assertEquals(1, config.takeoverProblems().size());
assertTrue(config.takeoverProblems().getFirst(), config.takeoverProblems().getFirst().contains("Broken Key"));
assertTrue(config.takeoverProblems().getFirst(), config.takeoverProblems().getFirst().contains(file.toString()));
} finally {
ModdedTakeoverReconcilerTest.deleteTree(file.getParent());
}
}
@Test
public void takeoverSectionThatIsNotAnObjectIsReported() throws IOException {
Path file = config("{\"dimensionTakeovers\": [\"aether:the_aether\"]}");
try {
ModdedModConfig config = ModdedModConfig.read(file);
assertEquals(Map.of(), config.dimensionTakeovers());
assertEquals(1, config.takeoverProblems().size());
assertTrue(config.takeoverProblems().getFirst(), config.takeoverProblems().getFirst().contains("dimensionTakeovers"));
} finally {
ModdedTakeoverReconcilerTest.deleteTree(file.getParent());
}
}
private static Path config(String json) throws IOException {
Path file = Files.createTempDirectory("iris-modded-config").resolve("modded.json");
Files.writeString(file, json, StandardCharsets.UTF_8);
return file;
}
}
@@ -33,6 +33,7 @@ import java.util.stream.Stream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class ModdedTakeoverReconcilerTest {
@@ -64,7 +65,7 @@ public class ModdedTakeoverReconcilerTest {
touch(worldRoot.resolve("players/data/player.dat"));
ModdedTakeoverReconciler.Result result = ModdedTakeoverReconciler.reconcile(
worldRoot, Map.of("iristest:skylands", "overworld"), recoveryParent);
worldRoot, Map.of("iristest:skylands", "overworld"), List.of(), recoveryParent);
assertEquals(Set.of("iristest:skylands"), result.changedLevels());
for (String folder : List.of("region", "entities", "poi", "mantle-hydrology", "iris")) {
@@ -92,14 +93,14 @@ public class ModdedTakeoverReconcilerTest {
touch(region);
ModdedTakeoverReconciler.Result unchanged = ModdedTakeoverReconciler.reconcile(
worldRoot, Map.of("iristest:skylands", "overworld"), recoveryParent);
worldRoot, Map.of("iristest:skylands", "overworld"), List.of(), recoveryParent);
assertEquals(Set.of(), unchanged.changedLevels());
assertNull(unchanged.recovery());
assertTrue(Files.isRegularFile(region));
ModdedTakeoverReconciler.Result removed = ModdedTakeoverReconciler.reconcile(
worldRoot, Map.of(), recoveryParent);
worldRoot, Map.of(), List.of(), recoveryParent);
assertEquals(Set.of("iristest:skylands"), removed.changedLevels());
assertFalse(Files.exists(region));
@@ -119,7 +120,7 @@ public class ModdedTakeoverReconcilerTest {
Files.createDirectories(worldRoot);
ModdedTakeoverReconciler.Result result = ModdedTakeoverReconciler.reconcile(
worldRoot, Map.of("aether:the_aether", "overworld"), recoveryParent);
worldRoot, Map.of("aether:the_aether", "overworld"), List.of(), recoveryParent);
assertEquals(Set.of("aether:the_aether"), result.changedLevels());
assertNull(result.recovery());
@@ -130,6 +131,59 @@ public class ModdedTakeoverReconcilerTest {
}
}
@Test
public void unreadableConfigWithRecordedTakeoversRefusesWithoutMovingAnything() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-unreadable");
Path worldRoot = temporary.resolve("world");
Path recoveryParent = temporary.resolve("config");
try {
Path region = worldRoot.resolve("dimensions/iristest/skylands/region/r.0.0.mca");
ModdedTakeoverReconciler.writeApplied(worldRoot, Map.of("iristest:skylands", "overworld"));
touch(region);
List<String> problems = List.of("Iris could not read /srv/config/irisworldgen/modded.json: Unterminated object");
IOException refused = assertThrows(IOException.class, () -> ModdedTakeoverReconciler.reconcile(
worldRoot, Map.of(), problems, recoveryParent));
assertTrue(refused.getMessage(), refused.getMessage().contains("modded.json"));
assertTrue(Files.isRegularFile(region));
assertFalse(Files.exists(recoveryParent));
assertEquals(Map.of("iristest:skylands", "overworld"), ModdedTakeoverReconciler.readApplied(worldRoot));
} finally {
deleteTree(temporary);
}
}
@Test
public void unreadableConfigWithAnEmptyStateFileStillRefuses() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-unreadable-empty");
Path worldRoot = temporary.resolve("world");
try {
ModdedTakeoverReconciler.writeApplied(worldRoot, Map.of());
assertThrows(IOException.class, () -> ModdedTakeoverReconciler.reconcile(
worldRoot, Map.of(), List.of("invalid takeover entry"), temporary.resolve("config")));
} finally {
deleteTree(temporary);
}
}
@Test
public void unreadableConfigWithoutRecordedStateProceeds() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-unreadable-fresh");
Path worldRoot = temporary.resolve("world");
try {
Files.createDirectories(worldRoot);
ModdedTakeoverReconciler.Result result = ModdedTakeoverReconciler.reconcile(
worldRoot, Map.of(), List.of("invalid takeover entry"), temporary.resolve("config"));
assertEquals(Set.of(), result.changedLevels());
} finally {
deleteTree(temporary);
}
}
private static void touch(Path file) throws IOException {
Files.createDirectories(file.getParent());
Files.writeString(file, "x", StandardCharsets.UTF_8);