This commit is contained in:
Brian Neumann-Fopiano
2026-08-11 20:41:41 -04:00
parent 623a02025c
commit d9860782ef
4 changed files with 646 additions and 22 deletions
@@ -6,8 +6,12 @@ import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.GeneratorReplacement; import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.GeneratorReplacement;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot; import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot;
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator; import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.core.lifecycle.WorldReplacementBootstrap;
import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem; import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem;
import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem.ReplacementPaths; import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem.ReplacementPaths;
import art.arcane.iris.core.lifecycle.WorldReplacementJournal;
import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Phase;
import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Transaction;
import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.service.StudioSVC; import art.arcane.iris.core.service.StudioSVC;
import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.core.tools.IrisToolbelt;
@@ -25,25 +29,17 @@ import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener; import org.bukkit.event.Listener;
import org.bukkit.event.world.WorldLoadEvent; import org.bukkit.event.world.WorldLoadEvent;
import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.LinkOption; import java.nio.file.LinkOption;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import java.util.Properties; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
@@ -51,10 +47,8 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
public final class PendingWorldReplacementManager implements Listener { public final class PendingWorldReplacementManager implements Listener {
private static final String JOURNAL_DIRECTORY = "pending-world-replacements";
private static final String JOURNAL_SUFFIX = ".properties";
private final Iris plugin; private final Iris plugin;
private final Set<UUID> cleanupInFlight = new HashSet<>();
public PendingWorldReplacementManager(Iris plugin) { public PendingWorldReplacementManager(Iris plugin) {
this.plugin = Objects.requireNonNull(plugin, "plugin"); this.plugin = Objects.requireNonNull(plugin, "plugin");
@@ -89,13 +83,6 @@ public final class PendingWorldReplacementManager implements Listener {
IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension"); IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension");
IrisStartupValidation.requireWorldCreationReady(); IrisStartupValidation.requireWorldCreationReady();
PackValidationRegistry.requireLoadable(requiredDimension.getLoader().getDataFolder().getName()); PackValidationRegistry.requireLoadable(requiredDimension.getLoader().getDataFolder().getName());
ExactWorldSlotPathPolicy.Target resolvedTarget = ExactWorldSlotPathPolicy.resolve(
IrisWorldStorage.levelRoot().toPath(),
requiredWorldKey
);
requireCompatibleEnvironment(resolvedTarget.slotKind(), requiredDimension.getEnvironment());
long effectiveSeed = resolveEffectiveSeed(resolvedTarget.slotKind(), seed);
String worldName = IrisWorldStorage.logicalName(requiredWorldKey);
LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get(); LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get();
try (LifecycleOperationCoordinator.Lease ignored = coordinator.acquire( try (LifecycleOperationCoordinator.Lease ignored = coordinator.acquire(
LifecycleOperationCoordinator.Domain.WORLD_MUTATION, LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
@@ -106,13 +93,16 @@ public final class PendingWorldReplacementManager implements Listener {
throw new IOException("A replacement is already pending for " + requiredWorldKey + "."); throw new IOException("A replacement is already pending for " + requiredWorldKey + ".");
} }
ExactWorldSlotPathPolicy.Target target = prepareTarget(requiredWorldKey); ExactWorldSlotPathPolicy.Target target = prepareTarget(requiredWorldKey);
requireCompatibleEnvironment(target.slotKind(), requiredDimension.getEnvironment());
long effectiveSeed = resolveEffectiveSeed(target.slotKind(), seed);
String worldName = WorldReplacementJournal.logicalWorldName(target.levelRoot(), requiredWorldKey);
DatapackInstallResult datapacks = ServerConfigurator.installDataPacksIfChanged(true); DatapackInstallResult datapacks = ServerConfigurator.installDataPacksIfChanged(true);
if (!datapacks.succeeded()) { if (!datapacks.succeeded()) {
throw new IOException("Iris could not compile the dimension datapacks."); throw new IOException("Iris could not compile the dimension datapacks.");
} }
UUID transactionId = UUID.randomUUID(); UUID transactionId = UUID.randomUUID();
ReplacementPaths paths = replacementPaths(target, transactionId); ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transactionId);
boolean targetPresent = Files.exists(paths.target(), LinkOption.NOFOLLOW_LINKS); boolean targetPresent = Files.exists(paths.target(), LinkOption.NOFOLLOW_LINKS);
WorldGeneratorSnapshot originalConfiguration = BukkitWorldConfiguration.snapshot( WorldGeneratorSnapshot originalConfiguration = BukkitWorldConfiguration.snapshot(
ServerProperties.BUKKIT_YML, ServerProperties.BUKKIT_YML,
@@ -131,12 +121,15 @@ public final class PendingWorldReplacementManager implements Listener {
if (installed == null) { if (installed == null) {
throw new IOException("Iris could not stage the dimension pack."); throw new IOException("Iris could not stage the dimension pack.");
} }
requireCompatibleEnvironment(target.slotKind(), installed.getEnvironment());
File stagedPack = paths.stage().resolve("iris/pack").toFile(); File stagedPack = paths.stage().resolve("iris/pack").toFile();
IrisWorldGeneratorResolver.requireSnapshotLoadable(stagedPack); IrisWorldGeneratorResolver.requireSnapshotLoadable(stagedPack);
String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath()); String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath());
transaction = new Transaction( transaction = new Transaction(
transactionId, transactionId,
requiredWorldKey, requiredWorldKey,
worldName,
target.levelRoot(),
installed.getLoadKey(), installed.getLoadKey(),
effectiveSeed, effectiveSeed,
packFingerprint, packFingerprint,
@@ -0,0 +1,223 @@
package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.ExactWorldSlotPathPolicy;
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 java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
public final class WorldReplacementBootstrap {
private WorldReplacementBootstrap() {
}
public static ReconcileResult reconcile(
Path dataDirectory,
Path levelRoot,
Path bukkitConfiguration,
Consumer<String> feedback
) throws IOException {
Path requiredDataDirectory = Objects.requireNonNull(dataDirectory, "dataDirectory")
.toAbsolutePath()
.normalize();
Path requiredLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot")
.toAbsolutePath()
.normalize();
File requiredBukkitConfiguration = Objects.requireNonNull(bukkitConfiguration, "bukkitConfiguration")
.toAbsolutePath()
.normalize()
.toFile();
Consumer<String> requiredFeedback = Objects.requireNonNull(feedback, "feedback");
List<Transaction> transactions = WorldReplacementJournal.load(requiredDataDirectory, requiredLevelRoot);
int published = 0;
int rolledBack = 0;
int retained = 0;
for (Transaction transaction : transactions) {
ReconcileAction action = reconcileTransaction(
requiredDataDirectory,
requiredLevelRoot,
requiredBukkitConfiguration,
transaction,
requiredFeedback
);
switch (action) {
case PUBLISHED -> published++;
case ROLLED_BACK -> rolledBack++;
case RETAINED -> retained++;
}
}
return new ReconcileResult(transactions.size(), published, rolledBack, retained);
}
public static WorldGeneratorSnapshot replacementSnapshot(Transaction transaction) {
Transaction requiredTransaction = Objects.requireNonNull(transaction, "transaction");
return new WorldGeneratorSnapshot(
true,
true,
true,
"Iris:" + requiredTransaction.dimension(),
true,
requiredTransaction.seed()
);
}
private static ReconcileAction reconcileTransaction(
Path dataDirectory,
Path levelRoot,
File bukkitConfiguration,
Transaction transaction,
Consumer<String> feedback
) throws IOException {
ExactWorldSlotPathPolicy.Target target = WorldReplacementJournal.resolveTarget(transaction, levelRoot);
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transaction.id());
WorldGeneratorSnapshot current = BukkitWorldConfiguration.snapshot(
bukkitConfiguration,
transaction.worldName()
);
WorldGeneratorSnapshot replacement = replacementSnapshot(transaction);
if (transaction.phase() == Phase.CLEANUP_PENDING) {
if (!current.matchesGeneratorAndSeed(replacement)) {
throw conflict(transaction, "A verified replacement no longer matches bukkit.yml.");
}
WorldReplacementFilesystem.validateCommittedTarget(paths, transaction.packFingerprint());
return ReconcileAction.RETAINED;
}
if (transaction.phase() == Phase.ROLLBACK_CLEANUP) {
if (!current.matchesGeneratorAndSeed(transaction.originalConfiguration())) {
throw conflict(transaction, "Rollback cleanup no longer matches the retained bukkit.yml state.");
}
WorldReplacementFilesystem.finishPreparedRollback(paths, transaction.originalTargetPresent());
WorldReplacementJournal.delete(dataDirectory, transaction.id());
feedback.accept("Restored the retained world for " + transaction.worldKey() + ".");
return ReconcileAction.ROLLED_BACK;
}
if (transaction.phase() == Phase.ROLLBACK_PENDING) {
rollback(dataDirectory, bukkitConfiguration, transaction, paths, current, replacement);
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) {
active = active.withPhase(Phase.ARMED);
WorldReplacementJournal.write(dataDirectory, active);
}
if (active.phase() == Phase.ARMED) {
WorldReplacementFilesystem.publish(
paths,
active.originalTargetPresent(),
active.packFingerprint()
);
active = active.withPhase(Phase.PUBLISHED);
WorldReplacementJournal.write(dataDirectory, active);
feedback.accept("Published Iris world replacement for " + active.worldKey()
+ "; waiting for runtime verification.");
return ReconcileAction.PUBLISHED;
}
if (active.phase() == Phase.PUBLISHED) {
WorldReplacementFilesystem.publish(
paths,
active.originalTargetPresent(),
active.packFingerprint()
);
return ReconcileAction.RETAINED;
}
throw conflict(active, "Replacement journal reached an unsupported bootstrap phase.");
}
private static void rollback(
Path dataDirectory,
File bukkitConfiguration,
Transaction transaction,
ReplacementPaths paths,
WorldGeneratorSnapshot current,
WorldGeneratorSnapshot replacement
) throws IOException {
if (!current.matchesGeneratorAndSeed(replacement)
&& !current.matchesGeneratorAndSeed(transaction.originalConfiguration())) {
throw conflict(transaction, "bukkit.yml conflicts with the pending world rollback.");
}
Transaction rollback = transaction.phase() == Phase.ROLLBACK_PENDING
? transaction
: transaction.withPhase(Phase.ROLLBACK_PENDING);
if (rollback != transaction) {
WorldReplacementJournal.write(dataDirectory, rollback);
}
WorldReplacementFilesystem.prepareRollback(paths, rollback.originalTargetPresent());
if (current.matchesGeneratorAndSeed(replacement)) {
boolean restored;
try {
restored = BukkitWorldConfiguration.restoreIfMatching(
bukkitConfiguration,
rollback.worldName(),
replacement,
rollback.originalConfiguration()
);
} catch (IOException failure) {
republishAfterConfigurationFailure(rollback, paths, failure);
throw failure;
}
if (!restored) {
WorldGeneratorSnapshot observed = BukkitWorldConfiguration.snapshot(
bukkitConfiguration,
rollback.worldName()
);
if (!observed.matchesGeneratorAndSeed(rollback.originalConfiguration())) {
IOException failure = conflict(
rollback,
"bukkit.yml changed during startup rollback."
);
republishAfterConfigurationFailure(rollback, paths, failure);
throw failure;
}
}
}
Transaction cleanup = rollback.withPhase(Phase.ROLLBACK_CLEANUP);
WorldReplacementJournal.write(dataDirectory, cleanup);
WorldReplacementFilesystem.finishPreparedRollback(paths, cleanup.originalTargetPresent());
WorldReplacementJournal.delete(dataDirectory, cleanup.id());
}
private static void republishAfterConfigurationFailure(
Transaction transaction,
ReplacementPaths paths,
IOException failure
) {
try {
WorldReplacementFilesystem.publish(
paths,
transaction.originalTargetPresent(),
transaction.packFingerprint()
);
} catch (Throwable republishFailure) {
failure.addSuppressed(republishFailure);
}
}
private static IOException conflict(Transaction transaction, String detail) {
return new IOException("Pending replacement for " + transaction.worldKey() + " is blocked: " + detail);
}
public record ReconcileResult(int transactions, int published, int rolledBack, int retained) {
}
private enum ReconcileAction {
PUBLISHED,
ROLLED_BACK,
RETAINED
}
}
@@ -1,6 +1,7 @@
package art.arcane.iris.core.lifecycle; package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.SnapshotDirectoryTreeDeleter; import art.arcane.iris.core.SnapshotDirectoryTreeDeleter;
import art.arcane.iris.core.ExactWorldSlotPathPolicy;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
@@ -20,6 +21,7 @@ import java.util.Comparator;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.UUID;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Stream; import java.util.stream.Stream;
@@ -32,6 +34,17 @@ public final class WorldReplacementFilesystem {
private WorldReplacementFilesystem() { private 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;
return new ReplacementPaths(
requiredTarget.worldDirectory(),
requiredTarget.namespaceRoot().resolve(artifactBase + ".stage"),
requiredTarget.namespaceRoot().resolve(artifactBase + ".backup")
);
}
public static void publish( public static void publish(
ReplacementPaths paths, ReplacementPaths paths,
boolean originalTargetPresent, boolean originalTargetPresent,
@@ -94,7 +107,7 @@ public final class WorldReplacementFilesystem {
move(requiredPaths.backup(), requiredPaths.target()); move(requiredPaths.backup(), requiredPaths.target());
state = inspect(requiredPaths); state = inspect(requiredPaths);
} }
if (!state.targetPresent() || state.backupPresent()) { if (!state.targetPresent() || !state.stagePresent() || state.backupPresent()) {
throw new IOException("Rollback could not restore the original world target."); throw new IOException("Rollback could not restore the original world target.");
} }
} else { } else {
@@ -111,9 +124,25 @@ public final class WorldReplacementFilesystem {
if (state.targetPresent()) { if (state.targetPresent()) {
throw new IOException("Rollback could not remove the replacement target."); throw new IOException("Rollback could not remove the replacement target.");
} }
if (!state.stagePresent()) {
throw new IOException("Rollback lost the quarantined replacement target.");
}
} }
} }
public static void finishPreparedRollback(ReplacementPaths paths, boolean originalTargetPresent)
throws IOException {
ReplacementPaths requiredPaths = Objects.requireNonNull(paths, "paths");
State state = inspect(requiredPaths);
if (state.backupPresent()) {
throw new IOException("Rollback cleanup cannot continue while a retained backup is still published.");
}
if (originalTargetPresent != state.targetPresent()) {
throw new IOException("Rollback cleanup target state does not match the retained world state.");
}
discardStage(requiredPaths);
}
public static void discardStage(ReplacementPaths paths) throws IOException { public static void discardStage(ReplacementPaths paths) throws IOException {
ReplacementPaths requiredPaths = Objects.requireNonNull(paths, "paths"); ReplacementPaths requiredPaths = Objects.requireNonNull(paths, "paths");
State state = inspect(requiredPaths); State state = inspect(requiredPaths);
@@ -136,6 +165,18 @@ public final class WorldReplacementFilesystem {
} }
} }
public static void validateCommittedTarget(ReplacementPaths paths, String expectedPackFingerprint)
throws IOException {
ReplacementPaths requiredPaths = Objects.requireNonNull(paths, "paths");
String expectedFingerprint = requireFingerprint(expectedPackFingerprint);
State state = inspect(requiredPaths);
if (!state.targetPresent() || state.stagePresent()) {
throw new IOException("Committed replacement storage does not contain one exact target directory.");
}
requireSafeTree(requiredPaths.target(), "replacement target");
requireFingerprint(requiredPaths.target().resolve("iris/pack"), expectedFingerprint);
}
public static String fingerprintPack(Path packRoot) throws IOException { public static String fingerprintPack(Path packRoot) throws IOException {
Path root = Objects.requireNonNull(packRoot, "packRoot").toAbsolutePath().normalize(); Path root = Objects.requireNonNull(packRoot, "packRoot").toAbsolutePath().normalize();
requireDirectory(root, "pack root"); requireDirectory(root, "pack root");
@@ -0,0 +1,367 @@
package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.ExactWorldSlotPathPolicy;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot;
import org.bukkit.NamespacedKey;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.DirectoryStream;
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.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Properties;
import java.util.UUID;
public final class WorldReplacementJournal {
public static final String DIRECTORY_NAME = "pending-world-replacements";
private static final String JOURNAL_SUFFIX = ".properties";
private WorldReplacementJournal() {
}
public static List<Transaction> load(Path dataDirectory, Path currentLevelRoot) throws IOException {
Path directory = directory(dataDirectory, false);
if (directory == null) {
return List.of();
}
ArrayList<Transaction> transactions = new ArrayList<>();
try (DirectoryStream<Path> files = Files.newDirectoryStream(directory, "*" + JOURNAL_SUFFIX)) {
for (Path file : files) {
if (Files.isSymbolicLink(file) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Replacement journal entry is unsafe: " + file);
}
transactions.add(read(file, currentLevelRoot));
}
}
transactions.sort(Comparator.comparing(transaction -> transaction.id().toString()));
return List.copyOf(transactions);
}
public static void write(Path dataDirectory, Transaction transaction) throws IOException {
Transaction requiredTransaction = Objects.requireNonNull(transaction, "transaction");
Path directory = Objects.requireNonNull(directory(dataDirectory, true));
Path target = directory.resolve(requiredTransaction.id() + JOURNAL_SUFFIX);
Properties properties = new Properties();
properties.setProperty("id", requiredTransaction.id().toString());
properties.setProperty("worldKey", requiredTransaction.worldKey().toString());
properties.setProperty("worldName", requiredTransaction.worldName());
properties.setProperty("levelRoot", requiredTransaction.levelRoot().toString());
properties.setProperty("dimension", requiredTransaction.dimension());
properties.setProperty("seed", Long.toString(requiredTransaction.seed()));
properties.setProperty("packFingerprint", requiredTransaction.packFingerprint());
properties.setProperty(
"originalTargetPresent",
Boolean.toString(requiredTransaction.originalTargetPresent())
);
properties.setProperty("phase", requiredTransaction.phase().name());
writeSnapshot(properties, "original.", requiredTransaction.originalConfiguration());
ByteArrayOutputStream output = new ByteArrayOutputStream();
properties.store(output, null);
writeAtomic(target, output.toByteArray());
}
public static void delete(Path dataDirectory, UUID id) throws IOException {
Path directory = directory(dataDirectory, false);
if (directory == null) {
return;
}
Files.deleteIfExists(directory.resolve(Objects.requireNonNull(id, "id") + JOURNAL_SUFFIX));
forceDirectory(directory);
}
public static ExactWorldSlotPathPolicy.Target resolveTarget(Transaction transaction, Path currentLevelRoot)
throws IOException {
Transaction requiredTransaction = Objects.requireNonNull(transaction, "transaction");
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.resolve(
currentLevelRoot,
requiredTransaction.worldKey()
);
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());
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) {
Path requiredLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize();
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
if ("iris".equals(requiredWorldKey.getNamespace())) {
return requiredWorldKey.getKey();
}
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)) {
return levelName;
}
if (NamespacedKey.minecraft("the_nether").equals(requiredWorldKey)) {
return levelName + "_nether";
}
if (NamespacedKey.minecraft("the_end").equals(requiredWorldKey)) {
return levelName + "_the_end";
}
throw new IllegalArgumentException("World key is not an exact replaceable world slot: " + requiredWorldKey);
}
private static Transaction read(Path file, Path currentLevelRoot) throws IOException {
Properties properties = new Properties();
try (InputStream input = Files.newInputStream(file)) {
properties.load(input);
}
UUID id;
try {
id = UUID.fromString(required(properties, "id"));
} catch (IllegalArgumentException failure) {
throw new IOException("Replacement journal contains an invalid transaction id.", failure);
}
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.");
}
String worldName = exact(properties, "worldName");
Path recordedLevelRoot = recordedLevelRoot(properties);
String dimension = required(properties, "dimension");
if (!safeDimension(dimension)) {
throw new IOException("Replacement journal contains an invalid dimension key.");
}
long seed = parseLong(properties, "seed");
String packFingerprint = required(properties, "packFingerprint");
if (!packFingerprint.matches("[0-9a-f]{64}")) {
throw new IOException("Replacement journal contains an invalid pack fingerprint.");
}
WorldGeneratorSnapshot original = readSnapshot(properties, "original.");
boolean originalTargetPresent = parseBoolean(properties, "originalTargetPresent");
Phase phase;
try {
phase = Phase.valueOf(required(properties, "phase"));
} catch (IllegalArgumentException failure) {
throw new IOException("Replacement journal contains an invalid phase.", failure);
}
Transaction transaction = new Transaction(
id,
worldKey,
worldName,
recordedLevelRoot,
dimension,
seed,
packFingerprint,
original,
originalTargetPresent,
phase
);
resolveTarget(transaction, currentLevelRoot);
return transaction;
}
private static Path recordedLevelRoot(Properties properties) throws IOException {
String value = exact(properties, "levelRoot");
Path parsed;
try {
parsed = Path.of(value);
} catch (RuntimeException failure) {
throw new IOException("Replacement journal contains an invalid level root.", failure);
}
if (!parsed.isAbsolute() || !parsed.normalize().equals(parsed)) {
throw new IOException("Replacement journal level root must be an absolute normalized path.");
}
return parsed;
}
private static Path directory(Path dataDirectory, boolean create) throws IOException {
Path dataRoot = Objects.requireNonNull(dataDirectory, "dataDirectory").toAbsolutePath().normalize();
Path directory = dataRoot.resolve(DIRECTORY_NAME);
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
if (!create) {
return null;
}
Files.createDirectories(directory);
}
if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Replacement journal storage is unsafe: " + directory);
}
return directory;
}
private static void writeSnapshot(Properties properties, String prefix, WorldGeneratorSnapshot snapshot) {
properties.setProperty(prefix + "worldsSectionPresent", Boolean.toString(snapshot.worldsSectionPresent()));
properties.setProperty(prefix + "worldSectionPresent", Boolean.toString(snapshot.worldSectionPresent()));
properties.setProperty(prefix + "generatorPresent", Boolean.toString(snapshot.generatorPresent()));
if (snapshot.generatorPresent()) {
properties.setProperty(prefix + "generator", snapshot.generator());
}
properties.setProperty(prefix + "seedPresent", Boolean.toString(snapshot.seedPresent()));
if (snapshot.seedPresent()) {
properties.setProperty(prefix + "seed", Long.toString(snapshot.seed()));
}
}
private static WorldGeneratorSnapshot readSnapshot(Properties properties, String prefix) throws IOException {
boolean worldsPresent = parseBoolean(properties, prefix + "worldsSectionPresent");
boolean worldPresent = parseBoolean(properties, prefix + "worldSectionPresent");
boolean generatorPresent = parseBoolean(properties, prefix + "generatorPresent");
String generator = generatorPresent ? exact(properties, prefix + "generator") : null;
boolean seedPresent = parseBoolean(properties, prefix + "seedPresent");
Long seed = seedPresent ? parseLong(properties, prefix + "seed") : null;
try {
return new WorldGeneratorSnapshot(
worldsPresent,
worldPresent,
generatorPresent,
generator,
seedPresent,
seed
);
} catch (IllegalArgumentException failure) {
throw new IOException("Replacement journal contains an invalid configuration snapshot.", failure);
}
}
private static boolean safeDimension(String value) {
if (value.isEmpty() || value.length() > 256 || value.startsWith(".") || value.contains("..")) {
return false;
}
String[] segments = value.split("/", -1);
if (segments.length > 16) {
return false;
}
for (String segment : segments) {
if (segment.isEmpty() || !segment.matches("[A-Za-z0-9_-]+")) {
return false;
}
}
return true;
}
private static String exact(Properties properties, String key) throws IOException {
String value = properties.getProperty(key);
if (value == null) {
throw new IOException("Replacement journal is missing " + key + ".");
}
return value;
}
private static String required(Properties properties, String key) throws IOException {
String value = exact(properties, key);
if (value.isBlank()) {
throw new IOException("Replacement journal is missing " + key + ".");
}
return value.trim();
}
private static boolean parseBoolean(Properties properties, String key) throws IOException {
String value = required(properties, key);
if (!"true".equals(value) && !"false".equals(value)) {
throw new IOException("Replacement journal contains an invalid boolean for " + key + ".");
}
return Boolean.parseBoolean(value);
}
private static long parseLong(Properties properties, String key) throws IOException {
try {
return Long.parseLong(required(properties, key));
} catch (NumberFormatException failure) {
throw new IOException("Replacement journal contains an invalid integer for " + key + ".", failure);
}
}
private static void writeAtomic(Path target, byte[] content) throws IOException {
Path parent = Objects.requireNonNull(target.getParent(), "journal parent");
Path temporary = parent.resolve("." + target.getFileName() + ".tmp-" + UUID.randomUUID());
try {
try (FileChannel channel = FileChannel.open(
temporary,
StandardOpenOption.CREATE_NEW,
StandardOpenOption.WRITE
)) {
ByteBuffer buffer = ByteBuffer.wrap(content);
while (buffer.hasRemaining()) {
channel.write(buffer);
}
channel.force(true);
}
try {
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException failure) {
Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
}
forceDirectory(parent);
} finally {
Files.deleteIfExists(temporary);
}
}
private static void forceDirectory(Path directory) throws IOException {
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
channel.force(true);
}
}
public record Transaction(
UUID id,
NamespacedKey worldKey,
String worldName,
Path levelRoot,
String dimension,
long seed,
String packFingerprint,
WorldGeneratorSnapshot originalConfiguration,
boolean originalTargetPresent,
Phase phase
) {
public Transaction {
Objects.requireNonNull(id, "id");
Objects.requireNonNull(worldKey, "worldKey");
Objects.requireNonNull(worldName, "worldName");
levelRoot = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize();
Objects.requireNonNull(dimension, "dimension");
Objects.requireNonNull(packFingerprint, "packFingerprint");
Objects.requireNonNull(originalConfiguration, "originalConfiguration");
Objects.requireNonNull(phase, "phase");
}
public Transaction withPhase(Phase nextPhase) {
return new Transaction(
id,
worldKey,
worldName,
levelRoot,
dimension,
seed,
packFingerprint,
originalConfiguration,
originalTargetPresent,
nextPhase
);
}
}
public enum Phase {
PREPARED,
ARMED,
PUBLISHED,
ROLLBACK_PENDING,
ROLLBACK_CLEANUP,
CLEANUP_PENDING
}
}