mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-12 03:24:14 +00:00
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:
@@ -83,6 +83,6 @@ final class FailedEnableLock {
|
|||||||
|
|
||||||
static boolean irisWorldsPresent(File levelRoot, File bukkitConfiguration) throws IOException {
|
static boolean irisWorldsPresent(File levelRoot, File bukkitConfiguration) throws IOException {
|
||||||
return IrisWorldStorage.hasManagedWorldStorage(levelRoot)
|
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 io.papermc.paper.ServerBuildInfo;
|
||||||
import art.arcane.iris.pack.DefaultPackBootstrapProvisioner.ProvisionResult;
|
import art.arcane.iris.pack.DefaultPackBootstrapProvisioner.ProvisionResult;
|
||||||
import art.arcane.iris.platform.bootstrap.SlimJar;
|
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.BootstrapContext;
|
||||||
import io.papermc.paper.plugin.bootstrap.PluginBootstrap;
|
import io.papermc.paper.plugin.bootstrap.PluginBootstrap;
|
||||||
import io.papermc.paper.plugin.lifecycle.event.LifecycleEvent;
|
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.io.IOException;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.function.Consumer;
|
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
|
* 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,
|
* 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,
|
* 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>
|
* <p>
|
||||||
* A world with no folder at all is not a startup failure: nothing enumerates it, nothing loads it, and
|
* 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
|
* 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
|
levelRoot
|
||||||
);
|
);
|
||||||
StringBuilder unusable = new StringBuilder();
|
StringBuilder unusable = new StringBuilder();
|
||||||
|
StringBuilder unbound = new StringBuilder();
|
||||||
for (IrisWorldStorageEntry entry : entries) {
|
for (IrisWorldStorageEntry entry : entries) {
|
||||||
switch (entry.state()) {
|
switch (entry.state()) {
|
||||||
case PRESENT -> {
|
case PRESENT -> {
|
||||||
@@ -152,15 +157,29 @@ public final class IrisBootstrap implements PluginBootstrap {
|
|||||||
.append(": ")
|
.append(": ")
|
||||||
.append(entry.detail() == null ? "storage is unusable" : entry.detail())
|
.append(entry.detail() == null ? "storage is unusable" : entry.detail())
|
||||||
.append(')');
|
.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()) {
|
List<String> refusals = new ArrayList<>(2);
|
||||||
return;
|
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) {
|
static void armStartupFailure(BootstrapContext context, Throwable failure) {
|
||||||
@@ -175,17 +194,30 @@ public final class IrisBootstrap implements PluginBootstrap {
|
|||||||
context.getLifecycleManager().registerEventHandler(eventType, event -> {
|
context.getLifecycleManager().registerEventHandler(eventType, event -> {
|
||||||
throw new IllegalStateException("Iris bootstrap did not establish safe world-generation state.", failure);
|
throw new IllegalStateException("Iris bootstrap did not establish safe world-generation state.", failure);
|
||||||
});
|
});
|
||||||
|
List<String> notice = startupRefusalNotice(failure);
|
||||||
try {
|
try {
|
||||||
context.getLogger().error(
|
for (String line : notice) {
|
||||||
"Iris bootstrap failed; registry and world startup will be stopped at datapack discovery.",
|
context.getLogger().error(line);
|
||||||
failure
|
}
|
||||||
);
|
context.getLogger().warn("Iris bootstrap failure", failure);
|
||||||
} catch (Throwable loggingFailure) {
|
} catch (Throwable loggingFailure) {
|
||||||
|
notice.forEach(System.err::println);
|
||||||
failure.addSuppressed(loggingFailure);
|
failure.addSuppressed(loggingFailure);
|
||||||
failure.printStackTrace(System.err);
|
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) {
|
private static ProvisionResult provision(BootstrapContext context, BukkitStartupPaths startupPaths) {
|
||||||
try {
|
try {
|
||||||
return DefaultPackBootstrapProvisioner.provision(new BootstrapRequest(
|
return DefaultPackBootstrapProvisioner.provision(new BootstrapRequest(
|
||||||
|
|||||||
+39
-2
@@ -73,6 +73,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pack validation, dimension lookup, and the world generator / biome provider resolution that the
|
* 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());
|
NamespacedKey messageKey = messageWorldKey(worldName, levelRoot.getName());
|
||||||
if (!IRIS_DIMENSION_NAMESPACE.equals(messageKey.getNamespace())) {
|
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
|
// Only bukkit.yml binds a vanilla slot at startup, and the refusal stops startup, so no command can run
|
||||||
// until that binding is gone.
|
// until that binding is gone.
|
||||||
throw new IllegalStateException("'" + worldName + "' (" + messageKey
|
throw new IllegalStateException("'" + worldName + "' (" + messageKey
|
||||||
+ ") is a vanilla world slot with no Iris world storage, so Iris cannot generate it."
|
+ ") 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"
|
+ " Remove worlds." + worldName + ".generator from bukkit.yml"
|
||||||
+ " /iris replace " + messageKey + " type=<pack> to replace it with Iris on the next restart.");
|
+ 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
|
throw new IllegalStateException("'" + worldName + "' (" + messageKey
|
||||||
+ ") has no Iris world storage, so Iris cannot generate it."
|
+ ") 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.");
|
+ " 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) {
|
private static boolean hasGenerationStorage(File dimensionRoot) {
|
||||||
GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(dimensionRoot.toPath());
|
GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(dimensionRoot.toPath());
|
||||||
return Files.isDirectory(paths.generationRoot(), LinkOption.NOFOLLOW_LINKS)
|
return Files.isDirectory(paths.generationRoot(), LinkOption.NOFOLLOW_LINKS)
|
||||||
|
|||||||
@@ -183,13 +183,17 @@ public class FailedEnableLockTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void irisWorldsAreManagedStorageOrAnyBukkitBinding() throws Exception {
|
public void irisWorldsAreManagedStorageOrABukkitBindingForAWorldThatLoads() throws Exception {
|
||||||
File levelRoot = temporaryFolder.newFolder("level", "world");
|
File levelRoot = temporaryFolder.newFolder("level", "world");
|
||||||
File bukkit = new File(temporaryFolder.getRoot(), "bukkit.yml");
|
File bukkit = new File(temporaryFolder.getRoot(), "bukkit.yml");
|
||||||
|
|
||||||
assertFalse(FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
|
assertFalse(FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
|
||||||
|
|
||||||
YamlConfiguration yaml = new YamlConfiguration();
|
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.set("worlds.world.generator", "Iris:overworld");
|
||||||
yaml.save(bukkit);
|
yaml.save(bukkit);
|
||||||
assertTrue("an Iris overworld lives in a vanilla slot", FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
|
assertTrue("an Iris overworld lives in a vanilla slot", FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
|
||||||
|
|||||||
+27
@@ -8,10 +8,16 @@ import io.papermc.paper.plugin.lifecycle.event.handler.configuration.LifecycleEv
|
|||||||
import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEventType;
|
import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEventType;
|
||||||
import net.kyori.adventure.text.logger.slf4j.ComponentLogger;
|
import net.kyori.adventure.text.logger.slf4j.ComponentLogger;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
import static org.junit.Assert.assertSame;
|
import static org.junit.Assert.assertSame;
|
||||||
import static org.junit.Assert.assertThrows;
|
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.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
public class IrisBootstrapFailureContractTest {
|
public class IrisBootstrapFailureContractTest {
|
||||||
@@ -32,6 +38,27 @@ public class IrisBootstrapFailureContractTest {
|
|||||||
assertSame(cause, failure.getCause());
|
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")
|
@SuppressWarnings("unchecked")
|
||||||
private static LifecycleEventType<BootstrapContext, LifecycleEvent, ?> lifecycleEventType() {
|
private static LifecycleEventType<BootstrapContext, LifecycleEvent, ?> lifecycleEventType() {
|
||||||
return (LifecycleEventType<BootstrapContext, LifecycleEvent, ?>) mock(LifecycleEventType.class);
|
return (LifecycleEventType<BootstrapContext, LifecycleEvent, ?>) mock(LifecycleEventType.class);
|
||||||
|
|||||||
+75
@@ -175,6 +175,81 @@ public class IrisBootstrapWorldStorageContractTest {
|
|||||||
assertFalse(MissingWorldStorageLog.hasWarned("world_iris_moon"));
|
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 {
|
private static BukkitStartupPaths startupPaths(Path serverRoot) throws Exception {
|
||||||
return BukkitStartupPaths.resolve(serverRoot, new String[0]);
|
return BukkitStartupPaths.resolve(serverRoot, new String[0]);
|
||||||
}
|
}
|
||||||
|
|||||||
+70
@@ -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
|
@Test
|
||||||
public void alreadyLoadedIrisKeyIsRefusedBeforeAnyEngineCanStart() throws Exception {
|
public void alreadyLoadedIrisKeyIsRefusedBeforeAnyEngineCanStart() throws Exception {
|
||||||
File worldContainer = temporaryFolder.newFolder("duplicate-key");
|
File worldContainer = temporaryFolder.newFolder("duplicate-key");
|
||||||
|
|||||||
@@ -18,11 +18,14 @@ import java.util.Locale;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Properties;
|
import java.util.Properties;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
public final class IrisWorldStorage {
|
public final class IrisWorldStorage {
|
||||||
private static final String IRIS_NAMESPACE = "iris";
|
private static final String IRIS_NAMESPACE = "iris";
|
||||||
private static final String DEFAULT_LEVEL_NAME = "world";
|
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
|
* Server#getLevelDirectory is Paper-API-only. Once a call throws NoSuchMethodError (plain
|
||||||
@@ -327,6 +330,46 @@ public final class IrisWorldStorage {
|
|||||||
return target.toFile();
|
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
|
* 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.
|
* 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.world.WorldSlotKey;
|
||||||
import art.arcane.iris.generation.runtime.IrisEngineMantle;
|
import art.arcane.iris.generation.runtime.IrisEngineMantle;
|
||||||
import art.arcane.iris.world.history.GenerationHistoryPaths;
|
import art.arcane.iris.world.history.GenerationHistoryPaths;
|
||||||
|
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
|
||||||
import org.bukkit.NamespacedKey;
|
import org.bukkit.NamespacedKey;
|
||||||
import org.bukkit.configuration.ConfigurationSection;
|
import org.bukkit.configuration.ConfigurationSection;
|
||||||
import org.bukkit.configuration.InvalidConfigurationException;
|
import org.bukkit.configuration.InvalidConfigurationException;
|
||||||
@@ -26,11 +27,16 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.function.Predicate;
|
import java.util.function.Predicate;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
public final class BukkitWorldConfiguration {
|
public final class BukkitWorldConfiguration {
|
||||||
private static final String DEFAULT_WORLD_CONTAINER = ".";
|
private static final String DEFAULT_WORLD_CONTAINER = ".";
|
||||||
private static final Object MUTATION_LOCK = new Object();
|
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() {
|
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>
|
* <p>
|
||||||
* {@link #readIrisGeneratorBindings} answers "which worlds can Iris bind", which deliberately drops
|
* {@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
|
* 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")
|
Path requiredLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot")
|
||||||
.toAbsolutePath()
|
.toAbsolutePath()
|
||||||
.normalize();
|
.normalize();
|
||||||
Path configurationPath = requiredConfigurationFile.toPath();
|
|
||||||
if (!Files.exists(configurationPath, LinkOption.NOFOLLOW_LINKS)) {
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
Path worldContainer = requiredLevelRoot.getParent();
|
Path worldContainer = requiredLevelRoot.getParent();
|
||||||
if (worldContainer == null) {
|
if (worldContainer == null) {
|
||||||
throw new IOException("Selected level root has no world container: " + requiredLevelRoot);
|
throw new IOException("Selected level root has no world container: " + requiredLevelRoot);
|
||||||
}
|
}
|
||||||
synchronized (MUTATION_LOCK) {
|
synchronized (MUTATION_LOCK) {
|
||||||
|
YamlConfiguration configuration = Files.exists(requiredConfigurationFile.toPath(), LinkOption.NOFOLLOW_LINKS)
|
||||||
|
? load(requiredConfigurationFile)
|
||||||
|
: new YamlConfiguration();
|
||||||
List<IrisWorldStorageEntry> entries = new ArrayList<>();
|
List<IrisWorldStorageEntry> entries = new ArrayList<>();
|
||||||
for (IrisWorldCandidate candidate : readIrisWorldCandidates(
|
for (IrisWorldCandidate candidate : readIrisWorldCandidates(configuration, requiredLevelName)) {
|
||||||
load(requiredConfigurationFile),
|
|
||||||
requiredLevelName
|
|
||||||
)) {
|
|
||||||
entries.add(classifyStorage(worldContainer, requiredLevelRoot, candidate));
|
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);
|
return List.copyOf(entries);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,12 +268,21 @@ public final class BukkitWorldConfiguration {
|
|||||||
hasWorldData(resolvedRoot)
|
hasWorldData(resolvedRoot)
|
||||||
? IrisWorldStorageState.UNUSABLE
|
? IrisWorldStorageState.UNUSABLE
|
||||||
: IrisWorldStorageState.EMPTY,
|
: IrisWorldStorageState.EMPTY,
|
||||||
unusablePack.getMessage());
|
describe(unusablePack));
|
||||||
}
|
}
|
||||||
return new IrisWorldStorageEntry(configuredName, slotKey, resolvedRoot,
|
return new IrisWorldStorageEntry(configuredName, slotKey, resolvedRoot,
|
||||||
IrisWorldStorageState.PRESENT, null);
|
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/}
|
* 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.
|
* 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
|
* True when a bukkit.yml world the server loads names Iris as its generator: the level's overworld, nether or
|
||||||
* for a generator only for these worlds, so they are the ones a disabled Iris would hand to vanilla.
|
* 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 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)) {
|
if (!Files.exists(configurationPath, LinkOption.NOFOLLOW_LINKS)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -410,7 +446,8 @@ public final class BukkitWorldConfiguration {
|
|||||||
}
|
}
|
||||||
for (String worldName : worlds.getKeys(false)) {
|
for (String worldName : worlds.getKeys(false)) {
|
||||||
if (worlds.get(worldName + ".generator") instanceof String generator
|
if (worlds.get(worldName + ".generator") instanceof String generator
|
||||||
&& isIrisGenerator(generator.trim())) {
|
&& isIrisGenerator(generator.trim())
|
||||||
|
&& loadsAtStartup(worldContainer, root, levelName.toString(), worldName)) {
|
||||||
return true;
|
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) {
|
private static boolean isIrisGenerator(String configuredGenerator) {
|
||||||
return configuredGenerator.equalsIgnoreCase("Iris")
|
return configuredGenerator.equalsIgnoreCase("Iris")
|
||||||
|| configuredGenerator.regionMatches(true, 0, "Iris:", 0, 5);
|
|| 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
|
* A world directory the server will load, whose frozen pack snapshot cannot be used. Iris cannot
|
||||||
* generate it and nothing else may.
|
* 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(
|
public record IrisWorldStorageEntry(
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import java.time.format.DateTimeFormatter;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
@@ -41,8 +40,6 @@ public final class HuskWorldQuarantine {
|
|||||||
private static final String[] CHUNK_DATA_DIRECTORIES = {
|
private static final String[] CHUNK_DATA_DIRECTORIES = {
|
||||||
"region", "entities", "poi", IrisEngineMantle.STORAGE_FOLDER_NAME
|
"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
|
private static final DateTimeFormatter STAMP = DateTimeFormatter
|
||||||
.ofPattern("yyyyMMdd'T'HHmmss")
|
.ofPattern("yyyyMMdd'T'HHmmss")
|
||||||
.withZone(ZoneOffset.UTC);
|
.withZone(ZoneOffset.UTC);
|
||||||
@@ -117,7 +114,7 @@ public final class HuskWorldQuarantine {
|
|||||||
if (!isReadableDirectory(dimensionRoot)) {
|
if (!isReadableDirectory(dimensionRoot)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (holdsIrisContent(dimensionRoot.resolve("iris"))) {
|
if (IrisWorldStorage.holdsIrisContent(dimensionRoot)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
for (String directory : CHUNK_DATA_DIRECTORIES) {
|
for (String directory : CHUNK_DATA_DIRECTORIES) {
|
||||||
@@ -128,44 +125,6 @@ public final class HuskWorldQuarantine {
|
|||||||
return true;
|
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
|
* 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.
|
* 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;
|
package art.arcane.iris.world.safeguard;
|
||||||
|
|
||||||
|
import java.nio.file.FileSystemException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.IdentityHashMap;
|
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
|
* 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) {
|
public static List<String> causes(Throwable failure) {
|
||||||
List<String> causes = new ArrayList<>();
|
List<String> causes = new ArrayList<>();
|
||||||
@@ -42,7 +44,11 @@ public final class GenerationRefusalNotice {
|
|||||||
String message = current.getMessage();
|
String message = current.getMessage();
|
||||||
boolean repeatsCause = cause != null && cause.toString().equals(message);
|
boolean repeatsCause = cause != null && cause.toString().equals(message);
|
||||||
if (!repeatsCause) {
|
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)) {
|
if (causes.isEmpty() || !causes.getLast().equals(text)) {
|
||||||
causes.add(text);
|
causes.add(text);
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-7
@@ -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
|
* 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.
|
* 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
|
@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");
|
File overworld = temporaryFolder.newFile("iris-overworld.yml");
|
||||||
YamlConfiguration yaml = new YamlConfiguration();
|
YamlConfiguration yaml = new YamlConfiguration();
|
||||||
yaml.set("worlds.world.generator", "iris:overworld");
|
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.generator", "Irish:overworld");
|
||||||
yaml.set("worlds.world_nether.seed", 1337L);
|
yaml.set("worlds.world_nether.seed", 1337L);
|
||||||
yaml.save(other);
|
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.configuresLoadingIrisWorld(overworld, levelRoot));
|
||||||
assertTrue(BukkitWorldConfiguration.configuresIrisGenerator(bare));
|
assertTrue(BukkitWorldConfiguration.configuresLoadingIrisWorld(bare, levelRoot));
|
||||||
assertFalse(BukkitWorldConfiguration.configuresIrisGenerator(other));
|
assertFalse(BukkitWorldConfiguration.configuresLoadingIrisWorld(other, levelRoot));
|
||||||
assertFalse(BukkitWorldConfiguration.configuresIrisGenerator(new File(temporaryFolder.getRoot(), "absent.yml")));
|
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
|
@Test
|
||||||
@@ -205,6 +218,35 @@ public class BukkitWorldConfigurationTest {
|
|||||||
assertTrue(entryOf(audited, "world_iris_broken").detail().contains("generation history"));
|
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
|
@Test
|
||||||
public void auditTreatsANonDirectoryStorageSlotAsAnOrphanTheServerNeverLoads() throws Exception {
|
public void auditTreatsANonDirectoryStorageSlotAsAnOrphanTheServerNeverLoads() throws Exception {
|
||||||
File configuration = temporaryFolder.newFile("audit-file-slot.yml");
|
File configuration = temporaryFolder.newFile("audit-file-slot.yml");
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package art.arcane.iris.world.safeguard;
|
|||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.nio.file.NoSuchFileException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CompletionException;
|
import java.util.concurrent.CompletionException;
|
||||||
|
|
||||||
@@ -33,6 +34,20 @@ public class GenerationRefusalNoticeTest {
|
|||||||
new IllegalStateException("history drifted", root)));
|
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
|
@Test
|
||||||
public void aCyclicCauseChainTerminates() {
|
public void aCyclicCauseChainTerminates() {
|
||||||
IllegalStateException first = new IllegalStateException("first");
|
IllegalStateException first = new IllegalStateException("first");
|
||||||
|
|||||||
Reference in New Issue
Block a user