diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java index fa8979423..58fd1c181 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java @@ -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.WorldGeneratorSnapshot; 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.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.service.StudioSVC; 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.world.WorldLoadEvent; -import java.io.ByteArrayOutputStream; import java.io.File; 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.HashSet; import java.util.List; import java.util.Locale; import java.util.Objects; -import java.util.Properties; +import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; @@ -51,10 +47,8 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; 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 Set cleanupInFlight = new HashSet<>(); public PendingWorldReplacementManager(Iris plugin) { this.plugin = Objects.requireNonNull(plugin, "plugin"); @@ -89,13 +83,6 @@ public final class PendingWorldReplacementManager implements Listener { IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension"); IrisStartupValidation.requireWorldCreationReady(); 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(); try (LifecycleOperationCoordinator.Lease ignored = coordinator.acquire( LifecycleOperationCoordinator.Domain.WORLD_MUTATION, @@ -106,13 +93,16 @@ public final class PendingWorldReplacementManager implements Listener { throw new IOException("A replacement is already pending for " + 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); if (!datapacks.succeeded()) { throw new IOException("Iris could not compile the dimension datapacks."); } UUID transactionId = UUID.randomUUID(); - ReplacementPaths paths = replacementPaths(target, transactionId); + ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transactionId); boolean targetPresent = Files.exists(paths.target(), LinkOption.NOFOLLOW_LINKS); WorldGeneratorSnapshot originalConfiguration = BukkitWorldConfiguration.snapshot( ServerProperties.BUKKIT_YML, @@ -131,12 +121,15 @@ public final class PendingWorldReplacementManager implements Listener { if (installed == null) { throw new IOException("Iris could not stage the dimension pack."); } + requireCompatibleEnvironment(target.slotKind(), installed.getEnvironment()); File stagedPack = paths.stage().resolve("iris/pack").toFile(); IrisWorldGeneratorResolver.requireSnapshotLoadable(stagedPack); String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath()); transaction = new Transaction( transactionId, requiredWorldKey, + worldName, + target.levelRoot(), installed.getLoadKey(), effectiveSeed, packFingerprint, diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrap.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrap.java new file mode 100644 index 000000000..e68378d04 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrap.java @@ -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 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 requiredFeedback = Objects.requireNonNull(feedback, "feedback"); + List 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 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 + } +} diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystem.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystem.java index 65b7740d7..89df19451 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystem.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystem.java @@ -1,6 +1,7 @@ package art.arcane.iris.core.lifecycle; import art.arcane.iris.core.SnapshotDirectoryTreeDeleter; +import art.arcane.iris.core.ExactWorldSlotPathPolicy; import java.io.IOException; import java.io.InputStream; @@ -20,6 +21,7 @@ import java.util.Comparator; import java.util.HexFormat; import java.util.List; import java.util.Objects; +import java.util.UUID; import java.util.regex.Pattern; import java.util.stream.Stream; @@ -32,6 +34,17 @@ public final class 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( ReplacementPaths paths, boolean originalTargetPresent, @@ -94,7 +107,7 @@ public final class WorldReplacementFilesystem { move(requiredPaths.backup(), requiredPaths.target()); 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."); } } else { @@ -111,9 +124,25 @@ public final class WorldReplacementFilesystem { if (state.targetPresent()) { 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 { ReplacementPaths requiredPaths = Objects.requireNonNull(paths, "paths"); 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 { Path root = Objects.requireNonNull(packRoot, "packRoot").toAbsolutePath().normalize(); requireDirectory(root, "pack root"); diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementJournal.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementJournal.java new file mode 100644 index 000000000..b58ded276 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementJournal.java @@ -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 load(Path dataDirectory, Path currentLevelRoot) throws IOException { + Path directory = directory(dataDirectory, false); + if (directory == null) { + return List.of(); + } + ArrayList transactions = new ArrayList<>(); + try (DirectoryStream 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 + } +}