diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/FailedEnableLock.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/FailedEnableLock.java index cc332873f..426ac062d 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/FailedEnableLock.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/FailedEnableLock.java @@ -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()); } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java index 0c40281bc..642b0d235 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java @@ -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/.json} is. + * before any level is created, exactly as a corrupt {@code dimensions/.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. *

* 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: in bukkit.yml, or move the folder aside to start a new" + + " vanilla world there"); } } - if (unusable.isEmpty()) { - return; + List 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 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 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( diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java index b4f4aeb3c..9fe77c6a3 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java @@ -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= 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= 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=; 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 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) diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java index 15039e517..25eaf2d03 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java @@ -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)); diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapFailureContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapFailureContractTest.java index ea0e6a038..8e58e530c 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapFailureContractTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapFailureContractTest.java @@ -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 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 lifecycleEventType() { return (LifecycleEventType) mock(LifecycleEventType.class); diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapWorldStorageContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapWorldStorageContractTest.java index 101e43b1d..78919ab63 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapWorldStorageContractTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapWorldStorageContractTest.java @@ -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]); } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisWorldGeneratorResolverTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisWorldGeneratorResolverTest.java index 59c2845c3..c945dcbbe 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisWorldGeneratorResolverTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisWorldGeneratorResolverTest.java @@ -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 = mockStatic(Bukkit.class); + MockedStatic 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 = mockStatic(Bukkit.class); + MockedStatic 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 = mockStatic(Bukkit.class); + MockedStatic 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"); diff --git a/core/src/main/java/art/arcane/iris/world/IrisWorldStorage.java b/core/src/main/java/art/arcane/iris/world/IrisWorldStorage.java index 98c4bec63..4ffc516bb 100644 --- a/core/src/main/java/art/arcane/iris/world/IrisWorldStorage.java +++ b/core/src/main/java/art/arcane/iris/world/IrisWorldStorage.java @@ -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 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. + *

+ * 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 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. diff --git a/core/src/main/java/art/arcane/iris/world/lifecycle/BukkitWorldConfiguration.java b/core/src/main/java/art/arcane/iris/world/lifecycle/BukkitWorldConfiguration.java index a34f8feda..e3acf3413 100644 --- a/core/src/main/java/art/arcane/iris/world/lifecycle/BukkitWorldConfiguration.java +++ b/core/src/main/java/art/arcane/iris/world/lifecycle/BukkitWorldConfiguration.java @@ -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 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. *

* {@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 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( diff --git a/core/src/main/java/art/arcane/iris/world/lifecycle/HuskWorldQuarantine.java b/core/src/main/java/art/arcane/iris/world/lifecycle/HuskWorldQuarantine.java index 6fc105721..89b6db1bd 100644 --- a/core/src/main/java/art/arcane/iris/world/lifecycle/HuskWorldQuarantine.java +++ b/core/src/main/java/art/arcane/iris/world/lifecycle/HuskWorldQuarantine.java @@ -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 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. - *

- * 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 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. diff --git a/core/src/main/java/art/arcane/iris/world/safeguard/GenerationRefusalNotice.java b/core/src/main/java/art/arcane/iris/world/safeguard/GenerationRefusalNotice.java index d182d42f1..b10741189 100644 --- a/core/src/main/java/art/arcane/iris/world/safeguard/GenerationRefusalNotice.java +++ b/core/src/main/java/art/arcane/iris/world/safeguard/GenerationRefusalNotice.java @@ -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 causes(Throwable failure) { List 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); } diff --git a/core/src/test/java/art/arcane/iris/world/lifecycle/BukkitWorldConfigurationTest.java b/core/src/test/java/art/arcane/iris/world/lifecycle/BukkitWorldConfigurationTest.java index 31fc03985..d76dc9250 100644 --- a/core/src/test/java/art/arcane/iris/world/lifecycle/BukkitWorldConfigurationTest.java +++ b/core/src/test/java/art/arcane/iris/world/lifecycle/BukkitWorldConfigurationTest.java @@ -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 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"); diff --git a/core/src/test/java/art/arcane/iris/world/safeguard/GenerationRefusalNoticeTest.java b/core/src/test/java/art/arcane/iris/world/safeguard/GenerationRefusalNoticeTest.java index fed73554b..057963b3a 100644 --- a/core/src/test/java/art/arcane/iris/world/safeguard/GenerationRefusalNoticeTest.java +++ b/core/src/test/java/art/arcane/iris/world/safeguard/GenerationRefusalNoticeTest.java @@ -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");