Startup refusal fixes

- Bootstrap audits vanilla slots that hold an Iris world
- Startup stops when such a slot has unusable history
- Startup also stops when bukkit.yml no longer binds such a slot to Iris
- The audit detail carries the history read cause
- File system failures are named by type
- Bootstrap failures print the refusal banner instead of a bare stack trace
- A vanilla slot holding an Iris world is told to restore its history and keep its binding
- The vanilla slot remedy adds deleting level.dat when the level was never created
- A failed enable locks only for bukkit.yml worlds the server loads
This commit is contained in:
Brian Neumann-Fopiano
2026-09-29 08:44:07 -04:00
parent b1fbede364
commit c7cca23155
13 changed files with 448 additions and 81 deletions
@@ -83,6 +83,6 @@ final class FailedEnableLock {
static boolean irisWorldsPresent(File levelRoot, File bukkitConfiguration) throws IOException {
return IrisWorldStorage.hasManagedWorldStorage(levelRoot)
|| BukkitWorldConfiguration.configuresIrisGenerator(bukkitConfiguration);
|| BukkitWorldConfiguration.configuresLoadingIrisWorld(bukkitConfiguration, levelRoot.toPath());
}
}
@@ -13,6 +13,7 @@ import art.arcane.iris.pack.datapack.DataVersion;
import io.papermc.paper.ServerBuildInfo;
import art.arcane.iris.pack.DefaultPackBootstrapProvisioner.ProvisionResult;
import art.arcane.iris.platform.bootstrap.SlimJar;
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
import io.papermc.paper.plugin.bootstrap.BootstrapContext;
import io.papermc.paper.plugin.bootstrap.PluginBootstrap;
import io.papermc.paper.plugin.lifecycle.event.LifecycleEvent;
@@ -21,6 +22,7 @@ import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
@@ -115,7 +117,9 @@ public final class IrisBootstrap implements PluginBootstrap {
* whose folder is present is going to be loaded whatever Iris does. The generator resolver answers a world
* it cannot generate with a fail-closed generator, which stops startup at that world's own level creation,
* after the levels before it have loaded. A pack snapshot that has gone missing is found here instead,
* before any level is created, exactly as a corrupt {@code dimensions/<id>.json} is.
* before any level is created, exactly as a corrupt {@code dimensions/<id>.json} is. An Iris world in a
* vanilla slot whose bukkit.yml entry no longer names Iris stops startup too: CraftServer would build it
* with the vanilla generator without ever asking Iris.
* <p>
* A world with no folder at all is not a startup failure: nothing enumerates it, nothing loads it, and
* its bukkit.yml and Multiverse entries are what make restoring the folder a complete recovery. Neither
@@ -135,6 +139,7 @@ public final class IrisBootstrap implements PluginBootstrap {
levelRoot
);
StringBuilder unusable = new StringBuilder();
StringBuilder unbound = new StringBuilder();
for (IrisWorldStorageEntry entry : entries) {
switch (entry.state()) {
case PRESENT -> {
@@ -152,15 +157,29 @@ public final class IrisBootstrap implements PluginBootstrap {
.append(": ")
.append(entry.detail() == null ? "storage is unusable" : entry.detail())
.append(')');
case UNBOUND -> unbound.append(unbound.isEmpty() ? "" : "; ")
.append(entry.configuredWorldName())
.append(" (")
.append(entry.storagePath())
.append("): set worlds.")
.append(entry.configuredWorldName())
.append(".generator to Iris:<pack> in bukkit.yml, or move the folder aside to start a new"
+ " vanilla world there");
}
}
if (unusable.isEmpty()) {
return;
List<String> refusals = new ArrayList<>(2);
if (!unusable.isEmpty()) {
refusals.add("Iris world storage is unusable and the server would generate vanilla terrain over it: "
+ unusable + ". Restore each world folder from a backup, or move it aside; a world with no"
+ " folder is reported and skipped, and the server starts.");
}
if (!unbound.isEmpty()) {
refusals.add("Iris worlds in vanilla slots are not bound to Iris, so the server would generate vanilla"
+ " terrain into them: " + unbound + ".");
}
if (!refusals.isEmpty()) {
throw new IllegalStateException(String.join(" ", refusals));
}
throw new IllegalStateException("Iris world storage is unusable and the server would generate vanilla"
+ " terrain over it: " + unusable
+ ". Restore each world folder from a backup, or move it aside; a world with no folder is"
+ " reported and skipped, and the server starts.");
}
static void armStartupFailure(BootstrapContext context, Throwable failure) {
@@ -175,17 +194,30 @@ public final class IrisBootstrap implements PluginBootstrap {
context.getLifecycleManager().registerEventHandler(eventType, event -> {
throw new IllegalStateException("Iris bootstrap did not establish safe world-generation state.", failure);
});
List<String> notice = startupRefusalNotice(failure);
try {
context.getLogger().error(
"Iris bootstrap failed; registry and world startup will be stopped at datapack discovery.",
failure
);
for (String line : notice) {
context.getLogger().error(line);
}
context.getLogger().warn("Iris bootstrap failure", failure);
} catch (Throwable loggingFailure) {
notice.forEach(System.err::println);
failure.addSuppressed(loggingFailure);
failure.printStackTrace(System.err);
}
}
static List<String> startupRefusalNotice(Throwable failure) {
return GenerationRefusalNotice.compose(
"Iris stopped server startup before any world loads",
GenerationRefusalNotice.causes(failure),
List.of(
"Startup stops at datapack discovery: no world loads and no chunks are written.",
"Fix the cause, then start the server again. Paper's --safeMode hint does not apply:"
+ " Iris worlds must not load without Iris."
));
}
private static ProvisionResult provision(BootstrapContext context, BukkitStartupPaths startupPaths) {
try {
return DefaultPackBootstrapProvisioner.provision(new BootstrapRequest(
@@ -73,6 +73,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
import java.util.stream.Stream;
/**
* Pack validation, dimension lookup, and the world generator / biome provider resolution that the
@@ -622,12 +623,21 @@ public final class IrisWorldGeneratorResolver {
}
NamespacedKey messageKey = messageWorldKey(worldName, levelRoot.getName());
if (!IRIS_DIMENSION_NAMESPACE.equals(messageKey.getNamespace())) {
if (dimensionRoot != null && IrisWorldStorage.holdsIrisContent(dimensionRoot.toPath())) {
throw new IllegalStateException("'" + worldName + "' (" + messageKey + ") is an Iris world whose"
+ " generation history is missing from "
+ GenerationHistoryPaths.forDimension(dimensionRoot.toPath()).generationRoot()
+ ", so Iris cannot generate it. Restore that folder from a backup. Keep worlds." + worldName
+ ".generator in bukkit.yml: without it the server generates vanilla terrain into this world.");
}
// Only bukkit.yml binds a vanilla slot at startup, and the refusal stops startup, so no command can run
// until that binding is gone.
throw new IllegalStateException("'" + worldName + "' (" + messageKey
+ ") is a vanilla world slot with no Iris world storage, so Iris cannot generate it."
+ " Remove worlds." + worldName + ".generator from bukkit.yml, start the server, then run"
+ " /iris replace " + messageKey + " type=<pack> to replace it with Iris on the next restart.");
+ " Remove worlds." + worldName + ".generator from bukkit.yml"
+ leftoverLevelDataRemoval(levelRoot)
+ ", start the server, then run /iris replace " + messageKey
+ " type=<pack> to replace it with Iris on the next restart.");
}
throw new IllegalStateException("'" + worldName + "' (" + messageKey
+ ") has no Iris world storage, so Iris cannot generate it."
@@ -635,6 +645,33 @@ public final class IrisWorldGeneratorResolver {
+ " type=<pack>; Iris registers them with Multiverse itself.");
}
/**
* A start refused before the level's overworld existed still leaves level.dat behind, and vanilla cannot start
* from a level.dat without world generation settings. A level that holds no chunks loses nothing to its removal.
*/
private static String leftoverLevelDataRemoval(File levelRoot) {
Path level = levelRoot.toPath();
Path overworld = level.resolve("dimensions").resolve("minecraft").resolve("overworld");
if (Files.exists(overworld.resolve("data").resolve("minecraft").resolve("world_gen_settings.dat"),
LinkOption.NOFOLLOW_LINKS)
|| hasEntries(overworld.resolve("region"))
|| hasEntries(level.resolve("region"))) {
return "";
}
return " and delete " + level.resolve("level.dat") + " (this level has no chunks and was never created)";
}
private static boolean hasEntries(Path directory) {
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
return false;
}
try (Stream<Path> entries = Files.list(directory)) {
return entries.findAny().isPresent();
} catch (IOException unreadable) {
return true;
}
}
private static boolean hasGenerationStorage(File dimensionRoot) {
GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(dimensionRoot.toPath());
return Files.isDirectory(paths.generationRoot(), LinkOption.NOFOLLOW_LINKS)
@@ -183,13 +183,17 @@ public class FailedEnableLockTest {
}
@Test
public void irisWorldsAreManagedStorageOrAnyBukkitBinding() throws Exception {
public void irisWorldsAreManagedStorageOrABukkitBindingForAWorldThatLoads() throws Exception {
File levelRoot = temporaryFolder.newFolder("level", "world");
File bukkit = new File(temporaryFolder.getRoot(), "bukkit.yml");
assertFalse(FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
YamlConfiguration yaml = new YamlConfiguration();
yaml.set("worlds.removed_iris_world.generator", "Iris:overworld");
yaml.save(bukkit);
assertFalse("a leftover entry for a world that never loads", FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
yaml.set("worlds.world.generator", "Iris:overworld");
yaml.save(bukkit);
assertTrue("an Iris overworld lives in a vanilla slot", FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
@@ -8,10 +8,16 @@ import io.papermc.paper.plugin.lifecycle.event.handler.configuration.LifecycleEv
import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEventType;
import net.kyori.adventure.text.logger.slf4j.ComponentLogger;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import java.io.IOException;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class IrisBootstrapFailureContractTest {
@@ -32,6 +38,27 @@ public class IrisBootstrapFailureContractTest {
assertSame(cause, failure.getCause());
}
@Test
public void bootstrapFailureIsReportedWithTheRefusalBanner() {
BootstrapContext context = mock(BootstrapContext.class);
ComponentLogger logger = mock(ComponentLogger.class);
when(context.getLogger()).thenReturn(logger);
when(context.getLifecycleManager()).thenReturn(new CapturingLifecycleManager());
IllegalStateException failure = new IllegalStateException("Iris world storage is unusable",
new IOException("Invalid generation manifest: manifest.json"));
IrisBootstrap.armStartupFailure(context, failure, lifecycleEventType());
ArgumentCaptor<String> lines = ArgumentCaptor.forClass(String.class);
verify(logger, atLeastOnce()).error(lines.capture());
String banner = String.join("\n", lines.getAllValues());
assertTrue(banner, banner.startsWith("=".repeat(78)));
assertTrue(banner, banner.contains("Cause: Iris world storage is unusable"));
assertTrue(banner, banner.contains("Caused by: Invalid generation manifest: manifest.json"));
assertTrue(banner, banner.contains("no chunks are written"));
assertTrue(banner, banner.contains("--safeMode"));
}
@SuppressWarnings("unchecked")
private static LifecycleEventType<BootstrapContext, LifecycleEvent, ?> lifecycleEventType() {
return (LifecycleEventType<BootstrapContext, LifecycleEvent, ?>) mock(LifecycleEventType.class);
@@ -175,6 +175,81 @@ public class IrisBootstrapWorldStorageContractTest {
assertFalse(MissingWorldStorageLog.hasWarned("world_iris_moon"));
}
/**
* A vanilla slot that holds an Iris world is generated from its history like any Iris world, so a broken
* history stops startup before the level exists instead of reaching the level-load refusal.
*/
@Test
public void startupIsRefusedWhenABoundVanillaSlotLostItsGenerationHistory() throws Exception {
Path serverRoot = temporaryFolder.newFolder("broken-overworld-server").toPath();
Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/overworld/iris/engine-data"));
Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/overworld/region"));
Files.writeString(serverRoot.resolve("world/dimensions/minecraft/overworld/region/r.0.0.mca"), "terrain");
writeServerProperties(serverRoot);
writeBukkitWorlds(serverRoot, "world");
IllegalStateException failure = assertThrows(
IllegalStateException.class,
() -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)));
assertTrue(failure.getMessage(),
failure.getMessage().contains(serverRoot.toRealPath().resolve("world/dimensions/minecraft/overworld")
.toString()));
assertTrue(failure.getMessage(), failure.getMessage().contains("generation history"));
assertFalse(failure.getMessage(), failure.getMessage().contains("Remove worlds."));
}
@Test
public void unusableStorageNamesWhyTheHistoryCannotBeRead() throws Exception {
Path serverRoot = temporaryFolder.newFolder("cut-history-server").toPath();
Path generation = serverRoot.resolve("world/dimensions/minecraft/overworld/iris/generation");
Files.createDirectories(generation);
Files.writeString(generation.resolve("manifest.json"), "{\"schema\":1,\"epochs\":[{\"id", StandardCharsets.UTF_8);
writeServerProperties(serverRoot);
writeBukkitWorlds(serverRoot, "world");
IllegalStateException failure = assertThrows(
IllegalStateException.class,
() -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)));
assertTrue(failure.getMessage(), failure.getMessage().contains("Invalid generation manifest"));
assertFalse(failure.getMessage(), failure.getMessage().contains("\n"));
}
/**
* Without its bukkit.yml binding the server builds an Iris world in a vanilla slot with the vanilla generator,
* which writes vanilla terrain into it.
*/
@Test
public void startupIsRefusedWhenAnIrisWorldInAVanillaSlotLostItsBinding() throws Exception {
Path serverRoot = temporaryFolder.newFolder("unbound-overworld-server").toPath();
Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/overworld/iris/pack"));
writeServerProperties(serverRoot);
writeBukkitWorlds(serverRoot);
IllegalStateException failure = assertThrows(
IllegalStateException.class,
() -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)));
assertTrue(failure.getMessage(), failure.getMessage().contains("worlds.world.generator"));
assertTrue(failure.getMessage(),
failure.getMessage().contains(serverRoot.toRealPath().resolve("world/dimensions/minecraft/overworld")
.toString()));
}
@Test
public void startupContinuesForVanillaSlotsWithoutIrisContent() throws Exception {
Path serverRoot = temporaryFolder.newFolder("vanilla-server").toPath();
Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/overworld/region"));
Files.writeString(serverRoot.resolve("world/dimensions/minecraft/overworld/region/r.0.0.mca"), "terrain");
Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/the_nether/iris"));
Files.writeString(serverRoot.resolve("world/dimensions/minecraft/the_nether/iris/.DS_Store"), "finder");
writeServerProperties(serverRoot);
writeBukkitWorlds(serverRoot);
IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot));
}
private static BukkitStartupPaths startupPaths(Path serverRoot) throws Exception {
return BukkitStartupPaths.resolve(serverRoot, new String[0]);
}
@@ -490,6 +490,76 @@ public class IrisWorldGeneratorResolverTest {
}
}
/**
* The fix for a vanilla slot bound to Iris is removing the binding. Given for an Iris world whose history is
* gone, that fix loads the world under the vanilla generator, which writes vanilla terrain into it.
*/
@Test
public void vanillaSlotHoldingAnIrisWorldKeepsItsBindingAndPointsAtTheHistory() throws Exception {
File worldContainer = temporaryFolder.newFolder("iris-in-vanilla-slot");
File levelRoot = new File(worldContainer, "world");
File dimensionRoot = new File(levelRoot, "dimensions/minecraft/overworld");
assertTrue(new File(dimensionRoot, "iris/engine-data").mkdirs());
assertTrue(new File(dimensionRoot, "data/minecraft").mkdirs());
assertTrue(new File(dimensionRoot, "data/minecraft/world_gen_settings.dat").createNewFile());
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
mockServer(bukkit, worldContainer, levelRoot, List.of());
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator("world", "overworld"));
assertTrue(refusal, refusal.contains("minecraft:overworld"));
assertTrue(refusal, refusal.contains("is an Iris world"));
assertTrue(refusal, refusal.contains(new File(dimensionRoot, "iris/generation").getPath()));
assertFalse(refusal, refusal.contains("Remove worlds.world.generator"));
assertFalse(refusal, refusal.contains("/iris replace"));
}
}
/**
* A start refused before the overworld existed still leaves level.dat behind, and vanilla cannot start from a
* level.dat that has no world generation settings next to it.
*/
@Test
public void vanillaSlotRefusalOnANeverCreatedLevelNamesTheLeftoverLevelDat() throws Exception {
File worldContainer = temporaryFolder.newFolder("never-created");
File levelRoot = new File(worldContainer, "world");
assertTrue(levelRoot.mkdirs());
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
mockServer(bukkit, worldContainer, levelRoot, List.of());
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator("world", "overworld"));
assertTrue(refusal, refusal.contains("Remove worlds.world.generator"));
assertTrue(refusal, refusal.contains(new File(levelRoot, "level.dat").getPath()));
}
}
@Test
public void vanillaSlotRefusalOnAnExistingLevelLeavesLevelDatAlone() throws Exception {
File worldContainer = temporaryFolder.newFolder("existing-vanilla");
File levelRoot = new File(worldContainer, "world");
File settings = new File(levelRoot, "dimensions/minecraft/overworld/data/minecraft/world_gen_settings.dat");
assertTrue(settings.getParentFile().mkdirs());
assertTrue(settings.createNewFile());
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
mockServer(bukkit, worldContainer, levelRoot, List.of());
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator("world", "overworld"));
assertTrue(refusal, refusal.contains("Remove worlds.world.generator"));
assertFalse(refusal, refusal.contains("level.dat"));
}
}
@Test
public void alreadyLoadedIrisKeyIsRefusedBeforeAnyEngineCanStart() throws Exception {
File worldContainer = temporaryFolder.newFolder("duplicate-key");
@@ -18,11 +18,14 @@ import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.Properties;
import java.util.Set;
import java.util.stream.Stream;
public final class IrisWorldStorage {
private static final String IRIS_NAMESPACE = "iris";
private static final String DEFAULT_LEVEL_NAME = "world";
private static final Set<String> OS_METADATA_FILES = Set.of(".DS_Store", "Thumbs.db", "desktop.ini");
private static final String APPLE_DOUBLE_PREFIX = "._";
/**
* Server#getLevelDirectory is Paper-API-only. Once a call throws NoSuchMethodError (plain
@@ -327,6 +330,46 @@ public final class IrisWorldStorage {
return target.toFile();
}
/**
* True when the world folder's {@code iris/} entry holds anything of Iris' own, which makes the folder an Iris
* world whatever its dimension namespace.
* <p>
* The desktop file managers recreate a directory to hold their own metadata while a delete is still in
* flight - on macOS a world folder can come back holding nothing but {@code iris/.DS_Store} - and a folder
* that holds only that is exactly as worthless as one with no {@code iris/} entry at all. Only files are
* ever treated as metadata: a directory, a symbolic link, an unreadable entry and anything that is not on
* the list all count as content.
*/
public static boolean holdsIrisContent(Path dimensionRoot) {
Path irisRoot = Objects.requireNonNull(dimensionRoot, "dimensionRoot").resolve(IRIS_NAMESPACE);
if (Files.isSymbolicLink(irisRoot)) {
return true;
}
if (!Files.exists(irisRoot, LinkOption.NOFOLLOW_LINKS)) {
return false;
}
if (!Files.isDirectory(irisRoot, LinkOption.NOFOLLOW_LINKS)) {
return true;
}
try (Stream<Path> entries = Files.list(irisRoot)) {
return entries.anyMatch(entry -> !isOperatingSystemMetadata(entry));
} catch (IOException unreadable) {
return true;
}
}
private static boolean isOperatingSystemMetadata(Path entry) {
if (!Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) {
return false;
}
Path name = entry.getFileName();
if (name == null) {
return false;
}
String fileName = name.toString();
return OS_METADATA_FILES.contains(fileName) || fileName.startsWith(APPLE_DOUBLE_PREFIX);
}
/**
* True when the level root holds at least one Iris-managed world directory. Used by the failure paths
* that have to decide whether letting the server continue would put vanilla terrain into Iris storage.
@@ -4,6 +4,7 @@ import art.arcane.iris.world.IrisWorldStorage;
import art.arcane.iris.world.WorldSlotKey;
import art.arcane.iris.generation.runtime.IrisEngineMantle;
import art.arcane.iris.world.history.GenerationHistoryPaths;
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
import org.bukkit.NamespacedKey;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.InvalidConfigurationException;
@@ -26,11 +27,16 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public final class BukkitWorldConfiguration {
private static final String DEFAULT_WORLD_CONTAINER = ".";
private static final Object MUTATION_LOCK = new Object();
private static final List<NamespacedKey> VANILLA_SLOTS = List.of(
NamespacedKey.minecraft("overworld"),
NamespacedKey.minecraft("the_nether"),
NamespacedKey.minecraft("the_end"));
private BukkitWorldConfiguration() {
}
@@ -160,7 +166,8 @@ public final class BukkitWorldConfiguration {
}
/**
* Classifies the world storage behind every Iris entry in bukkit.yml.
* Classifies the world storage behind every Iris entry in bukkit.yml, and behind every vanilla slot of the level
* that holds an Iris world, bound or not.
* <p>
* {@link #readIrisGeneratorBindings} answers "which worlds can Iris bind", which deliberately drops
* anything without storage. Startup needs the opposite view: a world whose folder is on disk but whose
@@ -178,22 +185,34 @@ public final class BukkitWorldConfiguration {
Path requiredLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot")
.toAbsolutePath()
.normalize();
Path configurationPath = requiredConfigurationFile.toPath();
if (!Files.exists(configurationPath, LinkOption.NOFOLLOW_LINKS)) {
return List.of();
}
Path worldContainer = requiredLevelRoot.getParent();
if (worldContainer == null) {
throw new IOException("Selected level root has no world container: " + requiredLevelRoot);
}
synchronized (MUTATION_LOCK) {
YamlConfiguration configuration = Files.exists(requiredConfigurationFile.toPath(), LinkOption.NOFOLLOW_LINKS)
? load(requiredConfigurationFile)
: new YamlConfiguration();
List<IrisWorldStorageEntry> entries = new ArrayList<>();
for (IrisWorldCandidate candidate : readIrisWorldCandidates(
load(requiredConfigurationFile),
requiredLevelName
)) {
for (IrisWorldCandidate candidate : readIrisWorldCandidates(configuration, requiredLevelName)) {
entries.add(classifyStorage(worldContainer, requiredLevelRoot, candidate));
}
for (NamespacedKey vanillaSlot : VANILLA_SLOTS) {
Path dimensionRoot = expectedDimensionRoot(requiredLevelRoot, vanillaSlot);
if (!IrisWorldStorage.holdsIrisContent(dimensionRoot)) {
continue;
}
String configuredName = IrisWorldStorage.configuredWorldName(vanillaSlot, requiredLevelName);
if (configuration.get("worlds." + configuredName + ".generator") instanceof String generator
&& isIrisGenerator(generator.trim())) {
entries.add(classifyStorage(worldContainer, requiredLevelRoot,
new IrisWorldCandidate(configuredName, vanillaSlot, generator.trim())));
} else {
entries.add(new IrisWorldStorageEntry(configuredName,
new WorldSlotKey(vanillaSlot.getNamespace(), vanillaSlot.getKey()), dimensionRoot,
IrisWorldStorageState.UNBOUND, "bukkit.yml does not name Iris as its generator"));
}
}
return List.copyOf(entries);
}
}
@@ -249,12 +268,21 @@ public final class BukkitWorldConfiguration {
hasWorldData(resolvedRoot)
? IrisWorldStorageState.UNUSABLE
: IrisWorldStorageState.EMPTY,
unusablePack.getMessage());
describe(unusablePack));
}
return new IrisWorldStorageEntry(configuredName, slotKey, resolvedRoot,
IrisWorldStorageState.PRESENT, null);
}
/**
* The cause chain on one line: why a history cannot be read sits below the failure that names the world.
*/
private static String describe(Throwable failure) {
return GenerationRefusalNotice.causes(failure).stream()
.map((String cause) -> cause.lines().findFirst().orElse(cause))
.collect(Collectors.joining("; "));
}
/**
* True when the world folder holds something a vanilla generator could overwrite: an {@code iris/}
* directory marking it as an Iris world whose pack snapshot broke, or stored chunk data.
@@ -395,11 +423,19 @@ public final class BukkitWorldConfiguration {
}
/**
* True when any bukkit.yml world names Iris as its generator, vanilla slots included. CraftServer asks a plugin
* for a generator only for these worlds, so they are the ones a disabled Iris would hand to vanilla.
* True when a bukkit.yml world the server loads names Iris as its generator: the level's overworld, nether or
* end, or a world whose dimension folder exists. CraftServer asks a plugin for a generator only for worlds it
* creates, so these are the ones a disabled Iris would hand to vanilla; an entry for a world that is never
* created is not.
*/
public static boolean configuresIrisGenerator(File configurationFile) throws IOException {
public static boolean configuresLoadingIrisWorld(File configurationFile, Path levelRoot) throws IOException {
Path configurationPath = Objects.requireNonNull(configurationFile, "configurationFile").toPath();
Path root = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize();
Path levelName = root.getFileName();
Path worldContainer = root.getParent();
if (levelName == null || worldContainer == null) {
throw new IOException("Selected level root has no world container: " + root);
}
if (!Files.exists(configurationPath, LinkOption.NOFOLLOW_LINKS)) {
return false;
}
@@ -410,7 +446,8 @@ public final class BukkitWorldConfiguration {
}
for (String worldName : worlds.getKeys(false)) {
if (worlds.get(worldName + ".generator") instanceof String generator
&& isIrisGenerator(generator.trim())) {
&& isIrisGenerator(generator.trim())
&& loadsAtStartup(worldContainer, root, levelName.toString(), worldName)) {
return true;
}
}
@@ -418,6 +455,21 @@ public final class BukkitWorldConfiguration {
}
}
private static boolean loadsAtStartup(Path worldContainer, Path levelRoot, String levelName, String worldName) {
if (worldName.equals(levelName)
|| worldName.equals(levelName + "_nether")
|| worldName.equals(levelName + "_the_end")) {
return true;
}
NamespacedKey worldKey;
try {
worldKey = IrisWorldStorage.keyFromConfiguredWorldName(worldName, levelName);
} catch (IllegalArgumentException unmappable) {
return false;
}
return hasDimensionDirectory(worldContainer, levelRoot, worldName, worldKey);
}
private static boolean isIrisGenerator(String configuredGenerator) {
return configuredGenerator.equalsIgnoreCase("Iris")
|| configuredGenerator.regionMatches(true, 0, "Iris:", 0, 5);
@@ -766,7 +818,12 @@ public final class BukkitWorldConfiguration {
* A world directory the server will load, whose frozen pack snapshot cannot be used. Iris cannot
* generate it and nothing else may.
*/
UNUSABLE
UNUSABLE,
/**
* An Iris world in a vanilla slot whose bukkit.yml entry does not name Iris, so the server would build
* it with the vanilla generator.
*/
UNBOUND
}
public record IrisWorldStorageEntry(
@@ -16,7 +16,6 @@ import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.function.Consumer;
import java.util.regex.Pattern;
import java.util.stream.Stream;
@@ -41,8 +40,6 @@ public final class HuskWorldQuarantine {
private static final String[] CHUNK_DATA_DIRECTORIES = {
"region", "entities", "poi", IrisEngineMantle.STORAGE_FOLDER_NAME
};
private static final Set<String> OS_METADATA_FILES = Set.of(".DS_Store", "Thumbs.db", "desktop.ini");
private static final String APPLE_DOUBLE_PREFIX = "._";
private static final DateTimeFormatter STAMP = DateTimeFormatter
.ofPattern("yyyyMMdd'T'HHmmss")
.withZone(ZoneOffset.UTC);
@@ -117,7 +114,7 @@ public final class HuskWorldQuarantine {
if (!isReadableDirectory(dimensionRoot)) {
return false;
}
if (holdsIrisContent(dimensionRoot.resolve("iris"))) {
if (IrisWorldStorage.holdsIrisContent(dimensionRoot)) {
return false;
}
for (String directory : CHUNK_DATA_DIRECTORIES) {
@@ -128,44 +125,6 @@ public final class HuskWorldQuarantine {
return true;
}
/**
* True when the {@code iris/} entry holds anything of Iris' own.
* <p>
* The desktop file managers recreate a directory to hold their own metadata while a delete is still in
* flight - on macOS a world folder can come back holding nothing but {@code iris/.DS_Store} - and a folder
* that holds only that is exactly as worthless as one with no {@code iris/} entry at all. Only files are
* ever treated as metadata: a directory, a symbolic link, an unreadable entry and anything that is not on
* the list all count as content.
*/
private static boolean holdsIrisContent(Path irisRoot) {
if (Files.isSymbolicLink(irisRoot)) {
return true;
}
if (!Files.exists(irisRoot, LinkOption.NOFOLLOW_LINKS)) {
return false;
}
if (!Files.isDirectory(irisRoot, LinkOption.NOFOLLOW_LINKS)) {
return true;
}
try (Stream<Path> entries = Files.list(irisRoot)) {
return entries.anyMatch(entry -> !isOperatingSystemMetadata(entry));
} catch (IOException unreadable) {
return true;
}
}
private static boolean isOperatingSystemMetadata(Path entry) {
if (!Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) {
return false;
}
Path name = entry.getFileName();
if (name == null) {
return false;
}
String fileName = name.toString();
return OS_METADATA_FILES.contains(fileName) || fileName.startsWith(APPLE_DOUBLE_PREFIX);
}
/**
* True when the path is a real directory that can actually be listed. The permission bits alone are not
* the answer: what matters is whether Iris can see everything in it before deciding it holds nothing.
@@ -1,5 +1,6 @@
package art.arcane.iris.world.safeguard;
import java.nio.file.FileSystemException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.IdentityHashMap;
@@ -31,7 +32,8 @@ public final class GenerationRefusalNotice {
/**
* Every distinct message in the cause chain, outermost first. A wrapper whose message only repeats its cause
* (CompletionException, a rethrow with the same text) adds nothing and is skipped.
* (CompletionException, a rethrow with the same text) adds nothing and is skipped. A file system failure carries
* only a path, so its type is named with it.
*/
public static List<String> causes(Throwable failure) {
List<String> causes = new ArrayList<>();
@@ -42,7 +44,11 @@ public final class GenerationRefusalNotice {
String message = current.getMessage();
boolean repeatsCause = cause != null && cause.toString().equals(message);
if (!repeatsCause) {
String text = message == null || message.isBlank() ? current.getClass().getSimpleName() : message.trim();
String text = message == null || message.isBlank()
? current.getClass().getSimpleName()
: current instanceof FileSystemException
? current.getClass().getSimpleName() + ": " + message.trim()
: message.trim();
if (causes.isEmpty() || !causes.getLast().equals(text)) {
causes.add(text);
}
@@ -54,11 +54,13 @@ public class BukkitWorldConfigurationTest {
}
/**
* CraftServer only asks a plugin for a generator when bukkit.yml names it, so any world bound to Iris - a vanilla
* slot included - is one a disabled Iris would hand to the vanilla generator.
* CraftServer only asks a plugin for a generator when bukkit.yml names it, so a world bound to Iris - a vanilla
* slot included - is one a disabled Iris would hand to the vanilla generator. An entry for a world the server
* never loads is not.
*/
@Test
public void detectsAnyWorldBoundToIrisIncludingVanillaSlots() throws Exception {
public void detectsIrisBindingsOnlyForWorldsTheServerLoads() throws Exception {
Path levelRoot = temporaryFolder.newFolder("loading", "world").toPath();
File overworld = temporaryFolder.newFile("iris-overworld.yml");
YamlConfiguration yaml = new YamlConfiguration();
yaml.set("worlds.world.generator", "iris:overworld");
@@ -73,11 +75,22 @@ public class BukkitWorldConfigurationTest {
yaml.set("worlds.world.generator", "Irish:overworld");
yaml.set("worlds.world_nether.seed", 1337L);
yaml.save(other);
File leftovers = temporaryFolder.newFile("leftovers.yml");
yaml = new YamlConfiguration();
yaml.set("worlds.oldiris.generator", "Iris:overworld");
yaml.set("worlds.world_iris_gone.generator", "Iris:overworld");
yaml.save(leftovers);
assertTrue(BukkitWorldConfiguration.configuresIrisGenerator(overworld));
assertTrue(BukkitWorldConfiguration.configuresIrisGenerator(bare));
assertFalse(BukkitWorldConfiguration.configuresIrisGenerator(other));
assertFalse(BukkitWorldConfiguration.configuresIrisGenerator(new File(temporaryFolder.getRoot(), "absent.yml")));
assertTrue(BukkitWorldConfiguration.configuresLoadingIrisWorld(overworld, levelRoot));
assertTrue(BukkitWorldConfiguration.configuresLoadingIrisWorld(bare, levelRoot));
assertFalse(BukkitWorldConfiguration.configuresLoadingIrisWorld(other, levelRoot));
assertFalse(BukkitWorldConfiguration.configuresLoadingIrisWorld(
new File(temporaryFolder.getRoot(), "absent.yml"), levelRoot));
assertFalse("no world folder, so the server never loads it",
BukkitWorldConfiguration.configuresLoadingIrisWorld(leftovers, levelRoot));
Files.createDirectories(levelRoot.resolve("dimensions/iris/gone"));
assertTrue(BukkitWorldConfiguration.configuresLoadingIrisWorld(leftovers, levelRoot));
}
@Test
@@ -205,6 +218,35 @@ public class BukkitWorldConfigurationTest {
assertTrue(entryOf(audited, "world_iris_broken").detail().contains("generation history"));
}
/**
* A vanilla slot holding an Iris world is audited like any Iris world while bukkit.yml binds it to Iris, and is
* reported unbound when it does not, since the server would then build it with the vanilla generator.
*/
@Test
public void auditCoversVanillaSlotsThatHoldIrisWorlds() throws Exception {
File configuration = temporaryFolder.newFile("audit-vanilla-slots.yml");
Path levelRoot = temporaryFolder.newFolder("audit-vanilla-slots", "world").toPath();
Files.createDirectories(levelRoot.resolve("dimensions/minecraft/overworld/iris/engine-data"));
Files.createDirectories(levelRoot.resolve("dimensions/minecraft/the_nether/iris/pack"));
Files.createDirectories(levelRoot.resolve("dimensions/minecraft/the_end/region"));
Files.writeString(levelRoot.resolve("dimensions/minecraft/the_end/region/r.0.0.mca"), "terrain");
YamlConfiguration yaml = new YamlConfiguration();
yaml.set("worlds.world.generator", "Iris:overworld");
yaml.set("worlds.world_the_end.generator", "Iris:overworld");
yaml.save(configuration);
List<BukkitWorldConfiguration.IrisWorldStorageEntry> audited =
BukkitWorldConfiguration.auditIrisWorldStorage(configuration, "world", levelRoot);
assertEquals(BukkitWorldConfiguration.IrisWorldStorageState.UNUSABLE, stateOf(audited, "world"));
assertEquals(BukkitWorldConfiguration.IrisWorldStorageState.UNBOUND, stateOf(audited, "world_nether"));
assertEquals(
levelRoot.resolve("dimensions/minecraft/the_nether").toAbsolutePath().normalize(),
entryOf(audited, "world_nether").storagePath());
assertFalse("a vanilla world bound to Iris is the generator resolver's refusal",
audited.stream().anyMatch(entry -> entry.configuredWorldName().equals("world_the_end")));
}
@Test
public void auditTreatsANonDirectoryStorageSlotAsAnOrphanTheServerNeverLoads() throws Exception {
File configuration = temporaryFolder.newFile("audit-file-slot.yml");
@@ -3,6 +3,7 @@ package art.arcane.iris.world.safeguard;
import org.junit.Test;
import java.io.IOException;
import java.nio.file.NoSuchFileException;
import java.util.List;
import java.util.concurrent.CompletionException;
@@ -33,6 +34,20 @@ public class GenerationRefusalNoticeTest {
new IllegalStateException("history drifted", root)));
}
/**
* A file system failure's message is only the path, which says nothing about what went wrong with it.
*/
@Test
public void fileSystemFailuresAreNamedByTheirType() {
IllegalStateException failure = new IllegalStateException("Iris generation history is unusable at /srv/moon.",
new NoSuchFileException("/srv/moon/iris/generation/epochs/a/epoch.json"));
assertEquals(List.of(
"Iris generation history is unusable at /srv/moon.",
"NoSuchFileException: /srv/moon/iris/generation/epochs/a/epoch.json"
), GenerationRefusalNotice.causes(failure));
}
@Test
public void aCyclicCauseChainTerminates() {
IllegalStateException first = new IllegalStateException("first");