This commit is contained in:
Brian Neumann-Fopiano
2026-08-12 00:42:33 -04:00
parent 8a189195ad
commit 12b97b7994
62 changed files with 2878 additions and 706 deletions
@@ -1,7 +1,5 @@
package art.arcane.iris.core;
import org.bukkit.NamespacedKey;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
@@ -17,13 +15,13 @@ public final class ExactWorldSlotPathPolicy {
private ExactWorldSlotPathPolicy() {
}
public static Target resolve(Path levelRoot, NamespacedKey worldKey) {
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
public static Target resolve(Path levelRoot, WorldSlotKey worldKey) {
WorldSlotKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
Path canonicalLevelRoot = canonicalLevelRoot(levelRoot);
SlotKind slotKind = classify(requiredWorldKey);
Path dimensionsRoot = canonicalLevelRoot.resolve("dimensions");
Path namespaceRoot = dimensionsRoot.resolve(requiredWorldKey.getNamespace());
Path worldDirectory = namespaceRoot.resolve(requiredWorldKey.getKey()).normalize();
Path namespaceRoot = dimensionsRoot.resolve(requiredWorldKey.namespace());
Path worldDirectory = namespaceRoot.resolve(requiredWorldKey.key()).normalize();
if (!Objects.equals(worldDirectory.getParent(), namespaceRoot)) {
throw new Rejection(
RejectionReason.PATH_TRAVERSAL,
@@ -37,7 +35,7 @@ public final class ExactWorldSlotPathPolicy {
return new Target(requiredWorldKey, slotKind, canonicalLevelRoot, namespaceRoot, worldDirectory);
}
public static Target validate(Path levelRoot, NamespacedKey worldKey, Path candidate) {
public static Target validate(Path levelRoot, WorldSlotKey worldKey, Path candidate) {
Path requiredCandidate = Objects.requireNonNull(candidate, "candidate");
rejectTraversal(requiredCandidate, "World candidate");
Target target = resolve(levelRoot, worldKey);
@@ -71,9 +69,9 @@ public final class ExactWorldSlotPathPolicy {
}
}
private static SlotKind classify(NamespacedKey worldKey) {
if ("iris".equals(worldKey.getNamespace())) {
if (!SAFE_IRIS_KEY.matcher(worldKey.getKey()).matches()) {
private static SlotKind classify(WorldSlotKey worldKey) {
if ("iris".equals(worldKey.namespace())) {
if (!SAFE_IRIS_KEY.matcher(worldKey.key()).matches()) {
throw new Rejection(
RejectionReason.INVALID_IRIS_KEY,
"Iris world keys must be safe single path segments."
@@ -81,13 +79,13 @@ public final class ExactWorldSlotPathPolicy {
}
return SlotKind.IRIS_MANAGED;
}
if (!NamespacedKey.MINECRAFT.equals(worldKey.getNamespace())) {
if (!"minecraft".equals(worldKey.namespace())) {
throw new Rejection(
RejectionReason.FOREIGN_NAMESPACE,
"Only Iris-managed and exact vanilla dimension slots can be replaced."
);
}
return switch (worldKey.getKey()) {
return switch (worldKey.key()) {
case "overworld" -> SlotKind.VANILLA_OVERWORLD;
case "the_nether" -> SlotKind.VANILLA_NETHER;
case "the_end" -> SlotKind.VANILLA_END;
@@ -154,7 +152,7 @@ public final class ExactWorldSlotPathPolicy {
}
public record Target(
NamespacedKey worldKey,
WorldSlotKey worldKey,
SlotKind slotKind,
Path levelRoot,
Path namespaceRoot,
@@ -167,8 +165,8 @@ public final class ExactWorldSlotPathPolicy {
namespaceRoot = Objects.requireNonNull(namespaceRoot, "namespaceRoot").toAbsolutePath().normalize();
worldDirectory = Objects.requireNonNull(worldDirectory, "worldDirectory").toAbsolutePath().normalize();
SlotKind expectedSlotKind = classify(worldKey);
Path expectedNamespaceRoot = levelRoot.resolve("dimensions").resolve(worldKey.getNamespace());
Path expectedWorldDirectory = expectedNamespaceRoot.resolve(worldKey.getKey());
Path expectedNamespaceRoot = levelRoot.resolve("dimensions").resolve(worldKey.namespace());
Path expectedWorldDirectory = expectedNamespaceRoot.resolve(worldKey.key());
if (slotKind != expectedSlotKind
|| !namespaceRoot.equals(expectedNamespaceRoot)
|| !worldDirectory.equals(expectedWorldDirectory)) {
@@ -0,0 +1,41 @@
package art.arcane.iris.core;
import java.util.Objects;
import java.util.regex.Pattern;
public record WorldSlotKey(String namespace, String key) {
private static final Pattern KEY_PATTERN = Pattern.compile("^[a-z0-9/._-]+$");
private static final Pattern NAMESPACE_PATTERN = Pattern.compile("^[a-z0-9._-]+$");
public WorldSlotKey {
namespace = Objects.requireNonNull(namespace, "namespace");
key = Objects.requireNonNull(key, "key");
if (!NAMESPACE_PATTERN.matcher(namespace).matches()) {
throw new IllegalArgumentException("World slot namespace is invalid: " + namespace);
}
if (!KEY_PATTERN.matcher(key).matches()) {
throw new IllegalArgumentException("World slot key is invalid: " + key);
}
}
public static WorldSlotKey parse(String value) {
String requiredValue = Objects.requireNonNull(value, "value");
int separator = requiredValue.indexOf(':');
if (separator <= 0 || separator != requiredValue.lastIndexOf(':') || separator == requiredValue.length() - 1) {
throw new IllegalArgumentException("World slot key must use namespace:key syntax.");
}
return new WorldSlotKey(
requiredValue.substring(0, separator),
requiredValue.substring(separator + 1)
);
}
public static WorldSlotKey minecraft(String key) {
return new WorldSlotKey("minecraft", key);
}
@Override
public String toString() {
return namespace + ":" + key;
}
}
@@ -0,0 +1,370 @@
package art.arcane.iris.core.lifecycle;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;
public final class BukkitStartupPaths {
private static final String DEFAULT_BUKKIT_CONFIGURATION = "bukkit.yml";
private static final String DEFAULT_LEVEL_NAME = "world";
private static final String DEFAULT_SERVER_PROPERTIES = "server.properties";
private static final String DEFAULT_WORLD_CONTAINER = ".";
private final Path bukkitConfiguration;
private final Path levelRoot;
private final String levelName;
private final Path serverProperties;
private final Path serverRoot;
private final Path worldContainer;
private BukkitStartupPaths(
Path serverRoot,
Path serverProperties,
Path bukkitConfiguration,
Path worldContainer,
String levelName,
Path levelRoot
) {
this.serverRoot = Objects.requireNonNull(serverRoot, "serverRoot");
this.serverProperties = Objects.requireNonNull(serverProperties, "serverProperties");
this.bukkitConfiguration = Objects.requireNonNull(bukkitConfiguration, "bukkitConfiguration");
this.worldContainer = Objects.requireNonNull(worldContainer, "worldContainer");
this.levelName = Objects.requireNonNull(levelName, "levelName");
this.levelRoot = Objects.requireNonNull(levelRoot, "levelRoot");
}
public static BukkitStartupPaths resolveCurrent() throws IOException {
return resolve(Path.of(""), currentArguments());
}
public static BukkitStartupPaths resolve(Path serverWorkingDirectory) throws IOException {
return resolve(serverWorkingDirectory, currentArguments());
}
public static BukkitStartupPaths resolve(Path serverWorkingDirectory, String[] processArguments)
throws IOException {
Path serverRoot = canonicalDirectory(
Objects.requireNonNull(serverWorkingDirectory, "serverWorkingDirectory").toAbsolutePath().normalize(),
"Server working directory"
);
StartupArguments arguments = parseArguments(processArguments);
Path serverProperties = resolveAgainstServerRoot(
serverRoot,
arguments.serverProperties(),
DEFAULT_SERVER_PROPERTIES,
"server properties"
);
Path bukkitConfiguration = resolveAgainstServerRoot(
serverRoot,
arguments.bukkitConfiguration(),
DEFAULT_BUKKIT_CONFIGURATION,
"Bukkit configuration"
);
String levelName = effectiveLevelName(serverProperties, arguments.levelName());
Path worldContainer = canonicalPotentialDirectory(
resolveAgainstServerRoot(
serverRoot,
arguments.worldContainer() == null
? BukkitWorldConfiguration.readWorldContainer(bukkitConfiguration.toFile())
: arguments.worldContainer(),
DEFAULT_WORLD_CONTAINER,
"world container"
),
"Configured world container"
);
Path configuredLevel = parsePath(levelName, "level name");
Path levelRoot = canonicalPotentialDirectory(
configuredLevel.isAbsolute()
? configuredLevel
: worldContainer.resolve(configuredLevel),
"Configured level root"
);
return new BukkitStartupPaths(
serverRoot,
serverProperties,
bukkitConfiguration,
worldContainer,
levelName,
levelRoot
);
}
public Path bukkitConfiguration() {
return bukkitConfiguration;
}
public Path levelRoot() {
return levelRoot;
}
public String levelName() {
return levelName;
}
public Path serverProperties() {
return serverProperties;
}
public Path serverRoot() {
return serverRoot;
}
public Path worldContainer() {
return worldContainer;
}
private static String[] currentArguments() {
return applicationArguments(ProcessHandle.current().info().arguments().orElse(new String[0]));
}
static String[] applicationArguments(String[] processArguments) {
String[] arguments = Objects.requireNonNull(processArguments, "processArguments");
for (int index = 0; index < arguments.length; index++) {
if (arguments[index].equals("-jar")
|| arguments[index].equals("-m")
|| arguments[index].equals("--module")) {
int applicationStart = Math.min(arguments.length, index + 2);
return Arrays.copyOfRange(arguments, applicationStart, arguments.length);
}
}
Set<String> optionsWithValues = Set.of(
"-cp",
"-classpath",
"--class-path",
"-p",
"--module-path",
"--upgrade-module-path",
"--add-modules",
"--enable-native-access",
"--limit-modules",
"--add-exports",
"--add-opens",
"--add-reads",
"--patch-module",
"--source"
);
for (int index = 0; index < arguments.length; index++) {
String argument = arguments[index];
if (optionsWithValues.contains(argument)) {
index++;
continue;
}
if (argument.startsWith("-") || argument.startsWith("@")) {
continue;
}
return Arrays.copyOfRange(arguments, index + 1, arguments.length);
}
return new String[0];
}
private static StartupArguments parseArguments(String[] processArguments) throws IOException {
String[] arguments = Objects.requireNonNull(processArguments, "processArguments");
String serverProperties = null;
String bukkitConfiguration = null;
String levelName = null;
String worldContainer = null;
for (int index = 0; index < arguments.length; index++) {
String argument = Objects.requireNonNull(arguments[index], "process argument");
if (argument.equals("--")) {
break;
}
ParsedArgument parsed = parseArgument(
argument,
index + 1 < arguments.length ? arguments[index + 1] : null
);
if (parsed == null) {
continue;
}
switch (parsed.kind()) {
case SERVER_PROPERTIES -> serverProperties = parsed.value();
case BUKKIT_CONFIGURATION -> bukkitConfiguration = parsed.value();
case LEVEL_NAME -> levelName = parsed.value();
case WORLD_CONTAINER -> worldContainer = parsed.value();
}
if (parsed.consumedFollowing()) {
index++;
}
}
return new StartupArguments(serverProperties, bukkitConfiguration, levelName, worldContainer);
}
private static ParsedArgument parseArgument(String argument, String following) throws IOException {
ParsedArgument parsed = parseArgument(
argument,
following,
ArgumentKind.SERVER_PROPERTIES,
"-c",
"--config"
);
if (parsed != null) {
return parsed;
}
parsed = parseArgument(
argument,
following,
ArgumentKind.BUKKIT_CONFIGURATION,
"-b",
"--bukkit-settings"
);
if (parsed != null) {
return parsed;
}
parsed = parseArgument(
argument,
following,
ArgumentKind.WORLD_CONTAINER,
"-W",
"--world-dir",
"--universe",
"--world-container"
);
if (parsed != null) {
return parsed;
}
return parseArgument(
argument,
following,
ArgumentKind.LEVEL_NAME,
"-w",
"--world",
"--level-name"
);
}
private static ParsedArgument parseArgument(
String argument,
String following,
ArgumentKind kind,
String... keys
) throws IOException {
for (String key : keys) {
if (argument.equals(key)) {
if (following == null || following.isBlank()) {
throw new IOException("Startup argument " + key + " requires a value");
}
return new ParsedArgument(kind, following, true);
}
if (key.length() == 2 && argument.startsWith(key) && argument.length() > key.length()) {
String value = argument.substring(key.length());
if (value.startsWith("=")) {
value = value.substring(1);
}
if (value.isBlank()) {
throw new IOException("Startup argument " + key + " requires a value");
}
return new ParsedArgument(kind, value, false);
}
String prefix = key + "=";
if (argument.startsWith(prefix)) {
String value = argument.substring(prefix.length());
if (value.isBlank()) {
throw new IOException("Startup argument " + key + " requires a value");
}
return new ParsedArgument(kind, value, false);
}
}
return null;
}
private static Path resolveAgainstServerRoot(
Path serverRoot,
String configuredValue,
String defaultValue,
String label
) throws IOException {
String value = configuredValue == null ? defaultValue : configuredValue;
Path configured = parsePath(value, label);
return configured.isAbsolute()
? configured.normalize()
: serverRoot.resolve(configured).normalize();
}
private static Path parsePath(String value, String label) throws IOException {
if (value == null || value.isBlank()) {
throw new IOException("Configured " + label + " is empty");
}
try {
return Path.of(value);
} catch (InvalidPathException exception) {
throw new IOException("Configured " + label + " is invalid", exception);
}
}
private static String effectiveLevelName(Path serverProperties, String argumentOverride) throws IOException {
if (argumentOverride != null) {
return requireValue(argumentOverride, "level name");
}
if (!Files.exists(serverProperties, LinkOption.NOFOLLOW_LINKS)) {
return DEFAULT_LEVEL_NAME;
}
if (!Files.isRegularFile(serverProperties)) {
throw new IOException("Configured server properties is not a regular file: " + serverProperties);
}
Properties properties = new Properties();
try (InputStream input = Files.newInputStream(serverProperties)) {
properties.load(input);
}
return requireValue(properties.getProperty("level-name", DEFAULT_LEVEL_NAME), "level name");
}
private static String requireValue(String value, String label) throws IOException {
if (value == null || value.isBlank()) {
throw new IOException("Configured " + label + " is empty");
}
return value;
}
private static Path canonicalDirectory(Path path, String label) throws IOException {
Path canonical = path.toRealPath();
if (!Files.isDirectory(canonical)) {
throw new IOException(label + " is not a directory: " + path);
}
return canonical;
}
private static Path canonicalPotentialDirectory(Path path, String label) throws IOException {
Path normalized = path.toAbsolutePath().normalize();
ArrayList<Path> missing = new ArrayList<>();
Path existing = normalized;
while (!Files.exists(existing, LinkOption.NOFOLLOW_LINKS)) {
Path fileName = existing.getFileName();
Path parent = existing.getParent();
if (fileName == null || parent == null) {
throw new IOException(label + " has no existing filesystem ancestor: " + normalized);
}
missing.add(fileName);
existing = parent;
}
Path canonical = canonicalDirectory(existing, label);
for (int index = missing.size() - 1; index >= 0; index--) {
canonical = canonical.resolve(missing.get(index));
}
return canonical.normalize();
}
private enum ArgumentKind {
SERVER_PROPERTIES,
BUKKIT_CONFIGURATION,
LEVEL_NAME,
WORLD_CONTAINER
}
private record ParsedArgument(ArgumentKind kind, String value, boolean consumedFollowing) {
}
private record StartupArguments(
String serverProperties,
String bukkitConfiguration,
String levelName,
String worldContainer
) {
}
}
@@ -9,14 +9,17 @@ import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Objects;
import java.util.function.Predicate;
public final class BukkitWorldConfiguration {
private static final String DEFAULT_WORLD_CONTAINER = ".";
private static final Object MUTATION_LOCK = new Object();
private BukkitWorldConfiguration() {
@@ -63,6 +66,31 @@ public final class BukkitWorldConfiguration {
}
}
public static String readWorldContainer(File configurationFile) throws IOException {
File requiredConfigurationFile = Objects.requireNonNull(configurationFile, "configurationFile");
Path configurationPath = requiredConfigurationFile.toPath();
if (!Files.exists(configurationPath, LinkOption.NOFOLLOW_LINKS)) {
return DEFAULT_WORLD_CONTAINER;
}
if (!Files.isRegularFile(configurationPath)) {
throw new IOException("Configured Bukkit configuration is not a regular file: " + configurationPath);
}
synchronized (MUTATION_LOCK) {
YamlConfiguration configuration = load(requiredConfigurationFile);
Object configured = configuration.get("settings.world-container");
if (configured == null) {
return DEFAULT_WORLD_CONTAINER;
}
if (!(configured instanceof String value)) {
throw new IOException("Bukkit settings.world-container must be a path string");
}
if (value.isBlank()) {
throw new IOException("Configured world container is empty");
}
return value;
}
}
public static GeneratorReplacement replaceIfMatching(
File configurationFile,
String worldName,
@@ -82,7 +110,7 @@ public final class BukkitWorldConfiguration {
return new GeneratorReplacement(false, current, replacement);
}
apply(configuration, requiredWorldName, replacement);
saveAtomic(configurationFile.toPath(), configuration);
saveAtomic(configurationFile.toPath(), configuration, true);
return new GeneratorReplacement(true, current, replacement);
}
}
@@ -104,7 +132,7 @@ public final class BukkitWorldConfiguration {
return false;
}
apply(configuration, requiredWorldName, requiredRestoration);
saveAtomic(configurationFile.toPath(), configuration);
saveAtomic(configurationFile.toPath(), configuration, true);
return true;
}
}
@@ -194,7 +222,16 @@ public final class BukkitWorldConfiguration {
}
static void saveAtomic(Path target, YamlConfiguration configuration) throws IOException {
saveAtomic(target, configuration, false);
}
private static void saveAtomic(
Path target,
YamlConfiguration configuration,
boolean requireAtomicReplacement
) throws IOException {
Path absoluteTarget = target.toAbsolutePath().normalize();
requireRegularConfigurationFile(absoluteTarget);
Path parent = absoluteTarget.getParent();
if (parent == null) {
throw new IOException("bukkit.yml target has no parent: " + absoluteTarget);
@@ -206,9 +243,16 @@ public final class BukkitWorldConfiguration {
try (FileChannel channel = FileChannel.open(staged, StandardOpenOption.WRITE)) {
channel.force(true);
}
requireRegularConfigurationFile(absoluteTarget);
try {
Files.move(staged, absoluteTarget, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException exception) {
if (requireAtomicReplacement) {
throw new IOException(
"Exact world replacement requires atomic bukkit.yml publication on this filesystem.",
exception
);
}
Files.move(staged, absoluteTarget, StandardCopyOption.REPLACE_EXISTING);
}
} finally {
@@ -217,6 +261,7 @@ public final class BukkitWorldConfiguration {
}
private static YamlConfiguration load(File configurationFile) throws IOException {
requireRegularConfigurationFile(configurationFile.toPath());
YamlConfiguration configuration = new YamlConfiguration();
try {
configuration.load(configurationFile);
@@ -226,6 +271,18 @@ public final class BukkitWorldConfiguration {
}
}
private static void requireRegularConfigurationFile(Path configurationFile) throws IOException {
Path path = configurationFile.toAbsolutePath().normalize();
BasicFileAttributes attributes = Files.readAttributes(
path,
BasicFileAttributes.class,
LinkOption.NOFOLLOW_LINKS
);
if (attributes.isSymbolicLink() || !attributes.isRegularFile()) {
throw new IOException("bukkit.yml must be an existing regular file and was not changed: " + path);
}
}
private static WorldGeneratorSnapshot snapshot(
YamlConfiguration configuration,
String worldName
@@ -102,21 +102,28 @@ public final class WorldReplacementBootstrap {
feedback.accept("Restored the retained world for " + transaction.worldKey() + ".");
return ReconcileAction.ROLLED_BACK;
}
if (current.matchesGeneratorAndSeed(transaction.originalConfiguration())) {
rollback(dataDirectory, bukkitConfiguration, transaction, paths, current, replacement);
feedback.accept("Cancelled or rolled back Iris world replacement for " + transaction.worldKey() + ".");
return ReconcileAction.ROLLED_BACK;
}
if (!current.matchesGeneratorAndSeed(replacement)) {
throw conflict(transaction, "bukkit.yml matches neither the replacement nor its retained original state.");
}
Transaction active = transaction;
if (active.phase() == Phase.PREPARED) {
if (current.matchesGeneratorAndSeed(active.originalConfiguration())) {
rollback(dataDirectory, bukkitConfiguration, active, paths, current, replacement);
feedback.accept("Cancelled incomplete Iris world replacement for " + active.worldKey() + ".");
return ReconcileAction.ROLLED_BACK;
}
if (!current.matchesGeneratorAndSeed(replacement)) {
throw conflict(active, "bukkit.yml matches neither the replacement nor its retained original state.");
}
active = active.withPhase(Phase.ARMED);
WorldReplacementJournal.write(dataDirectory, active);
}
if (active.phase() == Phase.ARMED) {
if (!current.matchesGeneratorAndSeed(replacement)) {
if (current.matchesGeneratorAndSeed(active.originalConfiguration())) {
rollback(dataDirectory, bukkitConfiguration, active, paths, current, replacement);
feedback.accept("Cancelled Iris world replacement for " + active.worldKey() + ".");
return ReconcileAction.ROLLED_BACK;
}
throw conflict(active, "bukkit.yml matches neither the replacement nor its retained original state.");
}
WorldReplacementFilesystem.publish(
paths,
active.originalTargetPresent(),
@@ -129,6 +136,14 @@ public final class WorldReplacementBootstrap {
return ReconcileAction.PUBLISHED;
}
if (active.phase() == Phase.PUBLISHED) {
if (!current.matchesGeneratorAndSeed(replacement)) {
if (current.matchesGeneratorAndSeed(active.originalConfiguration())) {
rollback(dataDirectory, bukkitConfiguration, active, paths, current, replacement);
feedback.accept("Rolled back Iris world replacement for " + active.worldKey() + ".");
return ReconcileAction.ROLLED_BACK;
}
throw conflict(active, "bukkit.yml matches neither the replacement nor its retained original state.");
}
WorldReplacementFilesystem.publish(
paths,
active.originalTargetPresent(),
@@ -154,7 +169,7 @@ public final class WorldReplacementBootstrap {
Transaction rollback = transaction.phase() == Phase.ROLLBACK_PENDING
? transaction
: transaction.withPhase(Phase.ROLLBACK_PENDING);
if (rollback != transaction) {
if (transaction.phase() != Phase.ROLLBACK_PENDING) {
WorldReplacementJournal.write(dataDirectory, rollback);
}
WorldReplacementFilesystem.prepareRollback(paths, rollback.originalTargetPresent());
@@ -0,0 +1,16 @@
package art.arcane.iris.core.lifecycle;
public final class WorldReplacementBootstrapMarker {
private static volatile boolean bootstrappedThisProcess;
private WorldReplacementBootstrapMarker() {
}
public static boolean wasBootstrappedThisProcess() {
return bootstrappedThisProcess;
}
public static void markBootstrapped() {
bootstrappedThisProcess = true;
}
}
@@ -1,8 +1,10 @@
package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.SnapshotDirectoryTreeDeleter;
import art.arcane.iris.core.ExactWorldSlotPathPolicy;
import art.arcane.iris.core.SnapshotDirectoryTreeDeleter;
import art.arcane.iris.spi.IrisLogging;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
@@ -26,6 +28,11 @@ import java.util.regex.Pattern;
import java.util.stream.Stream;
public final class WorldReplacementFilesystem {
private static final List<Path> PAPER_WORLD_METADATA = List.of(
Path.of("data/paper/metadata.dat"),
Path.of("data/paper/level_overrides.dat"),
Path.of("data/minecraft/world_gen_settings.dat")
);
private static final Pattern STAGE_NAME = Pattern.compile(
"^\\.iris-replace-[a-z0-9_-]+-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\.stage$");
private static final Pattern BACKUP_NAME = Pattern.compile(
@@ -37,7 +44,7 @@ public final class WorldReplacementFilesystem {
public static ReplacementPaths paths(ExactWorldSlotPathPolicy.Target target, UUID id) {
ExactWorldSlotPathPolicy.Target requiredTarget = Objects.requireNonNull(target, "target");
UUID requiredId = Objects.requireNonNull(id, "id");
String artifactBase = ".iris-replace-" + requiredTarget.worldKey().getKey() + "-" + requiredId;
String artifactBase = ".iris-replace-" + requiredTarget.worldKey().key() + "-" + requiredId;
return new ReplacementPaths(
requiredTarget.worldDirectory(),
requiredTarget.namespaceRoot().resolve(artifactBase + ".stage"),
@@ -45,6 +52,17 @@ public final class WorldReplacementFilesystem {
);
}
public static void requireExistingTarget(ReplacementPaths paths) throws IOException {
ReplacementPaths requiredPaths = Objects.requireNonNull(paths, "paths");
State state = inspect(requiredPaths);
if (!state.targetPresent()) {
throw new IOException("overwrite=true requires an existing exact world slot; use ordinary create for a new world.");
}
if (state.stagePresent() || state.backupPresent()) {
throw new IOException("A replacement artifact already exists for this transaction.");
}
}
public static void publish(
ReplacementPaths paths,
boolean originalTargetPresent,
@@ -56,6 +74,12 @@ public final class WorldReplacementFilesystem {
if (state.stagePresent()) {
requireSafeTree(requiredPaths.stage(), "replacement stage");
requireFingerprint(requiredPaths.stage().resolve("iris/pack"), expectedFingerprint);
if (originalTargetPresent) {
Path retainedWorld = state.targetPresent()
? requiredPaths.target()
: requiredPaths.backup();
preservePaperWorldMetadata(retainedWorld, requiredPaths.stage());
}
if (state.targetPresent()) {
if (state.backupPresent()) {
throw new IOException("Replacement target, stage, and backup are all present.");
@@ -86,6 +110,11 @@ public final class WorldReplacementFilesystem {
}
requireSafeTree(requiredPaths.target(), "replacement target");
requireFingerprint(requiredPaths.target().resolve("iris/pack"), expectedFingerprint);
if (originalTargetPresent) {
preservePaperWorldMetadata(requiredPaths.backup(), requiredPaths.target());
requireSafeTree(requiredPaths.target(), "replacement target");
requireFingerprint(requiredPaths.target().resolve("iris/pack"), expectedFingerprint);
}
}
public static void rollback(ReplacementPaths paths, boolean originalTargetPresent) throws IOException {
@@ -298,6 +327,71 @@ public final class WorldReplacementFilesystem {
}
}
private static void preservePaperWorldMetadata(Path retainedWorld, Path replacementWorld) throws IOException {
requireDirectory(retainedWorld, "retained world");
requireDirectory(replacementWorld, "replacement world");
requireDirectory(retainedWorld.resolve("data"), "retained world data");
requireDirectory(retainedWorld.resolve("data/paper"), "retained Paper data");
requireDirectory(retainedWorld.resolve("data/minecraft"), "retained Minecraft data");
for (Path relative : PAPER_WORLD_METADATA) {
BasicFileAttributes sourceAttributes = requireSafeEntry(retainedWorld.resolve(relative));
if (!sourceAttributes.isRegularFile()) {
throw new IOException("Retained Paper world metadata is not a regular file: " + relative);
}
}
ensureDirectory(replacementWorld.resolve("data"), "replacement world data");
ensureDirectory(replacementWorld.resolve("data/paper"), "replacement Paper data");
ensureDirectory(replacementWorld.resolve("data/minecraft"), "replacement Minecraft data");
for (Path relative : PAPER_WORLD_METADATA) {
Path destination = replacementWorld.resolve(relative);
if (Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) {
BasicFileAttributes destinationAttributes = requireSafeEntry(destination);
if (!destinationAttributes.isRegularFile()) {
throw new IOException("Replacement Paper world metadata is not a regular file: " + relative);
}
continue;
}
copyMetadataFile(retainedWorld.resolve(relative), destination);
}
}
private static void ensureDirectory(Path directory, String label) throws IOException {
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
requireDirectory(directory, label);
return;
}
Files.createDirectory(directory);
forceDirectoryRequired(directory.getParent());
}
private static void copyMetadataFile(Path source, Path destination) throws IOException {
Path temporary = destination.resolveSibling("." + destination.getFileName() + ".iris-replace.tmp");
if (Files.exists(temporary, LinkOption.NOFOLLOW_LINKS)) {
BasicFileAttributes attributes = requireSafeEntry(temporary);
if (!attributes.isRegularFile()) {
throw new IOException("Replacement metadata staging path is unsafe: " + temporary);
}
Files.delete(temporary);
}
try {
Files.copy(source, temporary, StandardCopyOption.COPY_ATTRIBUTES);
try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) {
channel.force(true);
}
try {
Files.move(temporary, destination, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException exception) {
throw new IOException(
"Exact world replacement requires atomic Paper metadata publication on this filesystem.",
exception
);
}
forceDirectoryRequired(destination.getParent());
} finally {
Files.deleteIfExists(temporary);
}
}
private static void update(MessageDigest digest, String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array());
@@ -313,13 +407,37 @@ public final class WorldReplacementFilesystem {
}
private static void move(Path source, Path target) throws IOException {
forceDirectoryRequired(source.getParent());
try {
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(source, target);
throw new IOException(
"Exact world replacement requires atomic directory moves on this filesystem.",
exception
);
}
try (FileChannel channel = FileChannel.open(source.getParent(), StandardOpenOption.READ)) {
forceDirectoryAfterCommit(source.getParent());
}
private static void forceDirectoryRequired(Path directory) throws IOException {
if (File.separatorChar == '\\') {
return;
}
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
channel.force(true);
} catch (UnsupportedOperationException failure) {
throw new IOException("Directory durability sync is unavailable for " + directory + ".", failure);
}
}
private static void forceDirectoryAfterCommit(Path directory) {
try {
forceDirectoryRequired(directory);
} catch (IOException failure) {
IrisLogging.reportError(
"A world-replacement move completed, but its parent directory could not be durability-synced.",
failure
);
}
}
@@ -1,10 +1,12 @@
package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.ExactWorldSlotPathPolicy;
import art.arcane.iris.core.WorldSlotKey;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot;
import org.bukkit.NamespacedKey;
import art.arcane.iris.spi.IrisLogging;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
@@ -18,9 +20,11 @@ import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;
import java.util.UUID;
public final class WorldReplacementJournal {
@@ -46,6 +50,12 @@ public final class WorldReplacementJournal {
}
}
transactions.sort(Comparator.comparing(transaction -> transaction.id().toString()));
Set<WorldSlotKey> worldKeys = new HashSet<>();
for (Transaction transaction : transactions) {
if (!worldKeys.add(transaction.worldKey())) {
throw new IOException("Multiple replacement journals target " + transaction.worldKey() + ".");
}
}
return List.copyOf(transactions);
}
@@ -77,8 +87,9 @@ public final class WorldReplacementJournal {
if (directory == null) {
return;
}
forceDirectoryRequired(directory);
Files.deleteIfExists(directory.resolve(Objects.requireNonNull(id, "id") + JOURNAL_SUFFIX));
forceDirectory(directory);
forceDirectoryAfterCommit(directory);
}
public static ExactWorldSlotPathPolicy.Target resolveTarget(Transaction transaction, Path currentLevelRoot)
@@ -91,31 +102,44 @@ public final class WorldReplacementJournal {
if (!target.levelRoot().equals(requiredTransaction.levelRoot())) {
throw new IOException("The configured level root changed after the world replacement was staged.");
}
String expectedWorldName = logicalWorldName(target.levelRoot(), requiredTransaction.worldKey());
String expectedWorldName;
try {
expectedWorldName = logicalWorldName(target.levelRoot(), requiredTransaction.worldKey());
} catch (IllegalArgumentException failure) {
throw new IOException("The replacement journal targets an ambiguous logical world name.", failure);
}
if (!expectedWorldName.equals(requiredTransaction.worldName())) {
throw new IOException("The logical world name changed after the world replacement was staged.");
}
return target;
}
public static String logicalWorldName(Path levelRoot, NamespacedKey worldKey) {
public static String logicalWorldName(Path levelRoot, WorldSlotKey worldKey) {
Path requiredLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize();
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
if ("iris".equals(requiredWorldKey.getNamespace())) {
return requiredWorldKey.getKey();
}
WorldSlotKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
Path fileName = requiredLevelRoot.getFileName();
if (fileName == null || fileName.toString().isBlank()) {
throw new IllegalArgumentException("Level root must have a logical world name.");
}
String levelName = fileName.toString();
if (NamespacedKey.minecraft("overworld").equals(requiredWorldKey)) {
if ("iris".equals(requiredWorldKey.namespace())) {
String logicalName = requiredWorldKey.key();
if (logicalName.equals(levelName)
|| logicalName.equals(levelName + "_nether")
|| logicalName.equals(levelName + "_the_end")) {
throw new IllegalArgumentException(
"An Iris-managed world cannot use a configured vanilla world alias."
);
}
return logicalName;
}
if (WorldSlotKey.minecraft("overworld").equals(requiredWorldKey)) {
return levelName;
}
if (NamespacedKey.minecraft("the_nether").equals(requiredWorldKey)) {
if (WorldSlotKey.minecraft("the_nether").equals(requiredWorldKey)) {
return levelName + "_nether";
}
if (NamespacedKey.minecraft("the_end").equals(requiredWorldKey)) {
if (WorldSlotKey.minecraft("the_end").equals(requiredWorldKey)) {
return levelName + "_the_end";
}
throw new IllegalArgumentException("World key is not an exact replaceable world slot: " + requiredWorldKey);
@@ -135,9 +159,11 @@ public final class WorldReplacementJournal {
if (!file.getFileName().toString().equals(id + JOURNAL_SUFFIX)) {
throw new IOException("Replacement journal filename does not match its transaction id.");
}
NamespacedKey worldKey = NamespacedKey.fromString(required(properties, "worldKey"));
if (worldKey == null) {
throw new IOException("Replacement journal contains an invalid world key.");
WorldSlotKey worldKey;
try {
worldKey = WorldSlotKey.parse(required(properties, "worldKey"));
} catch (IllegalArgumentException failure) {
throw new IOException("Replacement journal contains an invalid world key.", failure);
}
String worldName = exact(properties, "worldName");
Path recordedLevelRoot = recordedLevelRoot(properties);
@@ -300,26 +326,46 @@ public final class WorldReplacementJournal {
}
channel.force(true);
}
forceDirectoryRequired(parent);
try {
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException failure) {
Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
throw new IOException(
"Exact world replacement requires atomic journal publication on this filesystem.",
failure
);
}
forceDirectory(parent);
forceDirectoryAfterCommit(parent);
} finally {
Files.deleteIfExists(temporary);
}
}
private static void forceDirectory(Path directory) throws IOException {
private static void forceDirectoryRequired(Path directory) throws IOException {
if (File.separatorChar == '\\') {
return;
}
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
channel.force(true);
} catch (UnsupportedOperationException failure) {
throw new IOException("Directory durability sync is unavailable for " + directory + ".", failure);
}
}
private static void forceDirectoryAfterCommit(Path directory) {
try {
forceDirectoryRequired(directory);
} catch (IOException failure) {
IrisLogging.reportError(
"A world-replacement journal change completed, but its parent directory could not be durability-synced.",
failure
);
}
}
public record Transaction(
UUID id,
NamespacedKey worldKey,
WorldSlotKey worldKey,
String worldName,
Path levelRoot,
String dimension,
@@ -1,6 +1,7 @@
package art.arcane.iris.core.pack;
import art.arcane.iris.core.IrisDatapackCompiler;
import art.arcane.iris.core.lifecycle.BukkitStartupPaths;
import art.arcane.iris.core.nms.datapack.DataVersion;
import art.arcane.iris.core.nms.datapack.IDataFixer;
import art.arcane.volmlib.util.collection.KList;
@@ -27,7 +28,9 @@ import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;
@@ -40,9 +43,20 @@ import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
public final class DefaultPackBootstrapProvisioner {
private static final URI DEFAULT_SOURCE = URI.create("https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip");
private static final List<PackSpec> DEFAULT_PACKS = List.of(
new PackSpec(
"overworld",
URI.create("https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip"),
"overworld"
),
new PackSpec(
"underworld",
URI.create("https://github.com/IrisDimensions/underworld/releases/download/beta/underworld.zip"),
"underworld"
)
);
private static final String WORLD_DATAPACK_DIRECTORY = "iris";
private static final int MARKER_SCHEMA = 3;
private static final int MARKER_SCHEMA = 4;
private static final int MAX_ARCHIVE_ENTRIES = 100_000;
private static final long MAX_ARCHIVE_BYTES = 512L * 1024L * 1024L;
private static final long MAX_EXPANDED_BYTES = 2L * 1024L * 1024L * 1024L;
@@ -53,17 +67,28 @@ public final class DefaultPackBootstrapProvisioner {
private DefaultPackBootstrapProvisioner() {
}
static List<PackSpec> defaultPacks() {
return DEFAULT_PACKS;
}
public static ProvisionResult provision(Path dataDirectory, Consumer<String> feedback) throws IOException {
return provision(dataDirectory, feedback, BukkitStartupPaths.resolveCurrent());
}
public static ProvisionResult provision(
Path dataDirectory,
Consumer<String> feedback,
BukkitStartupPaths startupPaths
) throws IOException {
Objects.requireNonNull(dataDirectory, "dataDirectory");
Objects.requireNonNull(feedback, "feedback");
Path serverRoot = Path.of("").toAbsolutePath().normalize();
Path levelRoot = resolveLevelRoot(serverRoot);
Path levelRoot = Objects.requireNonNull(startupPaths, "startupPaths").levelRoot();
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.followRedirects(HttpClient.Redirect.ALWAYS)
.build();
ProvisionOptions options = new ProvisionOptions(
DEFAULT_SOURCE,
DEFAULT_PACKS,
client,
Clock.systemUTC(),
Duration.ofMinutes(30),
@@ -83,7 +108,7 @@ public final class DefaultPackBootstrapProvisioner {
try {
return isProvisioned(
dataDirectory,
resolveLevelRoot(Path.of("").toAbsolutePath().normalize())
BukkitStartupPaths.resolveCurrent().levelRoot()
);
} catch (IOException | RuntimeException exception) {
return false;
@@ -91,14 +116,17 @@ public final class DefaultPackBootstrapProvisioner {
}
static boolean isProvisioned(Path dataDirectory, Path levelRoot) {
return isProvisioned(dataDirectory, levelRoot, DEFAULT_PACKS);
}
static boolean isProvisioned(Path dataDirectory, Path levelRoot, List<PackSpec> requiredPacks) {
try {
Path normalizedData = dataDirectory.toAbsolutePath().normalize();
Path normalizedLevel = levelRoot.toAbsolutePath().normalize();
Path bootstrapRoot = normalizedData.resolve("bootstrap");
Path datapackRoot = worldDatapackRoot(normalizedLevel);
Path packRoot = normalizedData.resolve("packs/overworld");
Path markerFile = bootstrapRoot.resolve("provisioned.properties");
if (!Files.isRegularFile(markerFile) || !isPackRoot(packRoot) || !isDatapackRoot(datapackRoot)) {
if (!Files.isRegularFile(markerFile) || !isDatapackRoot(datapackRoot)) {
return false;
}
Properties marker = loadProperties(markerFile);
@@ -111,8 +139,16 @@ public final class DefaultPackBootstrapProvisioner {
.equals(marker.getProperty("compilerIdentity"))) {
return false;
}
return directoryFingerprint(packRoot).equals(marker.getProperty("defaultPackFingerprint"))
&& directoryFingerprint(datapackRoot).equals(marker.getProperty("datapackFingerprint"))
for (PackSpec spec : requiredPacks) {
Path packRoot = normalizedData.resolve("packs").resolve(spec.key());
if (!isPackRoot(packRoot, spec)
|| !spec.source().toString().equals(marker.getProperty(markerKey(spec, "source")))
|| !spec.requiredDimension().equals(marker.getProperty(markerKey(spec, "requiredDimension")))
|| !directoryFingerprint(packRoot).equals(marker.getProperty(markerKey(spec, "fingerprint")))) {
return false;
}
}
return directoryFingerprint(datapackRoot).equals(marker.getProperty("datapackFingerprint"))
&& datapackRoot.toString().equals(marker.getProperty("datapackPath"))
&& packRootsFingerprint(IrisDatapackCompiler.collectPackRoots(
normalizedData,
@@ -136,7 +172,6 @@ public final class DefaultPackBootstrapProvisioner {
PROVISIONED_COMPILER_INPUT_FINGERPRINT.set("");
Path normalizedData = dataDirectory.toAbsolutePath().normalize();
Path packsRoot = normalizedData.resolve("packs");
Path packRoot = packsRoot.resolve("overworld");
Path bootstrapRoot = normalizedData.resolve("bootstrap");
Path legacyDatapackRoot = bootstrapRoot.resolve("datapack");
Path datapacksRoot = options.levelRoot().toAbsolutePath().normalize().resolve("datapacks");
@@ -149,40 +184,58 @@ public final class DefaultPackBootstrapProvisioner {
Files.createDirectories(datapacksRoot);
Properties previousMarker = Files.isRegularFile(markerFile) ? loadProperties(markerFile) : new Properties();
boolean existingPack = isPackRoot(packRoot);
boolean existingDatapack = isDatapackRoot(datapackRoot);
String currentPackFingerprint = existingPack ? directoryFingerprint(packRoot) : "";
boolean markerOwned = "true".equals(previousMarker.getProperty("managedDefault"));
boolean unchangedManagedPack = markerOwned
&& currentPackFingerprint.equals(previousMarker.getProperty("defaultPackFingerprint"));
boolean managedDefault = !existingPack || unchangedManagedPack;
if (Files.isSymbolicLink(packRoot)) {
managedDefault = false;
List<PackPlan> plans = new ArrayList<>(options.packs().size());
for (PackSpec spec : options.packs()) {
Path packRoot = packsRoot.resolve(spec.key()).normalize();
if (!Objects.equals(packRoot.getParent(), packsRoot)) {
throw new IOException("Bootstrap pack target escapes the packs directory: " + packRoot);
}
boolean existingPack = isPackRoot(packRoot, spec);
String currentFingerprint = existingPack ? directoryFingerprint(packRoot) : "";
boolean markerOwned = "true".equals(markerProperty(previousMarker, spec, "managed"));
boolean unchangedManagedPack = markerOwned
&& currentFingerprint.equals(markerProperty(previousMarker, spec, "fingerprint"));
boolean managed = !existingPack || unchangedManagedPack;
if (Files.isSymbolicLink(packRoot)) {
managed = false;
}
String retainedSourceSha = markerProperty(previousMarker, spec, "sourceSha256");
Archive archive = managed
? acquireArchive(cacheRoot, previousMarker, spec, feedback, options)
: new Archive(null, retainedSourceSha == null || retainedSourceSha.isBlank()
? currentFingerprint
: retainedSourceSha);
boolean replacePack = !existingPack || managed
&& (!archive.sha256().equals(markerProperty(previousMarker, spec, "sourceSha256"))
|| !currentFingerprint.equals(markerProperty(previousMarker, spec, "fingerprint")));
plans.add(new PackPlan(spec, packRoot, existingPack, managed, archive, replacePack));
}
Archive archive = managedDefault
? acquireArchive(cacheRoot, previousMarker, feedback, options)
: new Archive(null, currentPackFingerprint);
boolean replacePack = !existingPack || managedDefault
&& (!archive.sha256().equals(previousMarker.getProperty("sourceSha256"))
|| !currentPackFingerprint.equals(previousMarker.getProperty("defaultPackFingerprint")));
Path stagedPack = null;
Path extractionRoot = null;
Map<PackPlan, Path> stagedPacks = new LinkedHashMap<>();
List<Path> extractionRoots = new ArrayList<>();
Path compileContainer = null;
Path packBackup = null;
Path datapackBackup = null;
boolean packReplaced = false;
boolean datapackReplaced = false;
List<PackPublication> packPublications = new ArrayList<>();
boolean committed = false;
try {
if (replacePack) {
extractionRoot = cacheRoot.resolve(".extract-" + UUID.randomUUID());
for (PackPlan plan : plans) {
if (!plan.replace()) {
continue;
}
Path extractionRoot = cacheRoot.resolve(".extract-" + plan.spec().key() + "-" + UUID.randomUUID());
extractionRoots.add(extractionRoot);
Files.createDirectories(extractionRoot);
Path extractedPack = extractArchive(archive.path(), extractionRoot);
stagedPack = packsRoot.resolve(".overworld-stage-" + UUID.randomUUID());
Path extractedPack = extractArchive(plan.archive().path(), extractionRoot, plan.spec());
Path stagedPack = packsRoot.resolve("." + plan.spec().key() + "-stage-" + UUID.randomUUID());
copyDirectory(extractedPack, stagedPack);
validatePackRoot(stagedPack);
packBackup = replaceWithBackup(stagedPack, packRoot);
packReplaced = true;
validatePackRoot(stagedPack, plan.spec());
stagedPacks.put(plan, stagedPack);
}
for (Map.Entry<PackPlan, Path> entry : stagedPacks.entrySet()) {
Path backup = replaceWithBackup(entry.getValue(), entry.getKey().root());
packPublications.add(new PackPublication(entry.getKey().root(), backup));
}
List<File> packRoots = IrisDatapackCompiler.collectPackRoots(normalizedData, options.levelRoot());
@@ -195,7 +248,8 @@ public final class DefaultPackBootstrapProvisioner {
}
String compilerIdentity = IrisDatapackCompiler.compilerIdentity(fixer);
String aggregateFingerprint = packRootsFingerprint(packRoots);
boolean rebuildDatapack = replacePack
boolean anyPackReplaced = !packPublications.isEmpty();
boolean rebuildDatapack = anyPackReplaced
|| !existingDatapack
|| !aggregateFingerprint.equals(previousMarker.getProperty("aggregateFingerprint"))
|| !compilerIdentity.equals(previousMarker.getProperty("compilerIdentity"))
@@ -214,11 +268,12 @@ public final class DefaultPackBootstrapProvisioner {
datapackReplaced = true;
}
validatePackRoot(packRoot);
for (PackPlan plan : plans) {
validatePackRoot(plan.root(), plan.spec());
}
if (!isDatapackRoot(datapackRoot)) {
throw new IOException("Bootstrap datapack output is incomplete at " + datapackRoot);
}
String finalPackFingerprint = directoryFingerprint(packRoot);
List<File> finalPackRoots = IrisDatapackCompiler.collectPackRoots(
normalizedData,
options.levelRoot());
@@ -230,47 +285,76 @@ public final class DefaultPackBootstrapProvisioner {
String finalDatapackFingerprint = directoryFingerprint(datapackRoot);
Properties marker = new Properties();
marker.setProperty("schema", Integer.toString(MARKER_SCHEMA));
marker.setProperty("source", options.source().toString());
marker.setProperty("sourceSha256", archive.sha256());
marker.setProperty("managedDefault", Boolean.toString(managedDefault));
marker.setProperty("defaultPackFingerprint", finalPackFingerprint);
for (PackPlan plan : plans) {
marker.setProperty(markerKey(plan.spec(), "source"), plan.spec().source().toString());
marker.setProperty(markerKey(plan.spec(), "sourceSha256"), plan.archive().sha256());
marker.setProperty(markerKey(plan.spec(), "managed"), Boolean.toString(plan.managed()));
marker.setProperty(markerKey(plan.spec(), "fingerprint"), directoryFingerprint(plan.root()));
marker.setProperty(markerKey(plan.spec(), "requiredDimension"), plan.spec().requiredDimension());
}
marker.setProperty("aggregateFingerprint", finalAggregateFingerprint);
marker.setProperty("compilerIdentity", compilerIdentity);
marker.setProperty("datapackFingerprint", finalDatapackFingerprint);
marker.setProperty("datapackPath", datapackRoot.toString());
marker.setProperty("completedAt", Long.toString(options.clock().millis()));
storePropertiesAtomic(markerFile, marker);
committed = true;
PROVISIONED_COMPILER_INPUT_FINGERPRINT.set(finalCompilerInputFingerprint);
PROVISIONED_THIS_STARTUP.set(true);
deleteQuietly(packBackup, feedback);
for (PackPublication publication : packPublications) {
deleteQuietly(publication.backup(), feedback);
}
deleteQuietly(datapackBackup, feedback);
deleteQuietly(legacyDatapackRoot, feedback);
boolean everyPackMissing = plans.stream().noneMatch(PackPlan::existed);
ProvisionStatus status;
if (!existingPack && !existingDatapack) {
if (everyPackMissing && !existingDatapack) {
status = ProvisionStatus.INSTALLED;
} else if (replacePack || rebuildDatapack) {
} else if (anyPackReplaced || rebuildDatapack) {
status = ProvisionStatus.UPDATED;
} else {
status = ProvisionStatus.UNCHANGED;
}
feedback.accept("Iris bootstrap pack is " + status.name().toLowerCase() + ".");
return new ProvisionResult(packRoot, datapackRoot, status);
feedback.accept("Iris bootstrap packs are " + status.name().toLowerCase() + ".");
Map<String, Path> provisionedPacks = new LinkedHashMap<>();
for (PackPlan plan : plans) {
provisionedPacks.put(plan.spec().key(), plan.root());
}
return new ProvisionResult(provisionedPacks, datapackRoot, status);
} catch (IOException failure) {
IOException rollbackFailure = rollback(packRoot, packBackup, packReplaced, datapackRoot, datapackBackup, datapackReplaced);
if (rollbackFailure != null) {
failure.addSuppressed(rollbackFailure);
if (!committed) {
IOException rollbackFailure = rollback(
packPublications,
datapackRoot,
datapackBackup,
datapackReplaced
);
if (rollbackFailure != null) {
failure.addSuppressed(rollbackFailure);
}
}
throw failure;
} catch (RuntimeException | LinkageError failure) {
IOException rollbackFailure = rollback(packRoot, packBackup, packReplaced, datapackRoot, datapackBackup, datapackReplaced);
if (rollbackFailure != null) {
failure.addSuppressed(rollbackFailure);
if (!committed) {
IOException rollbackFailure = rollback(
packPublications,
datapackRoot,
datapackBackup,
datapackReplaced
);
if (rollbackFailure != null) {
failure.addSuppressed(rollbackFailure);
}
}
throw new IOException("Iris bootstrap provisioning failed", failure);
} finally {
deleteQuietly(stagedPack, feedback);
deleteQuietly(extractionRoot, feedback);
for (Path stagedPack : stagedPacks.values()) {
deleteQuietly(stagedPack, feedback);
}
for (Path extractionRoot : extractionRoots) {
deleteQuietly(extractionRoot, feedback);
}
deleteQuietly(compileContainer, feedback);
}
}
@@ -278,35 +362,44 @@ public final class DefaultPackBootstrapProvisioner {
private static Archive acquireArchive(
Path cacheRoot,
Properties marker,
PackSpec spec,
Consumer<String> feedback,
ProvisionOptions options
) throws IOException {
Path archivePath = cacheRoot.resolve("default-overworld.zip");
Path metadataPath = cacheRoot.resolve("default-overworld.properties");
Path archivePath = cacheRoot.resolve("default-" + spec.key() + ".zip");
Path metadataPath = cacheRoot.resolve("default-" + spec.key() + ".properties");
Properties metadata = Files.isRegularFile(metadataPath) ? loadProperties(metadataPath) : new Properties();
boolean validCache = false;
if (Files.isRegularFile(archivePath)) {
try {
validCache = validateArchive(archivePath);
validCache = validateArchive(archivePath, spec);
} catch (IOException exception) {
feedback.accept("Cached default overworld archive is invalid; downloading a replacement.");
feedback.accept("Cached Iris " + spec.key() + " beta archive is invalid; downloading a replacement.");
}
}
String cachedSource = metadata.getProperty("source", "");
boolean cacheMatchesSource = validCache && (spec.source().toString().equals(cachedSource)
|| cachedSource.isBlank() && "overworld".equals(spec.key()));
long fetchedAt = parseLong(metadata.getProperty("fetchedAt"), 0L);
boolean fresh = validCache && options.clock().millis() - fetchedAt < options.refreshInterval().toMillis();
boolean fresh = cacheMatchesSource
&& options.clock().millis() - fetchedAt < options.refreshInterval().toMillis();
if (fresh) {
if (cachedSource.isBlank()) {
metadata.setProperty("source", spec.source().toString());
storePropertiesAtomic(metadataPath, metadata);
}
return new Archive(archivePath, sha256(archivePath));
}
IOException networkFailure = null;
for (int attempt = 1; attempt <= options.attempts(); attempt++) {
try {
HttpRequest.Builder request = HttpRequest.newBuilder(options.source())
HttpRequest.Builder request = HttpRequest.newBuilder(spec.source())
.timeout(options.requestTimeout())
.header("Accept", "application/octet-stream")
.header("User-Agent", "Iris-Bootstrap")
.GET();
if (validCache) {
if (cacheMatchesSource) {
String etag = metadata.getProperty("etag");
String lastModified = metadata.getProperty("lastModified");
if (etag != null && !etag.isBlank()) {
@@ -318,8 +411,9 @@ public final class DefaultPackBootstrapProvisioner {
}
HttpResponse<InputStream> response = options.client().send(request.build(), HttpResponse.BodyHandlers.ofInputStream());
int status = response.statusCode();
if (status == 304 && validCache) {
if (status == 304 && cacheMatchesSource) {
close(response.body());
metadata.setProperty("source", spec.source().toString());
metadata.setProperty("fetchedAt", Long.toString(options.clock().millis()));
storePropertiesAtomic(metadataPath, metadata);
return new Archive(archivePath, sha256(archivePath));
@@ -328,10 +422,10 @@ public final class DefaultPackBootstrapProvisioner {
Path temporary = cacheRoot.resolve(".download-" + UUID.randomUUID() + ".zip");
try {
try (InputStream input = response.body(); OutputStream output = Files.newOutputStream(temporary)) {
copyLimited(input, output, options.maxArchiveBytes());
copyLimited(input, output, options.maxArchiveBytes(), spec);
}
if (!validateArchive(temporary)) {
throw new IOException("Downloaded default overworld archive is invalid");
if (!validateArchive(temporary, spec)) {
throw new IOException("Downloaded Iris " + spec.key() + " beta archive is invalid");
}
move(temporary, archivePath, true);
} finally {
@@ -340,14 +434,15 @@ public final class DefaultPackBootstrapProvisioner {
Properties updated = new Properties();
response.headers().firstValue("etag").ifPresent(value -> updated.setProperty("etag", value));
response.headers().firstValue("last-modified").ifPresent(value -> updated.setProperty("lastModified", value));
updated.setProperty("source", spec.source().toString());
updated.setProperty("fetchedAt", Long.toString(options.clock().millis()));
updated.setProperty("sha256", sha256(archivePath));
storePropertiesAtomic(metadataPath, updated);
feedback.accept("Downloaded the Iris default overworld beta archive.");
feedback.accept("Downloaded the Iris " + spec.key() + " beta archive.");
return new Archive(archivePath, updated.getProperty("sha256"));
}
close(response.body());
IOException statusFailure = new IOException("Default overworld download returned HTTP " + status);
IOException statusFailure = new IOException("Iris " + spec.key() + " beta download returned HTTP " + status);
if (!retryableStatus(status)) {
networkFailure = statusFailure;
break;
@@ -355,7 +450,7 @@ public final class DefaultPackBootstrapProvisioner {
networkFailure = statusFailure;
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IOException("Default overworld download was interrupted", exception);
throw new IOException("Iris " + spec.key() + " beta download was interrupted", exception);
} catch (IOException exception) {
networkFailure = exception;
}
@@ -364,79 +459,38 @@ public final class DefaultPackBootstrapProvisioner {
}
}
if (validCache) {
feedback.accept("Default overworld download failed; using the validated cached archive.");
if (cacheMatchesSource) {
feedback.accept("Iris " + spec.key() + " beta download failed; using the validated cached archive.");
return new Archive(archivePath, sha256(archivePath));
}
if (isManagedPackOutputUsable(marker, cacheRoot.getParent().getParent())) {
String sourceSha = marker.getProperty("sourceSha256");
if (isManagedPackOutputUsable(marker, cacheRoot.getParent().getParent(), spec)) {
String sourceSha = markerProperty(marker, spec, "sourceSha256");
return new Archive(null, sourceSha);
}
throw networkFailure == null
? new IOException("Default overworld archive is unavailable and no valid cache exists")
: new IOException("Default overworld archive is unavailable and no valid cache exists", networkFailure);
? new IOException("Iris " + spec.key() + " beta archive is unavailable and no valid cache exists")
: new IOException("Iris " + spec.key() + " beta archive is unavailable and no valid cache exists", networkFailure);
}
private static boolean isManagedPackOutputUsable(Properties marker, Path dataDirectory) {
String sourceSha = marker.getProperty("sourceSha256");
String expectedFingerprint = marker.getProperty("defaultPackFingerprint");
private static boolean isManagedPackOutputUsable(Properties marker, Path dataDirectory, PackSpec spec) {
String sourceSha = markerProperty(marker, spec, "sourceSha256");
String expectedFingerprint = markerProperty(marker, spec, "fingerprint");
if (sourceSha == null || sourceSha.isBlank() || expectedFingerprint == null || expectedFingerprint.isBlank()) {
return false;
}
Path packRoot = dataDirectory.toAbsolutePath().normalize().resolve("packs/overworld");
Path packRoot = dataDirectory.toAbsolutePath().normalize().resolve("packs").resolve(spec.key());
try {
return isPackRoot(packRoot) && directoryFingerprint(packRoot).equals(expectedFingerprint);
return isPackRoot(packRoot, spec) && directoryFingerprint(packRoot).equals(expectedFingerprint);
} catch (IOException | RuntimeException exception) {
return false;
}
}
static Path resolveLevelRoot(Path serverRoot) throws IOException {
Path normalizedServerRoot = serverRoot.toAbsolutePath().normalize();
String levelName = readConfiguredLevelName(normalizedServerRoot);
Path configured = Path.of(levelName);
return configured.isAbsolute()
? configured.normalize()
: normalizedServerRoot.resolve(configured).normalize();
public static Path resolveLevelRoot(Path serverRoot) throws IOException {
return BukkitStartupPaths.resolve(serverRoot).levelRoot();
}
private static String readConfiguredLevelName(Path serverRoot) throws IOException {
String levelName = "world";
Path propertiesFile = serverRoot.resolve("server.properties");
if (Files.isRegularFile(propertiesFile)) {
Properties properties = loadProperties(propertiesFile);
levelName = properties.getProperty("level-name", levelName);
}
String[] arguments = ProcessHandle.current().info().arguments().orElse(new String[0]);
for (int index = 0; index < arguments.length; index++) {
String argument = arguments[index];
String following = index + 1 < arguments.length ? arguments[index + 1] : null;
String parsed = parseLevelArgument(argument, following);
if (parsed != null) {
levelName = parsed;
}
}
if (levelName.isBlank()) {
throw new IOException("Configured level name is empty");
}
return levelName;
}
private static String parseLevelArgument(String argument, String following) {
for (String key : List.of("-w", "--level-name", "--world")) {
if (argument.equals(key) && following != null && !following.isBlank()) {
return following;
}
String prefix = key + "=";
if (argument.startsWith(prefix) && argument.length() > prefix.length()) {
return argument.substring(prefix.length());
}
}
return null;
}
private static boolean validateArchive(Path archive) throws IOException {
private static boolean validateArchive(Path archive, PackSpec spec) throws IOException {
int entries = 0;
long expanded = 0L;
boolean dimensionFound = false;
@@ -446,13 +500,14 @@ public final class DefaultPackBootstrapProvisioner {
while ((entry = zip.getNextEntry()) != null) {
entries++;
if (entries > MAX_ARCHIVE_ENTRIES) {
throw new IOException("Default overworld archive contains too many entries");
throw new IOException("Iris " + spec.key() + " beta archive contains too many entries");
}
String name = normalizedZipEntry(entry.getName());
if (!paths.add(name)) {
throw new IOException("Default overworld archive contains duplicate path " + name);
throw new IOException("Iris " + spec.key() + " beta archive contains duplicate path " + name);
}
if (name.equals("dimensions/overworld.json") || name.endsWith("/dimensions/overworld.json")) {
String requiredDimension = "dimensions/" + spec.requiredDimension() + ".json";
if (name.equals(requiredDimension) || name.endsWith("/" + requiredDimension)) {
dimensionFound = true;
}
if (!entry.isDirectory()) {
@@ -461,7 +516,7 @@ public final class DefaultPackBootstrapProvisioner {
while ((read = zip.read(buffer)) >= 0) {
expanded += read;
if (expanded > MAX_EXPANDED_BYTES) {
throw new IOException("Default overworld archive expands beyond the safety limit");
throw new IOException("Iris " + spec.key() + " beta archive expands beyond the safety limit");
}
}
}
@@ -471,9 +526,9 @@ public final class DefaultPackBootstrapProvisioner {
return entries > 0 && dimensionFound;
}
private static Path extractArchive(Path archive, Path extractionRoot) throws IOException {
private static Path extractArchive(Path archive, Path extractionRoot, PackSpec spec) throws IOException {
if (archive == null) {
throw new IOException("Cached default pack archive is unavailable for required pack rebuild");
throw new IOException("Cached Iris " + spec.key() + " beta archive is unavailable for required pack rebuild");
}
long expanded = 0L;
int entries = 0;
@@ -482,12 +537,12 @@ public final class DefaultPackBootstrapProvisioner {
while ((entry = zip.getNextEntry()) != null) {
entries++;
if (entries > MAX_ARCHIVE_ENTRIES) {
throw new IOException("Default overworld archive contains too many entries");
throw new IOException("Iris " + spec.key() + " beta archive contains too many entries");
}
String name = normalizedZipEntry(entry.getName());
Path output = extractionRoot.resolve(name).normalize();
if (!output.startsWith(extractionRoot)) {
throw new IOException("Unsafe path in default overworld archive: " + entry.getName());
throw new IOException("Unsafe path in Iris " + spec.key() + " beta archive: " + entry.getName());
}
if (entry.isDirectory()) {
Files.createDirectories(output);
@@ -499,7 +554,7 @@ public final class DefaultPackBootstrapProvisioner {
while ((read = zip.read(buffer)) >= 0) {
expanded += read;
if (expanded > MAX_EXPANDED_BYTES) {
throw new IOException("Default overworld archive expands beyond the safety limit");
throw new IOException("Iris " + spec.key() + " beta archive expands beyond the safety limit");
}
file.write(buffer, 0, read);
}
@@ -508,42 +563,44 @@ public final class DefaultPackBootstrapProvisioner {
zip.closeEntry();
}
}
if (isPackRoot(extractionRoot)) {
if (isPackRoot(extractionRoot, spec)) {
return extractionRoot;
}
List<Path> candidates = new ArrayList<>();
try (DirectoryStream<Path> children = Files.newDirectoryStream(extractionRoot)) {
for (Path child : children) {
if (Files.isDirectory(child) && isPackRoot(child)) {
if (Files.isDirectory(child) && isPackRoot(child, spec)) {
candidates.add(child);
}
}
}
if (candidates.size() != 1) {
throw new IOException("Default overworld archive has an invalid root layout");
throw new IOException("Iris " + spec.key() + " beta archive has an invalid root layout");
}
return candidates.getFirst();
}
private static String normalizedZipEntry(String raw) throws IOException {
if (raw == null || raw.isBlank() || raw.indexOf('\0') >= 0 || raw.startsWith("/") || raw.startsWith("\\")) {
throw new IOException("Invalid path in default overworld archive");
throw new IOException("Invalid path in Iris bootstrap pack archive");
}
String normalized = raw.replace('\\', '/');
Path path = Path.of(normalized).normalize();
if (path.isAbsolute() || path.startsWith("..") || normalized.matches("^[A-Za-z]:.*")) {
throw new IOException("Unsafe path in default overworld archive: " + raw);
throw new IOException("Unsafe path in Iris bootstrap pack archive: " + raw);
}
return path.toString().replace('\\', '/');
}
private static boolean isPackRoot(Path path) {
return path != null && Files.isRegularFile(path.resolve("dimensions/overworld.json"));
private static boolean isPackRoot(Path path, PackSpec spec) {
return path != null
&& Files.isRegularFile(path.resolve("dimensions").resolve(spec.requiredDimension() + ".json"));
}
private static void validatePackRoot(Path path) throws IOException {
if (!isPackRoot(path)) {
throw new IOException("Default overworld pack is missing dimensions/overworld.json at " + path);
private static void validatePackRoot(Path path, PackSpec spec) throws IOException {
if (!isPackRoot(path, spec)) {
throw new IOException("Iris " + spec.key() + " beta pack is missing dimensions/"
+ spec.requiredDimension() + ".json at " + path);
}
}
@@ -636,26 +693,27 @@ public final class DefaultPackBootstrapProvisioner {
}
private static IOException rollback(
Path packRoot,
Path packBackup,
boolean packReplaced,
List<PackPublication> packPublications,
Path datapackRoot,
Path datapackBackup,
boolean datapackReplaced
) {
IOException failure = null;
try {
restore(packRoot, packBackup, packReplaced);
restore(datapackRoot, datapackBackup, datapackReplaced);
} catch (IOException exception) {
failure = exception;
}
try {
restore(datapackRoot, datapackBackup, datapackReplaced);
} catch (IOException exception) {
if (failure == null) {
failure = exception;
} else {
failure.addSuppressed(exception);
for (int index = packPublications.size() - 1; index >= 0; index--) {
PackPublication publication = packPublications.get(index);
try {
restore(publication.root(), publication.backup(), true);
} catch (IOException exception) {
if (failure == null) {
failure = exception;
} else {
failure.addSuppressed(exception);
}
}
}
return failure;
@@ -726,14 +784,19 @@ public final class DefaultPackBootstrapProvisioner {
}
}
private static void copyLimited(InputStream input, OutputStream output, long limit) throws IOException {
private static void copyLimited(
InputStream input,
OutputStream output,
long limit,
PackSpec spec
) throws IOException {
byte[] buffer = new byte[8192];
long total = 0L;
int read;
while ((read = input.read(buffer)) >= 0) {
total += read;
if (total > limit) {
throw new IOException("Default overworld archive exceeds the download size limit");
throw new IOException("Iris " + spec.key() + " beta archive exceeds the download size limit");
}
output.write(buffer, 0, read);
}
@@ -772,7 +835,7 @@ public final class DefaultPackBootstrapProvisioner {
Thread.sleep(duration.toMillis());
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IOException("Default overworld retry wait was interrupted", exception);
throw new IOException("Iris bootstrap pack retry wait was interrupted", exception);
}
}
@@ -787,6 +850,24 @@ public final class DefaultPackBootstrapProvisioner {
}
}
private static String markerKey(PackSpec spec, String property) {
return "pack." + spec.key() + "." + property;
}
private static String markerProperty(Properties marker, PackSpec spec, String property) {
String current = marker.getProperty(markerKey(spec, property));
if (current != null || !"overworld".equals(spec.key())) {
return current;
}
return switch (property) {
case "source" -> marker.getProperty("source");
case "sourceSha256" -> marker.getProperty("sourceSha256");
case "managed" -> marker.getProperty("managedDefault");
case "fingerprint" -> marker.getProperty("defaultPackFingerprint");
default -> null;
};
}
private static void close(InputStream input) {
try {
input.close();
@@ -800,16 +881,16 @@ public final class DefaultPackBootstrapProvisioner {
UNCHANGED
}
public record ProvisionResult(Path packRoot, Path datapackRoot, ProvisionStatus status) {
public record ProvisionResult(Map<String, Path> packRoots, Path datapackRoot, ProvisionStatus status) {
public ProvisionResult {
Objects.requireNonNull(packRoot, "packRoot");
packRoots = Map.copyOf(Objects.requireNonNull(packRoots, "packRoots"));
Objects.requireNonNull(datapackRoot, "datapackRoot");
Objects.requireNonNull(status, "status");
}
}
record ProvisionOptions(
URI source,
List<PackSpec> packs,
HttpClient client,
Clock clock,
Duration refreshInterval,
@@ -820,19 +901,49 @@ public final class DefaultPackBootstrapProvisioner {
Path levelRoot
) {
ProvisionOptions {
Objects.requireNonNull(source, "source");
packs = List.copyOf(Objects.requireNonNull(packs, "packs"));
Objects.requireNonNull(client, "client");
Objects.requireNonNull(clock, "clock");
Objects.requireNonNull(refreshInterval, "refreshInterval");
Objects.requireNonNull(requestTimeout, "requestTimeout");
Objects.requireNonNull(retryDelay, "retryDelay");
Objects.requireNonNull(levelRoot, "levelRoot");
if (attempts < 1 || maxArchiveBytes < 1L) {
if (packs.isEmpty() || attempts < 1 || maxArchiveBytes < 1L) {
throw new IllegalArgumentException("Invalid bootstrap provisioning options");
}
Set<String> keys = new HashSet<>();
for (PackSpec pack : packs) {
if (!keys.add(pack.key())) {
throw new IllegalArgumentException("Duplicate bootstrap pack key '" + pack.key() + "'");
}
}
}
}
record PackSpec(String key, URI source, String requiredDimension) {
PackSpec {
Objects.requireNonNull(key, "key");
Objects.requireNonNull(source, "source");
Objects.requireNonNull(requiredDimension, "requiredDimension");
if (!key.matches("[a-z0-9_-]+") || !requiredDimension.matches("[a-z0-9_/-]+")) {
throw new IllegalArgumentException("Invalid bootstrap pack specification for '" + key + "'");
}
}
}
private record PackPlan(
PackSpec spec,
Path root,
boolean existed,
boolean managed,
Archive archive,
boolean replace
) {
}
private record PackPublication(Path root, Path backup) {
}
private record Archive(Path path, String sha256) {
private Archive {
Objects.requireNonNull(sha256, "sha256");
@@ -36,10 +36,12 @@ import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
@@ -55,6 +57,14 @@ public final class PackDownloader {
private static final String DEFAULT_OVERWORLD_PACK = "overworld";
private static final String DEFAULT_OVERWORLD_REPOSITORY = "IrisDimensions/overworld";
private static final String DEFAULT_OVERWORLD_RELEASE_URL = "https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip";
private static final String UNDERWORLD_PACK = "underworld";
private static final String UNDERWORLD_REPOSITORY = "IrisDimensions/underworld";
private static final String UNDERWORLD_RELEASE_URL = "https://github.com/IrisDimensions/underworld/releases/download/beta/underworld.zip";
private static final List<String> MANAGED_BETA_PACK_KEYS = List.of(DEFAULT_OVERWORLD_PACK, UNDERWORLD_PACK);
private static final Map<String, ManagedBetaPack> MANAGED_BETA_PACKS = Map.of(
DEFAULT_OVERWORLD_PACK, new ManagedBetaPack(DEFAULT_OVERWORLD_REPOSITORY, DEFAULT_OVERWORLD_RELEASE_URL),
UNDERWORLD_PACK, new ManagedBetaPack(UNDERWORLD_REPOSITORY, UNDERWORLD_RELEASE_URL)
);
private static final Pattern GITHUB_REPOSITORY = Pattern.compile("[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+");
private static final Pattern GITHUB_REF = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._/-]*");
private static final Pattern COMMIT_SHA = Pattern.compile("[0-9a-fA-F]{40}");
@@ -74,6 +84,14 @@ public final class PackDownloader {
return DEFAULT_OVERWORLD_PACK.equals(pack);
}
public static boolean isManagedBetaPack(String pack) {
return pack != null && MANAGED_BETA_PACKS.containsKey(pack);
}
public static List<String> managedBetaPacks() {
return MANAGED_BETA_PACK_KEYS;
}
public static String defaultOverworldPack() {
return DEFAULT_OVERWORLD_PACK;
}
@@ -115,8 +133,40 @@ public final class PackDownloader {
}
}
public static boolean isManagedBetaPackPresent(File packsFolder, String key) {
if (!isManagedBetaPack(key) || !isPackPresent(packsFolder, key)) {
return false;
}
File resolvedPack = PackDirectoryResolver.resolveExisting(packsFolder, key);
if (resolvedPack == null) {
return false;
}
Path primaryDimension = resolvedPack.toPath().toAbsolutePath().normalize()
.resolve("dimensions")
.resolve(key + ".json");
return !Files.isSymbolicLink(primaryDimension)
&& Files.isRegularFile(primaryDimension, LinkOption.NOFOLLOW_LINKS);
}
public static PackInstallResult downloadDefaultOverworld(File packsFolder, boolean forceOverwrite, Consumer<String> feedback) throws IOException {
return download(packsFolder, DEFAULT_OVERWORLD_REPOSITORY, defaultOverworldReleaseUrl(), forceOverwrite, true, DEFAULT_OVERWORLD_PACK, feedback);
return downloadManagedBeta(packsFolder, DEFAULT_OVERWORLD_PACK, forceOverwrite, feedback);
}
public static PackInstallResult downloadManagedBeta(File packsFolder, String pack, boolean forceOverwrite,
Consumer<String> feedback) throws IOException {
ManagedBetaPack managed = pack == null ? null : MANAGED_BETA_PACKS.get(pack);
if (managed == null) {
throw new IllegalArgumentException("Pack '" + pack + "' has no managed beta release");
}
return download(
packsFolder,
managed.repository(),
managed.releaseUrl(),
forceOverwrite,
true,
pack,
feedback
);
}
/**
@@ -135,7 +185,10 @@ public final class PackDownloader {
}
String lockKey = expectedKey != null && !expectedKey.isBlank() ? "key:" + expectedKey : "ref:" + repo + "|" + ref;
return withDownloadLock(lockKey, () -> {
if (!forceOverwrite && isPackPresent(packsFolder, expectedKey)) {
boolean present = isManagedBetaPack(expectedKey)
? isManagedBetaPackPresent(packsFolder, expectedKey)
: isPackPresent(packsFolder, expectedKey);
if (!forceOverwrite && present) {
sendFeedback(output, IrisLanguage.plain(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey)));
return new PackInstallResult(expectedKey, false, false);
}
@@ -235,11 +288,11 @@ public final class PackDownloader {
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.CHECK_GITHUB));
return null;
}
if (dimensions.length != 1) {
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.ONE_DIMENSION_REQUIRED));
String selectedDimension = selectDimensionKey(dimensions, expectedKey, feedback);
if (selectedDimension == null) {
return null;
}
IrisDimension dimension = data.getDimensionLoader().load(dimensions[0]);
IrisDimension dimension = data.getDimensionLoader().load(selectedDimension);
if (dimension == null) {
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.INVALID_DIMENSION));
return null;
@@ -280,6 +333,29 @@ public final class PackDownloader {
return new PreparedPack(key, name, validation);
}
private static String selectDimensionKey(String[] dimensions, String expectedKey,
Consumer<String> feedback) throws IOException {
if (expectedKey == null || expectedKey.isBlank()) {
if (dimensions.length != 1) {
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.ONE_DIMENSION_REQUIRED));
return null;
}
return dimensions[0];
}
int matches = 0;
for (String dimension : dimensions) {
if (expectedKey.equals(dimension)) {
matches++;
}
}
if (matches != 1) {
throw new IOException("Downloaded pack dimensions " + Arrays.toString(dimensions)
+ " do not contain exactly one requested key '" + expectedKey + "'");
}
return expectedKey;
}
private static PackInstallResult publishPreparedPack(File packsFolder, Path packsRoot, Path staging, PreparedPack prepared,
boolean forceOverwrite, Consumer<String> feedback) throws IOException {
Path target = packsRoot.resolve(prepared.key()).normalize();
@@ -299,15 +375,19 @@ public final class PackDownloader {
));
return null;
}
if (!forceOverwrite && isPackPresent(packsRoot.toFile(), prepared.key())) {
boolean present = isManagedBetaPack(prepared.key())
? isManagedBetaPackPresent(packsRoot.toFile(), prepared.key())
: isPackPresent(packsRoot.toFile(), prepared.key());
if (!forceOverwrite && present) {
sendFeedback(feedback, IrisLanguage.plain(
PackDownloadMessages.PACK_KEY_CONFLICT,
MessageArgument.untrusted("key", prepared.key())
));
return null;
}
if (!forceOverwrite && Files.exists(target) && !isPackPresent(packsRoot.toFile(), prepared.key())) {
IrisLogging.warn("Replacing partial pack folder " + target + " (no dimension files found).");
if (!forceOverwrite && Files.exists(target) && !present) {
IrisLogging.warn("Replacing partial pack folder " + target
+ " (required primary dimension is missing).");
}
Optional<IrisData> loadedData = IrisData.getLoaded(new File(packsFolder, prepared.key()));
@@ -572,6 +652,10 @@ public final class PackDownloader {
return DEFAULT_OVERWORLD_RELEASE_URL;
}
static String underworldReleaseUrl() {
return UNDERWORLD_RELEASE_URL;
}
private static void validateGithubRef(String qualifiedRef) {
String refPath = qualifiedRef.startsWith("refs/heads/")
? qualifiedRef.substring("refs/heads/".length())
@@ -597,6 +681,9 @@ public final class PackDownloader {
private record PreparedPack(String key, String name, PackValidationResult validation) {
}
private record ManagedBetaPack(String repository, String releaseUrl) {
}
public record PackInstallResult(String key, boolean changed, boolean restartRequired) {
}
@@ -68,12 +68,13 @@ import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
@@ -97,20 +98,17 @@ public class StudioSVC implements IrisService {
@Override
public void onEnable() {
J.a(() -> {
String pack = IrisSettings.get().getGenerator().getDefaultWorldType();
VolmitSender console = BukkitPlatform.console();
runPackMutation(console, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD,
"managed-beta-packs", () -> installMissingManagedBetaPacks(console),
"Failed to install Iris managed beta packs at startup.");
// Presence means a non-empty pack folder: an empty leftover folder must still
// trigger the install instead of shadowing it forever.
if (!PackDownloader.isPackPresent(getWorkspaceFolder(), pack)) {
if (PackDownloader.isDefaultOverworld(pack)) {
IrisLogging.info("Downloading Default Pack " + pack + " (beta release)");
IrisServices.get(StudioSVC.class).downloadDefaultOverworld(BukkitPlatform.console(), false);
} else {
IrisLogging.warn("Default pack '" + pack + "' is not installed. Please download it manually with /iris download " + pack);
}
}
});
String configuredPack = IrisSettings.get().getGenerator().getDefaultWorldType();
if (!PackDownloader.isManagedBetaPack(configuredPack)
&& !PackDownloader.isPackPresent(getWorkspaceFolder(), configuredPack)) {
IrisLogging.warn("Default pack '" + configuredPack
+ "' is not installed. Please download it manually with /iris download " + configuredPack);
}
}
@Override
@@ -348,12 +346,15 @@ public class StudioSVC implements IrisService {
}, IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_FAILED_DOWNLOAD, MessageArgument.untrusted("key", String.valueOf(key))));
}
public void downloadDefaultOverworld(VolmitSender sender, boolean forceOverwrite) {
String key = PackDownloader.defaultOverworldPack();
public void downloadManagedBeta(VolmitSender sender, String key, boolean forceOverwrite) {
if (!PackDownloader.isManagedBetaPack(key)) {
sender.sendMessage("Iris pack '" + key + "' does not have a managed beta release.");
return;
}
runPackMutation(sender, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, key, () -> {
DownloadOutcome outcome = downloadDefaultOverworldLocked(sender, forceOverwrite);
DownloadOutcome outcome = downloadManagedBetaLocked(sender, key, forceOverwrite);
return finishStandalonePackMutation(sender, outcome);
}, IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_FAILED_DOWNLOAD_IRISDIMENSIONS_OVERWORLD_BETA_RELEASE));
}, "Failed to download IrisDimensions/" + key + " beta release.");
}
public void downloadBranch(VolmitSender sender, String repo, String branch, boolean forceOverwrite) {
@@ -380,8 +381,8 @@ public class StudioSVC implements IrisService {
}
private DownloadOutcome downloadSearchLocked(VolmitSender sender, String key, boolean forceOverwrite) throws IOException {
if (PackDownloader.isDefaultOverworld(key)) {
return downloadDefaultOverworldLocked(sender, forceOverwrite);
if (PackDownloader.isManagedBetaPack(key)) {
return downloadManagedBetaLocked(sender, key, forceOverwrite);
}
String descriptor = key.contains("/") ? key : getListing(false).get(key);
@@ -411,21 +412,52 @@ public class StudioSVC implements IrisService {
return new PackListingReference(repository, ref, expectedKey);
}
private DownloadOutcome downloadDefaultOverworldLocked(VolmitSender sender, boolean forceOverwrite) throws IOException {
String expectedKey = PackDownloader.defaultOverworldPack();
if (!forceOverwrite && PackDownloader.isPackPresent(getWorkspaceFolder(), expectedKey)) {
private DownloadOutcome downloadManagedBetaLocked(
VolmitSender sender,
String expectedKey,
boolean forceOverwrite
) throws IOException {
if (!forceOverwrite && PackDownloader.isManagedBetaPackPresent(getWorkspaceFolder(), expectedKey)) {
sender.sendMessage(IrisLanguage.text(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey)));
return DownloadOutcome.notChanged();
}
PackDownloader.PackInstallResult result = PackDownloader.downloadDefaultOverworld(
PackDownloader.PackInstallResult result = PackDownloader.downloadManagedBeta(
getWorkspaceFolder(),
expectedKey,
forceOverwrite,
sender::sendMessage
);
return DownloadOutcome.from(result);
}
private boolean installMissingManagedBetaPacks(VolmitSender sender) {
boolean changed = false;
boolean restartRequired = false;
for (String key : missingManagedBetaPacks(getWorkspaceFolder())) {
IrisLogging.info("Downloading managed Iris pack " + key + " (beta release)");
try {
DownloadOutcome outcome = downloadManagedBetaLocked(sender, key, false);
changed |= outcome.changed();
restartRequired |= outcome.restartRequired();
} catch (Throwable failure) {
IrisLogging.reportError("Failed to download IrisDimensions/" + key + " beta release.", failure);
sender.sendMessage("Failed to download IrisDimensions/" + key + " beta release. " + errorDetail(failure));
}
}
return finishStandalonePackMutation(sender, new DownloadOutcome(changed, restartRequired));
}
static List<String> missingManagedBetaPacks(File workspaceFolder) {
List<String> missing = new ArrayList<>();
for (String key : PackDownloader.managedBetaPacks()) {
if (!PackDownloader.isManagedBetaPackPresent(workspaceFolder, key)) {
missing.add(key);
}
}
return List.copyOf(missing);
}
private DownloadOutcome downloadLocked(
VolmitSender sender,
String repo,
@@ -1,7 +1,6 @@
package art.arcane.iris.util.common.misc;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import art.arcane.iris.core.lifecycle.BukkitStartupPaths;
import java.io.File;
import java.io.FileInputStream;
@@ -16,48 +15,19 @@ public class ServerProperties {
public static final String LEVEL_NAME;
static {
String[] args = ProcessHandle.current()
.info()
.arguments()
.orElse(new String[0]);
String propertiesPath = "server.properties";
String bukkitYml = "bukkit.yml";
String levelName = null;
for (int i = 0; i < args.length; i++) {
String arg = args[i];
String next = i < args.length - 1 ? args[i + 1] : null;
propertiesPath = parse(arg, next, propertiesPath, "-c", "--config");
bukkitYml = parse(arg, next, bukkitYml, "-b", "--bukkit-settings");
levelName = parse(arg, next, levelName, "-w", "--level-name", "--world");
BukkitStartupPaths startupPaths;
try {
startupPaths = BukkitStartupPaths.resolveCurrent();
} catch (IOException exception) {
throw new RuntimeException(exception);
}
SERVER_PROPERTIES = new File(propertiesPath);
BUKKIT_YML = new File(bukkitYml);
try (FileInputStream in = new FileInputStream(SERVER_PROPERTIES)){
SERVER_PROPERTIES = startupPaths.serverProperties().toFile();
BUKKIT_YML = startupPaths.bukkitConfiguration().toFile();
try (FileInputStream in = new FileInputStream(SERVER_PROPERTIES)) {
DATA.load(in);
} catch (IOException e) {
throw new RuntimeException(e);
} catch (IOException exception) {
throw new RuntimeException(exception);
}
if (levelName != null) LEVEL_NAME = levelName;
else LEVEL_NAME = DATA.getProperty("level-name", "world");
}
private static String parse(
@NotNull String current,
@Nullable String next,
String fallback,
@NotNull String @NotNull ... keys
) {
for (String k : keys) {
if (current.equals(k) && next != null)
return next;
if (current.startsWith(k + "=") && current.length() > k.length() + 1)
return current.substring(k.length() + 1);
}
return fallback;
LEVEL_NAME = startupPaths.levelName();
}
}
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "Dimension oder Pack, mit der bzw. dem die Welt erstellt wird",
"iris.director.commandiris.param.seed_generate_world_with": "Seed für die Generierung der Welt",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Ob man diese Welt automatisch als Hauptwelt benutzt oder nicht",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Den exakten vorhandenen Welt-Slot beim nächsten Neustart ersetzen",
"iris.director.commandiris.director.teleport_another_world": "Teleportieren in eine andere Welt",
"iris.director.commandiris.param.world_teleport": "Zielwelt der Teleportation",
"iris.director.commandiris.param.player_teleport": "Zu teleportierender Spieler",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "La dimensión o el pack con el que se creará el mundo",
"iris.director.commandiris.param.seed_generate_world_with": "La semilla con la que se generará el mundo",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Indica si este mundo debe usarse automáticamente como mundo principal",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Reemplazar el espacio exacto del mundo existente en el próximo reinicio",
"iris.director.commandiris.director.teleport_another_world": "Teletransportarse a otro mundo",
"iris.director.commandiris.param.world_teleport": "El mundo al que se teletransportará",
"iris.director.commandiris.param.player_teleport": "El jugador que se teletransportará",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "Ulottuvuus / paketti luoda maailma",
"iris.director.commandiris.param.seed_generate_world_with": "Siemenet tuottaa maailman kanssa",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Käytetäänkö tätä maailmaa automaattisesti päämaailmana",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Korvaa täsmällinen olemassa oleva maailmapaikka seuraavalla uudelleenkäynnistyksellä",
"iris.director.commandiris.director.teleport_another_world": "Teleporttautuminen toiseen maailmaan",
"iris.director.commandiris.param.world_teleport": "Maailman teleportata",
"iris.director.commandiris.param.player_teleport": "Pelaaja teleportata",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "La dimension ou le pack avec lequel créer le monde",
"iris.director.commandiris.param.seed_generate_world_with": "La graine avec laquelle générer le monde",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Indique si ce monde doit être utilisé automatiquement comme monde principal",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Remplacer lemplacement exact du monde existant au prochain redémarrage",
"iris.director.commandiris.director.teleport_another_world": "Se téléporter vers un autre monde",
"iris.director.commandiris.param.world_teleport": "Le monde vers lequel se téléporter",
"iris.director.commandiris.param.player_teleport": "Le joueur à téléporter",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "המימד / החבילה ליצירת העולם עם",
"iris.director.commandiris.param.seed_generate_world_with": "הזרע ליצור את העולם עם",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "בין אם להשתמש בעולם באופן אוטומטי כעולם הראשי",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "החלפת משבצת העולם הקיימת המדויקת בהפעלה מחדש הבאה",
"iris.director.commandiris.director.teleport_another_world": "טלפורט לעולם אחר",
"iris.director.commandiris.param.world_teleport": "העולם לטלפורט",
"iris.director.commandiris.param.player_teleport": "שחקן לטלפורט",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "La dimensione o il Pack con cui creare il mondo",
"iris.director.commandiris.param.seed_generate_world_with": "Il seed con cui generare il mondo",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Che sia o meno utilizzare automaticamente questo mondo come il mondo principale",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Sostituisci lo slot esatto del mondo esistente al prossimo riavvio",
"iris.director.commandiris.director.teleport_another_world": "Teletrasporto in un altro mondo",
"iris.director.commandiris.param.world_teleport": "Il mondo verso cui teletrasportarsi",
"iris.director.commandiris.param.player_teleport": "Il giocatore da teletrasportare",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "ワールドの作成に使用するディメンションまたはパック",
"iris.director.commandiris.param.seed_generate_world_with": "ワールドの生成に使用するシード",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "このワールドをメインワールドとして自動設定するかどうか",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "次回の再起動時に既存の正確なワールドスロットを置き換える",
"iris.director.commandiris.director.teleport_another_world": "別のワールドへテレポートします",
"iris.director.commandiris.param.world_teleport": "テレポート先のワールド",
"iris.director.commandiris.param.player_teleport": "テレポートさせるプレイヤー",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "월드 생성에 사용할 차원 또는 팩",
"iris.director.commandiris.param.seed_generate_world_with": "월드 생성에 사용할 시드",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "이 월드를 메인 월드로 자동 사용할지 여부",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "다음 재시작 시 기존의 정확한 월드 슬롯 교체",
"iris.director.commandiris.director.teleport_another_world": "다른 월드로 순간이동합니다",
"iris.director.commandiris.param.world_teleport": "순간이동할 월드",
"iris.director.commandiris.param.player_teleport": "순간이동시킬 플레이어",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "dimensija / paketas sukurti pasaulį su",
"iris.director.commandiris.param.seed_generate_world_with": "Sėkla generuoti pasaulį su",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Ar automatiškai naudoti šį pasaulį kaip pagrindinį pasaulį",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Per kitą paleidimą iš naujo pakeisti tikslų esamą pasaulio lizdą",
"iris.director.commandiris.director.teleport_another_world": "Teleportas į kitą pasaulį",
"iris.director.commandiris.param.world_teleport": "Pasaulis teleportui į",
"iris.director.commandiris.param.player_teleport": "Žaidėjas į teleportą",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "De dimensie/pack om de wereld te creëren met",
"iris.director.commandiris.param.seed_generate_world_with": "Het zaad om de wereld te genereren met",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Of deze wereld automatisch gebruikt moet worden als de belangrijkste wereld",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "De exacte bestaande wereldsleuf bij de volgende herstart vervangen",
"iris.director.commandiris.director.teleport_another_world": "Teleporteren naar een andere wereld",
"iris.director.commandiris.param.world_teleport": "Wereld te teleporteren naar",
"iris.director.commandiris.param.player_teleport": "Speler naar teleporteren",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "Wymiar / pakiet do tworzenia świata z",
"iris.director.commandiris.param.seed_generate_world_with": "Nasienie do generowania świata z",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Czy automatycznie używać tego świata jako głównego świata",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Zastąp dokładny istniejący slot świata przy następnym restarcie",
"iris.director.commandiris.director.teleport_another_world": "Teleport do innego świata",
"iris.director.commandiris.param.world_teleport": "Świat teleportować do",
"iris.director.commandiris.param.player_teleport": "Gracz do teleportowania",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "A dimensão/pack para criar o mundo com",
"iris.director.commandiris.param.seed_generate_world_with": "A semente para gerar o mundo com",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Se deve ou não usar automaticamente este mundo como o mundo principal",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Substituir o espaço exato do mundo existente no próximo reinício",
"iris.director.commandiris.director.teleport_another_world": "Teletransporte para outro mundo",
"iris.director.commandiris.param.world_teleport": "Mundo para teletransportar",
"iris.director.commandiris.param.player_teleport": "Jogador para teletransportar",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "Размер/пакет для создания мира",
"iris.director.commandiris.param.seed_generate_world_with": "Семя, чтобы создать мир с",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Использовать или не использовать этот мир как основной",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Заменить точный существующий слот мира при следующем перезапуске",
"iris.director.commandiris.director.teleport_another_world": "Телепорт в другой мир",
"iris.director.commandiris.param.world_teleport": "Телепортироваться в мир",
"iris.director.commandiris.param.player_teleport": "Игрок телепортируется",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "Dünyayı yaratmak için boyut / paket",
"iris.director.commandiris.param.seed_generate_world_with": "Dünyayı üretmek için tohum",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Bu dünyayı ana dünya olarak otomatik olarak kullanıp kullanmayalım",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Bir sonraki yeniden başlatmada mevcut tam dünya yuvasını değiştir",
"iris.director.commandiris.director.teleport_another_world": "Teleport başka bir dünyaya",
"iris.director.commandiris.param.world_teleport": "Dünya teleport'a",
"iris.director.commandiris.param.player_teleport": "Oyuncuya teleport",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "Kích thước/ lốc tạo ra thế giới với",
"iris.director.commandiris.param.seed_generate_world_with": "Hạt giống để tạo ra thế giới với",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Có nên tự động sử dụng thế giới này làm thế giới chính hay không",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Thay thế đúng vị trí thế giới hiện có vào lần khởi động lại tiếp theo",
"iris.director.commandiris.director.teleport_another_world": "Name",
"iris.director.commandiris.param.world_teleport": "Thế giới có thể dịch chuyển",
"iris.director.commandiris.param.player_teleport": "Người chơi cần dịch chuyển",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "用于创建世界的维度包",
"iris.director.commandiris.param.seed_generate_world_with": "用于生成世界的种子",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "是否自动将此世界设为主世界",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "在下次重启时替换指定的现有世界槽位",
"iris.director.commandiris.director.teleport_another_world": "传送到另一个世界",
"iris.director.commandiris.param.world_teleport": "要传送到的世界",
"iris.director.commandiris.param.player_teleport": "要传送的玩家",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "用於建立世界的維度包",
"iris.director.commandiris.param.seed_generate_world_with": "用於生成世界的種子",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "是否自動將此世界設為主世界",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "在下次重新啟動時取代指定的現有世界槽位",
"iris.director.commandiris.director.teleport_another_world": "傳送到另一個世界",
"iris.director.commandiris.param.world_teleport": "要傳送到的世界",
"iris.director.commandiris.param.player_teleport": "要傳送的玩家",
@@ -1,6 +1,5 @@
package art.arcane.iris.core;
import org.bukkit.NamespacedKey;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
@@ -22,22 +21,22 @@ public class ExactWorldSlotPathPolicyTest {
Path canonicalRoot = levelRoot.toRealPath();
List<SlotExpectation> expectations = List.of(
new SlotExpectation(
new NamespacedKey("iris", "underworld"),
new WorldSlotKey("iris", "underworld"),
ExactWorldSlotPathPolicy.SlotKind.IRIS_MANAGED,
"dimensions/iris/underworld"
),
new SlotExpectation(
NamespacedKey.minecraft("overworld"),
WorldSlotKey.minecraft("overworld"),
ExactWorldSlotPathPolicy.SlotKind.VANILLA_OVERWORLD,
"dimensions/minecraft/overworld"
),
new SlotExpectation(
NamespacedKey.minecraft("the_nether"),
WorldSlotKey.minecraft("the_nether"),
ExactWorldSlotPathPolicy.SlotKind.VANILLA_NETHER,
"dimensions/minecraft/the_nether"
),
new SlotExpectation(
NamespacedKey.minecraft("the_end"),
WorldSlotKey.minecraft("the_end"),
ExactWorldSlotPathPolicy.SlotKind.VANILLA_END,
"dimensions/minecraft/the_end"
)
@@ -63,7 +62,7 @@ public class ExactWorldSlotPathPolicyTest {
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.resolve(
levelRoot,
NamespacedKey.minecraft("the_nether")
WorldSlotKey.minecraft("the_nether")
);
assertEquals(worldDirectory.toRealPath(), target.worldDirectory());
@@ -75,15 +74,15 @@ public class ExactWorldSlotPathPolicyTest {
ExactWorldSlotPathPolicy.Rejection foreign = assertThrows(
ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, new NamespacedKey("foreign", "world"))
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, new WorldSlotKey("foreign", "world"))
);
ExactWorldSlotPathPolicy.Rejection nestedIris = assertThrows(
ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, new NamespacedKey("iris", "nested/world"))
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, new WorldSlotKey("iris", "nested/world"))
);
ExactWorldSlotPathPolicy.Rejection unsupportedMinecraft = assertThrows(
ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, NamespacedKey.minecraft("custom"))
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, WorldSlotKey.minecraft("custom"))
);
assertEquals(ExactWorldSlotPathPolicy.RejectionReason.FOREIGN_NAMESPACE, foreign.reason());
@@ -97,7 +96,7 @@ public class ExactWorldSlotPathPolicyTest {
@Test
public void validatesOnlyTheExactExpectedCandidate() throws Exception {
Path levelRoot = temporaryFolder.newFolder("candidate-policy").toPath();
NamespacedKey worldKey = NamespacedKey.minecraft("the_nether");
WorldSlotKey worldKey = WorldSlotKey.minecraft("the_nether");
Path expected = levelRoot.toRealPath().resolve("dimensions/minecraft/the_nether");
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.validate(
@@ -136,7 +135,7 @@ public class ExactWorldSlotPathPolicyTest {
ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve(
levelRoot.resolve("child/.."),
new NamespacedKey("iris", "underworld")
new WorldSlotKey("iris", "underworld")
)
);
@@ -148,41 +147,41 @@ public class ExactWorldSlotPathPolicyTest {
Path linkedLevelTarget = temporaryFolder.newFolder("linked-level-target").toPath();
Path levelLink = temporaryFolder.getRoot().toPath().resolve("linked-level");
Files.createSymbolicLink(levelLink, linkedLevelTarget);
assertSymbolicLinkRejected(levelLink, new NamespacedKey("iris", "underworld"));
assertSymbolicLinkRejected(levelLink, new WorldSlotKey("iris", "underworld"));
Path dimensionsLevel = temporaryFolder.newFolder("linked-dimensions").toPath();
Path externalDimensions = temporaryFolder.newFolder("external-dimensions").toPath();
Files.createSymbolicLink(dimensionsLevel.resolve("dimensions"), externalDimensions);
assertSymbolicLinkRejected(dimensionsLevel, new NamespacedKey("iris", "underworld"));
assertSymbolicLinkRejected(dimensionsLevel, new WorldSlotKey("iris", "underworld"));
Path namespaceLevel = temporaryFolder.newFolder("linked-namespace").toPath();
Path dimensions = Files.createDirectories(namespaceLevel.resolve("dimensions"));
Path externalNamespace = temporaryFolder.newFolder("external-namespace").toPath();
Files.createSymbolicLink(dimensions.resolve("minecraft"), externalNamespace);
assertSymbolicLinkRejected(namespaceLevel, NamespacedKey.minecraft("the_nether"));
assertSymbolicLinkRejected(namespaceLevel, WorldSlotKey.minecraft("the_nether"));
Path targetLevel = temporaryFolder.newFolder("linked-target").toPath();
Path namespace = Files.createDirectories(targetLevel.resolve("dimensions/minecraft"));
Path externalTarget = temporaryFolder.newFolder("external-target").toPath();
Files.createSymbolicLink(namespace.resolve("the_nether"), externalTarget);
assertSymbolicLinkRejected(targetLevel, NamespacedKey.minecraft("the_nether"));
assertSymbolicLinkRejected(targetLevel, WorldSlotKey.minecraft("the_nether"));
}
@Test
public void rejectsNonDirectoryStorageEntries() throws Exception {
Path dimensionsLevel = temporaryFolder.newFolder("file-dimensions").toPath();
Files.writeString(dimensionsLevel.resolve("dimensions"), "not a directory");
assertUnsafeEntryRejected(dimensionsLevel, new NamespacedKey("iris", "underworld"));
assertUnsafeEntryRejected(dimensionsLevel, new WorldSlotKey("iris", "underworld"));
Path namespaceLevel = temporaryFolder.newFolder("file-namespace").toPath();
Path dimensions = Files.createDirectories(namespaceLevel.resolve("dimensions"));
Files.writeString(dimensions.resolve("iris"), "not a directory");
assertUnsafeEntryRejected(namespaceLevel, new NamespacedKey("iris", "underworld"));
assertUnsafeEntryRejected(namespaceLevel, new WorldSlotKey("iris", "underworld"));
Path targetLevel = temporaryFolder.newFolder("file-target").toPath();
Path namespace = Files.createDirectories(targetLevel.resolve("dimensions/iris"));
Files.writeString(namespace.resolve("underworld"), "not a directory");
assertUnsafeEntryRejected(targetLevel, new NamespacedKey("iris", "underworld"));
assertUnsafeEntryRejected(targetLevel, new WorldSlotKey("iris", "underworld"));
}
@Test
@@ -191,13 +190,13 @@ public class ExactWorldSlotPathPolicyTest {
ExactWorldSlotPathPolicy.Rejection missingFailure = assertThrows(
ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve(missing, new NamespacedKey("iris", "underworld"))
() -> ExactWorldSlotPathPolicy.resolve(missing, new WorldSlotKey("iris", "underworld"))
);
ExactWorldSlotPathPolicy.Rejection filesystemFailure = assertThrows(
ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve(
missing.toAbsolutePath().getRoot(),
new NamespacedKey("iris", "underworld")
new WorldSlotKey("iris", "underworld")
)
);
@@ -205,7 +204,7 @@ public class ExactWorldSlotPathPolicyTest {
assertEquals(ExactWorldSlotPathPolicy.RejectionReason.UNSAFE_ENTRY, filesystemFailure.reason());
}
private void assertSymbolicLinkRejected(Path levelRoot, NamespacedKey worldKey) {
private void assertSymbolicLinkRejected(Path levelRoot, WorldSlotKey worldKey) {
ExactWorldSlotPathPolicy.Rejection failure = assertThrows(
ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, worldKey)
@@ -214,7 +213,7 @@ public class ExactWorldSlotPathPolicyTest {
assertEquals(ExactWorldSlotPathPolicy.RejectionReason.SYMBOLIC_LINK, failure.reason());
}
private void assertUnsafeEntryRejected(Path levelRoot, NamespacedKey worldKey) {
private void assertUnsafeEntryRejected(Path levelRoot, WorldSlotKey worldKey) {
ExactWorldSlotPathPolicy.Rejection failure = assertThrows(
ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, worldKey)
@@ -224,7 +223,7 @@ public class ExactWorldSlotPathPolicyTest {
}
private record SlotExpectation(
NamespacedKey worldKey,
WorldSlotKey worldKey,
ExactWorldSlotPathPolicy.SlotKind slotKind,
String relativePath
) {
@@ -0,0 +1,260 @@
package art.arcane.iris.core.lifecycle;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
public class BukkitStartupPathsTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void defaultsResolveAgainstServerWorkingDirectory() throws Exception {
Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath();
BukkitStartupPaths paths = BukkitStartupPaths.resolve(serverRoot, new String[0]);
assertEquals(serverRoot, paths.serverRoot());
assertEquals(serverRoot.resolve("server.properties"), paths.serverProperties());
assertEquals(serverRoot.resolve("bukkit.yml"), paths.bukkitConfiguration());
assertEquals(serverRoot, paths.worldContainer());
assertEquals("world", paths.levelName());
assertEquals(serverRoot.resolve("world"), paths.levelRoot());
}
@Test
public void shortSeparatedArgumentsOverrideRelativeDefaults() throws Exception {
Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath();
Path configurationRoot = Files.createDirectories(serverRoot.resolve("short-config"));
Path properties = configurationRoot.resolve("custom.properties");
Path bukkit = configurationRoot.resolve("custom-bukkit.yml");
Files.writeString(properties, "level-name=property-world\n", StandardCharsets.UTF_8);
Files.writeString(
bukkit,
"settings:\n world-container: relative-worlds\n",
StandardCharsets.UTF_8
);
BukkitStartupPaths paths = BukkitStartupPaths.resolve(
serverRoot,
new String[]{
"-c", "short-config/custom.properties",
"-b", "short-config/custom-bukkit.yml",
"-w", "argument-world"
}
);
assertEquals(properties, paths.serverProperties());
assertEquals(bukkit, paths.bukkitConfiguration());
assertEquals(serverRoot.resolve("relative-worlds"), paths.worldContainer());
assertEquals("argument-world", paths.levelName());
assertEquals(serverRoot.resolve("relative-worlds/argument-world"), paths.levelRoot());
}
@Test
public void longSeparatedArgumentsUseCustomPropertiesAndWorldContainer() throws Exception {
Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath();
Path configurationRoot = Files.createDirectories(serverRoot.resolve("long-config"));
Path properties = configurationRoot.resolve("server.properties");
Path bukkit = configurationRoot.resolve("bukkit.yml");
Files.writeString(properties, "level-name=ignored-property-world\n", StandardCharsets.UTF_8);
Files.writeString(
bukkit,
"settings:\n world-container: long-worlds\n",
StandardCharsets.UTF_8
);
BukkitStartupPaths paths = BukkitStartupPaths.resolve(
serverRoot,
new String[]{
"--config", "long-config/server.properties",
"--bukkit-settings", "long-config/bukkit.yml",
"--world", "long-world"
}
);
assertEquals(properties, paths.serverProperties());
assertEquals(bukkit, paths.bukkitConfiguration());
assertEquals(serverRoot.resolve("long-worlds"), paths.worldContainer());
assertEquals("long-world", paths.levelName());
assertEquals(serverRoot.resolve("long-worlds/long-world"), paths.levelRoot());
}
@Test
public void equalsArgumentsResolveRelativePathsAndLastWorldOverride() throws Exception {
Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath();
Path configurationRoot = Files.createDirectories(serverRoot.resolve("equals-config"));
Path properties = configurationRoot.resolve("server.properties");
Path bukkit = configurationRoot.resolve("bukkit.yml");
Files.writeString(properties, "level-name=property-world\n", StandardCharsets.UTF_8);
Files.writeString(
bukkit,
"settings:\n world-container: equals-worlds\n",
StandardCharsets.UTF_8
);
BukkitStartupPaths paths = BukkitStartupPaths.resolve(
serverRoot,
new String[]{
"--config=equals-config/server.properties",
"--bukkit-settings=equals-config/bukkit.yml",
"--world=first-world",
"-w=last-world"
}
);
assertEquals(properties, paths.serverProperties());
assertEquals(bukkit, paths.bukkitConfiguration());
assertEquals(serverRoot.resolve("equals-worlds"), paths.worldContainer());
assertEquals("last-world", paths.levelName());
assertEquals(serverRoot.resolve("equals-worlds/last-world"), paths.levelRoot());
}
@Test
public void absoluteConfigurationContainerAndLevelPathsRemainAbsolute() throws Exception {
Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath();
Path configurationRoot = Files.createDirectories(serverRoot.resolve("absolute-config"));
Path worldContainer = Files.createDirectories(serverRoot.resolve("absolute-worlds"));
Path absoluteLevel = serverRoot.resolve("absolute-level");
Path properties = configurationRoot.resolve("server.properties");
Path bukkit = configurationRoot.resolve("bukkit.yml");
Files.writeString(properties, "level-name=property-world\n", StandardCharsets.UTF_8);
Files.writeString(
bukkit,
"settings:\n world-container: '" + worldContainer + "'\n",
StandardCharsets.UTF_8
);
BukkitStartupPaths propertiesPaths = BukkitStartupPaths.resolve(
serverRoot,
new String[]{
"--config=" + properties,
"--bukkit-settings=" + bukkit
}
);
BukkitStartupPaths argumentPaths = BukkitStartupPaths.resolve(
serverRoot,
new String[]{
"-c", properties.toString(),
"-b", bukkit.toString(),
"-w", absoluteLevel.toString()
}
);
assertEquals(properties, propertiesPaths.serverProperties());
assertEquals(bukkit, propertiesPaths.bukkitConfiguration());
assertEquals(worldContainer, propertiesPaths.worldContainer());
assertEquals(worldContainer.resolve("property-world"), propertiesPaths.levelRoot());
assertEquals(absoluteLevel.toString(), argumentPaths.levelName());
assertEquals(absoluteLevel, argumentPaths.levelRoot());
}
@Test
public void worldContainerArgumentsOverrideBukkitConfiguration() throws Exception {
Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath();
Path bukkit = serverRoot.resolve("bukkit.yml");
Files.writeString(
bukkit,
"settings:\n world-container: ignored-worlds\n",
StandardCharsets.UTF_8
);
BukkitStartupPaths shortPaths = BukkitStartupPaths.resolve(
serverRoot,
new String[]{"-W", "short-worlds", "--world=short-level"}
);
BukkitStartupPaths longPaths = BukkitStartupPaths.resolve(
serverRoot,
new String[]{"--world-dir=first-worlds", "--universe", "second-worlds"}
);
BukkitStartupPaths explicitPaths = BukkitStartupPaths.resolve(
serverRoot,
new String[]{"--world-container=explicit-worlds"}
);
assertEquals(serverRoot.resolve("short-worlds"), shortPaths.worldContainer());
assertEquals(serverRoot.resolve("short-worlds/short-level"), shortPaths.levelRoot());
assertEquals(serverRoot.resolve("second-worlds"), longPaths.worldContainer());
assertEquals(serverRoot.resolve("second-worlds/world"), longPaths.levelRoot());
assertEquals(serverRoot.resolve("explicit-worlds"), explicitPaths.worldContainer());
assertEquals(serverRoot.resolve("explicit-worlds/world"), explicitPaths.levelRoot());
}
@Test
public void compactShortArgumentsMatchServerOptionParsing() throws Exception {
Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath();
Path configurationRoot = Files.createDirectories(serverRoot.resolve("compact-config"));
Path properties = configurationRoot.resolve("server.properties");
Path bukkit = configurationRoot.resolve("bukkit.yml");
Files.writeString(properties, "level-name=ignored-world\n", StandardCharsets.UTF_8);
Files.writeString(
bukkit,
"settings:\n world-container: ignored-worlds\n",
StandardCharsets.UTF_8
);
BukkitStartupPaths paths = BukkitStartupPaths.resolve(
serverRoot,
new String[]{
"-ccompact-config/server.properties",
"-bcompact-config/bukkit.yml",
"-Wcompact-worlds",
"-wcompact-level"
}
);
assertEquals(properties, paths.serverProperties());
assertEquals(bukkit, paths.bukkitConfiguration());
assertEquals(serverRoot.resolve("compact-worlds"), paths.worldContainer());
assertEquals("compact-level", paths.levelName());
assertEquals(serverRoot.resolve("compact-worlds/compact-level"), paths.levelRoot());
}
@Test
public void applicationArgumentsExcludeJvmAndLauncherOptions() {
assertArrayEquals(
new String[]{"-bconfig/bukkit.yml", "-Wworlds", "-wlevel", "-cserver.properties"},
BukkitStartupPaths.applicationArguments(new String[]{
"-Xmx4G",
"-jar",
"paper.jar",
"-bconfig/bukkit.yml",
"-Wworlds",
"-wlevel",
"-cserver.properties"
})
);
assertArrayEquals(
new String[]{"-bconfig/bukkit.yml"},
BukkitStartupPaths.applicationArguments(new String[]{
"-Xmx4G",
"-cp",
"paper.jar",
"org.bukkit.craftbukkit.Main",
"-bconfig/bukkit.yml"
})
);
}
@Test
public void endOfOptionsStopsStartupOptionParsing() throws Exception {
Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath();
BukkitStartupPaths paths = BukkitStartupPaths.resolve(
serverRoot,
new String[]{"--", "-bcustom-bukkit.yml", "-Wcustom-worlds", "-wcustom-level"}
);
assertEquals(serverRoot.resolve("bukkit.yml"), paths.bukkitConfiguration());
assertEquals(serverRoot, paths.worldContainer());
assertEquals("world", paths.levelName());
assertEquals(serverRoot.resolve("world"), paths.levelRoot());
}
}
@@ -7,13 +7,20 @@ import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.io.IOException;
import java.net.StandardProtocolFamily;
import java.net.UnixDomainSocketAddress;
import java.nio.channels.ServerSocketChannel;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.junit.Assume.assumeNoException;
import static org.junit.Assume.assumeTrue;
public class BukkitWorldConfigurationTest {
@Rule
@@ -317,4 +324,48 @@ public class BukkitWorldConfigurationTest {
assertTrue(failure.getMessage().contains("generator"));
assertEquals(malformed, Files.readString(configuration.toPath()));
}
@Test
public void replacementRejectsSymbolicConfigurationWithoutChangingLinkOrTarget() throws Exception {
Path target = temporaryFolder.newFile("shared-bukkit.yml").toPath();
String original = "settings:\n allow-end: true\n";
Files.writeString(target, original);
Path link = temporaryFolder.getRoot().toPath().resolve("bukkit.yml");
try {
Files.createSymbolicLink(link, target.getFileName());
} catch (IOException | UnsupportedOperationException failure) {
assumeNoException(failure);
}
BukkitWorldConfiguration.WorldGeneratorSnapshot expected =
new BukkitWorldConfiguration.WorldGeneratorSnapshot(false, false, false, null, false, null);
assertThrows(IOException.class, () -> BukkitWorldConfiguration.replaceIfMatching(
link.toFile(),
"world_nether",
expected,
"underworld",
1337L
));
assertTrue(Files.isSymbolicLink(link));
assertEquals(original, Files.readString(target));
}
@Test
public void snapshotRejectsSpecialConfigurationWithoutOpeningIt() throws Exception {
assumeTrue(FileSystems.getDefault().supportedFileAttributeViews().contains("unix"));
Path socket = temporaryFolder.getRoot().toPath().resolve("bukkit.yml");
try (ServerSocketChannel channel = ServerSocketChannel.open(StandardProtocolFamily.UNIX)) {
try {
channel.bind(UnixDomainSocketAddress.of(socket));
} catch (IOException | UnsupportedOperationException failure) {
assumeNoException(failure);
}
assertThrows(IOException.class, () -> BukkitWorldConfiguration.snapshot(
socket.toFile(),
"world_nether"
));
}
}
}
@@ -0,0 +1,386 @@
package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.ExactWorldSlotPathPolicy;
import art.arcane.iris.core.WorldSlotKey;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot;
import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem.ReplacementPaths;
import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Phase;
import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Transaction;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class WorldReplacementBootstrapTest {
private static final WorldSlotKey WORLD_KEY = WorldSlotKey.minecraft("the_nether");
private static final long SEED = 4242424242L;
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
private Path serverRoot;
private Path dataDirectory;
private Path levelRoot;
private Path bukkitConfiguration;
private ExactWorldSlotPathPolicy.Target target;
@Before
public void setUp() throws Exception {
serverRoot = temporaryFolder.newFolder("server").toPath();
dataDirectory = Files.createDirectories(serverRoot.resolve("plugins/Iris"));
levelRoot = Files.createDirectories(serverRoot.resolve("world"));
bukkitConfiguration = Files.createFile(serverRoot.resolve("bukkit.yml"));
target = ExactWorldSlotPathPolicy.resolve(levelRoot, WORLD_KEY);
Files.createDirectories(target.namespaceRoot());
}
@Test
public void publishesArmedReplacementBeforeRegistryCompilation() throws Exception {
Transaction transaction = stagedTransaction(Phase.ARMED, true, "original");
configureReplacement(transaction);
WorldReplacementBootstrap.ReconcileResult result = reconcile();
assertEquals(1, result.published());
assertEquals("replacement", replacementContent(target.worldDirectory()));
assertEquals("original", Files.readString(backup(transaction).resolve("original.txt")));
assertEquals(Phase.PUBLISHED, loadSingle().phase());
}
@Test
public void publishesArmedReplacementWhenOriginalConfigurationAlreadyMatchesReplacement() throws Exception {
configureExistingReplacement();
Transaction transaction = stagedTransaction(Phase.ARMED, true, "original");
WorldReplacementBootstrap.ReconcileResult result = reconcile();
assertEquals(1, result.published());
assertEquals("replacement", replacementContent(target.worldDirectory()));
assertEquals("original", Files.readString(backup(transaction).resolve("original.txt")));
assertEquals(Phase.PUBLISHED, loadSingle().phase());
}
@Test
public void retainsPublishedReplacementWhenOriginalConfigurationAlreadyMatchesReplacement() throws Exception {
configureExistingReplacement();
Transaction transaction = stagedTransaction(Phase.PUBLISHED, true, "original");
WorldReplacementFilesystem.publish(paths(transaction), true, transaction.packFingerprint());
WorldReplacementBootstrap.ReconcileResult result = reconcile();
assertEquals(1, result.retained());
assertEquals("replacement", replacementContent(target.worldDirectory()));
assertEquals("original", Files.readString(backup(transaction).resolve("original.txt")));
assertEquals(Phase.PUBLISHED, loadSingle().phase());
}
@Test
public void cancelsPreparedReplacementWhenOriginalConfigurationAlreadyMatchesReplacement() throws Exception {
configureExistingReplacement();
Transaction transaction = stagedTransaction(Phase.PREPARED, true, "original");
WorldReplacementBootstrap.ReconcileResult result = reconcile();
assertEquals(1, result.rolledBack());
assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt")));
assertFalse(Files.exists(paths(transaction).stage()));
assertTrue(WorldReplacementJournal.load(dataDirectory, levelRoot).isEmpty());
}
@Test
public void resumesPublicationAfterOriginalMoveCrash() throws Exception {
Transaction transaction = stagedTransaction(Phase.ARMED, true, "original");
configureReplacement(transaction);
ReplacementPaths paths = paths(transaction);
Files.move(paths.target(), paths.backup());
reconcile();
assertEquals("replacement", replacementContent(paths.target()));
assertEquals("original", Files.readString(paths.backup().resolve("original.txt")));
assertEquals(Phase.PUBLISHED, loadSingle().phase());
}
@Test
public void cancelsPreparedTransactionWhenConfigurationWasNotApplied() throws Exception {
Transaction transaction = stagedTransaction(Phase.PREPARED, true, "original");
WorldReplacementBootstrap.ReconcileResult result = reconcile();
assertEquals(1, result.rolledBack());
assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt")));
assertFalse(Files.exists(paths(transaction).stage()));
assertTrue(WorldReplacementJournal.load(dataDirectory, levelRoot).isEmpty());
}
@Test
public void restoresPublishedWorldWhenConfigurationWasReverted() throws Exception {
Transaction transaction = stagedTransaction(Phase.PUBLISHED, true, "original");
configureReplacement(transaction);
WorldReplacementFilesystem.publish(paths(transaction), true, transaction.packFingerprint());
restoreOriginalConfiguration(transaction);
WorldReplacementBootstrap.ReconcileResult result = reconcile();
assertEquals(1, result.rolledBack());
assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt")));
assertFalse(Files.exists(paths(transaction).stage()));
assertFalse(Files.exists(paths(transaction).backup()));
assertTrue(WorldReplacementJournal.load(dataDirectory, levelRoot).isEmpty());
}
@Test
public void rejectsThirdPartyConfigurationAfterPublicationWithoutMovingStorage() throws Exception {
Transaction transaction = stagedTransaction(Phase.PUBLISHED, true, "original");
configureReplacement(transaction);
WorldReplacementFilesystem.publish(paths(transaction), true, transaction.packFingerprint());
WorldGeneratorSnapshot replacement = WorldReplacementBootstrap.replacementSnapshot(transaction);
BukkitWorldConfiguration.replaceIfMatching(
bukkitConfiguration.toFile(),
transaction.worldName(),
replacement,
"other",
SEED
);
assertThrows(IOException.class, this::reconcile);
assertEquals("replacement", replacementContent(target.worldDirectory()));
assertEquals("original", Files.readString(backup(transaction).resolve("original.txt")));
assertEquals(Phase.PUBLISHED, loadSingle().phase());
}
@Test
public void completesRollbackAcrossPreparedStorageCrashBoundary() throws Exception {
Transaction transaction = stagedTransaction(Phase.ROLLBACK_PENDING, true, "original");
configureReplacement(transaction);
WorldReplacementFilesystem.publish(paths(transaction), true, transaction.packFingerprint());
WorldReplacementFilesystem.prepareRollback(paths(transaction), true);
restoreOriginalConfiguration(transaction);
WorldReplacementJournal.write(dataDirectory, transaction.withPhase(Phase.ROLLBACK_CLEANUP));
reconcile();
assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt")));
assertFalse(Files.exists(paths(transaction).stage()));
assertTrue(WorldReplacementJournal.load(dataDirectory, levelRoot).isEmpty());
}
@Test
public void retainsVerifiedTargetWhenBackupWasAlreadyCleaned() throws Exception {
Transaction transaction = stagedTransaction(Phase.CLEANUP_PENDING, true, "original");
configureReplacement(transaction);
WorldReplacementFilesystem.publish(paths(transaction), true, transaction.packFingerprint());
WorldReplacementFilesystem.cleanupBackup(paths(transaction));
WorldReplacementBootstrap.ReconcileResult result = reconcile();
assertEquals(1, result.retained());
assertEquals("replacement", replacementContent(target.worldDirectory()));
assertEquals(Phase.CLEANUP_PENDING, loadSingle().phase());
}
@Test
public void rejectsChangedLevelRootBeforeTouchingStagedStorage() throws Exception {
Transaction transaction = stagedTransaction(Phase.ARMED, true, "original");
configureReplacement(transaction);
Path otherLevelRoot = Files.createDirectories(serverRoot.resolve("renamed-world"));
assertThrows(
IOException.class,
() -> WorldReplacementBootstrap.reconcile(
dataDirectory,
otherLevelRoot,
bukkitConfiguration,
ignored -> {
}
)
);
assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt")));
assertTrue(Files.isDirectory(paths(transaction).stage()));
assertFalse(Files.exists(paths(transaction).backup()));
}
@Test
public void rejectsDuplicateWorldJournalsBeforePublishingEither() throws Exception {
Transaction first = stagedTransaction(Phase.ARMED, true, "original");
configureReplacement(first);
Transaction second = new Transaction(
UUID.randomUUID(),
first.worldKey(),
first.worldName(),
first.levelRoot(),
first.dimension(),
first.seed(),
first.packFingerprint(),
first.originalConfiguration(),
first.originalTargetPresent(),
first.phase()
);
WorldReplacementJournal.write(dataDirectory, second);
assertThrows(IOException.class, this::reconcile);
assertTrue(Files.isDirectory(paths(first).stage()));
assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt")));
assertFalse(Files.exists(paths(first).backup()));
}
@Test
public void roundTripsBlankAndWhitespaceOriginalGenerators() throws Exception {
for (String generator : List.of("", " ")) {
WorldGeneratorSnapshot original = new WorldGeneratorSnapshot(
true,
true,
true,
generator,
false,
null
);
Transaction transaction = transaction(UUID.randomUUID(), original, Phase.PREPARED, false, "replacement");
WorldReplacementJournal.write(dataDirectory, transaction);
Transaction loaded = loadSingle();
assertEquals(generator, loaded.originalConfiguration().generator());
WorldReplacementJournal.delete(dataDirectory, transaction.id());
}
}
@Test
public void rejectsIrisWorldKeysThatCollideWithConfiguredVanillaAliases() {
WorldGeneratorSnapshot original = new WorldGeneratorSnapshot(false, false, false, null, false, null);
for (String alias : List.of("world", "world_nether", "world_the_end")) {
Transaction transaction = new Transaction(
UUID.randomUUID(),
new WorldSlotKey("iris", alias),
alias,
levelRoot,
"underworld",
SEED,
"0".repeat(64),
original,
false,
Phase.ARMED
);
assertThrows(IOException.class, () -> WorldReplacementJournal.resolveTarget(transaction, levelRoot));
}
}
private WorldReplacementBootstrap.ReconcileResult reconcile() throws Exception {
return WorldReplacementBootstrap.reconcile(
dataDirectory,
levelRoot,
bukkitConfiguration,
ignored -> {
}
);
}
private Transaction stagedTransaction(Phase phase, boolean originalPresent, String originalContent)
throws Exception {
WorldGeneratorSnapshot original = BukkitWorldConfiguration.snapshot(
bukkitConfiguration.toFile(),
"world_nether"
);
return transaction(UUID.randomUUID(), original, phase, originalPresent, originalContent);
}
private Transaction transaction(
UUID id,
WorldGeneratorSnapshot original,
Phase phase,
boolean originalPresent,
String originalContent
) throws Exception {
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, id);
if (originalPresent) {
writeOriginalTarget(paths, originalContent);
}
Path dimension = paths.stage().resolve("iris/pack/dimensions/underworld.json");
Files.createDirectories(dimension.getParent());
Files.writeString(dimension, "replacement");
String fingerprint = WorldReplacementFilesystem.fingerprintPack(paths.stage().resolve("iris/pack"));
Transaction transaction = new Transaction(
id,
WORLD_KEY,
"world_nether",
target.levelRoot(),
"underworld",
SEED,
fingerprint,
original,
originalPresent,
phase
);
WorldReplacementJournal.write(dataDirectory, transaction);
return transaction;
}
private void configureReplacement(Transaction transaction) throws Exception {
BukkitWorldConfiguration.GeneratorReplacement result = BukkitWorldConfiguration.replaceIfMatching(
bukkitConfiguration.toFile(),
transaction.worldName(),
transaction.originalConfiguration(),
transaction.dimension(),
transaction.seed()
);
assertTrue(result.applied());
}
private void configureExistingReplacement() throws Exception {
BukkitWorldConfiguration.register(
bukkitConfiguration.toFile(),
"world_nether",
"underworld",
SEED
);
}
private void restoreOriginalConfiguration(Transaction transaction) throws Exception {
assertTrue(BukkitWorldConfiguration.restoreIfMatching(
bukkitConfiguration.toFile(),
transaction.worldName(),
WorldReplacementBootstrap.replacementSnapshot(transaction),
transaction.originalConfiguration()
));
}
private Transaction loadSingle() throws Exception {
return WorldReplacementJournal.load(dataDirectory, levelRoot).getFirst();
}
private ReplacementPaths paths(Transaction transaction) {
return WorldReplacementFilesystem.paths(target, transaction.id());
}
private Path backup(Transaction transaction) {
return paths(transaction).backup();
}
private void writeOriginalTarget(ReplacementPaths paths, String originalContent) throws Exception {
Files.createDirectories(paths.target().resolve("data/paper"));
Files.createDirectories(paths.target().resolve("data/minecraft"));
Files.writeString(paths.target().resolve("original.txt"), originalContent);
Files.writeString(paths.target().resolve("data/paper/metadata.dat"), "metadata");
Files.writeString(paths.target().resolve("data/paper/level_overrides.dat"), "overrides");
Files.writeString(paths.target().resolve("data/minecraft/world_gen_settings.dat"), "generation");
}
private String replacementContent(Path worldDirectory) throws Exception {
return Files.readString(worldDirectory.resolve("iris/pack/dimensions/underworld.json"));
}
}
@@ -31,15 +31,83 @@ public class WorldReplacementFilesystemTest {
public void publishesReplacementAndRetainsOriginalBackup() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("publish-existing", TRANSACTION_ID);
writeOriginalTarget(paths, "original");
Files.createDirectories(paths.target().resolve("region"));
Files.createDirectories(paths.target().resolve("entities"));
Files.createDirectories(paths.target().resolve("poi"));
Files.writeString(paths.target().resolve("region/r.0.0.mca"), "old-region");
Files.writeString(paths.target().resolve("entities/r.0.0.mca"), "old-entities");
Files.writeString(paths.target().resolve("poi/r.0.0.mca"), "old-poi");
String fingerprint = writeStage(paths, "replacement");
WorldReplacementFilesystem.publish(paths, true, fingerprint);
assertEquals("replacement", readPackContent(paths.target()));
assertEquals("original", Files.readString(paths.backup().resolve("original.txt")));
assertEquals("metadata", Files.readString(paths.target().resolve("data/paper/metadata.dat")));
assertEquals("overrides", Files.readString(paths.target().resolve("data/paper/level_overrides.dat")));
assertEquals(
"generation",
Files.readString(paths.target().resolve("data/minecraft/world_gen_settings.dat"))
);
assertFalse(Files.exists(paths.target().resolve("region/r.0.0.mca")));
assertFalse(Files.exists(paths.target().resolve("entities/r.0.0.mca")));
assertFalse(Files.exists(paths.target().resolve("poi/r.0.0.mca")));
assertTrue(Files.exists(paths.backup().resolve("region/r.0.0.mca")));
assertTrue(Files.exists(paths.backup().resolve("entities/r.0.0.mca")));
assertTrue(Files.exists(paths.backup().resolve("poi/r.0.0.mca")));
assertFalse(Files.exists(paths.stage()));
}
@Test
public void rejectsAbsentTargetForReplacementAdmission() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("admit-absent", TRANSACTION_ID);
IOException failure = assertThrows(
IOException.class,
() -> WorldReplacementFilesystem.requireExistingTarget(paths)
);
assertTrue(failure.getMessage().contains("requires an existing exact world slot"));
assertFalse(Files.exists(paths.target()));
assertFalse(Files.exists(paths.stage()));
assertFalse(Files.exists(paths.backup()));
}
@Test
public void completesMetadataForAlreadyPublishedReplacement() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("published-metadata", TRANSACTION_ID);
writeOriginalTarget(paths, "original");
String fingerprint = writeStage(paths, "replacement");
Files.move(paths.target(), paths.backup());
Files.move(paths.stage(), paths.target());
WorldReplacementFilesystem.publish(paths, true, fingerprint);
assertEquals("metadata", Files.readString(paths.target().resolve("data/paper/metadata.dat")));
assertEquals("overrides", Files.readString(paths.target().resolve("data/paper/level_overrides.dat")));
assertEquals(
"generation",
Files.readString(paths.target().resolve("data/minecraft/world_gen_settings.dat"))
);
}
@Test
public void rejectsUnmigratedRetainedWorldBeforePublication() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("missing-paper-metadata", TRANSACTION_ID);
Files.createDirectories(paths.target());
Files.writeString(paths.target().resolve("original.txt"), "original");
String fingerprint = writeStage(paths, "replacement");
assertThrows(
IOException.class,
() -> WorldReplacementFilesystem.publish(paths, true, fingerprint)
);
assertTrue(Files.isDirectory(paths.target()));
assertTrue(Files.isDirectory(paths.stage()));
assertFalse(Files.exists(paths.backup()));
}
@Test
public void publishesReplacementWithoutCreatingBackupForAbsentTarget() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("publish-absent", TRANSACTION_ID);
@@ -136,6 +204,37 @@ public class WorldReplacementFilesystemTest {
assertFalse(Files.exists(paths.backup()));
}
@Test
public void preparedRollbackCanRepublishWhenConfigurationRestoreFails() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("rollback-republish", TRANSACTION_ID);
writeOriginalTarget(paths, "original");
String fingerprint = writeStage(paths, "replacement");
WorldReplacementFilesystem.publish(paths, true, fingerprint);
WorldReplacementFilesystem.prepareRollback(paths, true);
WorldReplacementFilesystem.publish(paths, true, fingerprint);
assertEquals("replacement", readPackContent(paths.target()));
assertEquals("original", Files.readString(paths.backup().resolve("original.txt")));
assertFalse(Files.exists(paths.stage()));
}
@Test
public void preparedRollbackCleanupIsRetryableAfterStageDeletion() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("rollback-cleanup-retry", TRANSACTION_ID);
writeOriginalTarget(paths, "original");
String fingerprint = writeStage(paths, "replacement");
WorldReplacementFilesystem.publish(paths, true, fingerprint);
WorldReplacementFilesystem.prepareRollback(paths, true);
WorldReplacementFilesystem.finishPreparedRollback(paths, true);
WorldReplacementFilesystem.finishPreparedRollback(paths, true);
assertEquals("original", Files.readString(paths.target().resolve("original.txt")));
assertFalse(Files.exists(paths.stage()));
assertFalse(Files.exists(paths.backup()));
}
@Test
public void rejectsPackMutationBeforePublication() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("fingerprint-mutation", TRANSACTION_ID);
@@ -289,6 +388,11 @@ public class WorldReplacementFilesystemTest {
private void writeOriginalTarget(WorldReplacementFilesystem.ReplacementPaths paths, String content) throws Exception {
Files.createDirectories(paths.target());
Files.writeString(paths.target().resolve("original.txt"), content);
Files.createDirectories(paths.target().resolve("data/paper"));
Files.createDirectories(paths.target().resolve("data/minecraft"));
Files.writeString(paths.target().resolve("data/paper/metadata.dat"), "metadata");
Files.writeString(paths.target().resolve("data/paper/level_overrides.dat"), "overrides");
Files.writeString(paths.target().resolve("data/minecraft/world_gen_settings.dat"), "generation");
}
private String readPackContent(Path worldDirectory) throws Exception {
@@ -7,6 +7,7 @@ import org.junit.Test;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
@@ -17,10 +18,14 @@ import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
@@ -31,6 +36,26 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class DefaultPackBootstrapProvisionerTest {
@Test
public void defaultBetaSourcesArePinnedPerRequiredPack() {
Map<String, DefaultPackBootstrapProvisioner.PackSpec> packs = new LinkedHashMap<>();
for (DefaultPackBootstrapProvisioner.PackSpec pack : DefaultPackBootstrapProvisioner.defaultPacks()) {
packs.put(pack.key(), pack);
}
assertEquals(2, packs.size());
assertEquals(
URI.create("https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip"),
packs.get("overworld").source()
);
assertEquals("overworld", packs.get("overworld").requiredDimension());
assertEquals(
URI.create("https://github.com/IrisDimensions/underworld/releases/download/beta/underworld.zip"),
packs.get("underworld").source()
);
assertEquals("underworld", packs.get("underworld").requiredDimension());
}
@Test
public void coldInstallUsesFreshCacheWithoutSecondRequest() throws Exception {
byte[] archive = packArchive("overworld", "bootstrap_biome");
@@ -58,14 +83,23 @@ public class DefaultPackBootstrapProvisionerTest {
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.INSTALLED, installed.status());
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UNCHANGED, unchanged.status());
assertEquals(1, requests.get());
assertTrue(Files.isRegularFile(installed.packRoot().resolve("dimensions/overworld.json")));
assertEquals(2, requests.get());
assertTrue(Files.isRegularFile(installed.packRoots().get("overworld").resolve("dimensions/overworld.json")));
assertTrue(Files.isRegularFile(installed.packRoots().get("underworld").resolve("dimensions/underworld.json")));
assertTrue(Files.isRegularFile(installed.packRoots().get("underworld").resolve("dimensions/underworld_roof.json")));
assertEquals(root.resolve("datapacks/iris"), installed.datapackRoot());
assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("pack.mcmeta")));
assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("data/overworld/worldgen/biome/bootstrap_biome.json")));
assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("data/underworld/worldgen/biome/underworld_biome.json")));
assertFalse(Files.exists(dataDirectory.resolve("bootstrap/datapack")));
assertTrue(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root));
assertTrue(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root, options.packs()));
assertTrue(DefaultPackBootstrapProvisioner.wasProvisionedThisStartup());
Properties marker = loadProperties(dataDirectory.resolve("bootstrap/provisioned.properties"));
assertEquals("true", marker.getProperty("pack.overworld.managed"));
assertEquals("true", marker.getProperty("pack.underworld.managed"));
assertEquals("underworld", marker.getProperty("pack.underworld.requiredDimension"));
delete(installed.packRoots().get("underworld"));
assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root, options.packs()));
} finally {
server.stop(0);
delete(root);
@@ -101,9 +135,10 @@ public class DefaultPackBootstrapProvisionerTest {
);
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, rebuilt.status());
assertEquals(1, requests.get());
assertEquals(2, requests.get());
assertTrue(Files.isRegularFile(rebuilt.datapackRoot().resolve("pack.mcmeta")));
assertTrue(Files.isRegularFile(rebuilt.datapackRoot().resolve("data/overworld/worldgen/biome/bootstrap_biome.json")));
assertTrue(Files.isRegularFile(rebuilt.datapackRoot().resolve("data/underworld/worldgen/biome/underworld_biome.json")));
} finally {
if (!serverStopped) {
server.stop(0);
@@ -138,9 +173,10 @@ public class DefaultPackBootstrapProvisionerTest {
);
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.INSTALLED, installed.status());
assertEquals(1, requests.get());
assertEquals(2, requests.get());
assertTrue(Files.isSymbolicLink(dataDirectory.resolve("packs")));
assertTrue(Files.isRegularFile(sharedPacks.resolve("overworld/dimensions/overworld.json")));
assertTrue(Files.isRegularFile(sharedPacks.resolve("underworld/dimensions/underworld.json")));
assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("pack.mcmeta")));
} finally {
server.stop(0);
@@ -165,7 +201,7 @@ public class DefaultPackBootstrapProvisionerTest {
DefaultPackBootstrapProvisioner.provision(dataDirectory, ignored -> {
}, options);
assertEquals(2, requests.get());
assertEquals(3, requests.get());
try (ZipInputStream zip = new ZipInputStream(Files.newInputStream(cache))) {
assertTrue(zip.getNextEntry() != null);
}
@@ -245,13 +281,13 @@ public class DefaultPackBootstrapProvisionerTest {
options
);
assertEquals(0, requests.get());
assertEquals(1, requests.get());
assertTrue(Files.isSymbolicLink(link));
assertEquals(target.toRealPath(), link.toRealPath());
assertTrue(Files.isRegularFile(result.datapackRoot().resolve("data/overworld/worldgen/biome/local_biome.json")));
Files.writeString(target.resolve("biomes/local.json"), biomeJson("changed_biome"), StandardCharsets.UTF_8);
assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root));
assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root, options.packs()));
DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision(
dataDirectory,
ignored -> {
@@ -259,7 +295,7 @@ public class DefaultPackBootstrapProvisionerTest {
options
);
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, updated.status());
assertEquals(0, requests.get());
assertEquals(2, requests.get());
assertTrue(Files.isSymbolicLink(link));
} finally {
server.stop(0);
@@ -280,7 +316,7 @@ public class DefaultPackBootstrapProvisionerTest {
writePack(dataDirectory.resolve("packs/second"), "second", "second_biome");
writePack(root.resolve("dimensions/example/world/iris/pack"), "world_local", "world_local_biome");
assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root));
assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root, options.packs()));
DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision(
dataDirectory,
ignored -> {
@@ -289,7 +325,7 @@ public class DefaultPackBootstrapProvisionerTest {
);
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, updated.status());
assertEquals(1, requests.get());
assertEquals(2, requests.get());
assertTrue(Files.isRegularFile(updated.datapackRoot().resolve("data/second/worldgen/biome/second_biome.json")));
assertTrue(Files.isRegularFile(updated.datapackRoot().resolve("data/world_local/worldgen/biome/world_local_biome.json")));
} finally {
@@ -319,14 +355,15 @@ public class DefaultPackBootstrapProvisionerTest {
refreshOptions
);
assertEquals(1, requests.get());
assertEquals(3, requests.get());
assertTrue(Files.readString(editedBiome).contains("locally_edited_biome"));
assertTrue(Files.isRegularFile(updated.datapackRoot().resolve("data/overworld/worldgen/biome/locally_edited_biome.json")));
Properties marker = new Properties();
try (java.io.InputStream input = Files.newInputStream(dataDirectory.resolve("bootstrap/provisioned.properties"))) {
try (InputStream input = Files.newInputStream(dataDirectory.resolve("bootstrap/provisioned.properties"))) {
marker.load(input);
}
assertEquals("false", marker.getProperty("managedDefault"));
assertEquals("false", marker.getProperty("pack.overworld.managed"));
assertEquals("true", marker.getProperty("pack.underworld.managed"));
} finally {
server.stop(0);
delete(root);
@@ -334,39 +371,185 @@ public class DefaultPackBootstrapProvisionerTest {
}
@Test
public void failedAggregateCompilationPreservesPreviousOutputAndMarker() throws Exception {
public void underworldLocalEditRelinquishesOnlyUnderworldOwnership() throws Exception {
AtomicInteger requests = new AtomicInteger();
HttpServer server = server(packArchive("overworld", "first_biome"), requests);
Path root = Files.createTempDirectory("iris-bootstrap-rollback");
HttpServer server = server(packArchive("overworld", "overworld_managed"), requests);
Path root = Files.createTempDirectory("iris-bootstrap-underworld-edit");
try {
Path dataDirectory = root.resolve("plugins/Iris");
DefaultPackBootstrapProvisioner.ProvisionOptions options = options(server, root, Duration.ofHours(1));
DefaultPackBootstrapProvisioner.ProvisionResult first = DefaultPackBootstrapProvisioner.provision(
DefaultPackBootstrapProvisioner.ProvisionOptions initialOptions = options(
server,
root,
Duration.ofHours(1)
);
DefaultPackBootstrapProvisioner.provision(dataDirectory, ignored -> {
}, initialOptions);
Path editedBiome = dataDirectory.resolve("packs/underworld/biomes/local.json");
Files.writeString(editedBiome, biomeJson("underworld_local_edit"), StandardCharsets.UTF_8);
DefaultPackBootstrapProvisioner.ProvisionOptions refreshOptions = options(server, root, Duration.ZERO);
DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision(
dataDirectory,
ignored -> {
},
options
refreshOptions
);
byte[] marker = Files.readAllBytes(dataDirectory.resolve("bootstrap/provisioned.properties"));
byte[] metadata = Files.readAllBytes(first.datapackRoot().resolve("pack.mcmeta"));
Path invalidPack = dataDirectory.resolve("packs/invalid");
Files.createDirectories(invalidPack.resolve("dimensions"));
Files.writeString(invalidPack.resolve("dimensions/broken.json"), "{", StandardCharsets.UTF_8);
assertEquals(3, requests.get());
assertTrue(Files.readString(editedBiome).contains("underworld_local_edit"));
assertTrue(Files.isRegularFile(updated.datapackRoot()
.resolve("data/underworld/worldgen/biome/underworld_local_edit.json")));
Properties marker = loadProperties(dataDirectory.resolve("bootstrap/provisioned.properties"));
assertEquals("true", marker.getProperty("pack.overworld.managed"));
assertEquals("false", marker.getProperty("pack.underworld.managed"));
} finally {
server.stop(0);
delete(root);
}
}
@Test
public void betaPacksUpdateIndependentlyAndRecompileOneAggregateDatapack() throws Exception {
byte[] overworld = packArchive("overworld", "overworld_first");
byte[] underworldFirst = underworldArchive("underworld_first");
AtomicInteger requests = new AtomicInteger();
HttpServer initialServer = server(overworld, underworldFirst, requests);
HttpServer updateServer = null;
Path root = Files.createTempDirectory("iris-bootstrap-independent-update");
try {
Path dataDirectory = root.resolve("plugins/Iris");
DefaultPackBootstrapProvisioner.provision(
dataDirectory,
ignored -> {
},
options(initialServer, root, Duration.ofHours(1))
);
initialServer.stop(0);
initialServer = null;
byte[] underworldSecond = underworldArchive("underworld_second");
updateServer = server(overworld, underworldSecond, requests);
DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision(
dataDirectory,
ignored -> {
},
options(updateServer, root, Duration.ZERO)
);
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, updated.status());
assertEquals(4, requests.get());
assertTrue(Files.readString(dataDirectory.resolve("packs/overworld/biomes/local.json"))
.contains("overworld_first"));
assertTrue(Files.readString(dataDirectory.resolve("packs/underworld/biomes/local.json"))
.contains("underworld_second"));
assertTrue(Files.isRegularFile(updated.datapackRoot()
.resolve("data/overworld/worldgen/biome/overworld_first.json")));
assertTrue(Files.isRegularFile(updated.datapackRoot()
.resolve("data/underworld/worldgen/biome/underworld_second.json")));
assertFalse(Files.exists(updated.datapackRoot()
.resolve("data/underworld/worldgen/biome/underworld_first.json")));
} finally {
if (initialServer != null) {
initialServer.stop(0);
}
if (updateServer != null) {
updateServer.stop(0);
}
delete(root);
}
}
@Test
public void invalidUnderworldArchivePublishesNeitherRequiredPack() throws Exception {
byte[] overworld = packArchive("overworld", "overworld_valid");
byte[] invalidUnderworld = packArchive("underworld_roof", "roof_only");
AtomicInteger requests = new AtomicInteger();
HttpServer server = server(overworld, invalidUnderworld, requests);
Path root = Files.createTempDirectory("iris-bootstrap-underworld-invalid");
try {
Path dataDirectory = root.resolve("plugins/Iris");
assertThrows(IOException.class, () -> DefaultPackBootstrapProvisioner.provision(
dataDirectory,
ignored -> {
},
options
options(server, root, Duration.ZERO)
));
assertTrue(java.util.Arrays.equals(marker, Files.readAllBytes(dataDirectory.resolve("bootstrap/provisioned.properties"))));
assertTrue(java.util.Arrays.equals(metadata, Files.readAllBytes(first.datapackRoot().resolve("pack.mcmeta"))));
assertEquals(2, requests.get());
assertFalse(Files.exists(dataDirectory.resolve("packs/overworld")));
assertFalse(Files.exists(dataDirectory.resolve("packs/underworld")));
assertFalse(Files.exists(dataDirectory.resolve("bootstrap/provisioned.properties")));
assertFalse(Files.exists(root.resolve("datapacks/iris")));
} finally {
server.stop(0);
delete(root);
}
}
@Test
public void failedAggregateCompilationRollsBackBothPackUpdatesAndDatapack() throws Exception {
AtomicInteger requests = new AtomicInteger();
HttpServer initialServer = server(
packArchive("overworld", "overworld_first"),
underworldArchive("underworld_first"),
requests
);
HttpServer updateServer = null;
Path root = Files.createTempDirectory("iris-bootstrap-rollback");
try {
Path dataDirectory = root.resolve("plugins/Iris");
DefaultPackBootstrapProvisioner.ProvisionResult first = DefaultPackBootstrapProvisioner.provision(
dataDirectory,
ignored -> {
},
options(initialServer, root, Duration.ofHours(1))
);
initialServer.stop(0);
initialServer = null;
byte[] marker = Files.readAllBytes(dataDirectory.resolve("bootstrap/provisioned.properties"));
byte[] metadata = Files.readAllBytes(first.datapackRoot().resolve("pack.mcmeta"));
byte[] originalOverworld = Files.readAllBytes(dataDirectory.resolve("packs/overworld/biomes/local.json"));
byte[] originalUnderworld = Files.readAllBytes(dataDirectory.resolve("packs/underworld/biomes/local.json"));
Path invalidPack = dataDirectory.resolve("packs/invalid");
Files.createDirectories(invalidPack.resolve("dimensions"));
Files.writeString(invalidPack.resolve("dimensions/broken.json"), "{", StandardCharsets.UTF_8);
updateServer = server(
packArchive("overworld", "overworld_second"),
underworldArchive("underworld_second"),
requests
);
DefaultPackBootstrapProvisioner.ProvisionOptions updateOptions = options(
updateServer,
root,
Duration.ZERO
);
assertThrows(IOException.class, () -> DefaultPackBootstrapProvisioner.provision(
dataDirectory,
ignored -> {
},
updateOptions
));
assertTrue(Arrays.equals(marker, Files.readAllBytes(dataDirectory.resolve("bootstrap/provisioned.properties"))));
assertTrue(Arrays.equals(metadata, Files.readAllBytes(first.datapackRoot().resolve("pack.mcmeta"))));
assertTrue(Arrays.equals(originalOverworld,
Files.readAllBytes(dataDirectory.resolve("packs/overworld/biomes/local.json"))));
assertTrue(Arrays.equals(originalUnderworld,
Files.readAllBytes(dataDirectory.resolve("packs/underworld/biomes/local.json"))));
assertNoBootstrapTransactionPaths(dataDirectory.resolve("packs"));
assertNoBootstrapTransactionPaths(root.resolve("datapacks"));
} finally {
if (initialServer != null) {
initialServer.stop(0);
}
if (updateServer != null) {
updateServer.stop(0);
}
delete(root);
}
}
@Test
public void resolvesConfiguredLevelRootFromServerProperties() throws Exception {
Path serverRoot = Files.createTempDirectory("iris-bootstrap-level-root");
@@ -378,7 +561,7 @@ public class DefaultPackBootstrapProvisionerTest {
);
assertEquals(
serverRoot.resolve("levels/primary").normalize(),
serverRoot.toRealPath().resolve("levels/primary").normalize(),
DefaultPackBootstrapProvisioner.resolveLevelRoot(serverRoot)
);
} finally {
@@ -391,9 +574,20 @@ public class DefaultPackBootstrapProvisionerTest {
Path serverRoot,
Duration refreshInterval
) {
URI source = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/overworld.zip");
String sourceRoot = "http://127.0.0.1:" + server.getAddress().getPort();
return new DefaultPackBootstrapProvisioner.ProvisionOptions(
source,
List.of(
new DefaultPackBootstrapProvisioner.PackSpec(
"overworld",
URI.create(sourceRoot + "/overworld.zip"),
"overworld"
),
new DefaultPackBootstrapProvisioner.PackSpec(
"underworld",
URI.create(sourceRoot + "/underworld.zip"),
"underworld"
)
),
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build(),
Clock.fixed(Instant.parse("2026-07-12T12:00:00Z"), ZoneOffset.UTC),
refreshInterval,
@@ -406,8 +600,17 @@ public class DefaultPackBootstrapProvisionerTest {
}
private static HttpServer server(byte[] response, AtomicInteger requests) throws IOException {
return server(response, underworldArchive("underworld_biome"), requests);
}
private static HttpServer server(
byte[] overworldResponse,
byte[] underworldResponse,
AtomicInteger requests
) throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/overworld.zip", exchange -> respond(exchange, response, requests));
server.createContext("/overworld.zip", exchange -> respond(exchange, overworldResponse, requests));
server.createContext("/underworld.zip", exchange -> respond(exchange, underworldResponse, requests));
server.start();
return server;
}
@@ -428,6 +631,33 @@ public class DefaultPackBootstrapProvisionerTest {
return zip(files);
}
private static byte[] underworldArchive(String biomeId) throws IOException {
LinkedHashMap<String, String> files = new LinkedHashMap<>();
files.put("dimensions/underworld.json", dimensionJson("underworld"));
files.put("dimensions/underworld_roof.json", dimensionJson("underworld_roof"));
files.put("regions/local.json", "{\"name\":\"Local\",\"landBiomes\":[\"local\"]}");
files.put("biomes/local.json", biomeJson(biomeId));
return zip(files);
}
private static Properties loadProperties(Path path) throws IOException {
Properties properties = new Properties();
try (InputStream input = Files.newInputStream(path)) {
properties.load(input);
}
return properties;
}
private static void assertNoBootstrapTransactionPaths(Path root) throws IOException {
if (!Files.isDirectory(root)) {
return;
}
try (Stream<Path> stream = Files.list(root)) {
assertFalse(stream.anyMatch(path -> path.getFileName().toString().contains("-stage-")
|| path.getFileName().toString().contains("-backup-")));
}
}
private static void writePack(Path root, String dimensionKey, String biomeId) throws IOException {
Files.createDirectories(root.resolve("dimensions"));
Files.createDirectories(root.resolve("regions"));
@@ -462,8 +692,8 @@ public class DefaultPackBootstrapProvisionerTest {
if (!Files.exists(root)) {
return;
}
try (java.util.stream.Stream<Path> stream = Files.walk(root)) {
for (Path path : stream.sorted(java.util.Comparator.reverseOrder()).toList()) {
try (Stream<Path> stream = Files.walk(root)) {
for (Path path : stream.sorted(Comparator.reverseOrder()).toList()) {
Files.deleteIfExists(path);
}
}
@@ -49,6 +49,7 @@ import java.util.zip.ZipOutputStream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
@@ -95,6 +96,60 @@ public class PackDownloaderTest {
PackDownloader.defaultOverworldReleaseUrl()
);
assertTrue(PackDownloader.isDefaultOverworld("overworld"));
assertTrue(PackDownloader.isManagedBetaPack("overworld"));
assertEquals(List.of("overworld", "underworld"), PackDownloader.managedBetaPacks());
}
@Test
public void resolvesUnderworldBetaRelease() {
assertEquals(
"https://github.com/IrisDimensions/underworld/releases/download/beta/underworld.zip",
PackDownloader.underworldReleaseUrl()
);
assertTrue(PackDownloader.isManagedBetaPack("underworld"));
}
@Test
public void managedPackPresenceRequiresItsPrimaryDimension() throws Exception {
File packsFolder = temp.newFolder("managed-presence");
Path dimensions = Files.createDirectories(
packsFolder.toPath().resolve("underworld/dimensions")
);
writeDimension(packsFolder.toPath().resolve("underworld"), "underworld_roof");
assertTrue(PackDownloader.isPackPresent(packsFolder, "underworld"));
assertFalse(PackDownloader.isManagedBetaPackPresent(packsFolder, "underworld"));
writeDimension(packsFolder.toPath().resolve("underworld"), "underworld");
assertTrue(Files.isDirectory(dimensions));
assertTrue(PackDownloader.isManagedBetaPackPresent(packsFolder, "underworld"));
}
@Test
public void repairsManagedFolderMissingItsPrimaryDimension() throws Exception {
File packsFolder = temp.newFolder("managed-repair-packs");
Path target = packsFolder.toPath().resolve("underworld");
Files.createDirectories(target.resolve("dimensions"));
writeDimension(target, "underworld_roof");
Files.writeString(target.resolve("partial.txt"), "partial", StandardCharsets.UTF_8);
File extracted = writePack(temp.newFolder("managed-repair-source").toPath(), "underworld", "new");
writeDimension(extracted.toPath(), "underworld_roof");
PackDownloader.PackInstallResult result = PackDownloader.installExtractedPack(
packsFolder,
extracted,
false,
"underworld",
ignored -> {
}
);
assertNotNull(result);
assertTrue(result.changed());
assertTrue(Files.isRegularFile(target.resolve("dimensions/underworld.json")));
assertTrue(Files.isRegularFile(target.resolve("dimensions/underworld_roof.json")));
assertFalse(Files.exists(target.resolve("partial.txt")));
assertTransactionStateClean(packsFolder);
}
@Test
@@ -102,6 +157,9 @@ public class PackDownloaderTest {
assertFalse(PackDownloader.isDefaultOverworld("theend"));
assertFalse(PackDownloader.isDefaultOverworld(""));
assertFalse(PackDownloader.isDefaultOverworld(null));
assertFalse(PackDownloader.isManagedBetaPack("theend"));
assertFalse(PackDownloader.isManagedBetaPack(""));
assertFalse(PackDownloader.isManagedBetaPack(null));
}
@Test
@@ -345,6 +403,71 @@ public class PackDownloaderTest {
assertEquals(0, PackDownloader.downloadLockCount());
}
@Test
public void importsExpectedDimensionFromMultiDimensionPack() throws Exception {
File packsFolder = temp.newFolder("multi-dimension-packs");
File extracted = writePack(temp.newFolder("multi-dimension-source").toPath(), "underworld", "new");
writeDimension(extracted.toPath(), "underworld_roof");
PackDownloader.PackInstallResult result = PackDownloader.installExtractedPack(
packsFolder,
extracted,
false,
"underworld",
ignored -> {
}
);
assertEquals("underworld", result.key());
assertTrue(result.changed());
assertTrue(Files.isRegularFile(
packsFolder.toPath().resolve("underworld/dimensions/underworld_roof.json")
));
assertTransactionStateClean(packsFolder);
}
@Test
public void rejectsMultiDimensionPackWithoutExpectedDimension() throws Exception {
File packsFolder = temp.newFolder("missing-dimension-packs");
File extracted = writePack(temp.newFolder("missing-dimension-source").toPath(), "underworld", "new");
writeDimension(extracted.toPath(), "underworld_roof");
IOException failure = assertThrows(IOException.class, () -> PackDownloader.installExtractedPack(
packsFolder,
extracted,
false,
"missing",
ignored -> {
}
));
assertTrue(failure.getMessage().contains("missing"));
assertTrue(failure.getMessage().contains("underworld"));
assertFalse(new File(packsFolder, "missing").exists());
assertTransactionStateClean(packsFolder);
}
@Test
public void rejectsAmbiguousMultiDimensionPackWithoutExpectedKey() throws Exception {
File packsFolder = temp.newFolder("ambiguous-dimension-packs");
File extracted = writePack(temp.newFolder("ambiguous-dimension-source").toPath(), "underworld", "new");
writeDimension(extracted.toPath(), "underworld_roof");
List<String> feedback = new ArrayList<>();
PackDownloader.PackInstallResult result = PackDownloader.installExtractedPack(
packsFolder,
extracted,
false,
null,
feedback::add
);
assertNull(result);
assertFalse(feedback.isEmpty());
assertFalse(new File(packsFolder, "underworld").exists());
assertTransactionStateClean(packsFolder);
}
@Test
public void concurrentImportsForSameKeyPublishOnlyOnePack() throws Exception {
File packsFolder = temp.newFolder("concurrent-packs");
@@ -465,12 +588,7 @@ public class PackDownloaderTest {
Files.createDirectories(root.resolve("dimensions"));
Files.createDirectories(root.resolve("regions"));
Files.createDirectories(root.resolve("biomes"));
Files.writeString(
root.resolve("dimensions/" + key + ".json"),
"{\"name\":\"" + key + "\",\"regions\":[\"local\"],\"logicalHeight\":256,"
+ "\"dimensionHeight\":{\"min\":-64,\"max\":320}}",
StandardCharsets.UTF_8
);
writeDimension(root, key);
Files.writeString(
root.resolve("regions/local.json"),
"{\"name\":\"Local\",\"landBiomes\":[\"local\"]}",
@@ -485,6 +603,15 @@ public class PackDownloaderTest {
return root.toFile();
}
private static void writeDimension(Path root, String key) throws IOException {
Files.writeString(
root.resolve("dimensions/" + key + ".json"),
"{\"name\":\"" + key + "\",\"regions\":[\"local\"],\"logicalHeight\":256,"
+ "\"dimensionHeight\":{\"min\":-64,\"max\":320}}",
StandardCharsets.UTF_8
);
}
private static void writeArchive(Path archive, Map<String, String> entries) throws IOException {
try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(archive))) {
for (Map.Entry<String, String> entry : entries.entrySet()) {
@@ -0,0 +1,78 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.service;
import org.junit.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Stream;
import static org.junit.Assert.assertEquals;
public class StudioSVCManagedBetaPackTest {
@Test
public void startupSelectsOnlyMissingManagedBetaPacks() throws IOException {
Path workspace = Files.createTempDirectory("iris-managed-beta-startup");
try {
assertEquals(
List.of("overworld", "underworld"),
StudioSVC.missingManagedBetaPacks(workspace.toFile())
);
createPack(workspace, "overworld");
assertEquals(
List.of("underworld"),
StudioSVC.missingManagedBetaPacks(workspace.toFile())
);
createDimension(workspace, "underworld", "underworld_roof");
assertEquals(
List.of("underworld"),
StudioSVC.missingManagedBetaPacks(workspace.toFile())
);
createPack(workspace, "underworld");
assertEquals(List.of(), StudioSVC.missingManagedBetaPacks(workspace.toFile()));
} finally {
deleteTree(workspace);
}
}
private static void createPack(Path workspace, String key) throws IOException {
createDimension(workspace, key, key);
}
private static void createDimension(Path workspace, String folder, String key) throws IOException {
Path dimensions = Files.createDirectories(workspace.resolve(folder).resolve("dimensions"));
Files.writeString(dimensions.resolve(key + ".json"), "{}", StandardCharsets.UTF_8);
}
private static void deleteTree(Path root) throws IOException {
try (Stream<Path> paths = Files.walk(root)) {
for (Path path : paths.sorted(Comparator.comparingInt(Path::getNameCount).reversed()).toList()) {
Files.deleteIfExists(path);
}
}
}
}