Merge master into 3D terrain

- Resolve IrisTerrainNormalActuator by keeping master's primitive riverWaterSurfaceStream lookup and primarySurfaceLayerOrNull accessors while retaining the terrain column, span, layer surface and ceiling locals that write air through terrain3D gaps
- Resolve HydrologyFootprintCompiler by keeping the MaterializedSurface planned-surface record with terrain ownership beside master's package-private SurfaceRasterIndex, which retains its ownsTerrain override
- Resolve HydrologyCaveContainmentPlanner by dropping the nested RecordingCaveVoxelView, SurfaceObservations and ViewObservations types that master promoted to their own files
- Port the surface-observation ownership check into CaveSurfaceObservations so cached cave validations replay both height and ownership observations through Observation.matches
- Resolve PackExportClosureTest by keeping master's retirement of the source-text packager guard while retaining the snippet export tests, and restore the Files and Path imports those tests use
- Resolve IrisBiomeLayerSlopeSourceTest by folding the lower-ledge slope palette case into master's lockLayers parameterization
- Recapture the studio schema digest for the terrain3D biome property, its IrisTerrain3D definition, the terrain-3d snippet enumeration and the reworded upperDimensionCarving description, verified by reconstructing the previous digest from the stripped schema
This commit is contained in:
Brian Neumann-Fopiano
2026-09-08 13:58:00 -04:00
520 changed files with 45623 additions and 40734 deletions
@@ -32,6 +32,7 @@ import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.LockSupport;
import java.util.function.IntConsumer;
import static org.junit.Assert.assertEquals;
@@ -218,7 +219,7 @@ public class NativeStructureVolumeIndexTest {
if (cleared) {
break;
}
Thread.sleep(1);
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(10L));
}
assertTrue("Origin eviction did not complete before the query cache lock", cleared);
resolver.release();
@@ -6,6 +6,7 @@ import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.spi.PlatformRegistries;
import art.arcane.iris.testsupport.PlatformLeakGuard;
import net.minecraft.SharedConstants;
import net.minecraft.core.Direction;
import net.minecraft.server.Bootstrap;
@@ -16,6 +17,7 @@ import net.minecraft.world.level.block.RotatedPillarBlock;
import net.minecraft.world.level.block.LiquidBlock;
import net.minecraft.world.level.levelgen.Heightmap;
import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.Test;
import org.mockito.MockedStatic;
@@ -30,6 +32,9 @@ import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public final class NativeTransitionColumnTest {
@Rule
public final PlatformLeakGuard leakGuard = PlatformLeakGuard.clean();
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
+1 -8
View File
@@ -22,6 +22,7 @@ dependencies {
testImplementation(libs.paper.api)
testImplementation(libs.bstats)
testImplementation(project(':core'))
testImplementation(testFixtures(project(':core')))
testImplementation(volmLibCoordinate) {
transitive = false
}
@@ -59,12 +60,4 @@ tasks.named('jar', Jar).configure {
}
tasks.named('test').configure {
systemProperty('iris.pregeneratorJobSource', rootProject.file('core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java').absolutePath)
systemProperty('iris.bukkitEnginePlatformHooksSource', file('src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java').absolutePath)
systemProperty('iris.engineSvcSource', file('src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java').absolutePath)
systemProperty('iris.terrainSvcSource', file('src/main/java/art/arcane/iris/core/service/IrisTerrainSVC.java').absolutePath)
systemProperty('iris.apiEventSvcSource', file('src/main/java/art/arcane/iris/core/service/IrisApiEventSVC.java').absolutePath)
systemProperty('iris.worldInfoFactorySource', file('src/main/java/art/arcane/iris/core/service/terrain/IrisWorldInfoFactory.java').absolutePath)
systemProperty('iris.startupSource', file('src/main/java/art/arcane/iris/Iris.java').absolutePath)
systemProperty('iris.readmeSource', rootProject.file('README.md').absolutePath)
}
@@ -39,6 +39,7 @@ import art.arcane.iris.core.SettingsHotloadWatch;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.datapack.DatapackIngestService;
import art.arcane.iris.core.datapack.DatapackIngestService.StartupValidationOutcome;
import art.arcane.iris.core.lifecycle.IrisRuntimeStatics;
import art.arcane.iris.core.lifecycle.ManagedWorldLoader;
import art.arcane.iris.core.lifecycle.MissingWorldStorageLog;
import art.arcane.iris.core.lifecycle.PaperLibBootstrap;
@@ -87,6 +88,7 @@ import art.arcane.iris.engine.framework.PreservationRegistry;
import art.arcane.iris.engine.framework.TreeBlockMaterial;
import art.arcane.iris.engine.object.IrisCompat;
import art.arcane.iris.core.safeguard.IrisSafeguard;
import art.arcane.iris.core.safeguard.RuntimeLockNotice;
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.spi.IrisLogging;
@@ -147,6 +149,7 @@ import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -159,6 +162,7 @@ import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@SuppressWarnings("CanBeFinal")
public class Iris extends VolmitPlugin implements Listener, ReloadAware {
@@ -215,16 +219,23 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private volatile ServerShutdownBoundary serverShutdownBoundary;
public static VolmitSender getSender() {
if (sender == null) {
sender = new VolmitSender(Bukkit.getConsoleSender());
sender.setTag(instance.getTag());
VolmitSender current = sender;
if (current == null) {
Iris plugin = instance;
current = new VolmitSender(Bukkit.getConsoleSender());
current.setTag(plugin == null ? IrisSafeguard.mode().tag("") : plugin.getTag());
sender = current;
}
return sender;
return current;
}
@SuppressWarnings("unchecked")
public static <T> T service(Class<T> c) {
return (T) instance.services.get(c);
Iris plugin = instance;
if (plugin == null || plugin.services == null) {
throw new IllegalStateException("Iris is disabled; " + c.getSimpleName() + " is unavailable");
}
return (T) plugin.services.get(c);
}
public static void callEvent(Event e) {
@@ -263,7 +274,11 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
public static File getTemp() {
return instance.getDataFolder("cache", "temp");
Iris plugin = instance;
if (plugin == null) {
throw new IllegalStateException("Iris is disabled; the temp folder is unavailable");
}
return plugin.getDataFolder("cache", "temp");
}
public static void msg(String string) {
@@ -305,10 +320,11 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
return;
}
StackWalker.StackFrame frame = null;
StackWalker.StackFrame frame;
try {
frame = DEBUG_STACK_WALKER.walk(stream -> stream.skip(1).findFirst().orElse(null));
} catch (Throwable ignored) {
} catch (Throwable unavailable) {
frame = null;
}
if (frame == null) {
@@ -444,7 +460,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
try {
IrisSettings currentSettings = IrisSettings.settings != null ? IrisSettings.settings : IrisSettings.get();
debug = currentSettings != null && currentSettings.getGeneral().isDebug();
} catch (Throwable ignored) {
} catch (Throwable unreadable) {
debug = false;
}
}
@@ -541,11 +557,11 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
Iris::msg,
Iris::reportError,
(event) -> Iris.callEvent((org.bukkit.event.Event) event),
() -> Iris.instance.getDataFolder(),
(path) -> Iris.instance.getDataFile(path),
() -> Iris.instance.getJarFile(),
() -> Iris.instance.getIrisVersion(),
() -> Iris.instance.getMCVersion()));
this::getDataFolder,
this::getDataFile,
this::getJarFile,
this::getIrisVersion,
this::getMCVersion));
SlimJar.load();
}
@@ -612,6 +628,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
J.s(() -> Bukkit.getPluginManager().disablePlugin(this), 1);
return false;
}
EnableTimings timings = new EnableTimings();
alreadyDrained.set(false);
postStopFinisherStarted.set(false);
serverStopTeardownDeferred.set(false);
@@ -625,12 +642,15 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
MultiBurst.burst.reopen();
MultiBurst.ioBurst.reopen();
IrisLanguage.initialize();
debugDump = BukkitDebugDump.create(this);
debugDump = BukkitDebugDump.create(this, new BukkitDebugDump.Options(
() -> true,
() -> IrisSafeguard::debugReport));
languageSwitcher = BukkitLanguageSwitcher.register(this, IrisLanguage.selections(),
new BukkitLanguageSwitcher.Options("iris", "iris.all",
DirectorMiniMenu.Theme.irisGreen(), IrisLanguage.directorResolver(), IrisLanguage.editorOptions()));
PaperLibBootstrap.install();
SimdSupport.install();
timings.mark("bootstrap");
services = new KMap<>();
BukkitPlatform.hostHud(new HudActionBar(this), new HudBossBarLane());
// Explicit, ordered service list: the previous reflective jar scan gave hash-ordered
@@ -669,9 +689,13 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
IrisServices.register(PreservationRegistry.class, services.get(PreservationSVC.class));
compat = IrisCompat.configured(getDataFile("compat.json"));
IrisServices.register(IrisCompat.class, compat);
timings.mark("services");
ServerConfigurator.configure();
timings.mark("serverConfig");
StartupValidationOutcome datapackValidation = DatapackIngestService.validateOnStartup();
timings.mark("datapacks");
IrisSafeguard.execute();
timings.mark("safeguard");
getSender().setTag(getTag());
// A cosmetic banner must never abort the bootstrap.
J.attempt(this::splash);
@@ -680,6 +704,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
// Paper's bootstrap runs before any plugin logger exists, so orphan-storage warnings raised there
// never reach logs/latest.log. Replay them once now that the platform log is up.
MissingWorldStorageLog.replayToPlatformLog();
timings.mark("splash");
tickets = new ChunkTickets();
linkMultiverseCore = new MultiverseCoreLink();
IrisServices.register(MultiverseCoreLink.class, linkMultiverseCore);
@@ -692,18 +717,21 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
IrisServices.register(ManagedWorldLoader.class, (ManagedWorldLoader) this::loadManagedWorld);
SettingsHotloadWatch watch = new SettingsHotloadWatch(getDataFile("iris.json"));
settingsHotloadWatch = watch;
// Stale-temp cleanup must complete before services enable: StudioSVC.onEnable downloads
// packs through cache/temp on an async thread, and a concurrent delete of that folder
// truncated pack imports mid-copy (partial packs/<key> without dimensions/).
IO.delete(getTemp());
StudioSVC.gateDownloadsOnStaleTempCleanup(MultiBurst.ioBurst.completeValueAsync(() -> {
IO.delete(getTemp());
return null;
}));
timings.mark("tempSweep");
// One throwing service must not abort the bootstrap: the steps after this loop
// (listeners, shutdown hook, replacement journals) are the safety-critical ones.
// Only services that actually enabled get listeners and a later onDisable.
enabledServices.clear();
for (IrisService service : orderedServices) {
long serviceStartedAt = System.nanoTime();
try {
service.onEnable();
enabledServices.add(service);
timings.markService(service.getClass().getSimpleName(), serviceStartedAt);
} catch (Throwable e) {
// A service failure is NOT a datapack validation failure: the admission gate
// must never lock every login over a broken cosmetic service. Log loudly,
@@ -724,10 +752,12 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
Iris.reportError("Failed to register listener for " + service.getClass().getSimpleName() + ".", e);
}
}
timings.mark("serviceEnable");
if (datapackValidation == StartupValidationOutcome.READY) {
IrisServices.get(ExternalDataSVC.class).setContentChangeListener(generatorResolver::requestExternalContentRefresh);
generatorResolver.validateAllPacks();
}
timings.mark("packValidation");
addShutdownHook();
pendingWorldReplacements.processPendingStartupReplacements();
pendingWorldDeletes.processPendingStartupWorldDeletes();
@@ -755,9 +785,46 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
// match a slice type; the block-state slice is deliberately never retainable (regenerable, huge).
IrisToolbelt.retainMantleDataForSlice(TreeBlockMaterial.class.getCanonicalName());
});
timings.mark("startupTasks");
timings.report();
return true;
}
private static final class EnableTimings {
private static final int REPORTED_SERVICES = 3;
private final long startedAt = System.nanoTime();
private final StringBuilder phases = new StringBuilder();
private final Map<String, Long> serviceMillis = new LinkedHashMap<>();
private long lastMark = startedAt;
private void mark(String phase) {
long now = System.nanoTime();
if (!phases.isEmpty()) {
phases.append(' ');
}
phases.append(phase).append('=').append(TimeUnit.NANOSECONDS.toMillis(now - lastMark)).append("ms");
lastMark = now;
}
private void markService(String service, long serviceStartedAt) {
serviceMillis.put(service, TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - serviceStartedAt));
}
private void report() {
IrisLogging.notice("Enabled in %dms (%s)",
TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt), phases);
String slowest = serviceMillis.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
.limit(REPORTED_SERVICES)
.map(entry -> entry.getKey() + "=" + entry.getValue() + "ms")
.collect(Collectors.joining(" "));
if (!slowest.isBlank()) {
IrisLogging.notice("Slowest services: %s", slowest);
}
}
}
private void reconcileStartupWorlds() {
generatorResolver.startupWorldsReady().whenComplete((ignored, failure) -> {
if (failure != null) {
@@ -866,6 +933,25 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
.orElse("Iris startup validation requires a restart.");
startupBoundaryRestart.set(true);
ServerConfigurator.restartAtStartupBoundary(restartReason);
return;
}
reportLockedRuntime();
}
private static void reportLockedRuntime() {
String denial = IrisStartupValidation.denialReason().orElse(null);
if (denial == null) {
return;
}
boolean managedStorage;
try {
managedStorage = IrisWorldStorage.hasManagedWorldStorage(IrisWorldStorage.levelRoot());
} catch (Throwable unavailable) {
Iris.reportError("Could not inspect Iris world storage while reporting the locked runtime.", unavailable);
managedStorage = false;
}
for (String line : RuntimeLockNotice.compose(denial, managedStorage)) {
Iris.error(line);
}
}
@@ -1027,10 +1113,19 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
Iris.reportError("Failed to close Iris I/O workers.", failure);
}
clearQueues();
IrisServices.clear();
IrisRuntimeStatics.reset();
releaseStatics();
}
}
private static void releaseStatics() {
linkMultiverseCore = null;
compat = null;
tickets = null;
sender = null;
instance = null;
}
private void quiesceRuntimeForServerShutdown(String reason) {
serverStopTeardownDeferred.set(true);
INMS.get().deferPluginClassLoaderClose();
@@ -1173,6 +1268,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
failure.printStackTrace(SHUTDOWN_ERRORS);
}
}
BukkitPlatform.releaseHost();
}
}
@@ -1367,7 +1463,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
public int getIrisVersion() {
String input = Iris.instance.getDescription().getVersion();
String input = getDescription().getVersion();
int hyphenIndex = input.indexOf('-');
if (hyphenIndex != -1) {
String result = input.substring(0, hyphenIndex);
@@ -30,6 +30,7 @@ import art.arcane.iris.core.pack.PackValidationCache;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.pack.PackValidationResult;
import art.arcane.iris.core.pack.PackValidator;
import art.arcane.iris.core.runtime.RuntimeInjection;
import art.arcane.iris.core.service.ExternalDataSVC;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
@@ -486,17 +487,20 @@ public final class IrisWorldGeneratorResolver {
Iris.debug("Generator discovery probe for loaded world " + worldName);
return IrisFailClosedChunkGenerator.discoveryProbe(worldName);
}
Optional<String> startupDenial = IrisStartupValidation.denialReason();
if (startupDenial.isPresent()) {
Iris.warn("Keeping configured Iris world '" + worldName
+ "' generation-locked: " + startupDenial.get());
return IrisFailClosedChunkGenerator.startupLock(worldName, startupDenial.get());
ChunkGenerator startupLock = startupLockedGenerator(worldName);
if (startupLock != null) {
return startupLock;
}
ChunkGenerator stagedGenerator = WorldLifecycleStaging.consumeGenerator(worldName);
if (stagedGenerator != null) {
Iris.debug("Using staged runtime generator for " + worldName);
return stagedGenerator;
}
RuntimeInjection.installIfDeferred();
startupLock = startupLockedGenerator(worldName);
if (startupLock != null) {
return startupLock;
}
Iris.debug("Default World Generator Called for " + worldName + " using ID: " + id);
if (id == null || id.isEmpty()) id = IrisSettings.get().getGenerator().getDefaultWorldType();
Iris.debug("Generator ID: " + id + " requested by bukkit/plugin");
@@ -516,6 +520,17 @@ public final class IrisWorldGeneratorResolver {
}
}
@Nullable
private static ChunkGenerator startupLockedGenerator(String worldName) {
Optional<String> startupDenial = IrisStartupValidation.denialReason();
if (startupDenial.isEmpty()) {
return null;
}
Iris.warn("Keeping configured Iris world '" + worldName
+ "' generation-locked: " + startupDenial.get());
return IrisFailClosedChunkGenerator.startupLock(worldName, startupDenial.get());
}
private static boolean isPlotSquaredGeneratorDiscoveryProbe(String worldName, String id) {
return PLOT_SQUARED_DISCOVERY_WORLD.equals(worldName) && id != null && id.isEmpty();
}
@@ -1104,7 +1104,8 @@ public final class PendingWorldReplacementManager implements Listener {
Iris.instance.getServer().getAllowEnd()
);
} catch (Throwable failure) {
Iris.debug("Could not capture vanilla-slot availability yet: " + detail(failure));
Iris.reportError("Could not capture vanilla-slot availability at startup;"
+ " world replacement staging will resolve it on demand.", failure);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,424 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.Iris;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.structure.authoring.StructureKey;
import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionDiagnostic;
import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionInputKind;
import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionPlan;
import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionRequest;
import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionResult;
import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionService;
import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionStrategy;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.util.common.director.DirectorExecutor;
import art.arcane.iris.util.common.format.C;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.director.DirectorOrigin;
import art.arcane.volmlib.util.director.DirectorParameterHandler;
import art.arcane.volmlib.util.director.annotations.Director;
import art.arcane.volmlib.util.director.annotations.Param;
import art.arcane.volmlib.util.director.exceptions.DirectorParsingException;
import org.bukkit.entity.Player;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Director(name = "adopt", description = "Inspect and apply safe ownership adoption plans",
origin = DirectorOrigin.PLAYER)
public class CommandJigsawAdopt implements DirectorExecutor {
private static final Map<UUID, AdoptionPlanBinding> ADOPTION_PLANS = new ConcurrentHashMap<>();
private static final Map<Path, IrisStructureAdoptionService> ADOPTION_SERVICES = new ConcurrentHashMap<>();
@Director(description = "Inspect an Iris graph for safe Studio ownership adoption", sync = true,
origin = DirectorOrigin.PLAYER)
public void inspect(
@Param(description = "Pack dimension") IrisDimension dimension,
@Param(description = "Existing Iris structure key") String source,
@Param(description = "New owned Iris key, or auto", defaultValue = "auto") String target,
@Param(description = "auto, in-place, or clone", defaultValue = "auto",
customHandler = JigsawAdoptionStrategyHandler.class) String strategy
) {
VolmitSender commandSender = sender();
IrisData data = CommandJigsaw.requireData(dimension, commandSender);
if (data == null) {
return;
}
String sourceKey;
Optional<StructureKey> targetKey;
IrisStructureAdoptionStrategy adoptionStrategy;
Path root;
try {
sourceKey = parseAdoptionSource(source);
targetKey = parseAdoptionTarget(target);
adoptionStrategy = parseAdoptionStrategy(strategy);
root = canonicalAdoptionRoot(data.getDataFolder().toPath());
} catch (IOException | IllegalArgumentException exception) {
CommandJigsaw.sendError(commandSender, "Could not inspect adoption source: " + exception.getMessage());
return;
}
Player targetPlayer = player();
UUID ownerId = targetPlayer.getUniqueId();
commandSender.sendMessage(C.GRAY + "Inspecting Jigsaw adoption source '" + sourceKey
+ "' asynchronously...");
try {
J.a(() -> runAdoptionInspect(
targetPlayer,
commandSender,
ownerId,
dimension,
data,
root,
sourceKey,
targetKey,
adoptionStrategy));
} catch (RuntimeException exception) {
Iris.reportError("Failed to schedule Jigsaw adoption inspection for '"
+ sourceKey + "'.", exception);
CommandJigsaw.sendError(commandSender, "Jigsaw adoption inspection could not be scheduled: "
+ exception.getMessage());
}
}
@Director(description = "Apply one inspected adoption plan without overwriting", sync = true,
origin = DirectorOrigin.PLAYER)
public void apply(
@Param(description = "Plan UUID returned by adopt inspect",
customHandler = JigsawAdoptionPlanHandler.class) String planId
) {
VolmitSender commandSender = sender();
UUID parsedPlanId;
try {
parsedPlanId = UUID.fromString(planId);
} catch (IllegalArgumentException exception) {
CommandJigsaw.sendError(commandSender, "Adoption plan ID must be a UUID.");
return;
}
if (CommandJigsaw.studioIsActiveOrOpening()) {
CommandJigsaw.sendError(commandSender,
"Close the active or opening Jigsaw Studio before applying an adoption plan.");
return;
}
pruneAdoptionPlanBindings();
AdoptionPlanBinding binding = ADOPTION_PLANS.get(parsedPlanId);
UUID playerId = player().getUniqueId();
if (binding == null || !binding.ownerId().equals(playerId)) {
CommandJigsaw.sendError(commandSender, "No active adoption plan '" + parsedPlanId
+ "' belongs to your player session. Run adopt inspect again.");
return;
}
if (binding.service().plan(parsedPlanId).isEmpty()) {
ADOPTION_PLANS.remove(parsedPlanId, binding);
CommandJigsaw.sendError(commandSender, "Adoption plan '" + parsedPlanId
+ "' expired or was already consumed. Run adopt inspect again.");
return;
}
if (!ADOPTION_PLANS.remove(parsedPlanId, binding)) {
CommandJigsaw.sendError(commandSender, "Adoption plan '" + parsedPlanId + "' is already being applied.");
return;
}
Player targetPlayer = player();
commandSender.sendMessage(C.GRAY + "Applying adoption plan '" + parsedPlanId
+ "' asynchronously without overwrite...");
try {
J.a(() -> runAdoptionApply(targetPlayer, commandSender, parsedPlanId, binding));
} catch (RuntimeException exception) {
ADOPTION_PLANS.putIfAbsent(parsedPlanId, binding);
Iris.reportError("Failed to schedule Jigsaw adoption plan '" + parsedPlanId + "'.", exception);
CommandJigsaw.sendError(commandSender, "Jigsaw adoption could not be scheduled: " + exception.getMessage());
}
}
private static void pruneAdoptionPlanBindings() {
for (Map.Entry<UUID, AdoptionPlanBinding> entry : ADOPTION_PLANS.entrySet()) {
AdoptionPlanBinding binding = entry.getValue();
if (binding.service().plan(entry.getKey()).isEmpty()) {
ADOPTION_PLANS.remove(entry.getKey(), binding);
}
}
}
private static void runAdoptionApply(
Player targetPlayer,
VolmitSender commandSender,
UUID planId,
AdoptionPlanBinding binding
) {
if (CommandJigsaw.studioIsActiveOrOpening()) {
ADOPTION_PLANS.putIfAbsent(planId, binding);
J.runEntity(targetPlayer, () -> CommandJigsaw.sendError(commandSender,
"Close the active or opening Jigsaw Studio before applying an adoption plan."));
return;
}
IrisStructureAdoptionResult result;
Throwable failure = null;
try {
result = binding.service().apply(binding.plan());
} catch (Throwable throwable) {
result = null;
failure = throwable;
Iris.reportError("Failed to apply Jigsaw adoption plan '" + planId + "'.", throwable);
}
IrisStructureAdoptionResult completedResult = result;
Throwable completedFailure = failure;
J.runEntity(targetPlayer, () -> finishAdoptionApply(
targetPlayer,
commandSender,
binding,
completedResult,
completedFailure));
}
private static void runAdoptionInspect(
Player targetPlayer,
VolmitSender commandSender,
UUID ownerId,
IrisDimension dimension,
IrisData data,
Path root,
String sourceKey,
Optional<StructureKey> targetKey,
IrisStructureAdoptionStrategy strategy
) {
IrisStructureAdoptionService service = null;
IrisStructureAdoptionPlan plan = null;
Throwable failure = null;
try {
IrisStructureAdoptionInputKind inputKind = adoptionInputKind(root, sourceKey);
service = ADOPTION_SERVICES.computeIfAbsent(root, IrisStructureAdoptionService::new);
plan = service.inspect(new IrisStructureAdoptionRequest(
sourceKey,
targetKey,
strategy,
inputKind));
} catch (Throwable throwable) {
failure = throwable;
Iris.reportError("Failed to inspect Jigsaw adoption source '" + sourceKey + "'.", throwable);
}
IrisStructureAdoptionService completedService = service;
IrisStructureAdoptionPlan completedPlan = plan;
Throwable completedFailure = failure;
J.runEntity(targetPlayer, () -> finishAdoptionInspect(
commandSender,
ownerId,
dimension,
data,
completedService,
completedPlan,
completedFailure));
}
private static void finishAdoptionInspect(
VolmitSender commandSender,
UUID ownerId,
IrisDimension dimension,
IrisData data,
IrisStructureAdoptionService service,
IrisStructureAdoptionPlan plan,
Throwable failure
) {
if (failure != null || service == null || plan == null) {
CommandJigsaw.sendError(commandSender, "Jigsaw adoption inspection failed: "
+ (failure == null ? "no result" : failure.getMessage()));
return;
}
pruneAdoptionPlanBindings();
if (service.plan(plan.planId()).isPresent()) {
ADOPTION_PLANS.put(plan.planId(), new AdoptionPlanBinding(
ownerId, dimension, data, service, plan));
}
reportAdoptionPlan(commandSender, plan);
}
private static void finishAdoptionApply(
Player targetPlayer,
VolmitSender commandSender,
AdoptionPlanBinding binding,
IrisStructureAdoptionResult result,
Throwable failure
) {
if (failure != null || result == null) {
CommandJigsaw.sendError(commandSender, "Jigsaw adoption failed: "
+ (failure == null ? "no result" : failure.getMessage()));
return;
}
reportAdoptionResult(commandSender, result);
if (!result.successful()) {
return;
}
StructureKey target = result.receipt().orElseThrow().targetStructure();
binding.data().invalidateStructureResources();
CommandJigsawOpen.openProject(targetPlayer, commandSender, binding.dimension(), target.path(), CommandJigsaw.DEFAULT_STUDIO_SEED);
}
private static void reportAdoptionPlan(VolmitSender commandSender, IrisStructureAdoptionPlan plan) {
C statusColor = plan.canApply() ? C.GREEN : C.RED;
commandSender.sendMessage(statusColor + "Adoption plan " + plan.planId() + " -> "
+ plan.disposition() + " for " + plan.targetStructure().value() + ".");
commandSender.sendMessage(C.GRAY + "Resources=" + plan.resourceCount()
+ ", bytes=" + plan.totalSourceBytes()
+ ", errors=" + plan.errorCount()
+ ", warnings=" + plan.warningCount() + ".");
for (IrisStructureAdoptionDiagnostic diagnostic : plan.diagnostics()) {
commandSender.sendMessage(adoptionColor(diagnostic).toString() + diagnostic.summary());
}
if (plan.canApply()) {
commandSender.sendMessage(C.AQUA + "Apply with /iris jigsaw adopt apply " + plan.planId());
}
}
private static void reportAdoptionResult(VolmitSender commandSender, IrisStructureAdoptionResult result) {
commandSender.sendMessage((result.successful() ? C.GREEN : C.RED)
+ "Adoption plan " + result.planId() + ": " + result.status() + ".");
for (IrisStructureAdoptionDiagnostic diagnostic : result.diagnostics()) {
commandSender.sendMessage(adoptionColor(diagnostic).toString() + diagnostic.summary());
}
}
private static C adoptionColor(IrisStructureAdoptionDiagnostic diagnostic) {
return switch (diagnostic.severity()) {
case ERROR -> C.RED;
case WARNING -> C.YELLOW;
case INFO -> C.GRAY;
};
}
static String parseAdoptionSource(String value) {
String normalized = Objects.requireNonNull(value, "Adoption source").trim()
.toLowerCase(Locale.ROOT);
StructureKey source = StructureKey.parse(normalized, "iris");
if (!"iris".equals(source.namespace())) {
throw new IllegalArgumentException("Adoption sources must use the iris namespace.");
}
return source.path();
}
static Optional<StructureKey> parseAdoptionTarget(String value) {
String normalized = Objects.requireNonNull(value, "Adoption target").trim()
.toLowerCase(Locale.ROOT);
if (normalized.isEmpty() || "auto".equals(normalized)) {
return Optional.empty();
}
StructureKey target = StructureKey.parse(normalized, "iris");
if (!"iris".equals(target.namespace())) {
throw new IllegalArgumentException("Adoption targets must use the iris namespace.");
}
return Optional.of(target);
}
private static IrisStructureAdoptionStrategy parseAdoptionStrategy(String value) {
return switch (Objects.requireNonNull(value, "Adoption strategy").trim().toLowerCase(Locale.ROOT)) {
case "auto" -> IrisStructureAdoptionStrategy.AUTO;
case "in-place", "in_place", "inplace", "claim" -> IrisStructureAdoptionStrategy.IN_PLACE;
case "clone", "copy" -> IrisStructureAdoptionStrategy.CLONE;
default -> throw new IllegalArgumentException("Adoption strategy must be auto, in-place, or clone.");
};
}
static IrisStructureAdoptionInputKind adoptionInputKind(Path packRoot, String source) throws IOException {
Path root = canonicalAdoptionRoot(packRoot);
StructureKey sourceKey = new StructureKey("iris", parseAdoptionSource(source));
Path manifestPath = new StructureTransactionWriter(root).ownershipManifestPath(sourceKey);
if (!Files.exists(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
return IrisStructureAdoptionInputKind.UNOWNED_IRIS;
}
if (!Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Ownership manifest is not a regular file: " + manifestPath);
}
StructureOwnershipManifest manifest;
try {
manifest = StructureOwnershipManifest.fromJson(Files.readAllBytes(manifestPath));
} catch (RuntimeException exception) {
throw new IOException("Invalid ownership manifest at " + manifestPath, exception);
}
if (!manifest.structure().equals(sourceKey)) {
throw new IOException("Ownership manifest at " + manifestPath
+ " belongs to " + manifest.structure().value());
}
return manifest.provenance().origin() == StructureOwnershipManifest.Origin.MANAGED_DATAPACK
? IrisStructureAdoptionInputKind.MANAGED_DATAPACK
: IrisStructureAdoptionInputKind.UNOWNED_IRIS;
}
private static Path canonicalAdoptionRoot(Path packRoot) throws IOException {
Path normalized = Objects.requireNonNull(packRoot, "Adoption pack root").toAbsolutePath().normalize();
return Files.exists(normalized, LinkOption.NOFOLLOW_LINKS) ? normalized.toRealPath() : normalized;
}
private record AdoptionPlanBinding(
UUID ownerId,
IrisDimension dimension,
IrisData data,
IrisStructureAdoptionService service,
IrisStructureAdoptionPlan plan
) {
}
public static final class JigsawAdoptionStrategyHandler implements DirectorParameterHandler<String> {
@Override
public KList<String> getPossibilities() {
return new KList<>("auto", "in-place", "clone");
}
@Override
public String toString(String value) {
return value == null ? "" : value;
}
@Override
public String parse(String input, boolean force) throws DirectorParsingException {
try {
return switch (parseAdoptionStrategy(input)) {
case AUTO -> "auto";
case IN_PLACE -> "in-place";
case CLONE -> "clone";
};
} catch (IllegalArgumentException exception) {
throw new DirectorParsingException(exception.getMessage());
}
}
@Override
public boolean supports(Class<?> type) {
return type == String.class;
}
}
public static final class JigsawAdoptionPlanHandler implements DirectorParameterHandler<String> {
@Override
public KList<String> getPossibilities() {
return new KList<>();
}
@Override
public String toString(String value) {
return value == null ? "" : value;
}
@Override
public String parse(String input, boolean force) throws DirectorParsingException {
try {
return UUID.fromString(input).toString();
} catch (IllegalArgumentException exception) {
throw new DirectorParsingException("Adoption plan ID must be a UUID.");
}
}
@Override
public boolean supports(Class<?> type) {
return type == String.class;
}
}
}
@@ -0,0 +1,84 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioGraphEditor;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisPosition;
import art.arcane.iris.util.common.director.DirectorExecutor;
import art.arcane.volmlib.util.director.DirectorOrigin;
import art.arcane.volmlib.util.director.annotations.Director;
import art.arcane.volmlib.util.director.annotations.Param;
import org.bukkit.block.Block;
import org.bukkit.block.data.type.Jigsaw;
@Director(name = "connector", description = "Inspect and configure targeted jigsaw connectors",
origin = DirectorOrigin.PLAYER)
public class CommandJigsawConnector implements DirectorExecutor {
@Director(description = "Set the Iris-only channel on the targeted jigsaw marker", sync = true,
origin = DirectorOrigin.PLAYER)
public void channel(
@Param(description = "Exact channel, or none to clear") String channel
) {
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
if (context == null) {
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
return;
}
Block target = player().getTargetBlockExact(8);
if (target == null || !(target.getBlockData() instanceof Jigsaw)) {
CommandJigsaw.sendError(this, "Look directly at a jigsaw marker within eight blocks.");
return;
}
JigsawStudioBay bay = context.session().layout().findAt(
target.getX(), target.getY(), target.getZ());
JigsawStudioVariant activeVariant = bay == null
? null
: context.session().activeVariant(bay.stableId()).orElse(null);
if (activeVariant == null) {
CommandJigsaw.sendError(this, "The targeted marker must be inside a workcell with an active variant.");
return;
}
if (!activeVariant.owned()) {
CommandJigsaw.sendError(this, "The active variant is read-only. Adopt it into this project before editing it.");
return;
}
IrisObject object = context.request().source().getObjectLoader().load(activeVariant.objectKey());
if (object == null) {
CommandJigsaw.sendError(this, "The active variant object '" + activeVariant.objectKey() + "' is missing.");
return;
}
IrisPosition canonicalPosition = new IrisPosition(
target.getX() - bay.bounds().originX(),
target.getY() - bay.bounds().originY(),
target.getZ() - bay.bounds().originZ());
IrisPosition position;
try {
position = activeVariant.canonicalToSourcePosition(
canonicalPosition,
new JigsawStudioCellDimensions(object.getW(), object.getH(), object.getD()));
} catch (IllegalArgumentException exception) {
CommandJigsaw.sendError(this, "Connector channel update failed: " + exception.getMessage());
return;
}
String normalized = channel == null || channel.isBlank() || "none".equalsIgnoreCase(channel)
? "none"
: channel.trim();
CommandJigsaw.runGraphMutation(player(), context, () -> {
JigsawStudioGraphEditor.updateConnectorChannel(
context.request().source().getDataFolder().toPath(),
context.request().structureKey(),
activeVariant.pieceKey(),
position,
channel);
return CommandJigsaw.mappedMutationResult(
context,
"",
"",
"Persisted connector channel '" + normalized + "' on piece '"
+ activeVariant.pieceKey() + "' at " + position.getX() + "," + position.getY()
+ "," + position.getZ() + ".");
});
}
}
@@ -0,0 +1,159 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.Iris;
import art.arcane.iris.core.service.JigsawStudioService;
import art.arcane.iris.core.structure.export.VanillaJigsawDatapackExporter;
import art.arcane.iris.core.structure.export.VanillaJigsawExportDiagnostic;
import art.arcane.iris.core.structure.export.VanillaJigsawExportFormat;
import art.arcane.iris.core.structure.export.VanillaJigsawExportRequest;
import art.arcane.iris.core.structure.export.VanillaJigsawExportResult;
import art.arcane.iris.util.common.format.C;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.iris.util.common.scheduling.J;
import org.bukkit.entity.Player;
import java.nio.file.Path;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Pattern;
final class CommandJigsawExports {
private static final Pattern EXPORT_OUTPUT_NAME = Pattern.compile("[a-zA-Z0-9_-][a-zA-Z0-9._-]{0,127}");
private static final Set<UUID> EXPORTING_PLAYERS = ConcurrentHashMap.newKeySet();
private static final Set<Path> EXPORT_DESTINATIONS = ConcurrentHashMap.newKeySet();
private CommandJigsawExports() {
}
static String exportStartError(JigsawStudioService.ExportStart exportStart) {
Objects.requireNonNull(exportStart, "Jigsaw Studio export start status");
return switch (exportStart) {
case NOT_ACTIVE -> "The active Jigsaw Studio is no longer available.";
case NOT_OWNER -> "Only the Jigsaw Studio owner can export this project.";
case DIRTY -> "Wait for the pending autosave or discard the edits before exporting the on-disk graph.";
case CLOSING -> "The active Jigsaw Studio is closing and cannot be exported.";
case SAVE_IN_PROGRESS -> "Wait for the current Jigsaw Studio save to finish before exporting.";
case OPERATION_IN_PROGRESS -> "Wait for the current Jigsaw Studio operation to finish before exporting.";
case IN_PROGRESS -> "A Jigsaw Studio export is already in progress.";
case STARTED -> throw new IllegalArgumentException("STARTED is not an export failure");
};
}
static boolean beginExport(UUID playerId, Path destination) {
Objects.requireNonNull(playerId);
Path normalizedDestination = Objects.requireNonNull(destination).toAbsolutePath().normalize();
if (!EXPORTING_PLAYERS.add(playerId)) {
return false;
}
if (EXPORT_DESTINATIONS.add(normalizedDestination)) {
return true;
}
EXPORTING_PLAYERS.remove(playerId);
return false;
}
static void finishExport(UUID playerId, Path destination) {
EXPORTING_PLAYERS.remove(Objects.requireNonNull(playerId));
EXPORT_DESTINATIONS.remove(Objects.requireNonNull(destination).toAbsolutePath().normalize());
}
static void runExport(ExportOperation operation) {
VanillaJigsawExportResult result = null;
Throwable failure = null;
try {
result = new VanillaJigsawDatapackExporter().export(operation.request());
} catch (Throwable throwable) {
failure = throwable;
Iris.reportError("Vanilla Jigsaw export failed for '" + operation.structureKey() + "'.", throwable);
} finally {
operation.lease().release();
}
VanillaJigsawExportResult completedResult = result;
Throwable completedFailure = failure;
Runnable response = () -> reportExport(operation, completedResult, completedFailure);
J.runEntity(operation.player(), response);
}
private static void reportExport(
ExportOperation operation,
VanillaJigsawExportResult result,
Throwable failure
) {
if (failure != null || result == null) {
operation.sender().sendMessage(C.RED + "Background vanilla export for '"
+ operation.structureKey() + "' failed: "
+ (failure == null ? "no result" : failure.getMessage()));
return;
}
for (VanillaJigsawExportDiagnostic diagnostic : result.diagnostics()) {
C color = diagnostic.isBlocking() ? C.RED : C.YELLOW;
operation.sender().sendMessage(color.toString() + diagnostic.code() + ": " + diagnostic.message());
}
CommandJigsaw.ActiveContext current = CommandJigsaw.active(operation.player());
boolean sameSession = current != null
&& current.request().requestId().equals(operation.requestId());
String contextLabel = sameSession ? "Vanilla export" : "Background vanilla export for '"
+ operation.structureKey() + "'";
operation.sender().sendMessage((result.isSuccess() ? C.GREEN : C.RED) + contextLabel + " "
+ result.status() + ": " + result.output());
}
static VanillaJigsawExportFormat parseFormat(String value) {
return switch (value.trim().toLowerCase(Locale.ROOT)) {
case "zip" -> VanillaJigsawExportFormat.ZIP;
case "directory", "dir", "folder" -> VanillaJigsawExportFormat.DIRECTORY;
default -> throw new IllegalArgumentException("Export format must be directory or zip.");
};
}
static Path resolveExportDestination(
Path exportRoot,
String output,
VanillaJigsawExportFormat format
) {
Path root = Objects.requireNonNull(exportRoot, "Jigsaw export root").toAbsolutePath().normalize();
String requested = Objects.requireNonNull(output, "Jigsaw export output name");
String name = requested.trim();
if (!name.equals(requested) || !EXPORT_OUTPUT_NAME.matcher(name).matches()) {
throw new IllegalArgumentException(
"Export output must be one file name using letters, numbers, '.', '_', or '-'.");
}
String fileName = format == VanillaJigsawExportFormat.ZIP && !name.endsWith(".zip")
? name + ".zip" : name;
Path destination = root.resolve(fileName).normalize();
if (destination.equals(root) || !root.equals(destination.getParent())) {
throw new IllegalArgumentException("Export output must be one artifact inside the Studio exports folder.");
}
return destination;
}
record ExportOperation(
Player player,
VolmitSender sender,
UUID requestId,
String structureKey,
VanillaJigsawExportRequest request,
ExportLease lease
) {
}
static final class ExportLease {
private final Runnable release;
private final AtomicBoolean released;
ExportLease(Runnable release) {
this.release = Objects.requireNonNull(release, "Jigsaw export lease release action");
this.released = new AtomicBoolean();
}
void release() {
if (released.compareAndSet(false, true)) {
release.run();
}
}
}
}
@@ -0,0 +1,251 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.Iris;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.pack.BrokenPackException;
import art.arcane.iris.core.runtime.WorldRuntimeControlService;
import art.arcane.iris.core.runtime.StudioOpenCoordinator;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioGraphMapper;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession;
import art.arcane.iris.core.service.JigsawStudioService;
import art.arcane.iris.core.service.StudioSVC;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisStructure;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.util.common.format.C;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.iris.util.common.scheduling.J;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import java.util.UUID;
import java.util.concurrent.CompletionException;
final class CommandJigsawOpen {
private CommandJigsawOpen() {
}
static void openProject(
Player targetPlayer,
VolmitSender commandSender,
IrisDimension dimension,
String structure,
long seed
) {
UUID playerId = targetPlayer.getUniqueId();
UUID ownerId = JigsawStudioActivation.activeOwnerId();
if (ownerId != null && !ownerId.equals(playerId)) {
CommandJigsaw.sendError(commandSender, "Jigsaw Studio is already owned by another player session.");
return;
}
UUID openingOwner = JigsawStudioActivation.openingOwnerId();
if (openingOwner != null) {
CommandJigsaw.sendError(commandSender, openingOwner.equals(playerId)
? "Your Jigsaw Studio is already opening."
: "Jigsaw Studio is already opening for another player.");
return;
}
CommandJigsaw.ActiveContext previous = CommandJigsaw.active(targetPlayer);
if (ownerId != null && previous == null) {
CommandJigsaw.sendError(commandSender, "Your active Jigsaw Studio binding is unavailable. Close it before opening another project.");
return;
}
CommandJigsaw.ActiveContext dirty = CommandJigsaw.findDirtyContext();
if (dirty != null) {
CommandJigsaw.sendError(commandSender,
"The current Jigsaw Studio is waiting for autosave. Wait for it to finish or close with discard=true.");
return;
}
IrisData data = CommandJigsaw.requireData(dimension, commandSender);
if (data == null) {
return;
}
IrisStructure irisStructure = data.load(IrisStructure.class, structure, false);
if (irisStructure == null) {
CommandJigsaw.sendError(commandSender, "No Iris jigsaw structure '" + structure + "' exists in this pack.");
return;
}
JigsawStudioLayout layout;
try {
layout = JigsawStudioGraphMapper.map(data, irisStructure);
} catch (RuntimeException exception) {
Iris.reportError("Failed to map Jigsaw Studio graph '" + structure + "'.", exception);
CommandJigsaw.sendError(commandSender, "Could not map this jigsaw graph: " + exception.getMessage());
return;
}
String packKey = dimension.getLoadKey();
if (!JigsawStudioActivation.tryBeginOpen(playerId)) {
CommandJigsaw.sendError(commandSender,
"Jigsaw Studio began opening for another player. Try again after it finishes.");
return;
}
JigsawStudioService studioService = JigsawStudioService.get();
UUID closingRequestId = previous == null ? null : previous.request().requestId();
if (closingRequestId != null) {
JigsawStudioService.CloseStart closeStart = studioService.tryBeginClose(
closingRequestId,
playerId,
false);
if (closeStart != JigsawStudioService.CloseStart.STARTED) {
JigsawStudioActivation.finishOpen(playerId);
CommandJigsaw.sendError(commandSender, closeStart == JigsawStudioService.CloseStart.SAVE_IN_PROGRESS
? "The current Jigsaw Studio is saving. Wait for it to finish before opening another project."
: closeStart == JigsawStudioService.CloseStart.OPERATION_IN_PROGRESS
? "The current Jigsaw Studio is loading a variant. Wait for it to finish before opening another project."
: closeStart == JigsawStudioService.CloseStart.DIRTY
? "The current Jigsaw Studio is waiting for autosave. Wait for it to finish before opening another project."
: "The current Jigsaw Studio could not be reserved for replacement.");
return;
}
}
JigsawStudioActivation.StagedActivation staged;
try {
staged = JigsawStudioActivation.stage(
packKey,
structure,
layout.mode(),
CommandJigsaw.compatibilityOf(irisStructure),
layout.cellDimensions(),
data,
layout,
playerId,
closingRequestId);
} catch (RuntimeException exception) {
JigsawStudioActivation.finishOpen(playerId);
studioService.cancelClose(closingRequestId);
Iris.reportError("Failed to stage Jigsaw Studio for '" + structure + "'.", exception);
CommandJigsaw.sendError(commandSender, "Jigsaw Studio staging failed: " + exception.getMessage());
return;
}
commandSender.sendMessage(C.GREEN + "Opening transient Jigsaw Studio for '" + structure + "' with "
+ layout.bays().size() + " workcells.");
try {
Iris.service(StudioSVC.class).openTracked(
commandSender,
seed,
packKey,
CommandJigsaw.STUDIO_OPEN_KIND,
() -> beginStagedOpen(staged),
world -> finishOpen(targetPlayer, world, staged, studioService))
.whenComplete((result, throwable) -> J.s(() -> finishOpenAttempt(
commandSender,
playerId,
structure,
closingRequestId,
studioService,
staged,
result,
throwable)));
} catch (Throwable exception) {
JigsawStudioActivation.finishOpen(playerId);
studioService.cancelClose(closingRequestId);
JigsawStudioActivation.rollback(staged);
Iris.reportError("Failed to open Jigsaw Studio for '" + structure + "'.", exception);
CommandJigsaw.sendError(commandSender, "Jigsaw Studio open failed: " + exception.getMessage());
}
}
private static void beginStagedOpen(JigsawStudioActivation.StagedActivation staged) {
if (!JigsawStudioActivation.beginStagedGeneration(staged)) {
throw new IllegalStateException("The staged Jigsaw Studio activation is no longer current");
}
}
private static void finishOpenAttempt(
VolmitSender commandSender,
UUID playerId,
String structureKey,
UUID closingRequestId,
JigsawStudioService studioService,
JigsawStudioActivation.StagedActivation staged,
StudioOpenCoordinator.StudioOpenResult result,
Throwable throwable
) {
JigsawStudioActivation.finishOpen(playerId);
if (throwable == null && result != null) {
studioService.cancelClose(closingRequestId);
return;
}
studioService.cancelClose(closingRequestId);
JigsawStudioActivation.rollback(staged);
Throwable failure = unwrapCompletionFailure(throwable == null
? new IllegalStateException("Studio open completed without a result")
: throwable);
if (isExpectedOpenDenial(failure)) {
Iris.warn("Jigsaw Studio open for '%s' was denied: %s",
structureKey, failure.getMessage());
return;
}
Iris.reportError("Failed to open Jigsaw Studio for '" + structureKey + "'.", failure);
CommandJigsaw.sendError(commandSender, "Jigsaw Studio open failed: " + failure.getMessage());
}
private static void finishOpen(
Player targetPlayer,
World world,
JigsawStudioActivation.StagedActivation staged,
JigsawStudioService studioService
) {
if (world == null) {
throw new IllegalStateException("Jigsaw Studio opened without a world");
}
JigsawStudioActivation.Request request = staged.request();
JigsawStudioSession session = staged.session();
try {
WorldRuntimeControlService.get().applyObjectStudioWorldRules(world);
} catch (Throwable exception) {
Iris.reportError("Failed to apply Jigsaw Studio world rules for '" + world.getName() + "'.", exception);
}
JigsawStudioBay destination = initialBay(session.layout());
Location target = destination == null
? new Location(world, 0.5D, 66D, 0.5D)
: new Location(
world,
destination.bounds().originX() + destination.bounds().dimensions().width() / 2.0D,
destination.bounds().maxY() + 2.0D,
destination.bounds().originZ() + destination.bounds().dimensions().depth() / 2.0D);
if (destination != null) {
session.selectBay(destination.stableId());
}
if (!JigsawStudioActivation.commit(staged)) {
throw new IllegalStateException("The staged Jigsaw Studio activation could not be committed");
}
studioService.activationCommitted(world, request.requestId());
CommandJigsaw.PLAYER_PACKS.put(targetPlayer.getUniqueId(), new CommandJigsaw.SessionBinding(
request.packKey(), request.requestId()));
try {
J.runEntity(targetPlayer, () -> BukkitPlatform.teleportAsync(targetPlayer, target)
.thenRun(() -> J.runEntity(targetPlayer, () -> targetPlayer.setGameMode(GameMode.CREATIVE))));
} catch (RuntimeException exception) {
Iris.reportError("Failed to move the player to the committed Jigsaw Studio workcell.", exception);
}
}
private static JigsawStudioBay initialBay(JigsawStudioLayout layout) {
for (JigsawStudioBay bay : layout.bays()) {
if (layout.defaultVariant(bay).isPresent()) {
return bay;
}
}
return layout.bays().isEmpty() ? null : layout.bays().getFirst();
}
private static Throwable unwrapCompletionFailure(Throwable failure) {
Throwable current = failure;
while (current instanceof CompletionException && current.getCause() != null) {
current = current.getCause();
}
return current;
}
static boolean isExpectedOpenDenial(Throwable failure) {
return unwrapCompletionFailure(failure) instanceof BrokenPackException;
}
}
@@ -0,0 +1,238 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarTopology;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCompatibilityTarget;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioGraphEditor;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPoolEditor;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant;
import art.arcane.iris.core.service.JigsawStudioService;
import art.arcane.iris.engine.object.IrisJigsawPiece;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisPosition;
import art.arcane.iris.util.common.director.DirectorExecutor;
import art.arcane.volmlib.util.director.DirectorOrigin;
import art.arcane.volmlib.util.director.annotations.Director;
import art.arcane.volmlib.util.director.annotations.Param;
import org.bukkit.Location;
import java.io.IOException;
@Director(name = "piece", description = "Manage contextual Studio pieces", origin = DirectorOrigin.PLAYER)
public class CommandJigsawPiece implements DirectorExecutor {
@Director(description = "Create an owned piece from the selected Studio topology", sync = true,
origin = DirectorOrigin.PLAYER)
public void create(
@Param(description = "Owned pool resource key") String poolKey,
@Param(description = "New owned piece and object key") String pieceKey,
@Param(description = "Positive selection weight", defaultValue = "1") int weight
) {
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
if (context == null) {
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
return;
}
JigsawPlanarTopology topology = null;
JigsawStudioBay targetWorkcell = context.session().layout().get(JigsawStudioLayout.SPATIAL_WORKCELL_ID);
if (context.session().layout().mode() == JigsawStudioMode.PLANAR_JIGSAW) {
Location location = player().getLocation();
JigsawStudioBay contextual = context.session().layout().findAt(
location.getBlockX(), location.getBlockY(), location.getBlockZ());
if (contextual == null) {
contextual = CommandJigsaw.selectedBay(context);
} else {
context.session().selectBay(contextual.stableId());
}
if (contextual == null || contextual.topology().isEmpty()) {
CommandJigsaw.sendError(this, "Stand in or select a planar topology workcell before creating a variant.");
return;
}
topology = contextual.topology().get();
targetWorkcell = contextual;
}
if (targetWorkcell == null) {
CommandJigsaw.sendError(this, "The selected Jigsaw Studio workcell is no longer available.");
return;
}
JigsawPlanarTopology selectedTopology = topology;
String activationWorkcellId = targetWorkcell.stableId();
JigsawStudioCellDimensions dimensions = targetWorkcell.capacity();
String topologyText = selectedTopology == null ? "blank spatial" : selectedTopology.name();
CommandJigsaw.runGraphMutation(player(), context, () -> {
JigsawStudioGraphEditor.createPiece(
context.request().source().getDataFolder().toPath(),
context.request().structureKey(),
poolKey,
pieceKey,
weight,
dimensions,
selectedTopology);
return CommandJigsaw.mappedMutationResult(
context,
activationWorkcellId,
pieceKey,
"Created owned " + topologyText + " piece '" + pieceKey
+ "' and added it to pool '" + poolKey + "' atomically.");
});
}
@Director(description = "Add an existing jigsaw piece to an owned pool transactionally", sync = true,
origin = DirectorOrigin.PLAYER)
public void add(
@Param(description = "Owned pool resource key") String poolKey,
@Param(description = "Existing jigsaw piece key") String pieceKey,
@Param(description = "Positive selection weight", defaultValue = "1") int weight
) {
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
if (context == null) {
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
return;
}
IrisJigsawPiece piece = context.request().source().getJigsawPieceLoader().load(pieceKey);
if (piece == null) {
CommandJigsaw.sendError(this, "No jigsaw piece '" + pieceKey + "' exists in this pack.");
return;
}
if (piece.getObject() == null || piece.getObject().isBlank()) {
CommandJigsaw.sendError(this, "Jigsaw piece '" + pieceKey + "' does not declare an object.");
return;
}
IrisObject object = context.request().source().getObjectLoader().load(piece.getObject());
if (object == null) {
CommandJigsaw.sendError(this, "Jigsaw piece '" + pieceKey + "' has no loadable object.");
return;
}
CommandJigsaw.PieceWorkcellResolution resolution;
try {
resolution = CommandJigsaw.resolvePieceWorkcell(context.session().layout(), piece, object);
} catch (IllegalArgumentException exception) {
CommandJigsaw.sendError(this, "Jigsaw piece '" + pieceKey + "' cannot be assigned to a Studio workcell: "
+ exception.getMessage());
return;
}
if (!resolution.fits()) {
IrisPosition required = resolution.requiredDimensions();
JigsawStudioCellDimensions capacity = resolution.workcell().capacity();
CommandJigsaw.sendError(this, "Jigsaw piece '" + pieceKey + "' requires " + required.getX() + "x"
+ required.getY() + "x" + required.getZ() + " in " + resolution.workcell().displayName()
+ ", but that workcell capacity is " + capacity.width() + "x" + capacity.height()
+ "x" + capacity.depth() + ".");
return;
}
String objectKey = piece.getObject();
CommandJigsaw.runGraphMutation(player(), context, () -> {
if (!JigsawStudioGraphEditor.ownsPiece(
context.request().source().getDataFolder().toPath(),
context.request().structureKey(),
pieceKey,
objectKey)) {
throw new IOException("Piece add accepts only piece/object resources already owned by this project; "
+ "use piece create for a new owned piece");
}
JigsawStudioPoolEditor.PoolUpdate update = JigsawStudioPoolEditor.addPiece(
context.request().source().getDataFolder().toPath(),
context.request().structureKey(),
poolKey,
pieceKey,
weight);
if (!update.changed()) {
throw new IOException("The owned pool is missing or already references '" + pieceKey + "'");
}
JigsawStudioLayout layout = CommandJigsaw.loadPersistedLayout(context);
String activationWorkcellId = CommandJigsaw.workcellIdForVariant(layout, pieceKey);
return new JigsawStudioService.CommandGraphMutationResult(
layout,
activationWorkcellId,
pieceKey,
"Added '" + pieceKey + "' to pool '" + poolKey + "' in one graph transaction.");
});
}
@Director(description = "Remove the selected piece from an owned pool transactionally", sync = true,
origin = DirectorOrigin.PLAYER)
public void remove(
@Param(description = "Owned pool resource key") String poolKey
) {
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
if (context == null) {
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
return;
}
JigsawStudioVariant selected = CommandJigsaw.selectedVariant(context);
if (selected == null) {
CommandJigsaw.sendError(this, "Select a workcell with an active variant before removing its pool entry.");
return;
}
String pieceKey = selected.pieceKey();
CommandJigsaw.runGraphMutation(player(), context, () -> {
JigsawStudioPoolEditor.PoolUpdate update = JigsawStudioPoolEditor.removePiece(
context.request().source().getDataFolder().toPath(),
context.request().structureKey(),
poolKey,
pieceKey);
if (!update.changed()) {
throw new IOException("The owned pool does not reference selected piece '" + pieceKey + "'");
}
return CommandJigsaw.mappedMutationResult(
context,
"",
"",
"Removed '" + pieceKey + "' from pool '" + poolKey + "' in one graph transaction.");
});
}
@Director(description = "Allow or forbid cardinal rotation of the selected piece", sync = true,
origin = DirectorOrigin.PLAYER)
public void rotatable(
@Param(description = "Whether the selected piece may rotate") boolean rotatable
) {
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
if (context == null) {
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
return;
}
JigsawStudioVariant selected = CommandJigsaw.selectedVariant(context);
if (selected == null) {
CommandJigsaw.sendError(this, "Select a workcell with an active variant before changing rotation.");
return;
}
if (!rotatable
&& context.request().compatibilityTarget()
== JigsawStudioCompatibilityTarget.VANILLA_PORTABLE) {
CommandJigsaw.sendError(this, "Vanilla-portable pieces must remain rotatable.");
return;
}
String pieceKey = selected.pieceKey();
CommandJigsaw.runGraphMutation(player(), context, () -> {
JigsawStudioGraphEditor.updateRotatable(
context.request().source().getDataFolder().toPath(),
context.request().structureKey(),
pieceKey,
rotatable);
return CommandJigsaw.mappedMutationResult(
context,
"",
"",
"Persisted piece '" + pieceKey + "' rotatable=" + rotatable + ".");
});
}
@Director(description = "Resize the selected piece object exactly to workcell capacity", sync = true,
origin = DirectorOrigin.PLAYER)
public void expand() {
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
if (context == null) {
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
return;
}
JigsawStudioBay workcell = CommandJigsaw.selectedBay(context);
if (workcell == null) {
CommandJigsaw.sendError(this, "Select a workcell before expanding its active variant.");
return;
}
JigsawStudioService.get().expandVariantToCell(player(), workcell.stableId());
}
}
@@ -0,0 +1,38 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioGraphEditor;
import art.arcane.iris.util.common.director.DirectorExecutor;
import art.arcane.volmlib.util.director.DirectorOrigin;
import art.arcane.volmlib.util.director.annotations.Director;
import art.arcane.volmlib.util.director.annotations.Param;
@Director(name = "pool", description = "Manage owned jigsaw pools", origin = DirectorOrigin.PLAYER)
public class CommandJigsawPool implements DirectorExecutor {
@Director(description = "Create an empty owned jigsaw pool", sync = true,
origin = DirectorOrigin.PLAYER)
public void create(
@Param(description = "New owned pool resource key") String poolKey,
@Param(description = "Owned fallback pool key, or none", defaultValue = "none")
String fallbackPoolKey
) {
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
if (context == null) {
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
return;
}
String fallback = "none".equalsIgnoreCase(fallbackPoolKey) ? "" : fallbackPoolKey;
CommandJigsaw.runGraphMutation(player(), context, () -> {
JigsawStudioGraphEditor.createPool(
context.request().source().getDataFolder().toPath(),
context.request().structureKey(),
poolKey,
fallback);
return CommandJigsaw.mappedMutationResult(
context,
"",
"",
"Created owned pool '" + poolKey + "' with fallback '"
+ (fallback.isBlank() ? "none" : fallback) + "'.");
});
}
}
@@ -0,0 +1,26 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.core.service.JigsawStudioService;
import art.arcane.iris.util.common.director.DirectorExecutor;
import art.arcane.volmlib.util.director.DirectorOrigin;
import art.arcane.volmlib.util.director.annotations.Director;
import art.arcane.volmlib.util.director.annotations.Param;
@Director(name = "preview", description = "Assemble a read-only jigsaw preview",
origin = DirectorOrigin.PLAYER)
public class CommandJigsawPreview implements DirectorExecutor {
@Director(name = "goto", aliases = "teleport", description = "Teleport to the live seed-1337 preview",
sync = true, origin = DirectorOrigin.PLAYER)
public void gotoPreview() {
JigsawStudioService.get().goToPreview(player());
}
@Director(description = "Assemble a deterministic preview at your location", sync = true,
origin = DirectorOrigin.PLAYER)
public void assemble(
@Param(description = "Assembly seed", defaultValue = "1337") long seed
) {
JigsawStudioService.get().previewStudio(player(), seed);
}
}
@@ -0,0 +1,74 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCompatibilityTarget;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPoolEditor;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioStructureEditor;
import art.arcane.iris.util.common.director.DirectorExecutor;
import art.arcane.volmlib.util.director.DirectorOrigin;
import art.arcane.volmlib.util.director.annotations.Director;
import art.arcane.volmlib.util.director.annotations.Param;
@Director(name = "rules", aliases = "rule", description = "Manage structure-wide jigsaw rules",
origin = DirectorOrigin.PLAYER)
public class CommandJigsawRules implements DirectorExecutor {
@Director(description = "Set maximum expansion depth and horizontal radius", sync = true,
origin = DirectorOrigin.PLAYER)
public void limits(
@Param(description = "Maximum expansion depth") int maxDepth,
@Param(description = "Maximum radius in chunks") int maxSizeChunks
) {
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
if (context == null) {
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
return;
}
if (context.request().compatibilityTarget() == JigsawStudioCompatibilityTarget.VANILLA_PORTABLE
&& (maxDepth > 20 || maxSizeChunks > 8)) {
CommandJigsaw.sendError(this, "Vanilla-portable rules require maxDepth <= 20 and maxSizeChunks <= 8.");
return;
}
CommandJigsaw.runGraphMutation(player(), context, () -> {
JigsawStudioStructureEditor.updateLimits(
context.request().source().getDataFolder().toPath(),
context.request().structureKey(),
maxDepth,
maxSizeChunks);
return CommandJigsaw.mappedMutationResult(
context,
"",
"",
"Persisted jigsaw limits: maxDepth=" + maxDepth
+ ", maxSizeChunks=" + maxSizeChunks + ".");
});
}
@Director(description = "Set or clear one owned pool's direct fallback", sync = true,
origin = DirectorOrigin.PLAYER)
public void fallback(
@Param(description = "Owned pool resource key") String poolKey,
@Param(description = "Owned fallback pool key, or none") String fallbackPoolKey
) {
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
if (context == null) {
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
return;
}
String fallback = "none".equalsIgnoreCase(fallbackPoolKey) ? "" : fallbackPoolKey;
CommandJigsaw.runGraphMutation(player(), context, () -> {
JigsawStudioPoolEditor.PoolUpdate update = JigsawStudioPoolEditor.updateFallback(
context.request().source().getDataFolder().toPath(),
context.request().structureKey(),
poolKey,
fallback);
if (!update.changed()) {
return CommandJigsaw.mappedMutationResult(context, "", "", "Pool fallback is unchanged.");
}
return CommandJigsaw.mappedMutationResult(
context,
"",
"",
"Persisted pool '" + poolKey + "' fallback as '"
+ (fallback.isBlank() ? "none" : fallback) + "'.");
});
}
}
@@ -0,0 +1,169 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPoolEditor;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant;
import art.arcane.iris.core.service.JigsawStudioService;
import art.arcane.iris.util.common.director.DirectorExecutor;
import art.arcane.volmlib.util.director.DirectorOrigin;
import art.arcane.volmlib.util.director.annotations.Director;
import art.arcane.volmlib.util.director.annotations.Param;
import java.io.IOException;
@Director(name = "variant", description = "Manage selected jigsaw variants", origin = DirectorOrigin.PLAYER)
public class CommandJigsawVariant implements DirectorExecutor {
@Director(description = "Set the selected piece weight in a pool", sync = true,
origin = DirectorOrigin.PLAYER)
public void weight(
@Param(description = "Pool resource key") String poolKey,
@Param(description = "Positive selection weight") int weight
) {
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
if (context == null) {
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
return;
}
JigsawStudioVariant selected = CommandJigsaw.selectedVariant(context);
if (selected == null) {
CommandJigsaw.sendError(this, "Select a workcell with an active variant before changing its weight.");
return;
}
String pieceKey = selected.pieceKey();
CommandJigsaw.runGraphMutation(player(), context, () -> {
JigsawStudioPoolEditor.WeightUpdate update = JigsawStudioPoolEditor.updateWeight(
context.request().source().getDataFolder().toPath(),
context.request().structureKey(),
poolKey,
pieceKey,
weight);
if (!update.changed()) {
throw new IOException("Pool '" + poolKey + "' does not reference selected piece '"
+ pieceKey + "'");
}
return CommandJigsaw.mappedMutationResult(
context,
"",
"",
"Updated " + update.changedEntries() + " pool entry weight atomically in "
+ update.poolPath() + ".");
});
}
@Director(description = "Resize only the active variant object within workcell capacity", sync = true,
origin = DirectorOrigin.PLAYER)
public void resize(
@Param(description = "Variant width") int width,
@Param(description = "Variant height") int height,
@Param(description = "Variant depth") int depth
) {
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
if (context == null) {
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
return;
}
JigsawStudioBay selectedWorkcell = CommandJigsaw.selectedBay(context);
JigsawStudioVariant selectedVariant = CommandJigsaw.selectedVariant(context);
if (selectedWorkcell == null || selectedVariant == null) {
CommandJigsaw.sendError(this, "Select a workcell with an active variant before resizing it.");
return;
}
JigsawStudioCellDimensions dimensions;
try {
dimensions = new JigsawStudioCellDimensions(width, height, depth);
} catch (IllegalArgumentException exception) {
CommandJigsaw.sendError(this, exception.getMessage());
return;
}
JigsawStudioService.get().resizeVariant(
player(),
selectedWorkcell.stableId(),
selectedVariant.pieceKey(),
dimensions);
}
@Director(description = "Set the active variant's author-facing label", sync = true,
origin = DirectorOrigin.PLAYER)
public void label(
@Param(description = "Display label; quote labels containing spaces") String displayName
) {
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
if (context == null) {
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
return;
}
JigsawStudioBay selectedWorkcell = CommandJigsaw.selectedBay(context);
JigsawStudioVariant selectedVariant = CommandJigsaw.selectedVariant(context);
if (selectedWorkcell == null || selectedVariant == null) {
CommandJigsaw.sendError(this, "Select a workcell with an active variant before renaming it.");
return;
}
JigsawStudioService.get().updateVariantDisplayName(
player(),
selectedWorkcell.stableId(),
selectedVariant.pieceKey(),
displayName);
}
@Director(name = "label-reset", aliases = "reset-label",
description = "Reset the active variant to its resource-key label", sync = true,
origin = DirectorOrigin.PLAYER)
public void labelReset() {
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
if (context == null) {
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
return;
}
JigsawStudioBay selectedWorkcell = CommandJigsaw.selectedBay(context);
JigsawStudioVariant selectedVariant = CommandJigsaw.selectedVariant(context);
if (selectedWorkcell == null || selectedVariant == null) {
CommandJigsaw.sendError(this, "Select a workcell with an active variant before resetting its label.");
return;
}
JigsawStudioService.get().updateVariantDisplayName(
player(),
selectedWorkcell.stableId(),
selectedVariant.pieceKey(),
"");
}
@Director(description = "Duplicate the active variant in only this workcell", sync = true,
origin = DirectorOrigin.PLAYER)
public void duplicate() {
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
if (context == null) {
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
return;
}
JigsawStudioBay selected = CommandJigsaw.selectedBay(context);
if (selected == null || CommandJigsaw.selectedVariant(context) == null) {
CommandJigsaw.sendError(this, "Select a workcell with an active variant before duplicating it.");
return;
}
JigsawStudioService.get().createVariant(player(), selected.stableId(), true);
}
@Director(name = "duplicate-family", aliases = "family",
description = "Duplicate every enabled workcell's active variant as one coherent family",
sync = true, origin = DirectorOrigin.PLAYER)
public void duplicateFamily(
@Param(description = "Family key or next", defaultValue = "next") String themeKey
) {
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
if (context == null) {
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
return;
}
String selectedTheme = themeKey;
if ("next".equalsIgnoreCase(themeKey)) {
try {
selectedTheme = CommandJigsaw.nextThemeKey(context);
} catch (IOException | ArithmeticException exception) {
CommandJigsaw.sendError(this, exception.getMessage());
return;
}
}
JigsawStudioService.get().duplicateActiveFamily(player(), selectedTheme);
}
}
@@ -0,0 +1,81 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
import art.arcane.iris.core.service.JigsawStudioService;
import art.arcane.iris.util.common.director.DirectorExecutor;
import art.arcane.volmlib.util.director.DirectorOrigin;
import art.arcane.volmlib.util.director.annotations.Director;
import art.arcane.volmlib.util.director.annotations.Param;
@Director(name = "workcell", aliases = "cell", description = "Manage the selected Studio workcell",
origin = DirectorOrigin.PLAYER)
public class CommandJigsawWorkcell implements DirectorExecutor {
@Director(description = "Set capacity without resizing any variant object", sync = true,
origin = DirectorOrigin.PLAYER)
public void capacity(
@Param(description = "Capacity width") int width,
@Param(description = "Capacity height") int height,
@Param(description = "Capacity depth") int depth
) {
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
if (context == null) {
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
return;
}
JigsawStudioBay selected = CommandJigsaw.selectedBay(context);
if (selected == null) {
CommandJigsaw.sendError(this, "Select a workcell before changing its capacity.");
return;
}
JigsawStudioCellDimensions dimensions;
try {
dimensions = new JigsawStudioCellDimensions(width, height, depth);
} catch (IllegalArgumentException exception) {
CommandJigsaw.sendError(this, exception.getMessage());
return;
}
JigsawStudioService.get().updateWorkcellDimensions(
player(),
selected.stableId(),
dimensions);
}
@Director(description = "Set the selected workcell's author-facing label", sync = true,
origin = DirectorOrigin.PLAYER)
public void label(
@Param(description = "Display label; quote labels containing spaces") String displayName
) {
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
if (context == null) {
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
return;
}
JigsawStudioBay selected = CommandJigsaw.selectedBay(context);
if (selected == null) {
CommandJigsaw.sendError(this, "Select a workcell before renaming it.");
return;
}
JigsawStudioService.get().updateWorkcellDisplayName(
player(),
selected.stableId(),
displayName);
}
@Director(name = "label-reset", aliases = "reset-label",
description = "Reset the selected workcell to its canonical solver label", sync = true,
origin = DirectorOrigin.PLAYER)
public void labelReset() {
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
if (context == null) {
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
return;
}
JigsawStudioBay selected = CommandJigsaw.selectedBay(context);
if (selected == null) {
CommandJigsaw.sendError(this, "Select a workcell before resetting its label.");
return;
}
JigsawStudioService.get().updateWorkcellDisplayName(player(), selected.stableId(), "");
}
}
@@ -75,8 +75,8 @@ public class WandSVC implements IrisService {
private static final int MS_PER_TICK = Integer.parseInt(System.getProperty("iris.ms_per_tick", "30"));
private static final int PLAYER_RESCAN_INTERVAL_TICKS = 100;
private static ItemStack dust;
private static ItemStack wand;
private static volatile ItemStack dust;
private static volatile ItemStack wand;
private final Map<UUID, Player> activePlayers = new ConcurrentHashMap<>();
private final AtomicBoolean playerRescanScheduled = new AtomicBoolean(false);
@@ -374,12 +374,14 @@ public class WandSVC implements IrisService {
@Override
public void onEnable() {
wand = createWand();
dust = createDust();
enabled = true;
activePlayers.clear();
ticksUntilPlayerRescan = 0;
taskId = J.ar(this::tickAll, 1);
J.s(() -> {
wand = createWand();
dust = createDust();
});
}
@Override
@@ -389,6 +391,8 @@ public class WandSVC implements IrisService {
J.car(taskId);
taskId = -1;
}
wand = null;
dust = null;
activePlayers.clear();
playerRescanScheduled.set(false);
}
@@ -607,7 +611,8 @@ public class WandSVC implements IrisService {
return false;
}
Byte marker = is.getItemMeta().getPersistentDataContainer().get(dustKey(), PersistentDataType.BYTE);
return (marker != null && marker == (byte) 1) || is.isSimilar(dust);
ItemStack template = dust;
return (marker != null && marker == (byte) 1) || (template != null && is.isSimilar(template));
}
private static NamespacedKey wandKey() {
@@ -3,14 +3,10 @@ package art.arcane.iris;
import art.arcane.iris.spi.LogLevel;
import org.junit.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.logging.Level;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
/**
* Core states a severity on every message it logs and the modded adapters honour it. Bukkit must preserve
@@ -33,58 +29,4 @@ public class IrisDiagnosticLogLevelTest {
public void lifecycleNoticesReachThePluginLoggerAtInfo() {
assertEquals(Level.INFO, Iris.diagnosticLevel(LogLevel.NOTICE));
}
@Test
public void adapterSideWarningsCarryTheSameSeverityAsCoreWarnings() throws Exception {
String source = Files.readString(Path.of("src/main/java/art/arcane/iris/Iris.java")).replace("\r\n", "\n");
String warn = method(source, "public static void warn(String format, Object... objs)");
assertTrue(warn, warn.contains("diagnostic(Level.WARNING"));
assertFalse(warn, warn.contains("msg("));
String error = method(source, "public static void error(String format, Object... objs)");
assertTrue(error, error.contains("diagnostic(Level.SEVERE"));
assertFalse(error, error.contains("msg("));
}
@Test
public void theCoreLogBridgeRoutesDiagnosticsToThePluginLogger() throws Exception {
String source = Files.readString(Path.of("src/main/java/art/arcane/iris/Iris.java")).replace("\r\n", "\n");
String bridge = method(source, "private static void bridgeLog(LogLevel level, String message)");
assertTrue(bridge, bridge.contains("diagnosticLevel(target)"));
assertTrue(bridge, bridge.contains("diagnostic(diagnostic, message)"));
String diagnostic = method(source, "private static void diagnostic(Level level, String message)");
assertTrue(diagnostic, diagnostic.contains("ComponentLog.log("));
assertTrue(diagnostic, diagnostic.contains("ComponentText.literal(line)"));
}
@Test
public void informationalMessagesUseTheSharedComponentLogger() throws Exception {
String source = Files.readString(Path.of("src/main/java/art/arcane/iris/Iris.java")).replace("\r\n", "\n");
String message = method(source, "public static void msg(String string)");
assertTrue(message, message.contains("ComponentLog.logMarkup("));
assertTrue(message, message.contains("logPrefix(plugin)"));
assertFalse(message, message.contains("getSender().sendMessage"));
}
private static String method(String source, String signature) {
int start = source.indexOf(signature);
assertTrue("method not found: " + signature, start >= 0);
int open = source.indexOf('{', start);
int depth = 0;
for (int i = open; i < source.length(); i++) {
char c = source.charAt(i);
if (c == '{') {
depth++;
} else if (c == '}') {
depth--;
if (depth == 0) {
return source.substring(start, i + 1);
}
}
}
throw new AssertionError("unterminated method: " + signature);
}
}
@@ -1,72 +0,0 @@
package art.arcane.iris.core;
import org.junit.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertTrue;
public class IrisStartupOrderingTest {
@Test
public void admissionGateIsRegisteredBeforeStartupValidationBegins() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.startupSource")));
String onEnable = section(source, "public void onEnable()", "public void onDisable()");
assertOrdered(onEnable,
"IrisStartupValidation.begin();",
"registerEvents(new IrisStartupAdmissionListener(), this);",
"enable();");
}
@Test
public void externalDatapacksValidateBeforeDimensionPacks() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.startupSource")));
String enable = section(source, "private boolean enable()", "public void addShutdownHook()");
assertOrdered(enable,
"DatapackIngestService.validateOnStartup();",
"generatorResolver.validateAllPacks();");
}
@Test
public void externalProvidersEnableBeforePackValidation() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.startupSource")));
String enable = section(source, "private boolean enable()", "public void addShutdownHook()");
assertOrdered(enable,
"service.onEnable();",
"setContentChangeListener(generatorResolver::requestExternalContentRefresh)",
"generatorResolver.validateAllPacks();");
}
@Test
public void restartRequiredStartupTerminatesAfterPluginInitialization() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.startupSource")));
String onEnable = section(source, "public void onEnable()", "public void onDisable()");
assertOrdered(onEnable,
"BukkitGuiHost.install();",
"super.onEnable();",
"IrisStartupValidation.isRestartRequired()",
"ServerConfigurator.restartAtStartupBoundary(restartReason);");
}
private static String section(String source, String startMarker, String endMarker) {
int start = source.indexOf(startMarker);
int end = source.indexOf(endMarker, start);
assertTrue("Missing source section starting with " + startMarker, start >= 0);
assertTrue("Missing source section ending with " + endMarker, end > start);
return source.substring(start, end);
}
private static void assertOrdered(String source, String... markers) {
int previous = -1;
for (String marker : markers) {
int current = source.indexOf(marker);
assertTrue("Missing source marker " + marker, current >= 0);
assertTrue("Source marker is out of order: " + marker, current > previous);
previous = current;
}
}
}
@@ -140,23 +140,6 @@ public class IrisWorldGeneratorResolverTest {
}
}
@Test
public void startupValidationPublishesFingerprintBoundExactRootResults() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java")).replace("\r\n", "\n");
int validateAll = source.indexOf("public void validateAllPacks()");
int snapshot = source.indexOf("ServerConfigurator.computePackContentSnapshot(packsRoot)", validateAll);
int perPackFingerprint = source.indexOf("contentSnapshot.packContents()", snapshot);
int exactRootPublish = source.indexOf(
"PackValidationRegistry.publish(packDirectory.toPath(), result, packFingerprint)",
perPackFingerprint);
assertTrue(validateAll >= 0);
assertTrue(snapshot > validateAll);
assertTrue(perPackFingerprint > snapshot);
assertTrue(exactRootPublish > perPackFingerprint);
}
@Test
public void paperStartupAliasResolvesToCanonicalRuntimeKey() throws Exception {
File levelRoot = ownedLevelRoot("startup-alias", "moon");
@@ -251,87 +234,6 @@ public class IrisWorldGeneratorResolverTest {
return levelRoot;
}
@Test
public void configuredWorldResolutionUsesOnlyActiveImmutableGenerationPack() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java")).replace("\r\n", "\n");
int resolverStart = source.indexOf("private ChunkGenerator resolveFrozenWorldGenerator(");
int resolverEnd = source.indexOf("private record FreshValidation", resolverStart);
String resolver = source.substring(resolverStart, resolverEnd);
int dimensionRoot = resolver.indexOf("IrisWorldStorage.requireFrozenDimensionRoot(");
int currentPlatformRoot = resolver.indexOf("WorldCreatorCompat.persistentDimensionRoot(worldKey)");
int layoutRefusal = resolver.indexOf(
"Frozen Iris world storage does not match the current platform layout",
currentPlatformRoot
);
int history = resolver.indexOf("requireGenerationHistory(dimensionRoot, id, worldSeed)");
int snapshotRoot = resolver.indexOf("requireActivePack(history)", history);
int validation = resolver.indexOf("requireSnapshotLoadable(snapshotRoot)");
int exactLoad = resolver.indexOf("requireHistoricalDimension(history, snapshotRoot, id)");
int canonicalIdentity = resolver.indexOf(".platformIdentity(worldKey.toString())");
int historicalSeed = resolver.indexOf(".seed(history.activeEpoch().worldSeed())");
int resolvedStorage = resolver.indexOf(".worldFolder(dimensionRoot)");
assertTrue(dimensionRoot >= 0);
assertTrue(currentPlatformRoot > dimensionRoot);
assertTrue(layoutRefusal > currentPlatformRoot);
assertTrue(history > layoutRefusal);
assertTrue(snapshotRoot > history);
assertTrue(validation > snapshotRoot);
assertTrue(exactLoad > validation);
assertTrue(canonicalIdentity > exactLoad);
assertTrue(historicalSeed > canonicalIdentity);
assertTrue(resolvedStorage > historicalSeed);
assertFalse(resolver.contains("loadDimension("));
assertFalse(resolver.contains("loadAnyDimension("));
assertFalse(resolver.contains("replaceIntoWorld("));
assertFalse(resolver.contains("installIntoWorld("));
assertFalse(resolver.contains(".seed(1337)"));
assertTrue(resolver.contains("GenerationHistory.openIfPresent(root, worldSeed)"));
assertTrue(resolver.contains("GenerationHistory.adoptLegacyPack("));
assertTrue(resolver.contains("GenerationRegistryContractFactory.captureRequiredDefinitions("));
}
@Test
public void ownedWorldSnapshotFailureStopsStartupAndRethrows() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java")).replace("\r\n", "\n");
int resolverStart = source.indexOf("public ChunkGenerator resolveDefaultWorldGenerator(");
int resolverEnd = source.indexOf("private ChunkGenerator resolveFrozenWorldGenerator(", resolverStart);
String resolver = source.substring(resolverStart, resolverEnd);
int plotSquaredProbe = resolver.indexOf("isPlotSquaredGeneratorDiscoveryProbe(worldName, id)");
int probe = resolver.indexOf("isGeneratorDiscoveryProbe(worldName, id)", plotSquaredProbe);
int denial = resolver.indexOf("IrisStartupValidation.denialReason()", probe);
int failClosed = resolver.indexOf("IrisFailClosedChunkGenerator.startupLock(", denial);
int staged = resolver.indexOf("WorldLifecycleStaging.consumeGenerator(worldName)", failClosed);
int duplicateGuard = resolver.indexOf("requireWorldKeyAvailable(worldName, worldKey)");
int ownership = resolver.indexOf("requireOwnedWorld(worldName, levelRoot, worldKey)");
int frozen = resolver.indexOf("return resolveFrozenWorldGenerator(", ownership);
int failureCapture = resolver.indexOf("catch (RuntimeException failure)", frozen);
int report = resolver.indexOf("Iris.reportError(", failureCapture);
int shutdown = resolver.indexOf("Bukkit.shutdown()", report);
int rethrow = resolver.indexOf("throw failure", shutdown);
assertTrue(plotSquaredProbe >= 0);
assertTrue(probe > plotSquaredProbe);
assertTrue(denial > probe);
assertTrue(failClosed > denial);
assertTrue(staged > failClosed);
assertTrue(duplicateGuard > staged);
assertTrue(ownership > duplicateGuard);
assertTrue(frozen > ownership);
assertTrue(failureCapture > frozen);
assertTrue(report > failureCapture);
assertTrue(shutdown > report);
assertTrue(rethrow > shutdown);
String shutdownScope = "the fail-fast shutdown must stay scoped to the frozen snapshot of an owned world";
assertEquals(shutdownScope, resolverStart + shutdown, source.indexOf("Bukkit.shutdown()"));
assertEquals(shutdownScope, resolverStart + shutdown, source.lastIndexOf("Bukkit.shutdown()"));
}
@Test
public void plotSquaredDiscoveryProbeIsIgnoredQuietly() {
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
@@ -1,26 +0,0 @@
package art.arcane.iris.core.commands;
import org.junit.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public final class CommandDeveloperGenerationHistoryTest {
@Test
public void updateWorldStagesThePackWithTheLiveWorldSeed() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java"
)).replace("\r\n", "\n");
int updateStart = source.indexOf("public void updateWorld(");
int updateEnd = source.indexOf("@Director(description = \"Test\"", updateStart);
String update = source.substring(updateStart, updateEnd);
assertTrue(update.contains(
"replaceIntoWorld(sender(), pack, folder, world.getSeed())"
));
assertFalse(update.contains("iris/pack"));
}
}
@@ -7,8 +7,6 @@ import org.junit.Test;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import static org.junit.Assert.assertEquals;
@@ -73,26 +71,4 @@ public class CommandIrisCreateOverwriteContractTest {
assertEquals(Long.valueOf(Long.MAX_VALUE), handler.parse(Long.toString(Long.MAX_VALUE), false));
assertThrows(DirectorParsingException.class, () -> handler.parse("9223372036854775808", false));
}
@Test
public void createAndReplaceUseLocalizedStyledMissingDimensionFeedback() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/commands/CommandIris.java"
)).replace("\r\n", "\n");
assertEquals(2, occurrences(source, "COMMAND_IRIS_DIMENSION_NOT_FOUND"));
assertEquals(2, occurrences(source, "COMMAND_IRIS_INSTALL_PACK_AND_RESTART"));
assertFalse(source.contains("sendMessage(\"Could not find dimension"));
assertFalse(source.contains("sendMessage(\"Install its pack with"));
}
private static int occurrences(String value, String match) {
int count = 0;
int offset = 0;
while ((offset = value.indexOf(match, offset)) >= 0) {
count++;
offset += match.length();
}
return count;
}
}
@@ -5,13 +5,10 @@ import org.junit.Test;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
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;
@@ -47,20 +44,6 @@ public class CommandIrisDownloadContractTest {
assertThrows(Exception.class, () -> handler.parse("custom", false));
}
@Test
public void commandDelegatesAcceptedDownloadsWithoutRawPreamble() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/commands/CommandIris.java"
)).replace("\r\n", "\n");
String download = method(source, "public void download(");
assertTrue(download.contains("downloadBuiltIn(sender(), builtInPack)"));
assertTrue(download.contains("downloadUrl(sender(), directLink)"));
assertFalse(download.contains("Downloading built-in Iris pack"));
assertFalse(download.contains("Downloading Iris pack from"));
assertFalse(download.contains("sendMessage(directLink"));
}
private static String method(String source, String signature) {
int start = source.indexOf(signature);
assertTrue(start >= 0);
@@ -108,28 +108,13 @@ public class CommandJigsawContractTest {
BrokenPackException failure = new BrokenPackException(
"overworld", List.of("Biome 'broken' has no resolvable regions."));
assertTrue(CommandJigsaw.isExpectedOpenDenial(failure));
assertTrue(CommandJigsaw.isExpectedOpenDenial(
assertTrue(CommandJigsawOpen.isExpectedOpenDenial(failure));
assertTrue(CommandJigsawOpen.isExpectedOpenDenial(
new CompletionException(failure)));
assertFalse(CommandJigsaw.isExpectedOpenDenial(
assertFalse(CommandJigsawOpen.isExpectedOpenDenial(
new IllegalStateException("Unexpected Studio failure")));
}
@Test
public void committedActivationStartsInitialEvaluationBeforePlayerBinding() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/commands/CommandJigsaw.java")).replace("\r\n", "\n");
int commit = source.indexOf("JigsawStudioActivation.commit(staged)");
int evaluation = source.indexOf(
"studioService.activationCommitted(world, request.requestId())",
commit);
int binding = source.indexOf("PLAYER_PACKS.put", evaluation);
assertTrue(commit >= 0);
assertTrue(evaluation > commit);
assertTrue(binding > evaluation);
}
@Test
public void convertIsAddOnlyWorkflowWithAliasesAndDefaults() throws Exception {
Method convert = CommandJigsaw.class.getDeclaredMethod(
@@ -153,20 +138,20 @@ public class CommandJigsawContractTest {
@Test
public void adoptionCommandsExposeTwoStepPlanContract() throws Exception {
Method inspect = CommandJigsaw.CommandJigsawAdopt.class.getDeclaredMethod(
Method inspect = CommandJigsawAdopt.class.getDeclaredMethod(
"inspect", IrisDimension.class, String.class, String.class, String.class);
Method apply = CommandJigsaw.CommandJigsawAdopt.class.getDeclaredMethod("apply", String.class);
Method apply = CommandJigsawAdopt.class.getDeclaredMethod("apply", String.class);
Parameter[] inspectParameters = inspect.getParameters();
assertNotNull(inspect.getAnnotation(Director.class));
assertNotNull(apply.getAnnotation(Director.class));
assertParameter(inspectParameters[2], "auto", null);
assertParameter(inspectParameters[3], "auto", CommandJigsaw.JigsawAdoptionStrategyHandler.class);
assertEquals(CommandJigsaw.JigsawAdoptionPlanHandler.class,
assertParameter(inspectParameters[3], "auto", CommandJigsawAdopt.JigsawAdoptionStrategyHandler.class);
assertEquals(CommandJigsawAdopt.JigsawAdoptionPlanHandler.class,
apply.getParameters()[0].getAnnotation(Param.class).customHandler());
CommandJigsaw.JigsawAdoptionStrategyHandler strategyHandler =
new CommandJigsaw.JigsawAdoptionStrategyHandler();
CommandJigsawAdopt.JigsawAdoptionStrategyHandler strategyHandler =
new CommandJigsawAdopt.JigsawAdoptionStrategyHandler();
assertEquals(List.of("auto", "in-place", "clone"), strategyHandler.getPossibilities());
assertEquals("in-place", strategyHandler.parse("claim", false));
assertEquals("clone", strategyHandler.parse("copy", false));
@@ -178,14 +163,14 @@ public class CommandJigsawContractTest {
Path root = temporaryFolder.newFolder("adoption-provenance").toPath();
assertEquals(IrisStructureAdoptionInputKind.UNOWNED_IRIS,
CommandJigsaw.adoptionInputKind(root, "unowned"));
CommandJigsawAdopt.adoptionInputKind(root, "unowned"));
writeManifest(root, "datapack-created", StructureSource.Kind.DATAPACK,
StructureOwnershipManifest.Provenance.created());
assertEquals(IrisStructureAdoptionInputKind.UNOWNED_IRIS,
CommandJigsaw.adoptionInputKind(root, "datapack-created"));
CommandJigsawAdopt.adoptionInputKind(root, "datapack-created"));
writeManifest(root, "managed-provenance", StructureSource.Kind.IRIS, managedProvenance());
assertEquals(IrisStructureAdoptionInputKind.MANAGED_DATAPACK,
CommandJigsaw.adoptionInputKind(root, "managed-provenance"));
CommandJigsawAdopt.adoptionInputKind(root, "managed-provenance"));
}
@Test
@@ -290,88 +275,74 @@ public class CommandJigsawContractTest {
@Test
public void exposesNestedPieceVariantAndPreviewCommands() throws Exception {
assertNotNull(CommandJigsaw.CommandJigsawPool.class
assertNotNull(CommandJigsawPool.class
.getDeclaredMethod("create", String.class, String.class)
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawConnector.class
assertNotNull(CommandJigsawConnector.class
.getDeclaredMethod("channel", String.class)
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawPiece.class
assertNotNull(CommandJigsawPiece.class
.getDeclaredMethod("create", String.class, String.class, int.class)
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawPiece.class
assertNotNull(CommandJigsawPiece.class
.getDeclaredMethod("add", String.class, String.class, int.class)
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawPiece.class
assertNotNull(CommandJigsawPiece.class
.getDeclaredMethod("remove", String.class)
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawPiece.class
assertNotNull(CommandJigsawPiece.class
.getDeclaredMethod("rotatable", boolean.class)
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawPiece.class
assertNotNull(CommandJigsawPiece.class
.getDeclaredMethod("expand")
.getAnnotation(Director.class));
assertEquals("Resize the selected piece object exactly to workcell capacity",
CommandJigsaw.CommandJigsawPiece.class
CommandJigsawPiece.class
.getDeclaredMethod("expand")
.getAnnotation(Director.class)
.description());
assertNotNull(CommandJigsaw.CommandJigsawVariant.class
assertNotNull(CommandJigsawVariant.class
.getDeclaredMethod("weight", String.class, int.class)
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawVariant.class
assertNotNull(CommandJigsawVariant.class
.getDeclaredMethod("resize", int.class, int.class, int.class)
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawVariant.class
assertNotNull(CommandJigsawVariant.class
.getDeclaredMethod("label", String.class)
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawVariant.class
assertNotNull(CommandJigsawVariant.class
.getDeclaredMethod("labelReset")
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawVariant.class
assertNotNull(CommandJigsawVariant.class
.getDeclaredMethod("duplicate")
.getAnnotation(Director.class));
Method duplicateFamily = CommandJigsaw.CommandJigsawVariant.class
Method duplicateFamily = CommandJigsawVariant.class
.getDeclaredMethod("duplicateFamily", String.class);
assertNotNull(duplicateFamily.getAnnotation(Director.class));
assertParameter(duplicateFamily.getParameters()[0], "next", null);
assertNotNull(CommandJigsaw.CommandJigsawWorkcell.class
assertNotNull(CommandJigsawWorkcell.class
.getDeclaredMethod("capacity", int.class, int.class, int.class)
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawWorkcell.class
assertNotNull(CommandJigsawWorkcell.class
.getDeclaredMethod("label", String.class)
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawWorkcell.class
assertNotNull(CommandJigsawWorkcell.class
.getDeclaredMethod("labelReset")
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawRules.class
assertNotNull(CommandJigsawRules.class
.getDeclaredMethod("limits", int.class, int.class)
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawRules.class
assertNotNull(CommandJigsawRules.class
.getDeclaredMethod("fallback", String.class, String.class)
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawPreview.class
assertNotNull(CommandJigsawPreview.class
.getDeclaredMethod("assemble", long.class)
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawPreview.class
assertNotNull(CommandJigsawPreview.class
.getDeclaredMethod("gotoPreview")
.getAnnotation(Director.class));
}
@Test
public void pieceCreateUsesResolvedWorkcellCapacityInsteadOfLayoutDefault() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/commands/CommandJigsaw.java")).replace("\r\n", "\n");
int pieceCommands = source.indexOf("public static class CommandJigsawPiece");
int create = source.indexOf("public void create(", pieceCommands);
int add = source.indexOf("public void add(", create);
String createSource = source.substring(create, add);
assertTrue(createSource.contains("targetWorkcell = contextual;"));
assertTrue(createSource.contains("JigsawStudioCellDimensions dimensions = targetWorkcell.capacity();"));
assertFalse(createSource.contains("layout().cellDimensions()"));
}
@Test
public void pieceAddUsesCanonicalAxesForRotatedRectangularPlanarWorkcells() {
JigsawStudioLayout layout = nonuniformPlanarLayout();
@@ -422,14 +393,14 @@ public class CommandJigsawContractTest {
public void everyExecutableCommandRejectsNonPlayerOrigins() {
Class<?>[] commandTypes = {
CommandJigsaw.class,
CommandJigsaw.CommandJigsawConnector.class,
CommandJigsaw.CommandJigsawPool.class,
CommandJigsaw.CommandJigsawPiece.class,
CommandJigsaw.CommandJigsawVariant.class,
CommandJigsaw.CommandJigsawWorkcell.class,
CommandJigsaw.CommandJigsawRules.class,
CommandJigsaw.CommandJigsawPreview.class,
CommandJigsaw.CommandJigsawAdopt.class
CommandJigsawConnector.class,
CommandJigsawPool.class,
CommandJigsawPiece.class,
CommandJigsawVariant.class,
CommandJigsawWorkcell.class,
CommandJigsawRules.class,
CommandJigsawPreview.class,
CommandJigsawAdopt.class
};
for (Class<?> commandType : commandTypes) {
@@ -446,17 +417,17 @@ public class CommandJigsawContractTest {
public void exportOutputIsOneSafeChildArtifact() {
Path root = Path.of("build", "jigsaw-exports").toAbsolutePath().normalize();
assertEquals(root.resolve("village.zip"), CommandJigsaw.resolveExportDestination(
assertEquals(root.resolve("village.zip"), CommandJigsawExports.resolveExportDestination(
root, "village", VanillaJigsawExportFormat.ZIP));
assertEquals(root.resolve("village"), CommandJigsaw.resolveExportDestination(
assertEquals(root.resolve("village"), CommandJigsawExports.resolveExportDestination(
root, "village", VanillaJigsawExportFormat.DIRECTORY));
assertThrows(IllegalArgumentException.class, () -> CommandJigsaw.resolveExportDestination(
assertThrows(IllegalArgumentException.class, () -> CommandJigsawExports.resolveExportDestination(
root, "", VanillaJigsawExportFormat.DIRECTORY));
assertThrows(IllegalArgumentException.class, () -> CommandJigsaw.resolveExportDestination(
assertThrows(IllegalArgumentException.class, () -> CommandJigsawExports.resolveExportDestination(
root, ".", VanillaJigsawExportFormat.DIRECTORY));
assertThrows(IllegalArgumentException.class, () -> CommandJigsaw.resolveExportDestination(
assertThrows(IllegalArgumentException.class, () -> CommandJigsawExports.resolveExportDestination(
root, "../all-exports", VanillaJigsawExportFormat.DIRECTORY));
assertThrows(IllegalArgumentException.class, () -> CommandJigsaw.resolveExportDestination(
assertThrows(IllegalArgumentException.class, () -> CommandJigsawExports.resolveExportDestination(
root, "nested/export", VanillaJigsawExportFormat.DIRECTORY));
}
@@ -467,42 +438,42 @@ public class CommandJigsawContractTest {
Path firstDestination = Path.of("build", "jigsaw-exports", "first.zip");
Path secondDestination = Path.of("build", "jigsaw-exports", "second.zip");
assertEquals(true, CommandJigsaw.beginExport(firstPlayer, firstDestination));
assertEquals(true, CommandJigsawExports.beginExport(firstPlayer, firstDestination));
try {
assertEquals(false, CommandJigsaw.beginExport(firstPlayer, secondDestination));
assertEquals(false, CommandJigsaw.beginExport(secondPlayer, firstDestination));
assertEquals(false, CommandJigsawExports.beginExport(firstPlayer, secondDestination));
assertEquals(false, CommandJigsawExports.beginExport(secondPlayer, firstDestination));
} finally {
CommandJigsaw.finishExport(firstPlayer, firstDestination);
CommandJigsawExports.finishExport(firstPlayer, firstDestination);
}
assertEquals(true, CommandJigsaw.beginExport(secondPlayer, firstDestination));
CommandJigsaw.finishExport(secondPlayer, firstDestination);
assertEquals(true, CommandJigsawExports.beginExport(secondPlayer, firstDestination));
CommandJigsawExports.finishExport(secondPlayer, firstDestination);
}
@Test
public void exportStartFailuresHavePreciseOperatorMessages() {
assertEquals("The active Jigsaw Studio is no longer available.",
CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.NOT_ACTIVE));
CommandJigsawExports.exportStartError(JigsawStudioService.ExportStart.NOT_ACTIVE));
assertEquals("Only the Jigsaw Studio owner can export this project.",
CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.NOT_OWNER));
CommandJigsawExports.exportStartError(JigsawStudioService.ExportStart.NOT_OWNER));
assertEquals("Wait for the pending autosave or discard the edits before exporting the on-disk graph.",
CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.DIRTY));
CommandJigsawExports.exportStartError(JigsawStudioService.ExportStart.DIRTY));
assertEquals("The active Jigsaw Studio is closing and cannot be exported.",
CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.CLOSING));
CommandJigsawExports.exportStartError(JigsawStudioService.ExportStart.CLOSING));
assertEquals("Wait for the current Jigsaw Studio save to finish before exporting.",
CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.SAVE_IN_PROGRESS));
CommandJigsawExports.exportStartError(JigsawStudioService.ExportStart.SAVE_IN_PROGRESS));
assertEquals("Wait for the current Jigsaw Studio operation to finish before exporting.",
CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.OPERATION_IN_PROGRESS));
CommandJigsawExports.exportStartError(JigsawStudioService.ExportStart.OPERATION_IN_PROGRESS));
assertEquals("A Jigsaw Studio export is already in progress.",
CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.IN_PROGRESS));
CommandJigsawExports.exportStartError(JigsawStudioService.ExportStart.IN_PROGRESS));
assertThrows(IllegalArgumentException.class,
() -> CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.STARTED));
() -> CommandJigsawExports.exportStartError(JigsawStudioService.ExportStart.STARTED));
}
@Test
public void exportLeaseReleaseActionRunsExactlyOnce() {
AtomicInteger releases = new AtomicInteger();
CommandJigsaw.ExportLease lease = new CommandJigsaw.ExportLease(releases::incrementAndGet);
CommandJigsawExports.ExportLease lease = new CommandJigsawExports.ExportLease(releases::incrementAndGet);
lease.release();
lease.release();
@@ -510,25 +481,6 @@ public class CommandJigsawContractTest {
assertEquals(1, releases.get());
}
@Test
public void exportSourceAcquiresStudioLeaseBeforeStaticLeaseAndReleasesBothPaths() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/commands/CommandJigsaw.java")).replace("\r\n", "\n");
int serviceLease = source.indexOf("studioService.tryBeginExport(requestId, playerId)");
int staticLease = source.indexOf("beginExport(playerId, destination)", serviceLease);
int dispatch = source.indexOf("J.a(() -> runExport(operation))", staticLease);
int schedulingRelease = source.indexOf("operation.lease().release()", dispatch);
int exporter = source.indexOf("new VanillaJigsawDatapackExporter().export(operation.request())", dispatch);
int completionRelease = source.indexOf("operation.lease().release()", exporter);
assertTrue(serviceLease >= 0);
assertTrue(staticLease > serviceLease);
assertTrue(dispatch > staticLease);
assertTrue(schedulingRelease > dispatch);
assertTrue(exporter > schedulingRelease);
assertTrue(completionRelease > exporter);
}
private static void assertCommand(String name, Class<?>... parameterTypes) throws Exception {
Method method = CommandJigsaw.class.getDeclaredMethod(name, parameterTypes);
assertNotNull(method.getAnnotation(Director.class));
@@ -7,10 +7,8 @@ import art.arcane.iris.core.pack.PackValidationResult;
import art.arcane.volmlib.util.director.compat.DirectorAnnotationCompatibility;
import art.arcane.volmlib.util.director.runtime.DirectorNodeDescriptor;
import art.arcane.volmlib.util.director.runtime.DirectorParameterDescriptor;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import org.junit.After;
import org.junit.Before;
import art.arcane.iris.testsupport.PlatformLeakGuard;
import org.junit.Rule;
import org.junit.Test;
import java.util.List;
@@ -20,22 +18,8 @@ import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class CommandPackCompatTest {
private IrisPlatform previousPlatform;
/** The persisted Minecraft version is only used when no platform is bound, so pin that here. */
@Before
public void unbindPlatform() {
previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
IrisPlatforms.unbind();
}
@After
public void rebindPlatform() {
IrisPlatforms.unbind();
if (previousPlatform != null) {
IrisPlatforms.bind(previousPlatform);
}
}
@Rule
public final PlatformLeakGuard leakGuard = PlatformLeakGuard.clean();
private static CompatFinding block(CompatAction action, String subjectKey) {
return new CompatFinding(
@@ -7,8 +7,6 @@ import org.bukkit.OfflinePlayer;
import org.bukkit.World;
import org.junit.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
@@ -212,20 +210,6 @@ public class IrisPapiExpansionTest {
assertTrue(expansion.persist());
}
@Test
public void theReadmeDocumentsEveryPublishedKeyAndEveryRetiredOne() throws Exception {
String readme = Files.readString(Path.of(System.getProperty("iris.readmeSource")));
for (String key : PUBLISHED_KEYS) {
assertTrue("README must document %iris_" + key + "%", readme.contains("%iris_" + key + "%"));
}
for (String retired : RETIRED_KEYS) {
assertTrue("README must carry the migration row for %iris_" + retired + "%",
readme.contains("%iris_" + retired + "%"));
}
}
@Test
public void theTerrainServiceIsTheOnlyDoorIntoIris() {
IrisTerrainService service = new FakeIrisTerrainService();
@@ -1,137 +0,0 @@
package art.arcane.iris.core.link;
import org.junit.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class IrisPapiLifecycleTest {
private static final Path PLUGIN_SOURCE = Path.of("src/main/java/art/arcane/iris/Iris.java");
private static final String SETUP = "private void setupPapi() {";
private static final String TEARDOWN = "private void teardownPapi() {";
private static String source() throws Exception {
// The assertions below match against literals written with \n, so a CRLF checkout has to
// be folded first or every multi-line expectation misses.
return Files.readString(PLUGIN_SOURCE).replace("\r\n", "\n");
}
private static String body(String declaration) throws Exception {
String source = source();
int start = source.indexOf(declaration);
assertTrue("Iris.java must declare " + declaration, start >= 0);
int open = source.indexOf('{', start);
int depth = 0;
for (int index = open; index < source.length(); index++) {
char character = source.charAt(index);
if (character == '{') {
depth++;
continue;
}
if (character != '}') {
continue;
}
depth--;
if (depth == 0) {
return source.substring(open + 1, index);
}
}
throw new AssertionError(declaration + " is not brace balanced");
}
@Test
public void registrationIsGatedOnPlaceholderApiBeingEnabled() throws Exception {
assertTrue("setupPapi must bail out when PlaceholderAPI is absent",
body(SETUP).contains("if (!PlaceholderRegistration.isPlaceholderApiEnabled()) {"));
}
@Test
public void theRegistrationTheListenerAndTheStateAreAllRetained() throws Exception {
String source = source();
assertTrue(source.contains("private volatile PlaceholderRegistration papiRegistration;"));
assertTrue(source.contains("private volatile IrisPapiListener papiListener;"));
assertTrue(source.contains("private volatile IrisPapiState papiState;"));
}
@Test
public void teardownUnregistersTheExpansionTheListenerAndClearsTheState() throws Exception {
String teardown = body(TEARDOWN);
assertTrue("the retained registration must be unregistered inside teardownPapi",
teardown.contains("registration.unregister();"));
assertTrue("the retained listener must be detached inside teardownPapi",
teardown.contains("HandlerList.unregisterAll(listener);"));
assertTrue("the retained state must drop every held world reference inside teardownPapi",
teardown.contains("state.clear();"));
assertTrue("teardownPapi must drop the retained listener", teardown.contains("papiListener = null;"));
assertTrue("teardownPapi must drop the retained registration", teardown.contains("papiRegistration = null;"));
assertTrue("teardownPapi must drop the retained state", teardown.contains("papiState = null;"));
}
@Test
public void bothDisablePathsTearThePlaceholderSurfaceDown() throws Exception {
String disable = body("public void onDisable() {");
String preUnload = body("public void onPreUnload(ReloadAware.PreUnloadReason reason) {");
int disableTeardown = disable.indexOf("teardownPapi();");
int preUnloadTeardown = preUnload.indexOf("teardownPapi();");
assertTrue("onDisable must tear the expansion down before branching into runtime shutdown",
disableTeardown >= 0 && disableTeardown < disable.indexOf("IrisToolbelt.isServerStopping()"));
assertTrue("the BileTools pre-unload hook must tear the expansion down before any shutdown branch",
preUnloadTeardown >= 0 && preUnloadTeardown < preUnload.indexOf("IrisToolbelt.isServerStopping()"));
}
@Test
public void aFailedListenerAttachDoesNotLeaveTheExpansionRegistered() throws Exception {
String setup = body(SETUP);
int attach = setup.indexOf("registerEvents(listener, this)");
assertTrue("the listener must be attached inside setupPapi", attach >= 0);
int rescue = setup.indexOf("} catch (Throwable failure) {", attach);
assertTrue("the attach must be guarded inside setupPapi", rescue > attach);
int rollback = setup.indexOf("registration.unregister();", rescue);
assertTrue("a failed attach must roll the registration back inside setupPapi", rollback > rescue);
int bail = setup.indexOf("return;", rollback);
assertTrue("a failed attach must leave setupPapi", bail > rollback);
int retained = setup.indexOf("papiRegistration = registration;");
assertTrue("setupPapi must retain the registration", retained > 0);
assertTrue("the registration may only be retained after a successful attach", retained > bail);
}
@Test
public void theExpansionIsNeverConstructedFromAPluginStatic() throws Exception {
String setup = body(SETUP);
assertFalse("the plugin class must not construct the expansion: that forces PlaceholderExpansion to load during enable and crashes a server without PlaceholderAPI",
setup.contains("new IrisPapiExpansion("));
assertTrue("the expansion takes its state as a constructor argument, built inside the installer",
installerSource().contains("new IrisPapiExpansion(state, logger)"));
assertFalse("the hand rolled PlaceholderAPI presence check must be gone",
source().contains("isPluginEnabled(\"PlaceholderAPI\")"));
}
private static String installerSource() throws Exception {
return java.nio.file.Files.readString(java.nio.file.Path.of(
"src/main/java/art/arcane/iris/core/link/IrisPapiInstaller.java")).replace("\r\n", "\n");
}
}
@@ -1,53 +0,0 @@
package art.arcane.iris.core.runtime;
import org.junit.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class StudioPlayerModeContractTest {
@Test
public void autoStartedStudioUsesSpectatorMode() throws IOException {
String plugin = Files.readString(Path.of("src/main/java/art/arcane/iris/Iris.java")).replace("\r\n", "\n");
String commands = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/commands/CommandStudio.java")).replace("\r\n", "\n");
assertFalse(plugin.contains("GameMode.CREATIVE"));
assertFalse(commands.contains("GameMode.CREATIVE"));
assertTrue(plugin.contains("GameMode.SPECTATOR"));
}
@Test
public void tpStudioUsesThePreparedCoordinatorEntry() throws IOException {
String commands = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/commands/CommandStudio.java")).replace("\r\n", "\n");
int methodStart = commands.indexOf("public void tpstudio()");
int methodEnd = commands.indexOf("\n @Director", methodStart);
String method = commands.substring(methodStart, methodEnd);
assertTrue(method.contains("StudioSVC studioService = Iris.service(StudioSVC.class)"));
assertTrue(method.contains("studioService.teleportToActiveProject(player)"));
assertFalse(method.contains("setGameMode("));
assertFalse(method.contains("getActiveProject()"));
assertFalse(method.contains("BukkitPlatform.teleportAsync"));
assertFalse(method.contains("BukkitWorldBinding.spawnLocation"));
}
@Test
public void editingStudiosRemainCreative() throws IOException {
String objectCommands = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/commands/CommandObject.java")).replace("\r\n", "\n");
String jigsawCommands = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/commands/CommandJigsaw.java")).replace("\r\n", "\n");
assertTrue(objectCommands.contains("GameMode.CREATIVE"));
assertTrue(objectCommands.contains("StudioOpenCoordinator.StudioOpenKind.OBJECT"));
assertTrue(jigsawCommands.contains("GameMode.CREATIVE"));
assertFalse(objectCommands.contains("GameMode.SPECTATOR"));
assertFalse(jigsawCommands.contains("GameMode.SPECTATOR"));
}
}
@@ -1,195 +0,0 @@
package art.arcane.iris.core.service;
import org.junit.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class IrisApiWiringContractTest {
@Test
public void theTerrainServiceRegistersOnBothRegistriesAndReleasesBoth() throws IOException {
String source = source("iris.terrainSvcSource");
String onEnable = method(source, "public void onEnable()");
String onDisable = method(source, "public void onDisable()");
assertTrue(onEnable.contains("Bukkit.getServicesManager().register("));
assertTrue(onEnable.contains("IrisTerrainService.class"));
assertTrue(onEnable.contains("IrisServices.register(IrisTerrainService.class, this)"));
assertTrue(onDisable.contains("Bukkit.getServicesManager().unregister(IrisTerrainService.class, this)"));
assertTrue(onDisable.contains("IrisServices.remove(IrisTerrainService.class)"));
assertBefore(onDisable, "serviceEnabled.set(false)", "Bukkit.getServicesManager().unregister(");
}
@Test
public void theTerrainServiceNeverForcesEngineInitialisationOrLoadsTheMantle() throws IOException {
String source = source("iris.terrainSvcSource");
assertFalse("isIrisWorld must not touch the generator", source.contains("IrisToolbelt"));
assertFalse("isIrisWorld must not touch the generator", source.contains(".touch("));
assertFalse("terrain queries must not load mantle chunks", source.contains("getMantle()"));
assertFalse("terrain queries must not load mantle chunks", source.contains("getObjectsAt("));
assertFalse("terrain queries must not load mantle chunks", source.contains("getPOIsAt("));
assertFalse("terrain queries must not load mantle chunks", source.contains("getCaveOrMantleBiome("));
assertFalse("terrain queries must never block", source.contains(".join()"));
assertFalse("terrain queries must never block", source.contains("synchronized"));
assertFalse("terrain queries must never block", source.contains("J.sfut("));
}
@Test
public void sampleColumnsBoundsTheQueryBeforeItWalksAndTreatsAThrowingSinkAsARefusal() throws IOException {
String sampleColumns = method(source("iris.terrainSvcSource"),
"public boolean sampleColumns(World world, IrisColumnQuery query, IrisColumnSink sink)");
assertBefore(sampleColumns, "IrisSampleLimits.withinLimits(", "IrisColumnWalk.walk(");
assertBefore(sampleColumns, "IrisColumnWalk.walk(", "catch (Throwable error)");
assertTrue(sampleColumns.contains("reportSinkFault(world, error)"));
assertTrue(sampleColumns.contains("return false;"));
assertTrue("the walk must abort when the engine closes underneath it",
sampleColumns.contains("engine.isClosed()"));
}
@Test
public void thePregeneratorDispatchesPhasesFromItsExistingTickAndNeverBlocksOnTheSink() throws IOException {
String source = source("iris.pregeneratorJobSource");
String dispatch = method(source, "private void dispatchApiPhases(List<PregenApiPhase> phases)");
assertTrue(dispatch.contains("IrisServices.getOrNull(PregenApiSink.class)"));
assertTrue(dispatch.contains("catch (Throwable error)"));
assertBefore(dispatch, "sink.pregen(phase, progress)", "catch (Throwable error)");
assertFalse(dispatch.contains(".join()"));
assertTrue(method(source, "public void onTick(").contains("dispatchApiPhases(apiPhases.onTick(paused()))"));
assertTrue(method(source, "public void onSaving()").contains("dispatchApiPhases(apiPhases.onSaving())"));
assertTrue(method(source, "public void onClose()").contains("dispatchApiPhases(apiPhases.onClose(reachedTotal()))"));
}
@Test
public void worldPhasesAreFiredOutsideTheRegistrationLock() throws IOException {
String source = source("iris.engineSvcSource");
String add = method(source, "private void add(World world)");
assertBefore(add, "catch (RejectedExecutionException exception)", "phases.ready(world)");
assertBefore(add, "registered = true;", "phases.ready(world)");
String remove = method(source,
"private boolean remove(World world, CompletionStage<Boolean> unloadBoundary)");
assertBefore(remove, "registered = worlds.remove(world)", "phases.closing(world)");
assertBefore(remove, "phases.closing(world)",
"deferCloseUntilWorldUnload(world, registered, closing, unloadBoundary)");
}
@Test
public void everyRegisteredWorldIsAnnouncedClosingWhenTheServiceShutsDown() throws IOException {
String onDisable = method(source("iris.engineSvcSource"), "public void onDisable()");
assertTrue("service shutdown must announce ENGINE_CLOSING for every registered world",
onDisable.contains("phases.closing(teardown.world())"));
assertBefore(onDisable, "worlds.clear()", "phases.closing(teardown.world())");
assertBefore(onDisable, "phases.closing(teardown.world())", "shutdownAndDrain(activeService)");
assertBefore(onDisable, "phases.closing(teardown.world())",
"startClose(teardown.registered(), teardown.closing())");
}
@Test
public void aReplacedEngineIsAnnouncedClosingBeforeTheRetryReRegistersTheWorld() throws IOException {
String add = method(source("iris.engineSvcSource"), "private void add(World world)");
assertBefore(add, "catch (RejectedExecutionException exception)", "phases.closing(world)");
assertBefore(add, "phases.closing(world)", "retryRegistrationAfterClose(world, retryAfter)");
assertBefore(add, "phases.closing(world)", "startClose(replaced, replacementClose)");
}
@Test
public void aWorldPhaseIsNeverConditionalOnAnotherServicesRegistration() throws IOException {
String source = source("iris.apiEventSvcSource");
String fire = method(source, "public static void fireWorldPhase(World world, IrisWorldPhase phase)");
assertFalse("world lifecycle must not resolve a swappable service to build its payload",
fire.contains("IrisServices"));
assertFalse("world lifecycle must not resolve a swappable service to build its payload",
fire.contains("IrisTerrainService"));
assertTrue(fire.contains("deliver(new IrisWorldEngineEvent(world, phase, describe(world, phase)))"));
String describe = method(source, "private static IrisWorldInfo describe(World world, IrisWorldPhase phase)");
assertTrue(describe.contains("IrisWorldInfoFactory.forWorld(world)"));
assertTrue("an undescribable world must be reported, not swallowed",
describe.contains("IrisLogging.reportError("));
assertTrue("an undescribable world must still deliver the phase", describe.contains("return null;"));
}
@Test
public void aWorldPhaseRaisedFromAServerThreadIsDeliveredBeforeThatThreadMovesOn() throws IOException {
String deliver = method(source("iris.apiEventSvcSource"), "private static void deliver(Event event)");
assertBefore(deliver, "Bukkit.isPrimaryThread()", "Bukkit.getPluginManager().callEvent(event)");
assertBefore(deliver, "Bukkit.getPluginManager().callEvent(event)", "Iris.callEvent(event)");
}
@Test
public void theWorldInfoFactoryNeverForcesEngineInitialisationOrLoadsTheMantle() throws IOException {
String source = source("iris.worldInfoFactorySource");
assertFalse("the factory must not touch the generator", source.contains("IrisToolbelt"));
assertFalse("the factory must not touch the generator", source.contains(".touch("));
assertFalse("the factory must not load mantle chunks", source.contains("getMantle()"));
assertFalse("the factory must never block", source.contains(".join()"));
assertFalse("the factory must never block", source.contains("synchronized"));
assertTrue("the factory must refuse a closed engine", source.contains("engine.isClosed()"));
}
@Test
public void theHotloadHookStillFiresTheLegacyEventAlongsideTheApiEvent() throws IOException {
String hook = method(source("iris.bukkitEnginePlatformHooksSource"), "public void fireHotloadEvent(Engine engine)");
assertBefore(hook, "new IrisEngineHotloadEvent(engine)",
"IrisApiEventSVC.fireWorldPhase(BukkitWorldBinding.world(engine.getWorld()), IrisWorldPhase.ENGINE_HOTLOADED)");
}
@Test
public void theEventServiceNeverLetsAThirdPartyFailureEscapeALifecyclePath() throws IOException {
String fire = method(source("iris.apiEventSvcSource"),
"public static void fireWorldPhase(World world, IrisWorldPhase phase)");
assertTrue(fire.contains("catch (Throwable error)"));
assertTrue(fire.contains("IrisLogging.reportError("));
assertFalse(fire.contains("printStackTrace"));
}
private static String source(String property) throws IOException {
String path = System.getProperty(property);
assertTrue("missing source property " + property, path != null && !path.isBlank());
return Files.readString(Path.of(path));
}
private static void assertBefore(String source, String first, String second) {
int firstIndex = source.indexOf(first);
int secondIndex = source.indexOf(second);
assertTrue("Missing source contract token: " + first, firstIndex >= 0);
assertTrue("Missing source contract token: " + second, secondIndex >= 0);
assertTrue(first + " must occur before " + second, firstIndex < secondIndex);
}
private static String method(String source, String signature) {
int start = source.indexOf(signature);
assertTrue("Missing source contract signature: " + signature, start >= 0);
int openBrace = source.indexOf('{', start);
assertTrue("Missing source contract method body: " + signature, openBrace >= 0);
int depth = 0;
for (int index = openBrace; index < source.length(); index++) {
char current = source.charAt(index);
if (current == '{') {
depth++;
} else if (current == '}') {
depth--;
if (depth == 0) {
return source.substring(start, index + 1);
}
}
}
throw new IllegalArgumentException("Unclosed source contract method: " + signature);
}
}
+1 -1
View File
@@ -141,6 +141,7 @@ dependencies {
implementation("net.fabricmc:fabric-loader:${fabricLoaderVersion}")
testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(testFixtures('art.arcane:core'))
testImplementation(libs.spigot) {
transitive = false
}
@@ -207,7 +208,6 @@ tasks.named('compileJava', JavaCompile).configure {
}
tasks.named('test').configure {
systemProperty('iris.moddedCommonSources', file('../modded-common/src/main/java').absolutePath)
}
loom {
+1 -1
View File
@@ -158,6 +158,7 @@ dependencies {
implementation minecraft.dependency("net.minecraftforge:forge:${forgeVersion}")
testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(testFixtures('art.arcane:core'))
testImplementation(libs.spigot) {
transitive = false
}
@@ -240,7 +241,6 @@ tasks.named('compileJava', JavaCompile).configure {
}
tasks.named('test').configure {
systemProperty('iris.moddedCommonSources', file('../modded-common/src/main/java').absolutePath)
}
processResources {
@@ -73,15 +73,9 @@ public class IrisModLanguageAssetsTest {
private Set<String> resourceFiles() throws Exception {
URL resource = IrisModLanguageAssetsTest.class.getClassLoader().getResource(ROOT);
Path directory;
if (resource != null && "file".equals(resource.getProtocol())) {
directory = Path.of(resource.toURI());
} else {
String sources = System.getProperty("iris.moddedCommonSources");
assertNotNull("Missing mod language resource directory and source root", sources);
directory = Path.of(sources).getParent()
.resolve("resources").resolve(ROOT);
}
assertNotNull("Missing mod language resource directory: " + ROOT, resource);
assertEquals("Mod language resources must resolve to a directory on disk", "file", resource.getProtocol());
Path directory = Path.of(resource.toURI());
try (Stream<Path> paths = Files.list(directory)) {
return paths
.filter(Files::isRegularFile)
@@ -9,13 +9,9 @@ import net.minecraft.world.level.biome.MobSpawnSettings;
import org.junit.Test;
import org.junit.BeforeClass;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class IrisModdedChunkGeneratorSpawnTest {
@BeforeClass
@@ -48,21 +44,4 @@ public class IrisModdedChunkGeneratorSpawnTest {
assertEquals(explicitCow, entries.get(2).value());
assertEquals(3, entries.get(2).weight());
}
@Test
public void vanillaChunkGenerationMobsUseTheVisibleBiomesVanillaDerivative() throws IOException {
Path sourcePath = Path.of(System.getProperty("iris.moddedCommonSources"),
"art/arcane/iris/modded/IrisModdedChunkGenerator.java");
String source = Files.readString(sourcePath);
int spawnStart = source.indexOf("public void spawnOriginalMobs");
int spawnEnd = source.indexOf("@Override", spawnStart + 1);
String spawn = source.substring(spawnStart, spawnEnd);
assertTrue(spawn.contains("spawnTables.initializeVanillaSpawnBiomes(registry)"));
assertTrue(spawn.contains("structureBiomeSource.getVisibleSurfaceBiome("));
assertTrue(spawn.contains("spawnTables.vanillaSpawnBiome(visibleBiome.value())"));
assertTrue(spawn.contains("NaturalSpawner.spawnMobsForChunkGeneration("));
assertTrue(spawn.contains("new LegacyRandomSource(RandomSupport.generateUniqueSeed())"));
assertTrue(spawn.contains("random.setDecorationSeed(region.getSeed()"));
}
}
@@ -14,9 +14,6 @@ import net.minecraft.server.Bootstrap;
import org.junit.BeforeClass;
import org.junit.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.Set;
@@ -64,16 +61,10 @@ public class IrisModdedStructureParityTest {
}
@Test
public void strongholdRingSearchSamplesOneQuartColumnPerChunk() throws IOException {
public void strongholdRingSearchSamplesOneQuartColumnPerChunk() {
assertEquals(4, IrisModdedBiomeSource.horizontalBiomeSearchQuartStep(0, 112));
assertEquals(1, IrisModdedBiomeSource.horizontalBiomeSearchQuartStep(1, 112));
assertEquals(1, IrisModdedBiomeSource.horizontalBiomeSearchQuartStep(0, 111));
String source = Files.readString(Path.of(
System.getProperty("iris.moddedCommonSources"),
"art/arcane/iris/modded/IrisModdedBiomeSource.java"));
assertTrue(source.contains("x, y, z, searchRadius, quartStep, allowed, random, false, sampler"));
assertTrue(source.contains(
"return super.findBiomeHorizontal(x, y, z, searchRadius, allowed, random, sampler)"));
}
@Test
@@ -130,126 +121,12 @@ public class IrisModdedStructureParityTest {
}
@Test
public void visibleBiomeResolutionSelectsStackOwnershipBeforeHostCaves() throws IOException {
String source = Files.readString(Path.of(
System.getProperty("iris.moddedCommonSources"),
"art/arcane/iris/modded/IrisModdedBiomeSource.java"));
int resolutionStart = source.indexOf("private BiomeResolution resolveBiomeResolution(");
int resolutionEnd = source.indexOf("private Holder<Biome> resolveRequiredStructureBiome(", resolutionStart);
assertTrue(resolutionStart >= 0);
assertTrue(resolutionEnd > resolutionStart);
String resolution = source.substring(resolutionStart, resolutionEnd);
assertTrue(resolution.contains("resolveDimensionStackLayer("));
assertTrue(resolution.contains("!stackLayer.terrainContext().isSelfReferencing()"));
assertTrue(resolution.indexOf("resolveDimensionStackLayer(")
< resolution.indexOf("boolean underground"));
assertTrue(resolution.contains("includeDimensionStack"));
assertTrue(resolution.contains(
"return resolveBiomeResolution(engine, quartX, quartY, quartZ, false)"));
assertTrue(source.contains("resolution.packName(), resolution.dimension(), customBiome.getId()"));
int helperStart = source.indexOf("private static IrisBiome resolveSurfaceStructureBiome(");
int helperEnd = source.indexOf("private static Set<String> registeredBiomeKeys(", helperStart);
String helper = source.substring(helperStart, helperEnd);
assertTrue(helper.contains("engine.getComplex().getTrueBiomeStream().get(blockX, blockZ)"));
assertFalse(helper.contains("getDimensionStackContext()"));
assertFalse(helper.contains("getSurfaceBiome("));
}
@Test
public void stackBiomesAreVisibleButExcludedFromStructureReachability() throws IOException {
String source = Files.readString(Path.of(
System.getProperty("iris.moddedCommonSources"),
"art/arcane/iris/modded/IrisModdedBiomeSource.java"));
assertTrue(source.contains("return biomeKeySets().structureRequired()"));
assertTrue(source.contains("generatedBiomeKeys.addAll(keys.visibleRequired())"));
assertTrue(source.contains("collectStructureBiomeKeys("));
assertTrue(source.contains("dimension.getReachableBiomes(() -> data)"));
}
@Test
public void biomeLocatorSearchesTheVisibleStack() throws IOException {
String source = Files.readString(Path.of(
System.getProperty("iris.moddedCommonSources"),
"art/arcane/iris/modded/IrisModdedBiomeSource.java"));
int start = source.indexOf("public Pair<BlockPos, Holder<Biome>> findClosestBiome3d(");
int end = source.indexOf("private Holder<Biome> getNoiseBiome(", start);
String method = source.substring(start, end);
assertTrue(method.contains("modded_locate_visible_biome"));
assertTrue(method.contains("return super.findClosestBiome3d("));
assertTrue(method.contains("collectVisibleBiomeHolders()"));
assertTrue(method.contains("QuartPos.toBlock(quartX), QuartPos.toBlock(quartZ)"));
assertTrue(method.contains("resolveVisibleBiome("));
assertFalse(method.contains("Holder<Biome> structureBiome = getNoiseBiome("));
assertTrue(method.indexOf("tryAcquireGenerationLease(engine, \"modded_locate_visible_biome\")")
< method.indexOf("engine.getDimensionStackContext()"));
}
@Test
public void surfaceSpawnBiomeUsesTheVisibleQuartCell() throws IOException {
String source = Files.readString(Path.of(
System.getProperty("iris.moddedCommonSources"),
"art/arcane/iris/modded/IrisModdedBiomeSource.java"));
int start = source.indexOf("Holder<Biome> getVisibleSurfaceBiome(");
int end = source.indexOf("boolean isStructureReachable(", start);
String method = source.substring(start, end);
assertTrue(method.contains("QuartPos.fromBlock(blockX)"));
assertTrue(method.contains("QuartPos.toBlock(quartX)"));
assertTrue(method.contains("QuartPos.fromBlock(internalY + engine.getMinHeight())"));
assertTrue(method.contains("stackContext.getLayout(sampleX, sampleZ).surfaceLayer()"));
assertTrue(method.contains("visibleBiomeCache"));
assertTrue(method.contains("isBiomeCacheable(engine, sampleX, sampleZ)"));
assertTrue(method.indexOf("boolean cacheable = isBiomeCacheable(engine, sampleX, sampleZ)")
< method.indexOf("Engine.hostHeight(engine, sampleX, sampleZ, true)"));
assertCacheGuarded(method, "visibleBiomeCache");
}
@Test
public void temporaryNaturalBiomeAnswersAreNotMemoized() throws IOException {
public void temporaryNaturalBiomeAnswersAreNotMemoized() {
Engine engine = mock(Engine.class);
when(engine.answersFromNaturalTerrain(12, -8)).thenReturn(true);
assertFalse(IrisModdedBiomeSource.isBiomeCacheable(engine, 12, -8));
when(engine.answersFromNaturalTerrain(12, -8)).thenReturn(false);
assertTrue(IrisModdedBiomeSource.isBiomeCacheable(engine, 12, -8));
String source = Files.readString(Path.of(
System.getProperty("iris.moddedCommonSources"),
"art/arcane/iris/modded/IrisModdedBiomeSource.java"));
int structureStart = source.indexOf("private Holder<Biome> getNoiseBiome(Engine engine");
int structureEnd = source.indexOf("Holder<Biome> getVisibleNoiseBiome(", structureStart);
String structure = source.substring(structureStart, structureEnd);
assertTrue(structure.contains("QuartPos.toBlock(quartX)"));
assertTrue(structure.contains("QuartPos.toBlock(quartZ)"));
assertCacheGuarded(structure, "structureBiomeCache");
int visibleStart = source.indexOf(
"private Holder<Biome> getVisibleNoiseBiomeWithActiveGenerationLease(");
int visibleEnd = source.indexOf("Holder<Biome> getVisibleSurfaceBiome(", visibleStart);
String visible = source.substring(visibleStart, visibleEnd);
assertTrue(visible.contains("QuartPos.toBlock(quartX)"));
assertTrue(visible.contains("QuartPos.toBlock(quartZ)"));
assertCacheGuarded(visible, "visibleBiomeCache");
int predicateStart = source.indexOf("static boolean isBiomeCacheable(");
int predicateEnd = source.indexOf("static boolean isMonumentSurfaceBiomeQuery(", predicateStart);
String predicate = source.substring(predicateStart, predicateEnd);
assertTrue(predicate.contains("return !engine.answersFromNaturalTerrain(blockX, blockZ)"));
assertFalse(predicate.contains("getDimensionStackContext()"));
}
@Test
public void importedFeaturesCollectOnlyHostOwnedStackCells() throws IOException {
String source = Files.readString(Path.of(
System.getProperty("iris.moddedCommonSources"),
"art/arcane/iris/modded/ModdedImportedFeatureStage.java"));
assertTrue(source.contains("stackContext.getLayerAt("));
assertTrue(source.contains("layer.terrainContext().isSelfReferencing()"));
assertTrue(source.contains("section.getBiomes().get(quartX, quartY, quartZ)"));
}
@Test
@@ -281,17 +158,6 @@ public class IrisModdedStructureParityTest {
assertTrue(recursiveKeys.contains("layers:sky/aurora"));
}
@Test
public void biomeWriterDerivativeFallbackMatchesCanonicalRecursiveKeys() throws IOException {
String source = Files.readString(Path.of(
System.getProperty("iris.moddedCommonSources"),
"art/arcane/iris/modded/ModdedBiomeWriter.java"));
assertTrue(source.contains(
"key.equalsIgnoreCase(dimension.getCustomBiomeKey(custom.getId()))"));
assertFalse(source.contains("String dimensionLoadKey = key.substring(0, colon)"));
}
@Test
public void structureStateRejectsBiomesOutsideThePackContract() {
Set<String> generated = Set.of("minecraft:deep_ocean", "minecraft:dark_forest");
@@ -410,21 +276,4 @@ public class IrisModdedStructureParityTest {
public void initialEntitySpawnsUseThePaperCompletionMarker() {
assertSame(MantleFlag.INITIAL_SPAWNED_MARKER, ModdedWorldManager.INITIAL_SPAWN_COMPLETION_FLAG);
}
private static void assertCacheGuarded(String method, String cacheName) {
int predicate = method.indexOf("boolean cacheable = isBiomeCacheable(");
int cache = method.indexOf("BiomeHolderTable cache = " + cacheName, predicate);
int read = method.indexOf("cache.get(", predicate);
int readGuard = method.lastIndexOf("if (cacheable)", read);
int write = method.indexOf("cache.put(", read);
int writeGuard = method.lastIndexOf("if (cacheable)", write);
assertTrue(predicate >= 0);
assertTrue(cache >= 0);
assertTrue(read > predicate);
assertTrue(readGuard >= predicate);
assertTrue(write > read);
assertTrue(writeGuard > read);
}
}
@@ -1,91 +0,0 @@
package art.arcane.iris.modded;
import org.junit.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertTrue;
public class ModdedDynamicLevelLifecycleContractTest {
private static final String SOURCE_ROOT_PROPERTY = "iris.moddedCommonSources";
@Test
public void sharedManagerPublishesDynamicLoadAndUnloadAroundRegistration() throws IOException {
String manager = commonSource("ModdedDimensionManager.java");
String loader = commonSource("ModdedLoader.java");
String injection = method(manager, "private static Handle inject(");
String removal = method(manager, "public static boolean remove(");
String injectionRollback = method(manager, "private static void rollbackInjection(");
String removalRollback = method(manager, "private static void rollbackRemoval(");
assertTrue(loader.contains("void fireDynamicLevelLoad(MinecraftServer server, ServerLevel level);"));
assertTrue(loader.contains("void fireDynamicLevelUnload(MinecraftServer server, ServerLevel level);"));
assertBefore(injection, "serverAccess.putLevelIfAbsent(server, key, level);",
"fireDynamicLevelLoad(server, level);");
assertBefore(removal, "fireDynamicLevelUnload(server, level);",
"serverAccess.removeLevel(server, key);");
assertTrue(injectionRollback.contains("fireDynamicLevelUnload(server, level);"));
assertTrue(removalRollback.contains("fireDynamicLevelLoad(server, level);"));
}
@Test
public void everyLoaderUsesItsNativeLifecycleBus() throws IOException {
String fabric = loaderSource("fabric", "art/arcane/iris/fabric/FabricModdedLoader.java");
String forge = loaderSource("forge", "art/arcane/iris/forge/ForgeModdedLoader.java");
String neoForge = loaderSource("neoforge", "art/arcane/iris/neoforge/NeoForgeModdedLoader.java");
assertTrue(fabric.contains("ServerLevelEvents.LOAD.invoker().onLevelLoad(server, level);"));
assertTrue(fabric.contains("ServerLevelEvents.UNLOAD.invoker().onLevelUnload(server, level);"));
assertTrue(forge.contains("LevelEvent.Load.BUS.post(new LevelEvent.Load(level));"));
assertTrue(forge.contains("LevelEvent.Unload.BUS.post(new LevelEvent.Unload(level));"));
assertTrue(neoForge.contains("NeoForge.EVENT_BUS.post(new LevelEvent.Load(level));"));
assertTrue(neoForge.contains("NeoForge.EVENT_BUS.post(new LevelEvent.Unload(level));"));
}
private static String commonSource(String file) throws IOException {
return Files.readString(commonRoot().resolve("art/arcane/iris/modded").resolve(file))
.replace("\r\n", "\n");
}
private static String loaderSource(String loader, String relative) throws IOException {
Path adaptersRoot = commonRoot().getParent().getParent().getParent().getParent();
return Files.readString(adaptersRoot.resolve(loader).resolve("src/main/java").resolve(relative))
.replace("\r\n", "\n");
}
private static Path commonRoot() {
String root = System.getProperty(SOURCE_ROOT_PROPERTY);
if (root == null || root.isBlank()) {
throw new IllegalStateException("Missing test property " + SOURCE_ROOT_PROPERTY);
}
return Path.of(root);
}
private static String method(String source, String signature) {
int start = source.indexOf(signature);
if (start < 0) {
throw new IllegalArgumentException("Missing source method " + signature);
}
int open = source.indexOf('{', start);
int depth = 0;
for (int index = open; index < source.length(); index++) {
char current = source.charAt(index);
if (current == '{') {
depth++;
} else if (current == '}' && --depth == 0) {
return source.substring(start, index + 1);
}
}
throw new IllegalArgumentException("Unclosed source method " + signature);
}
private static void assertBefore(String source, String first, String second) {
int firstIndex = source.indexOf(first);
int secondIndex = source.indexOf(second);
assertTrue("Missing source token " + first, firstIndex >= 0);
assertTrue("Missing source token " + second, secondIndex >= 0);
assertTrue(first + " must precede " + second, firstIndex < secondIndex);
}
}
@@ -7,12 +7,8 @@ import net.minecraft.world.level.Level;
import net.minecraft.world.level.chunk.ChunkGenerator;
import org.junit.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
@@ -24,8 +20,6 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class ModdedGenerationBootstrapBindingTest {
private static final String SOURCE_ROOT_PROPERTY = "iris.moddedCommonSources";
@Test
public void publishedLevelRequiresCurrentGeneratorIdentity() {
IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class, CALLS_REAL_METHODS);
@@ -89,62 +83,4 @@ public class ModdedGenerationBootstrapBindingTest {
assertSame(dynamicLevel, generator.resolveBoundLevel(server, List.of(dynamicLevel)));
verify(server, never()).getLevel(Level.OVERWORLD);
}
@Test
public void firstGenerationStagesProvideAuthoritativeLevelContext() throws IOException {
String source = source("IrisModdedChunkGenerator.java");
String structures = method(source, "public void createStructures(");
String references = method(source, "public void createReferences(");
assertTrue(structures.contains("Engine current = engine(levelKey);"));
assertTrue(references.contains("Engine current = engine(level.getLevel());"));
String publishedBinding = method(source, "private Engine engine(ResourceKey<Level> levelKey)");
assertTrue(publishedBinding.contains("requirePublishedLevel(ModdedEngineBootstrap.currentServer(), levelKey)"));
assertTrue(publishedBinding.contains("return bindGenerationLevel(level);"));
assertFalse(publishedBinding.contains("ModdedServerLevels.level("));
String fullBinding = method(source, "private Engine bindGenerationLevel(ServerLevel level)");
assertTrue(fullBinding.contains("bindLevel(level);"));
assertTrue(fullBinding.contains("Engine bound = readyEngine();"));
String genericBinding = method(source, "Engine engine()");
assertTrue(genericBinding.contains("return bindGenerationLevel(level);"));
}
@Test
public void constructorMixinIsNotPartOfGenerationBootstrap() throws IOException {
String sourceRoot = System.getProperty(SOURCE_ROOT_PROPERTY);
assertTrue(sourceRoot != null && !sourceRoot.isBlank());
Path root = Path.of(sourceRoot);
assertFalse(Files.exists(root.resolve(Path.of("art", "arcane", "iris", "modded", "mixin",
"ServerLevelBindingMixin.java"))));
String mixinConfig = Files.readString(root.getParent().resolve("resources/irisworldgen.entity.mixins.json"));
assertFalse(mixinConfig.contains("ServerLevelBindingMixin"));
}
private String source(String fileName) throws IOException {
String sourceRoot = System.getProperty(SOURCE_ROOT_PROPERTY);
assertTrue(sourceRoot != null && !sourceRoot.isBlank());
return Files.readString(Path.of(sourceRoot, "art", "arcane", "iris", "modded", fileName));
}
private String method(String source, String signature) {
int start = source.indexOf(signature);
assertTrue(start >= 0);
int body = source.indexOf('{', start);
assertTrue(body >= 0);
int depth = 0;
for (int index = body; index < source.length(); index++) {
char token = source.charAt(index);
if (token == '{') {
depth++;
} else if (token == '}') {
depth--;
if (depth == 0) {
return source.substring(start, index + 1);
}
}
}
throw new IllegalArgumentException("Unclosed source contract method: " + signature);
}
}
@@ -1,203 +0,0 @@
package art.arcane.iris.modded;
import org.junit.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class ModdedGenerationHistoryRoutingContractTest {
private static final String SOURCE_ROOT_PROPERTY = "iris.moddedCommonSources";
@Test
public void startupPublishesOnlyAnImmutableHistoryBoundRuntime() throws IOException {
String source = source("ModdedWorldEngines.java");
String create = method(source, "private static Engine create(");
String createHistoryEngine = method(source, "private static Engine createHistoryEngine(");
String buildEngine = method(source, "private static IrisEngine buildEngine(");
assertTrue(create.contains("ModdedGenerationHistoryStorage.openOrAdopt("));
assertFalse(create.contains("resolvePack("));
assertTrue(createHistoryEngine.contains("candidate.attachGenerationHistory("));
assertTrue(createHistoryEngine.contains("IrisBoundarySignatureSampler.INSTANCE"));
assertTrue(createHistoryEngine.contains("getGenerationTransitionWidthBlocks()"));
assertTrue(createHistoryEngine.contains("router.preloadActiveRuntimes();"));
assertBefore(createHistoryEngine, "history.prepareCurrentGenerator(",
"opened = ModdedGenerationHistoryStorage.resolveActive(history)");
assertBefore(createHistoryEngine, "opened = ModdedGenerationHistoryStorage.resolveActive(history)",
"IrisEngine candidate = buildEngine(level, seed, opened)");
String promoted = method(createHistoryEngine,
"if (history.activeActivation().activationId() != openedActivationId)");
assertBefore(promoted, "close(candidate);", "ModdedGenerationHistoryStorage.resolveActive(history)");
assertBefore(promoted, "ModdedGenerationHistoryStorage.resolveActive(history)",
"candidate = buildEngine(level, seed, promoted)");
assertTrue(createHistoryEngine.contains("GenerationHistoryRuntimeRouter.attach("));
assertTrue(buildEngine.contains("active.packRoot().toFile()"));
assertTrue(buildEngine.contains("history.paths().activationMantleRoot(activation.activationId())"));
assertTrue(buildEngine.contains("epoch.kernelVersion()"));
assertTrue(buildEngine.contains("transitionPlan"));
}
@Test
public void onlyExplicitTransientStudioEnginesBypassHistory() throws IOException {
String manager = source("ModdedDimensionManager.java");
String transientCreate = method(manager, "public static Handle createTransientStudio(");
String persistentCreate = method(manager, "public static Handle createPersistent(");
String engines = source("ModdedWorldEngines.java");
String studio = method(engines, "private static IrisEngine createTransientStudioEngine(");
String generator = source("IrisModdedChunkGenerator.java");
String bypass = method(generator, "boolean allowsGenerationHistoryBypass(");
String route = method(generator, "private GenerationHistoryRuntimeRouter.RuntimeRoute openHistoryRoute(");
assertTrue(transientCreate.contains("ModdedGenerationMode.TRANSIENT_STUDIO"));
assertTrue(persistentCreate.contains("ModdedGenerationMode.PERSISTENT_CREATE"));
assertTrue(studio.contains("IrisData.openRuntime(packDir)"));
assertTrue(studio.contains("IrisEngine.InitializationMode.STUDIO"));
assertFalse(studio.contains("ModdedGenerationHistoryStorage"));
assertFalse(studio.contains("attachGenerationHistory("));
assertTrue(bypass.contains("generationMode != ModdedGenerationMode.TRANSIENT_STUDIO"));
assertTrue(bypass.contains("irisEngine.isStudio()"));
assertTrue(bypass.contains("Only an explicitly transient Iris Studio engine"));
assertBefore(route, "allowsGenerationHistoryBypass(current)", "requireHistoryRouter(current, operation)");
}
@Test
public void everyCoordinateBearingChunkStageOpensItsRoutedRuntime() throws IOException {
String source = source("IrisModdedChunkGenerator.java");
assertRuntimeRoute(source, "public Pair<BlockPos, Holder<Structure>> findNearestMapStructure(");
assertCoordinateRoute(source, "public WeightedList<MobSpawnSettings.SpawnerData> getMobsAt(");
assertRuntimeRoute(source, "public CompletableFuture<ChunkAccess> createBiomes(");
assertRuntimeRoute(source, "public void applyCarvers(");
assertRuntimeRoute(source, "public void buildSurface(");
assertRuntimeRoute(source, "public void applyBiomeDecoration(");
assertRuntimeRoute(source, "public void createStructures(");
assertRuntimeRoute(source, "public void createReferences(");
assertRuntimeRoute(source, "public void spawnOriginalMobs(");
assertCoordinateRoute(source, "public int getBaseHeight(");
assertCoordinateRoute(source, "public NoiseColumn getBaseColumn(");
}
@Test
public void asyncTerrainKeepsTheRouteUntilDurableSemanticClaim() throws IOException {
String source = source("IrisModdedChunkGenerator.java");
String fill = method(source, "public CompletableFuture<ChunkAccess> fillFromNoise(");
String terrain = method(source, "private ChunkAccess generateTerrain(");
String completion = method(source, "private static CompletableFuture<ChunkAccess> closeRouteOnCompletion(");
assertBefore(fill, "openHistoryRoute(", "CompletableFuture.supplyAsync(");
assertTrue(fill.contains("generateTerrain(chunk, generationEngine, pos, air, route)"));
assertTrue(fill.contains("return closeRouteOnCompletion(pipeline, route);"));
assertTrue(terrain.contains("openHistoryRuntimeScope(route)"));
assertBefore(terrain, "generationEngine.generate(", "writeBlocks(chunk, blocks, dimMinY, height)");
assertBefore(terrain, "writeBlocks(chunk, blocks, dimMinY, height)", "route.claimGeneratedSemantics(");
assertTrue(terrain.contains("chunk.getBlockState(position.set(x, dimMinY + y, z))"));
assertTrue(terrain.contains("state.isAir() || state.liquid()"));
assertBefore(terrain, "route.claimGeneratedSemantics(", "ModdedNativeTerrainReceipts.persist(chunk, route)");
assertBefore(terrain, "ModdedNativeTerrainReceipts.persist(chunk, route)", "return chunk;");
assertTrue(completion.contains("pipeline.whenComplete("));
assertTrue(completion.contains("route.close();"));
assertBefore(completion, "pipeline.whenComplete(", "route.detachThread()");
assertBefore(completion, "route.detachThread()", "return completion;");
}
@Test
public void biomeQueriesRouteEachCoordinateAndKeepRuntimeSpecificCaches() throws IOException {
String source = source("IrisModdedBiomeSource.java");
String structure = method(source, "public Holder<Biome> getNoiseBiome(");
String visible = method(source, "Holder<Biome> getVisibleNoiseBiome(");
String batch = method(source, "public Set<Holder<Biome>> getBiomesWithin(");
String resolution = method(source, "private Holder<Biome> resolveVisibleBiome(");
assertTrue(structure.contains("openHistoryCoordinateScope("));
assertTrue(visible.contains("openHistoryCoordinateScope("));
assertTrue(batch.contains("quartX << 2, quartZ << 2"));
assertTrue(source.contains("int runtimeIdentity = engine.getCacheID();"));
assertTrue(source.contains("cache.get(runtimeIdentity, key)"));
assertTrue(source.contains("cache.put(runtimeIdentity, key, resolved)"));
assertBefore(resolution, "historicalPhysicalBiomeKeyAt(", "ModdedWorldgenIds.biomeRef(engine");
}
@Test
public void pendingNativeStagesPreserveSavedStartsAndGuardNewFeatureFootprints() throws IOException {
String generator = source("IrisModdedChunkGenerator.java");
String discrete = method(generator, "private boolean allowsRoutedDiscreteGeneration(");
String decoration = method(generator, "public void applyBiomeDecoration(");
String structures = method(generator, "public void createStructures(");
String mobs = method(generator, "public void spawnOriginalMobs(");
String nativeStage = source("ModdedNativeStructureStage.java");
String adjust = method(nativeStage, "void adjustGeneratedStructures(");
String place = method(nativeStage, "void placeVanillaStructures(");
String savedStart = method(nativeStage, "private boolean isHistoricalStructureStart(");
assertTrue(discrete.contains("chunk.getPersistedStatus().isOrAfter(stage)"));
assertFalse(discrete.contains("router.history().activeActivation().activationId()"));
assertFalse(discrete.contains("return true;"));
assertTrue(discrete.contains("allowsNewGenerationChunk("));
assertTrue(discrete.contains("NativeGenerationWriteGuard.allowsPendingStage(current, chunk, stage)"));
assertTrue(decoration.contains("ChunkStatus.FEATURES"));
assertTrue(structures.contains("ChunkStatus.STRUCTURE_STARTS"));
assertTrue(mobs.contains("ChunkStatus.SPAWN"));
assertBefore(decoration, "chunk.getPersistedStatus().isOrAfter(ChunkStatus.FEATURES)",
"nativeStructures.placeVanillaStructures(");
assertBefore(decoration, "nativeStructures.placeVanillaStructures(", "allowsRoutedDiscreteGeneration(");
assertBefore(decoration, "allowsRoutedDiscreteGeneration(", "importedFeatures.run(");
assertBefore(decoration, "NativeGenerationWriteGuard.allowsDecoration(", "importedFeatures.run(");
assertBefore(structures, "allowsRoutedDiscreteGeneration(", "super.createStructures(");
assertBefore(mobs, "allowsRoutedDiscreteGeneration(", "NaturalSpawner.spawnMobsForChunkGeneration(");
assertBefore(adjust, "allowsNewGenerationFootprint(", "recordWorldCheckStructureShift(");
assertBefore(place, "!isHistoricalStructureStart(current, world, start)", "allowsNewGenerationFootprint(");
assertTrue(savedStart.contains("world.getChunk(origin.x(), origin.z(), ChunkStatus.EMPTY, false)"));
assertTrue(savedStart.contains("ModdedNativeTerrainReceipts.structureActivation(source)"));
assertBefore(structures, "super.createStructures(", "ModdedNativeTerrainReceipts.persistStructureActivation(");
}
private static void assertRuntimeRoute(String source, String signature) {
String body = method(source, signature);
assertTrue(signature + " must open a generation-history route", body.contains("openHistoryRoute("));
assertTrue(signature + " must open its routed runtime", body.contains("openHistoryRuntimeScope(route)"));
}
private static void assertCoordinateRoute(String source, String signature) {
String body = method(source, signature);
assertTrue(signature + " must route its coordinate", body.contains("openHistoryCoordinateScope("));
}
private static void assertBefore(String source, String first, String second) {
int firstIndex = source.indexOf(first);
int secondIndex = source.indexOf(second);
assertTrue("Missing source contract token: " + first, firstIndex >= 0);
assertTrue("Missing source contract token: " + second, secondIndex >= 0);
assertTrue(first + " must occur before " + second, firstIndex < secondIndex);
}
private static String source(String fileName) throws IOException {
String sourceRoot = System.getProperty(SOURCE_ROOT_PROPERTY);
assertTrue("Missing system property " + SOURCE_ROOT_PROPERTY,
sourceRoot != null && !sourceRoot.isBlank());
return Files.readString(Path.of(sourceRoot, "art", "arcane", "iris", "modded", fileName));
}
private static String method(String source, String signature) {
int start = source.indexOf(signature);
assertTrue("Missing source contract signature: " + signature, start >= 0);
int openBrace = source.indexOf('{', start);
assertTrue("Missing source contract method body: " + signature, openBrace >= 0);
int depth = 0;
for (int index = openBrace; index < source.length(); index++) {
char current = source.charAt(index);
if (current == '{') {
depth++;
} else if (current == '}') {
depth--;
if (depth == 0) {
return source.substring(start, index + 1);
}
}
}
throw new IllegalArgumentException("Unclosed source contract method: " + signature);
}
}
@@ -1,244 +0,0 @@
package art.arcane.iris.modded;
import org.junit.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class ModdedGenerationLeaseContractTest {
private static final String SOURCE_ROOT_PROPERTY = "iris.moddedCommonSources";
@Test
public void biomeRuntimeReadsAreGenerationLeased() throws IOException {
String source = source("art/arcane/iris/modded/IrisModdedBiomeSource.java");
assertTrue(source.contains("tryAcquireGenerationLease(engine, \"modded_structure_biome\")"));
assertTrue(source.contains("tryAcquireGenerationLease(engine, \"modded_visible_biome\")"));
assertTrue(source.contains("tryAcquireGenerationLease(engine, \"modded_biomes_within\")"));
assertTrue(source.contains("tryAcquireGenerationLease(engine, \"modded_structure_reachability\")"));
assertTrue(source.contains("tryAcquireGenerationLease(engine, \"modded_structure_state_biome\")"));
assertTrue(source.contains("tryAcquireGenerationLease(engine, \"modded_structure_biome_keys\")"));
assertTrue(source.contains("engine == null || engine.isClosed()"));
assertTrue(source.contains("catch (GenerationSessionException e)"));
assertTrue(source.contains("e.isExpectedTeardown()"));
assertTrue(source.contains("Iris structure biome key lookup was rejected during an engine transition"));
assertTrue(source.contains("int runtimeIdentity = engine.getCacheID();"));
assertTrue(source.contains("cache.get(runtimeIdentity, key)"));
assertTrue(source.contains("cache.put(runtimeIdentity, key, resolved)"));
}
@Test
public void terrainAndHeightQueriesShareGenerationSessions() throws IOException {
String source = source("art/arcane/iris/modded/IrisModdedChunkGenerator.java");
assertTrue(source.contains("generationEngine.acquireGenerationLease(\"modded_chunk_pipeline\")"));
assertTrue(source.contains("current.acquireGenerationLease(\"modded_base_height\")"));
assertTrue(source.contains("current.acquireGenerationLease(\"modded_base_column\")"));
assertTrue(source.contains("current.acquireGenerationLease(\"modded_configured_biome_keys\")"));
assertTrue(source.contains("generationEngine.isClosing() || e.isExpectedTeardown()"));
String terrain = method(source, "private ChunkAccess generateTerrain(");
int sessionFailure = terrain.indexOf("catch (GenerationSessionException e)");
int generalFailure = terrain.indexOf("catch (Throwable e)", sessionFailure);
String sessionHandler = terrain.substring(sessionFailure, generalFailure);
assertTrue(sessionHandler.contains("throw new IllegalStateException"));
assertFalse(sessionHandler.contains("return chunk;"));
String references = method(source, "public void createReferences(");
assertTrue(references.contains("requireGenerationLease(current, \"modded_create_references\")"));
assertTrue(references.contains("IrisContext.open(current, lease.sessionId(), null)"));
assertTrue(references.contains("NativeStructureReferenceRepair.createReferences("));
assertFalse(references.contains("super.createReferences(level, structureManager, chunk);"));
}
@Test
public void preservationDereferencesSharedPackData() throws IOException {
String source = source("art/arcane/iris/modded/service/ModdedPreservationService.java");
int dereference = source.indexOf("public void dereference()");
int irisData = source.indexOf("IrisData.dereference();", dereference);
int trackedResources = source.indexOf("threads.removeIf", dereference);
assertTrue(dereference >= 0);
assertTrue(irisData > dereference);
assertTrue(trackedResources > irisData);
}
@Test
public void hotloadShutdownOnlyTargetsItsOwnWorld() throws IOException {
String source = source("art/arcane/iris/modded/service/ModdedStudioHotloadService.java");
int shutdown = source.indexOf("public void shutdownPregenerator(Engine engine)");
int nextMethod = source.indexOf("private boolean throttled", shutdown);
String method = source.substring(shutdown, nextMethod);
assertTrue(method.contains("ModdedPregenJob.shutdownForWorld(world.identity());"));
assertFalse(method.contains("PregeneratorJob.shutdownInstance();"));
}
@Test
public void maintenanceOwnsOneGenerationSessionUntilWorkCompletes() throws IOException {
String source = source("art/arcane/iris/modded/service/ModdedEngineMaintenanceService.java");
String maintain = method(source, "private void maintain(Engine engine, long scheduledAt)");
assertTrue(maintain.contains("engine.acquireGenerationLease(\"modded_engine_maintenance\")"));
assertTrue(maintain.contains("IrisContext.open(engine, lease.sessionId(), null)"));
assertTrue(maintain.contains("exception.isExpectedTeardown()"));
assertTrue(source.contains("active.awaitTermination(INTERRUPT_DRAIN_TIMEOUT_SECONDS, TimeUnit.SECONDS)"));
}
@Test
public void packDownloadAdmissionPrecedesAsyncDispatch() throws IOException {
String source = source("art/arcane/iris/modded/command/IrisModdedCommands.java");
String download = method(source, "static int download(CommandSourceStack source, String rawRequest)");
int admission = download.indexOf("LifecycleOperationCoordinator.get().acquire(");
int executionTracking = download.indexOf("new PackDownloadExecution(");
int dispatch = download.indexOf("scheduler.asyncIfRunning(execution, execution::cancel)");
assertTrue(admission >= 0);
assertTrue(executionTracking > admission);
assertTrue(dispatch > admission);
assertTrue(download.contains("execution.cancel();"));
String execution = method(source, "private static void executeDownload(");
assertTrue(execution.contains("PackDownloader.DownloadCancellation cancellation"));
assertTrue(execution.contains("catch (PackDownloader.PackDownloadCancelledException error)"));
assertTrue(execution.contains("dispatchDownloadFeedback(source,"));
assertFalse(execution.contains("scheduler.global("));
assertFalse(execution.contains("lease.close();"));
String feedback = method(source, "private static void dispatchDownloadFeedback(");
assertTrue(feedback.contains("source.getServer().execute(feedback);"));
}
@Test
public void packDownloadsDrainBeforeModdedSchedulerShutdown() throws IOException {
String commands = source("art/arcane/iris/modded/command/IrisModdedCommands.java");
String shutdownDownloads = method(commands, "public static void shutdownDownloads()");
assertTrue(shutdownDownloads.contains("downloadAdmissionOpen = false;"));
assertTrue(shutdownDownloads.contains("execution.cancel();"));
assertTrue(shutdownDownloads.contains("execution.await("));
String bootstrap = source("art/arcane/iris/modded/ModdedEngineBootstrap.java");
String stop = method(bootstrap, "public static void stop()");
int downloads = stop.indexOf("IrisModdedCommands::shutdownDownloads");
int scheduler = stop.indexOf("scheduler::shutdown");
assertTrue(downloads >= 0);
assertTrue(scheduler > downloads);
}
@Test
public void moddedSchedulerRejectsAndCancelsAbandonedDownloadSubmissions() throws IOException {
String scheduler = source("art/arcane/iris/modded/ModdedScheduler.java");
String dispatch = method(scheduler, "public boolean asyncIfRunning(Runnable task, Runnable rejection)");
assertTrue(dispatch.contains("RejectionAwareTask"));
assertTrue(dispatch.contains("Objects.requireNonNull(rejection"));
String shutdown = method(scheduler, "public void shutdown()");
assertTrue(shutdown.contains("asyncExecutor.shutdownNow()"));
assertTrue(shutdown.contains("rejectAbandonedTasks(abandonedTasks);"));
String reset = method(scheduler, "public void reset()");
assertTrue(reset.contains("asyncExecutor.getQueue().drainTo(abandonedTasks);"));
assertTrue(reset.contains("rejectAbandonedTasks(abandonedTasks);"));
String rejection = method(scheduler, "private static void rejectAbandonedTasks(");
assertTrue(rejection.contains("rejectionAwareTask.reject();"));
}
@Test
public void blockingPregenShutdownDefersItsFinalSaveToTheServerThread() throws IOException {
String jobSource = source("art/arcane/iris/modded/command/ModdedPregenJob.java");
String shutdown = method(jobSource, "private static boolean shutdownAndSave(");
assertTrue(shutdown.contains("deferFinalSaveToServerThread()"));
assertTrue(shutdown.contains("completeDeferredFinalSave()"));
assertTrue(shutdown.contains("cancelDeferredFinalSave()"));
String methodSource = source("art/arcane/iris/modded/command/ModdedPregenMethod.java");
String close = method(methodSource, "public void close()");
assertTrue(close.contains("deferFinalSaveIfRequested()"));
String await = method(methodSource, "private void awaitFinalSave(");
assertTrue(await.contains("FINAL_SAVE_POLL_MILLIS"));
assertTrue(await.contains("deferFinalSaveIfRequested()"));
String complete = method(methodSource, "void completeDeferredFinalSave()");
int cancel = complete.indexOf("cancelQueuedFinalSave();");
int directSave = complete.indexOf("saveLevelOnServerThread();");
assertTrue(cancel >= 0);
assertTrue(directSave > cancel);
String queuedSave = method(methodSource, "private void executeFinalSave(");
assertTrue(queuedSave.contains("if (!request.claim())"));
String cancelRequest = method(methodSource, "private void cancelQueuedFinalSave()");
assertTrue(cancelRequest.contains("request.cancel();"));
String pending = method(methodSource, "boolean hasPendingFinalSave()");
assertTrue(pending.contains("queuedFinalSave.get() != null"));
}
@Test
public void firstPregenChunkFailureRetainsItsFullCause() throws IOException {
String methodSource = source("art/arcane/iris/modded/command/ModdedPregenMethod.java");
String logFailure = method(methodSource, "private void logChunkFailure(");
assertTrue(methodSource.contains("AtomicBoolean failureDetailLogged"));
assertTrue(logFailure.contains("unwrap(failure)"));
assertTrue(logFailure.contains("failureDetailLogged.compareAndSet(false, true)"));
assertTrue(logFailure.contains("x, z, cause);"));
assertTrue(logFailure.contains("cause.toString()"));
}
@Test
public void pregenReportsOnlyCleanupCallbacksFromCoveredTargets() throws IOException {
String source = source("art/arcane/iris/modded/command/ModdedPregenMethod.java");
String cleanup = method(source, "private void cleanupMantleChunksCoveredBy(");
assertTrue(source.contains("cleanupMantleChunksCoveredBy(x, z, listener);"));
assertFalse(source.contains("cleanupMantleChunk(x, z);"));
assertTrue(cleanup.contains("cleanupChunksCoveredBy(x, z, true, listener::onChunkCleaned)"));
assertFalse(cleanup.contains("forceCleanupChunk"));
}
@Test
public void invalidPregenBoundsFailBeforeRuntimeMutation() throws IOException {
String jobSource = source("art/arcane/iris/modded/command/ModdedPregenJob.java");
String start = method(jobSource, "public static boolean start(");
int taskConstruction = start.indexOf("PregenTask task = PregenTask.builder()");
int profileMutation = start.indexOf("PregenPerformanceProfile.apply(engine)");
assertTrue(taskConstruction >= 0);
assertTrue(profileMutation > taskConstruction);
String commandSource = source("art/arcane/iris/modded/command/ModdedPregenCommands.java");
String command = method(commandSource, "static int pregenStart(");
assertTrue(command.contains("catch (IllegalArgumentException failure)"));
assertTrue(command.contains("IrisModdedCommands.fail(source, failure.getMessage())"));
}
private static String source(String relativePath) throws IOException {
String root = System.getProperty(SOURCE_ROOT_PROPERTY);
assertTrue("Missing system property " + SOURCE_ROOT_PROPERTY, root != null && !root.isBlank());
return Files.readString(Path.of(root, relativePath));
}
private static String method(String source, String signature) {
int start = source.indexOf(signature);
assertTrue("Missing source contract signature: " + signature, start >= 0);
int openBrace = source.indexOf('{', start);
assertTrue("Missing source contract method body: " + signature, openBrace >= 0);
int depth = 0;
for (int index = openBrace; index < source.length(); index++) {
char current = source.charAt(index);
if (current == '{') {
depth++;
} else if (current == '}') {
depth--;
if (depth == 0) {
return source.substring(start, index + 1);
}
}
}
throw new IllegalArgumentException("Unclosed source contract method: " + signature);
}
}
@@ -2,32 +2,9 @@ package art.arcane.iris.modded;
import org.junit.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Stream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
/**
* The level router is a switch statement, which the compiler does not check for exhaustiveness. A level added
* to {@link art.arcane.iris.spi.LogLevel} with no arm here would compile and then drop every message at that
* level without a trace, so the fallback arm is what keeps a new level visible on the modded loaders.
*/
public class ModdedIrisLogLevelCoverageTest {
@Test
public void everyLogLevelReachesTheLoggerIncludingOnesAddedLater() throws IOException {
String source = Files.readString(Path.of(System.getProperty("iris.moddedCommonSources"),
"art/arcane/iris/modded/ModdedIrisLog.java"));
int router = source.indexOf("public static void log(LogLevel level, String message)");
assertTrue("log(LogLevel, String) not found", router >= 0);
String body = source.substring(router, source.indexOf("public static void debug(", router));
assertTrue(body, body.contains("default -> info(message);"));
}
@Test
public void slf4jStyleArgumentsAndTrailingThrowableArePreserved() {
RuntimeException failure = new RuntimeException("broken");
@@ -37,39 +14,4 @@ public class ModdedIrisLogLevelCoverageTest {
assertEquals("chunk 4,9 failed", rendered.message());
assertEquals(failure, rendered.error());
}
@Test
public void formattedDebugThrowableUsesTheVisibleDebugRouteWhenEnabled() throws IOException {
String source = Files.readString(Path.of(System.getProperty("iris.moddedCommonSources"),
"art/arcane/iris/modded/ModdedIrisLog.java"));
int start = source.indexOf("public static void debug(String format, Object... arguments)");
int end = source.indexOf("public static void info(String message)", start);
assertTrue("formatted debug overload not found", start >= 0);
assertTrue("formatted debug overload boundary not found", end > start);
String body = source.substring(start, end);
assertTrue(body, body.contains("if (!debugEnabled())"));
assertTrue(body, body.contains("LOGGER.info(\"[Iris/DEBUG] \" + clean(rendered.message()), rendered.error())"));
}
@Test
public void moddedProductionUsesTheIrisLogFrontDoor() throws IOException {
Path root = Path.of(System.getProperty("iris.moddedCommonSources"));
try (Stream<Path> files = Files.walk(root)) {
List<Path> bypasses = files
.filter(path -> path.toString().endsWith(".java"))
.filter(path -> !path.getFileName().toString().equals("ModdedIrisLog.java"))
.filter(path -> {
try {
String source = Files.readString(path);
return source.contains("LoggerFactory.getLogger")
|| source.contains("private static final Logger LOGGER");
} catch (IOException unreadable) {
throw new IllegalStateException(unreadable);
}
})
.toList();
assertTrue(bypasses.toString(), bypasses.isEmpty());
}
}
}
@@ -2,10 +2,6 @@ package art.arcane.iris.modded;
import org.junit.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
@@ -48,25 +44,6 @@ public class ModdedLevelDataAttachmentTest {
assertNull(failure.getCause());
}
@Test
public void initializesLevelDataBeforeBindingAndPublication() throws IOException {
String source = Files.readString(sourcePath("ModdedDimensionManager.java"));
int construction = source.indexOf("ServerLevel level = new ServerLevel(");
int initialization = source.indexOf("serverAccess.initializeLevelData(server, level);", construction);
int binding = source.indexOf("generator.bindLevel(level);", initialization);
int publication = source.indexOf("serverAccess.putLevelIfAbsent(server, key, level);", binding);
assertTrue(construction >= 0);
assertTrue(initialization > construction);
assertTrue(binding > initialization);
assertTrue(publication > binding);
}
private static Path sourcePath(String fileName) {
return Path.of(System.getProperty("iris.moddedCommonSources"),
"art/arcane/iris/modded", fileName);
}
public static final class AttachedLevelData {
private RootData rootData;
private DimensionKey dimensionKey;
@@ -1,436 +0,0 @@
package art.arcane.iris.modded;
import org.junit.Test;
import java.io.IOException;
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.assertTrue;
public class ModdedLifecycleFailureContractTest {
private static final String SOURCE_ROOT_PROPERTY = "iris.moddedCommonSources";
@Test
public void existingDimensionStagesWithoutReplacingTheLiveEngine() throws IOException {
String managerSource = source("ModdedDimensionManager.java");
String create = method(managerSource, "private static Handle create(");
int existingStart = requiredIndex(create, "if (existing != null");
int presentStart = requiredIndex(create, "if (serverAccess.hasLevel(server, key))", existingStart + 1);
int injectionStart = requiredIndex(create, "try {", presentStart);
assertFalse(create.substring(existingStart, presentStart).contains("repointAndBind("));
assertFalse(create.substring(presentStart, injectionStart).contains("repointAndBind("));
assertBefore(create, "ModdedGenerationHistoryStorage.createOrStage(", "if (existing != null");
String generatorSource = source("IrisModdedChunkGenerator.java");
String repointAndBind = method(generatorSource, "void repointAndBind(");
assertBefore(repointAndBind, "ModdedWorldEngines.prepareReplacement(",
"ModdedWorldEngines.installReplacement(");
assertBefore(repointAndBind, "ModdedWorldEngines.installReplacement(", "this.activePack = pack;");
String failure = catchBlock(repointAndBind);
assertTrue(failure.contains("ModdedWorldEngines.closeUnregistered(replacement);"));
assertTrue(failure.contains("addSuppressed("));
String worldEnginesSource = source("ModdedWorldEngines.java");
String installReplacement = method(worldEnginesSource, "static void installReplacement(");
assertTrue(installReplacement.contains("ENGINES.compute("));
assertBefore(installReplacement, "close(current);", "return activeReplacement;");
assertFalse(installReplacement.contains("catch ("));
}
@Test
public void persistentPackUpdatesUseAnExplicitRestartBoundPath() throws IOException {
String managerSource = source("ModdedDimensionManager.java");
String createPersistent = method(managerSource, "public static Handle createPersistent(");
String update = method(managerSource, "public static UpdateResult stagePersistentUpdate(");
assertBefore(createPersistent, "if (previous != null)", "ModdedDimensionRegistryStore.put(");
assertTrue(createPersistent.contains("Use the Iris world update command"));
assertTrue(update.contains("ModdedGenerationHistoryStorage.createOrStage("));
assertTrue(update.contains("active.history().pendingActivation()"));
assertTrue(update.contains("restartRequired()"));
assertFalse(update.contains("inject("));
assertFalse(update.contains("repointAndBind("));
assertBefore(update, "ModdedDimensionRegistryStore.put(server, updated);",
"ModdedGenerationHistoryStorage.createOrStage(");
assertTrue(update.contains("ModdedDimensionRegistryStore.put(server, previous);"));
String commandsSource = source("command/ModdedWorldCommands.java");
assertTrue(commandsSource.contains("root.then(updateTree());"));
String command = method(commandsSource, "private static int update(");
assertTrue(command.contains("ModdedDimensionManager.stagePersistentUpdate("));
assertTrue(command.contains("Restart the server to activate it"));
assertTrue(command.contains("running world remains on its current pack"));
}
@Test
public void injectionBindsBeforeRegistrationAndRollsBackEveryFailure() throws IOException {
String source = source("ModdedDimensionManager.java");
String injection = method(source, "private static Handle inject(");
assertBefore(injection, "generator.bindLevel(level);", "serverAccess.putLevelIfAbsent(server, key, level);");
assertBefore(injection, "serverAccess.putLevelIfAbsent(server, key, level);",
"server.getPlayerList().addWorldborderListener(level);");
assertFalse(injection.contains("serverAccess.putLevel(server, key, previous);"));
String failure = catchBlock(injection);
assertBefore(failure, "rollbackInjection(", "throw ");
String rollback = method(source, "void rollbackInjection(");
assertBefore(rollback, "serverAccess.hasLevel(server, key)", "serverAccess.removeLevel(server, key);");
assertTrue(rollback.contains("generator.unbindEngine("));
assertTrue(rollback.contains("level.close();"));
assertTrue(rollback.contains("addSuppressed("));
}
@Test
public void persistentReinjectionQuarantinesBrokenEntriesAndContinues() throws IOException {
String source = source("ModdedStartup.java");
String reinjection = method(source, "private static void reinjectPersistentDimensions(");
String failure = catchBlock(reinjection);
assertTrue(failure.contains("ModdedIrisLog.error("));
assertFalse(failure.contains("e.toString()"));
assertFalse(failure.contains("throw new IllegalStateException("));
assertTrue(reinjection.contains("injected++;"));
}
@Test
public void persistentReinjectionWaitsForPlayerServicesAndServerStarted() throws IOException {
String bootstrapSource = source("ModdedEngineBootstrap.java");
String start = method(bootstrapSource, "public static void start(");
String serverAboutToStart = method(bootstrapSource, "public static void serverAboutToStart(");
String serverStarted = method(bootstrapSource, "public static void serverStarted(");
assertFalse(start.contains("ModdedStartup.runOnce(server);"));
assertFalse(serverAboutToStart.contains("ModdedStartup.runOnce(server);"));
assertEquals(1, occurrences(serverStarted, "ModdedStartup.runOnce(server);"));
assertBefore(serverStarted, "bindWorldGenerators(server);", "ModdedStartup.runOnce(server);");
assertBefore(serverStarted, "ModdedStartup.runOnce(server);", "reconcileSpawn(server);");
String startupSource = source("ModdedStartup.java");
String runOnce = method(startupSource, "public static void runOnce(");
assertBefore(runOnce, "server.getPlayerList() == null", "STARTED.compareAndSet(false, true)");
}
@Test
public void dynamicLevelMutationsInvalidateLoaderTickCaches() throws IOException {
String levelsSource = source("ModdedServerLevels.java");
String put = method(levelsSource, "public ServerLevel putLevel(");
String putIfAbsent = method(levelsSource, "public ServerLevel putLevelIfAbsent(");
String remove = method(levelsSource, "public ServerLevel removeLevel(");
assertBefore(put, "server.levels.put(key, level);", "levelCacheInvalidator.accept(server);");
assertTrue(put.contains("if (previous != level)"));
assertBefore(putIfAbsent, "server.levels.putIfAbsent(key, level);", "levelCacheInvalidator.accept(server);");
assertTrue(putIfAbsent.contains("if (previous == null)"));
assertBefore(remove, "server.levels.remove(key);", "levelCacheInvalidator.accept(server);");
assertTrue(remove.contains("if (removed != null)"));
String loaderSource = source("ModdedLoader.java");
assertTrue(loaderSource.contains("void invalidateLevelCache(MinecraftServer server);"));
String bootstrapSource = source("ModdedEngineBootstrap.java");
assertTrue(bootstrapSource.contains("new ModdedServerLevels(boundLoader::invalidateLevelCache)"));
}
@Test
public void persistentRestoreOpensHistoryBeforeConsultingTheGlobalPack() throws IOException {
String source = source("ModdedGenerationHistoryStorage.java");
String restore = method(source, "static GenerationHistory restoreOrAdopt(");
assertBefore(restore, "GenerationHistory.openIfPresent(", "sourceSupplier\").get();");
assertTrue(restore.contains("if (existing.isPresent())"));
assertBefore(restore, "requireRegistryDefinitions(existing.get()", "return existing.get();");
}
@Test
public void productionPackResolutionNeverUsesTheParityProbeAsData() throws IOException {
String source = source("ModdedWorldEngines.java");
String resolvePack = method(source, "static File resolvePack(");
assertFalse(source.contains("System.getProperty(\"iris.parity\")"));
assertFalse(source.contains("parityPack"));
assertFalse(source.contains("falling back to parity pack"));
assertTrue(resolvePack.contains("throw new IllegalStateException("));
}
@Test
public void engineBootstrapResetsStartupExactlyOnceDuringStop() throws IOException {
String source = source("ModdedEngineBootstrap.java");
String stop = method(source, "public static void stop(");
assertEquals(1, occurrences(stop, "ModdedStartup::reset"));
}
@Test
public void engineBootstrapAttemptsEveryShutdownStageAndAggregatesFailures() throws IOException {
String source = source("ModdedEngineBootstrap.java");
String stop = method(source, "public static void stop(");
assertBefore(stop, "\"services\"", "\"world engines\"");
assertBefore(stop, "\"world engines\"", "\"dimension manager\"");
assertBefore(stop, "\"server state\"", "if (failure != null)");
assertFalse(stop.contains("throw"));
assertTrue(stop.contains("ModdedIrisLog.error(\"Iris modded shutdown completed with failures\", failure);"));
String runStage = method(source, "private static Throwable runStopStage(");
assertTrue(runStage.contains("catch (Throwable stageFailure)"));
assertTrue(runStage.contains("failure.addSuppressed(stageFailure);"));
}
@Test
public void worldShutdownRemovesOnlySuccessfullyClosedMappings() throws IOException {
String source = source("ModdedWorldEngines.java");
String shutdown = method(source, "public static void shutdown()");
assertTrue(shutdown.contains("new ArrayList<>(ENGINES.entrySet())"));
assertFalse(shutdown.contains("ENGINES.clear()"));
// Bound generators must go through unbindEngine (latch unloading, then strict evict) so a
// late chunk-system drain cannot rebuild an engine nothing closes again.
assertTrue(shutdown.contains("generator.unbindEngine(level);"));
assertBefore(shutdown, "close(engine);", "ENGINES.remove(level, engine)");
assertTrue(shutdown.contains("ENGINES.containsKey(level)"));
assertTrue(shutdown.contains("failure.addSuppressed(e);"));
assertTrue(shutdown.contains("failed mappings were retained"));
}
@Test
public void engineEvictionRetainsTheMappingUntilCloseSucceeds() throws IOException {
String source = source("ModdedWorldEngines.java");
String eviction = method(source, "static void evictOrThrow(");
assertTrue(eviction.contains("ENGINES.computeIfPresent("));
assertFalse(eviction.contains("ENGINES.remove("));
assertBefore(eviction, "close(current);", "return null;");
}
@Test
public void generatorUnbindRetainsOwnershipWhenEvictionFails() throws IOException {
String source = source("IrisModdedChunkGenerator.java");
String unbind = method(source, "synchronized void unbindEngine(ServerLevel level)");
assertFalse(unbind.contains("finally"));
assertBefore(unbind, "unloading = true;", "ModdedWorldEngines.evictOrThrow(level);");
assertBefore(unbind, "ModdedWorldEngines.evictOrThrow(level);", "clearEngineBinding();");
String binding = method(source, "private Engine bindEngine(ServerLevel level)");
assertTrue(binding.contains("requireBindingAllowed();"));
assertTrue(binding.contains("requireCompletedShutdown(cached);"));
String bindLevel = method(source, "synchronized void bindLevel(ServerLevel level)");
assertBefore(bindLevel, "requireCompletedShutdown(engine);", "unloading = false;");
String boundEngine = method(source, "public Engine engineIfBound()");
assertTrue(boundEngine.contains("unloading || current == null || current.isClosing() || current.isClosed()"));
}
@Test
public void loaderUnloadSurfacesCloseFailureAndDynamicRemovalUsesStrictEviction() throws IOException {
String bootstrapSource = source("ModdedEngineBootstrap.java");
String unload = method(bootstrapSource, "public static void levelUnloaded(ServerLevel level)");
String failure = catchBlock(unload);
assertTrue(failure.contains("ModdedIrisLog.error("));
assertTrue(failure.contains("throw "));
String managerSource = source("ModdedDimensionManager.java");
String remove = method(managerSource, "public static boolean remove(MinecraftServer server, String dimensionId, boolean wipeStorage)");
assertTrue(remove.contains("ModdedWorldEngines.evictOrThrow(level);"));
assertFalse(remove.contains("ModdedWorldEngines.evict(level);"));
assertTrue(remove.contains("unloadEventStarted = true;"));
assertTrue(remove.contains("rollbackRemoval(server, serverAccess, key, level, generator, unloadEventStarted, e);"));
String rollback = method(managerSource, "private static void rollbackRemoval(");
assertTrue(rollback.contains("serverAccess.hasLevel(server, key)"));
assertTrue(rollback.contains("generator.bindLevel(level);"));
assertTrue(rollback.contains("failure.addSuppressed(rollbackFailure);"));
assertTrue(rollback.contains("ModdedIrisLog.error("));
}
@Test
public void engineBootstrapRollsBackEveryPublishedBindingBeforeRethrowing() throws IOException {
String source = source("ModdedEngineBootstrap.java");
String bind = method(source, "public static ModdedPlatform bind(");
String failure = catchBlock(bind);
assertBefore(bind, "createdServices.enableAll();", "runtime = new BoundRuntime(");
assertTrue(bind.contains("rollback.add(() -> GuiHost.suppressDesktop("));
assertTrue(bind.contains("rollback.add(() -> restorePlatform("));
assertTrue(bind.contains("rollback.add(() -> ModdedDimensionManager.restoreAccess("));
assertTrue(bind.contains("rollback.add(() -> IrisObjectRotation.restorePlatformRotator("));
assertTrue(bind.contains("rollback.add(() -> BlockDataMergeSupport.restorePlatformMerger("));
assertTrue(bind.contains("rollback.add(() -> TileData.restorePlatformReader("));
assertTrue(bind.contains("rollback.add(() -> TileData.restorePlatformFactory("));
assertTrue(bind.contains("rollback.add(() -> GuiHost.set("));
assertTrue(bind.contains("rollback.add(() -> DecoratorPlatformHooks.restore("));
assertTrue(bind.contains("bindService(PreservationRegistry.class"));
assertTrue(bind.contains("bindService(EngineEffectsProvider.class"));
assertTrue(bind.contains("bindService(EnginePlatformHooks.class"));
assertTrue(bind.contains("bindService(EngineWorldManagerProvider.class"));
assertTrue(bind.contains("rollback.add(customContentDiscovery::rollback);"));
assertBefore(failure, "createdServices.rollback(failure);", "rollback.restore(failure);");
assertBefore(failure, "rollback.restore(failure);", "throw ");
}
private static void assertBefore(String source, String first, String second) {
int firstIndex = requiredIndex(source, first);
int secondIndex = requiredIndex(source, second);
assertTrue(first + " must occur before " + second, firstIndex < secondIndex);
}
private static int requiredIndex(String source, String needle) {
return requiredIndex(source, needle, 0);
}
private static int requiredIndex(String source, String needle, int start) {
int index = source.indexOf(needle, start);
assertTrue("Missing source contract token: " + needle, index >= 0);
return index;
}
private static String source(String fileName) throws IOException {
String sourceRoot = System.getProperty(SOURCE_ROOT_PROPERTY);
assertTrue("Missing system property " + SOURCE_ROOT_PROPERTY,
sourceRoot != null && !sourceRoot.isBlank());
Path sourcePath = Path.of(sourceRoot, "art", "arcane", "iris", "modded", fileName);
return Files.readString(sourcePath);
}
private static String method(String source, String signature) {
int start = requiredIndex(source, signature);
int openBrace = openingBrace(source, start);
return source.substring(start, matchingBrace(source, openBrace) + 1);
}
/**
* Returns the block of the shallowest catch in the method, i.e. the one attached to the
* method's outermost try. Textual first-match would return a nested catch instead, and a
* nested catch handles a different failure than the one these contracts pin.
*/
private static String catchBlock(String source) {
int bodyStart = openingBrace(source, 0);
int depth = 0;
int bestDepth = Integer.MAX_VALUE;
int catchIndex = -1;
int i = bodyStart;
while (i < source.length()) {
int skipped = skipNonCode(source, i);
if (skipped > i) {
i = skipped;
continue;
}
char current = source.charAt(i);
if (current == '{') {
depth++;
} else if (current == '}') {
depth--;
if (depth == 0) {
break;
}
} else if (current == 'c' && depth < bestDepth && source.startsWith("catch (", i)) {
bestDepth = depth;
catchIndex = i;
}
i++;
}
assertTrue("Missing source contract token: catch (", catchIndex >= 0);
return blockAt(source, openingBrace(source, catchIndex));
}
private static String blockAt(String source, int openBrace) {
return source.substring(openBrace, matchingBrace(source, openBrace) + 1);
}
private static int openingBrace(String source, int start) {
int i = start;
while (i < source.length()) {
int skipped = skipNonCode(source, i);
if (skipped > i) {
i = skipped;
continue;
}
if (source.charAt(i) == '{') {
return i;
}
i++;
}
throw new IllegalArgumentException("No source block after offset " + start);
}
private static int matchingBrace(String source, int openBrace) {
int depth = 0;
int i = openBrace;
while (i < source.length()) {
int skipped = skipNonCode(source, i);
if (skipped > i) {
i = skipped;
continue;
}
char current = source.charAt(i);
if (current == '{') {
depth++;
} else if (current == '}') {
depth--;
if (depth == 0) {
return i;
}
}
i++;
}
throw new IllegalArgumentException("Unclosed source block");
}
/**
* Advances past a comment, string, char or text block starting at the index, otherwise returns
* the index unchanged. Comments must be skipped: an apostrophe in prose ("generator's") would
* otherwise open a char literal and swallow the rest of the file.
*/
private static int skipNonCode(String source, int index) {
char current = source.charAt(index);
if (current == '/' && index + 1 < source.length()) {
char next = source.charAt(index + 1);
if (next == '/') {
int end = source.indexOf('\n', index);
return end < 0 ? source.length() : end + 1;
}
if (next == '*') {
int end = source.indexOf("*/", index + 2);
return end < 0 ? source.length() : end + 2;
}
return index;
}
if (current == '"' && source.startsWith("\"\"\"", index)) {
int end = source.indexOf("\"\"\"", index + 3);
return end < 0 ? source.length() : end + 3;
}
if (current == '"' || current == '\'') {
return endOfLiteral(source, index, current);
}
return index;
}
private static int endOfLiteral(String source, int index, char terminator) {
for (int i = index + 1; i < source.length(); i++) {
char current = source.charAt(i);
if (current == '\\') {
i++;
continue;
}
if (current == terminator) {
return i + 1;
}
}
return source.length();
}
private static int occurrences(String source, String needle) {
int count = 0;
int offset = 0;
while (true) {
int index = source.indexOf(needle, offset);
if (index < 0) {
return count;
}
count++;
offset = index + needle.length();
}
}
}
@@ -5,13 +5,9 @@ import art.arcane.iris.engine.object.IrisEntitySpawn;
import art.arcane.volmlib.util.collection.KList;
import org.junit.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class ModdedSpawnerRarityParityTest {
private static IrisEntitySpawn spawn(int rarity) {
@@ -39,37 +35,4 @@ public class ModdedSpawnerRarityParityTest {
assertEquals(1, IRare.get(spawn(-5)));
assertEquals(3, IRare.get(spawn(3)));
}
@Test
public void perPositionSpawnLoopsDoNotRerollEntryRarity() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.moddedCommonSources"),
"art/arcane/iris/modded/ModdedWorldManager.java"));
for (String method : List.of("private int spawnEntry(", "private int spawnEntryAt(")) {
String body = methodBody(source, method);
assertFalse(method + " must not re-roll rarity per position; rarityPick already weighted the pool",
body.contains("getRarity()"));
assertTrue(method + " must keep the min/max spawn-count roll",
body.contains("LootResolver.inclusive("));
}
}
private static String methodBody(String source, String declaration) {
int start = source.indexOf(declaration);
assertTrue("ModdedWorldManager must declare " + declaration, start >= 0);
int open = source.indexOf('{', start);
int depth = 0;
for (int index = open; index < source.length(); index++) {
char character = source.charAt(index);
if (character == '{') {
depth++;
} else if (character == '}') {
depth--;
if (depth == 0) {
return source.substring(open + 1, index);
}
}
}
throw new AssertionError(declaration + " is not brace balanced");
}
}
@@ -1,30 +0,0 @@
package art.arcane.iris.modded;
import org.junit.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class ModdedStructurePlacementFailureContractTest {
@Test
public void structurePlacementPropagatesRuntimeFailuresWithContext() throws IOException {
Path sourcePath = Path.of(System.getProperty("iris.moddedCommonSources"),
"art/arcane/iris/modded/ModdedStructureHooks.java");
String source = Files.readString(sourcePath);
int methodStart = source.indexOf("public int[] placeStructure(");
int methodEnd = source.indexOf("\n @Override", methodStart + 1);
String method = source.substring(methodStart, methodEnd);
int catchStart = method.indexOf("catch (RuntimeException error)");
assertTrue(catchStart >= 0);
String failurePath = method.substring(catchStart);
assertTrue(failurePath.contains("throw NativeStructureGenerationException.failure("));
assertTrue(failurePath.contains("\"capture placement\", structureKey, chunkX, chunkZ, error"));
assertFalse(failurePath.contains("return null;"));
assertFalse(failurePath.contains("catch (Throwable"));
}
}
@@ -4,8 +4,6 @@ import org.junit.Test;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
@@ -64,14 +62,9 @@ public class ModdedStructureTemplateCacheTest {
config = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
assertTrue(config.contains("\"StructureTemplatePaletteConcurrencyMixin\""));
Path source = Path.of(System.getProperty("iris.moddedCommonSources"))
.resolve("art/arcane/iris/modded/mixin/StructureTemplatePaletteConcurrencyMixin.java");
String mixin = Files.readString(source);
assertTrue(mixin.contains("@Mixin(StructureTemplate.Palette.class)"));
assertTrue(mixin.contains("@Shadow\n @Final\n @Mutable"));
assertTrue(mixin.contains("private Map<Block, List<StructureTemplate.StructureBlockInfo>> cache;"));
assertTrue(mixin.contains("@Inject(method = \"<init>(Ljava/util/List;)V\", at = @At(\"RETURN\"))"));
assertTrue(mixin.contains("cache = new ConcurrentHashMap<>();"));
assertNotNull(Class.forName(
"art.arcane.iris.modded.mixin.StructureTemplatePaletteConcurrencyMixin",
false,
ModdedStructureTemplateCacheTest.class.getClassLoader()));
}
}
@@ -5,9 +5,6 @@ import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.levelgen.structure.BoundingBox;
import org.junit.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -26,32 +23,6 @@ public class ModdedWorldCheckTest {
assertTrue(thread.isDaemon());
}
@Test
public void poiAuditRunsInASecondServerTaskAfterVillageGeneration() throws IOException {
Path sourceRoot = Path.of(System.getProperty("iris.moddedCommonSources"));
String source = Files.readString(sourceRoot.resolve("art/arcane/iris/modded/ModdedWorldCheck.java"));
String auditSource = Files.readString(
sourceRoot.resolve("art/arcane/iris/modded/WorldCheckStructureAudit.java"));
int preparationSubmit = source.indexOf(
"WorldCheckPreparation preparation = serverRef.submit(() -> run(serverRef))");
int completionSubmit = source.indexOf(
"exitCode = serverRef.submit(() -> runAndRequestStop(", preparationSubmit);
int completionMethod = source.indexOf("private static boolean completeWorldCheck");
int deferredAudit = source.indexOf(
"PoiAudit poi = WorldCheckStructureAudit.auditStructurePois", completionMethod);
int structureMethod = auditSource.indexOf("private static StructureCheckResult checkNativeStructure");
int structureMethodEnd = auditSource.indexOf("private static StructureStart resolveStructureStart",
structureMethod);
String structureSource = auditSource.substring(structureMethod, structureMethodEnd);
assertTrue(preparationSubmit >= 0);
assertTrue(completionSubmit > preparationSubmit);
assertTrue(deferredAudit > completionMethod);
assertFalse(structureSource.contains("auditStructurePois"));
assertFalse(source.contains("prepareDeferredAudits"));
assertFalse(auditSource.contains("prepareDeferredAudits"));
}
@Test
public void validStructureStartIsGenerationEvidence() {
assertTrue(WorldCheckPredicates.hasNativeStructureEvidence(true, 0));
@@ -6,11 +6,6 @@ import net.minecraft.server.Bootstrap;
import org.junit.BeforeClass;
import org.junit.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
@@ -22,25 +17,6 @@ public class NativeStructureFailureContractTest {
Bootstrap.bootStrap();
}
@Test
public void structureLocateDoesNotCatchAndFallThroughToAnotherImplementation() throws IOException {
String source = moddedSource("IrisModdedChunkGenerator.java");
int locateStart = source.indexOf("public Pair<BlockPos, Holder<Structure>> findNearestMapStructure");
int locateEnd = source.indexOf("public boolean isNativeStructureReachable", locateStart);
String locate = source.substring(locateStart, locateEnd);
String stage = moddedSource("ModdedNativeStructureStage.java");
int filterStart = stage.indexOf("HolderSet<Structure> filterReachableNativeStructures");
int filterEnd = stage.indexOf("void adjustGeneratedStructures", filterStart);
String filter = stage.substring(filterStart, filterEnd);
assertTrue(locate.contains("Engine current = engine();"));
assertFalse(locate.contains("catch (Throwable"));
assertFalse(locate.contains("return null;\n } catch"));
assertTrue(filter.contains("unregistered structure holder"));
assertFalse(filter.contains("catch (Throwable"));
assertFalse(filter.contains("failed closed"));
}
@Test
public void globalStructureDisableRefusesGeneratorBinding() {
IrisModdedChunkGenerator.requireGlobalStructureGeneration(true, "overworld:overworld");
@@ -56,66 +32,6 @@ public class NativeStructureFailureContractTest {
assertTrue(error.getMessage().contains("importedStructures.disabledExact"));
}
@Test
public void structureVegetationCleanupPrecedesTerrainPreparationAndPlacement() throws IOException {
String source = moddedSource("ModdedNativeStructureStage.java");
int placementStart = source.indexOf("void placeVanillaStructures");
int placementEnd = source.indexOf("private static String nativeStructureBatchContext", placementStart);
String placement = source.substring(placementStart, placementEnd);
assertTrue(placement.contains("\"terrain integration\""));
assertTrue(placement.contains("\"terrain preparation\""));
assertTrue(placement.contains("\"foundation repair\""));
assertFalse(placement.contains("\"terrain carving\""));
assertTrue(placement.contains("visitExistingPois(chunk"));
assertTrue(placement.contains("level.updatePOIOnBlockStateChange("));
assertTrue(placement.contains("Blocks.AIR.defaultBlockState(), state"));
assertTrue(placement.indexOf("visitExistingPois(chunk")
< placement.indexOf("WorldgenTerrainHeightmaps.primeStructurePlacement("));
assertTrue(placement.contains("prepareSurfaceStructures"));
assertTrue(placement.contains("clearIntersectingVegetation"));
assertTrue(placement.indexOf("clearIntersectingVegetation")
< placement.indexOf("prepareSurfaceStructures"));
assertTrue(placement.indexOf("prepareSurfaceStructures")
< placement.indexOf("for (NativePlacementGroup group"));
assertTrue(placement.indexOf("repairVacuumFoundations")
> placement.indexOf("for (NativePlacementGroup group"));
}
@Test
public void structurePlacementPrimesNeighbourWorldgenHeightmapsBeforeTerrainPreparation() throws IOException {
String source = moddedSource("ModdedNativeStructureStage.java");
int placementStart = source.indexOf("void placeVanillaStructures");
int placementEnd = source.indexOf("private static String nativeStructureBatchContext", placementStart);
String placement = source.substring(placementStart, placementEnd);
assertTrue(source.contains("import art.arcane.iris.nativegen.WorldgenTerrainHeightmaps;"));
assertTrue(placement.contains("WorldgenTerrainHeightmaps.primeStructurePlacement("));
assertTrue(placement.contains("\"heightmap priming\""));
assertTrue(placement.contains("heightmapStarts.add(start);"));
assertTrue(placement.indexOf("WorldgenTerrainHeightmaps.primeStructurePlacement(")
< placement.indexOf("prepareSurfaceStructures"));
assertTrue(source.contains("Engine.hostHeight(generationEngine, x, z, false) + runtimeMinY + 1"));
assertTrue(source.contains("Engine.hostHeight(generationEngine, x, z, true) + runtimeMinY + 1"));
assertTrue(placement.contains("current.getDimensionStackContext() != null"));
assertTrue(source.contains("int minY = chunk.getMinY() + 1;"));
}
@Test
public void nativeStructurePostProcessingUsesBoundedWorldgenAccess() throws IOException {
String source = moddedSource("ModdedNativeStructureStage.java");
int placementStart = source.indexOf("void placeVanillaStructures");
int placementEnd = source.indexOf("private List<List<Structure>> structuresByStep", placementStart);
String placement = source.substring(placementStart, placementEnd);
assertTrue(placement.contains("ModdedNativeStructureWorldgenAccess.create("));
assertTrue(placement.contains("nativeStructureProtection(current, staticObjects)"));
assertTrue(placement.contains("Engine.hostHeight(current, x, z, true)"));
assertTrue(placement.contains("placeVanillaStructure(boundedWorld, structureManager"));
assertFalse(placement.contains("NativeStructurePostProcessor.place(world,"));
assertTrue(placement.contains("world.setCurrentlyGenerating(null);"));
}
@Test
public void structureFailurePreservesPhaseIdentityChunkAndCause() {
IllegalArgumentException cause = new IllegalArgumentException("broken placement");
@@ -128,10 +44,4 @@ public class NativeStructureFailureContractTest {
assertTrue(error.getMessage().contains("12,-8"));
assertTrue(error.getMessage().contains("aborted"));
}
private static String moddedSource(String fileName) throws IOException {
Path sourcePath = Path.of(System.getProperty("iris.moddedCommonSources"),
"art", "arcane", "iris", "modded", fileName);
return Files.readString(sourcePath);
}
}
@@ -7,9 +7,6 @@ import com.mojang.datafixers.util.Pair;
import net.minecraft.core.BlockPos;
import org.junit.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
@@ -48,30 +45,6 @@ public class IrisModdedStructureCommandTest {
assertEquals(1, nativeReferences.get());
}
@Test
public void gotoStructureSupportsIrisAndNativeRegistryTargets() throws IOException {
String source = source("ModdedLocateCommands.java");
String suggestions = source("ModdedCommandSuggestions.java");
assertTrue(source.contains("IrisStructureLocator.hasLocatableEditablePlacement(engine, key)"));
assertTrue(source.contains("registry.get(identifier)"));
assertTrue(source.contains("getPlacementsForStructure(holder)"));
assertTrue(source.contains("generator.findNearestMapStructure("));
assertTrue(source.contains("NATIVE_STRUCTURE_LOCATE_RADIUS = 100"));
assertTrue(source.contains("HolderSet.direct(target.holder())"));
assertFalse(source.contains("NativeStructureLocateCapability"));
assertTrue(source.contains("boolean teleported = player.teleportTo("));
assertTrue(suggestions.contains("combineStructureKeys(unregisteredIrisKeys, nativeKeys)"));
assertTrue(source.contains("irisGenerator.isNativeStructureReachable(holder)"));
assertTrue(source.contains("LocateStatus.SEARCH_LIMIT_REACHED"));
assertTrue(source.contains("IRIS_MODDED_COMMANDS_UNABLE_LOCATE_IRIS_PLACED_STRUCTURE_DENSITY_SEARCH_SAFETY_LIMIT_WAS"));
assertTrue(source.contains("int targetX = result.originX()"));
assertTrue(source.contains("int targetY = result.baseY() + 2"));
assertTrue(source.contains("int targetZ = result.originZ()"));
assertFalse(source.contains("at[0] + 8"));
assertFalse(source.contains("at[2] + 8"));
}
@Test
public void registeredStructureEligibilityMatchesGotoAndSuggestions() {
IrisNativeStructureDecision nativeDecision = decision(NativeStructureGenerationStatus.GENERATE_NATIVE);
@@ -175,162 +148,6 @@ public class IrisModdedStructureCommandTest {
.contains("non-positive density or a Y band outside"));
}
@Test
public void unregisteredCommandUsesGotoEligibilityAndPrintsToConsole() throws IOException {
String command = source("ModdedUnregisteredStructures.java");
String tree = source("ModdedCommandTree.java");
assertTrue(tree.contains("Commands.literal(\"unregistered\")"));
assertTrue(tree.contains("ModdedUnregisteredStructures.print(context.getSource())"));
assertTrue(command.contains("ModdedCommandSuggestions.isEligibleRegisteredStructure("));
assertTrue(command.contains("ModdedLocateCommands.registeredStructureUnavailableMessage("));
assertTrue(command.contains("engine.getData().getStructureLoader().getPossibleKeys()"));
assertTrue(command.contains("IrisStructureLocator.hasLocatableEditablePlacement(engine, key)"));
assertTrue(command.contains("ModdedIrisLog.info(\"[Iris goto unregistered] [{}] {} - {}\""));
assertTrue(command.contains("UNREGISTERED(\"unregistered\")"));
assertFalse(command.contains("DatapackIngestService"));
}
@Test
public void structureSuggestionsUseActiveLevelAvailability() throws IOException {
String suggestions = source("ModdedCommandSuggestions.java");
int methodStart = suggestions.indexOf("static CompletableFuture<Suggestions> suggestStructureKeys(");
int methodEnd = suggestions.indexOf("static boolean isEligibleRegisteredStructure(", methodStart);
String method = suggestions.substring(methodStart, methodEnd);
assertTrue(method.contains("ServerLevel level = source.getLevel()"));
assertTrue(method.contains("Engine engine = IrisModdedCommands.engineFor(level)"));
assertFalse(method.contains("IrisStructureLocator.locatableKeys(engine)"));
assertTrue(method.contains("IrisStructureLocator.locatableEditableKeys(engine)"));
assertTrue(method.contains("StructureReachability.reachableKeys(engine)"));
assertTrue(method.contains("NativeStructureGenerationPolicy.resolve(engine, key, false)"));
assertTrue(method.contains("IrisStructureLocator.hasLocatableNativePlacement(engine, key)"));
assertTrue(method.contains("IrisStructureLocator.hasLocatableEditablePlacement(engine, key)"));
assertTrue(method.contains("reachableNativeKeys.contains(normalizeKey(key))"));
assertTrue(method.contains("nativeGenerationEnabled"));
assertTrue(method.contains("eligibleUnregisteredEditableKeys("));
assertTrue(method.contains("IrisStructureLocator.hasNativePlacement(engine, candidate)"));
assertFalse(method.contains("NativeStructureGenerationKeys.active(level)"));
assertFalse(method.contains("getPlacementsForStructure"));
}
@Test
public void generatorLocateUsesEveryIrisPlacedNativeStructure() throws IOException {
String source = moddedSource("ModdedNativeStructureStage.java");
int methodStart = source.indexOf("Pair<BlockPos, Holder<Structure>> findNearestIrisStructure(");
int methodEnd = source.indexOf("HolderSet<Structure> filterReachableNativeStructures(", methodStart);
String method = source.substring(methodStart, methodEnd);
int registryLookup = method.indexOf("level.registryAccess().lookupOrThrow(Registries.STRUCTURE)");
int placedCheck = method.indexOf(
"if (!IrisStructureLocator.hasNativePlacement(current, structureId))");
int irisLocate = method.indexOf("NativeStructureLocatePersistence.search(", placedCheck);
int nearestSelection = method.indexOf(
"NativeStructureLocateResults.nearest(pos, predicted, nativeLocated)", irisLocate);
int selectedVerification = method.indexOf("bestSearch.search().verify(bestResult)", nearestSelection);
int selectedReference = method.indexOf(
"selectedSearch.search().reference(selectedStart)", selectedVerification);
assertTrue(registryLookup >= 0);
assertTrue(placedCheck > registryLookup);
assertTrue(irisLocate > placedCheck);
assertTrue(nearestSelection > irisLocate);
assertTrue(selectedVerification > nearestSelection);
assertTrue(selectedReference > selectedVerification);
assertTrue(method.contains("NativeStructureLocatePersistence.probe("));
assertTrue(method.contains("findUnexplored"));
assertTrue(method.contains("LocateStatus.SEARCH_LIMIT_REACHED"));
assertTrue(method.contains("verified.ownership().locatorY()"));
assertTrue(method.contains("selectedSearch.search().reference(selectedStart)"));
assertTrue(method.contains("NativeStructureLocateResults.selectAndReference("));
assertFalse(method.contains("NativeStructureLocateCapability"));
assertTrue(source.contains("structureBiomeSource.isStructureReachable(holder)"));
assertFalse(source.contains("isPaperUnavailable"));
String generator = moddedSource("IrisModdedChunkGenerator.java");
assertTrue(generator.contains("NativeStructureVanillaLocator.predict("));
assertFalse(generator.contains("super.findNearestMapStructure(level, reachable"));
}
@Test
public void commandResolvesNativePolicyBeforeAnyVanillaAliasLookup() throws IOException {
String source = source("ModdedLocateCommands.java");
int methodStart = source.indexOf("static int gotoStructure(");
int methodEnd = source.indexOf("private static void locateIrisStructure(", methodStart);
String method = source.substring(methodStart, methodEnd);
int nativeResolution = method.indexOf("resolveNativeStructure(source, level, engine, key)");
int editableIrisLookup = method.indexOf(
"IrisStructureLocator.hasLocatableEditablePlacement(engine, key)", nativeResolution);
int policyResolution = method.indexOf(
"NativeStructureGenerationPolicy.resolve(engine, target.key(), false)", editableIrisLookup);
int eligibilityCheck = method.indexOf(
"ModdedCommandSuggestions.isEligibleRegisteredStructure(", policyResolution);
int replacementLocate = method.indexOf(
"locateIrisStructure(source, level, engine, player, target.key())", eligibilityCheck);
int nativeLocate = method.indexOf("runNativeStructureLocate(source, level, player, target)",
replacementLocate);
assertTrue(nativeResolution >= 0);
assertTrue(editableIrisLookup > nativeResolution);
assertTrue(policyResolution > editableIrisLookup);
assertTrue(eligibilityCheck > policyResolution);
assertTrue(replacementLocate > eligibilityCheck);
assertTrue(nativeLocate > replacementLocate);
assertTrue(method.contains("IrisStructureLocator.hasLocatableNativePlacement(engine, target.key())"));
assertTrue(method.contains("IrisStructureLocator.hasLocatableEditablePlacement(engine, target.key())"));
assertTrue(method.contains("!IrisStructureLocator.hasNativePlacement(engine, key)"));
assertTrue(method.contains("StructureReachability.isReachable(engine, target.key())"));
assertTrue(method.contains("getWorldGenSettings().options().generateStructures()"));
assertFalse(method.contains("IrisStructureLocator.isPlaced(engine, key)"));
}
@Test
public void verifyResolvesRegisteredNativeBeforeGenericIrisAliases() throws IOException {
String source = source("ModdedLocateCommands.java");
int methodStart = source.indexOf("private static int verifyStructure(");
int methodEnd = source.indexOf("private static Optional<NativeStructureTarget> resolveNativeStructure(",
methodStart);
String method = source.substring(methodStart, methodEnd);
int nativeResolution = method.indexOf("resolveNativeStructure(source, level, engine, key)");
int genericIrisLookup = method.indexOf("IrisStructureLocator.isPlaced(engine, key)", nativeResolution);
assertTrue(nativeResolution >= 0);
assertTrue(genericIrisLookup > nativeResolution);
assertTrue(method.contains("NativeStructureAvailability.IRIS_SUPPRESSED"));
}
@Test
public void structureVerificationNoLongerClaimsNativeGenerationIsMissing() throws IOException {
String source = source("ModdedStructureCommands.java");
assertTrue(source.contains("verifyTree(\"verify\")"));
assertTrue(source.contains("verifyTree(\"locateall\")"));
assertTrue(source.contains("IrisModdedCommands.verifyStructures("));
assertFalse(source.contains("Vanilla structure locate is meaningless here"));
}
private String source(String fileName) throws IOException {
Path source = commonSourceRoot().resolve("art/arcane/iris/modded/command/")
.resolve(fileName)
.normalize();
return Files.readString(source);
}
private String moddedSource(String fileName) throws IOException {
Path source = commonSourceRoot().resolve("art/arcane/iris/modded/")
.resolve(fileName)
.normalize();
return Files.readString(source);
}
private Path commonSourceRoot() {
String configuredRoot = System.getProperty("iris.moddedCommonSources");
if (configuredRoot != null && !configuredRoot.isBlank()) {
return Path.of(configuredRoot);
}
return Path.of(System.getProperty("user.dir"))
.resolve("../modded-common/src/main/java")
.normalize();
}
private IrisNativeStructureDecision decision(NativeStructureGenerationStatus status) {
return new IrisNativeStructureDecision(status, 0, null, false, null, null);
}
@@ -1,28 +0,0 @@
package art.arcane.iris.modded.command;
import org.junit.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public final class ModdedRiverLocateContractTest {
@Test
public void riverLookupMergesRecordedAndEligibleActiveFeatures() throws IOException {
Path sourcePath = Path.of(System.getProperty("iris.moddedCommonSources"))
.resolve("art/arcane/iris/modded/command/ModdedLocateCommands.java");
String source = Files.readString(sourcePath);
int methodStart = source.indexOf("static int gotoRiver(");
int methodEnd = source.indexOf("static int gotoStructure(", methodStart);
String method = source.substring(methodStart, methodEnd);
assertTrue(method.contains("GenerationSemanticQueries.nearestRiver("));
assertTrue(method.contains("runtime == null"));
assertTrue(method.contains("feature.y() + 2"));
assertFalse(method.contains("runtime.nearestFeature("));
assertFalse(method.contains("feature.y() + engine.getDimension().getMinHeight()"));
}
}
@@ -1,34 +0,0 @@
package art.arcane.iris.modded.command;
import org.junit.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public final class ModdedStructureHistoryContractTest {
@Test
public void retainedStructureNamesReachTheSemanticSearchOnBothAdmissionBranches() throws IOException {
Path root = Path.of(System.getProperty("iris.moddedCommonSources"));
String source = Files.readString(root.resolve("art/arcane/iris/modded/command/ModdedLocateCommands.java"));
int start = source.indexOf("static int gotoStructure(");
int end = source.indexOf("static String registeredStructureUnavailableMessage(", start);
String admission = source.substring(start, end);
String retainedCheck = "GenerationFindCatalog.hasRetainedStructurePlacement(engine, key)";
int firstCheck = admission.indexOf(retainedCheck);
assertTrue(firstCheck >= 0);
assertTrue(admission.indexOf(retainedCheck, firstCheck + retainedCheck.length()) >= 0);
int locateStart = source.indexOf("private static void locateIrisStructure(");
int locateEnd = source.indexOf("private static void runNativeStructureLocate(", locateStart);
String locate = source.substring(locateStart, locateEnd);
assertTrue(locate.contains("GenerationSemanticQueries.nearestStructure("));
assertFalse(locate.contains("IrisStructureLocator.locate("));
String suggestions = Files.readString(root.resolve("art/arcane/iris/modded/command/ModdedCommandSuggestions.java"));
assertTrue(suggestions.contains("GenerationFindCatalog.retainedStructureKeys(engine)"));
}
}
@@ -1,89 +0,0 @@
package art.arcane.iris.modded.command;
import org.junit.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class ModdedStudioTeleportContractTest {
private static final String SOURCE_ROOT_PROPERTY = "iris.moddedCommonSources";
@Test
public void studioOpenSerializesReplacementAndWaitsForTheNativePath() throws IOException {
String source = source("command/ModdedStudioCommands.java");
String open = method(source, "private static int open(");
String execute = method(source, "private static void executeStudioOpen(");
assertTrue(open.contains("TRANSITIONS.submit("));
assertFalse(open.contains("orTimeout("));
assertFalse(open.contains("deadlineNanos"));
assertBefore(execute, "replaceExistingStudio(", "ModdedDimensionManager.createTransientStudio(");
assertBefore(execute, "ModdedDimensionManager.createTransientStudio(", "ModdedDimensionManager.teleportAsync(");
assertFalse(execute.contains("deadlineNanos"));
assertFalse(source.contains("player.teleportTo(studio"));
}
@Test
public void studioTeleportAndVisionShareWarmFutureSemantics() throws IOException {
String studio = source("command/ModdedStudioCommands.java");
String vision = source("command/ModdedVisionOverlay.java");
String dimensions = source("ModdedDimensionManager.java");
assertTrue(studio.contains("private static CompletableFuture<Void> teleportToStudio("));
assertTrue(studio.contains("ModdedDimensionManager.teleportAsync("));
assertTrue(dimensions.contains("TELEPORT_WARM_RADIUS = 0"));
assertTrue(dimensions.contains("addTicketAndLoadWithRadius(\n"
+ " TELEPORT_WARM_TICKET,\n"
+ " chunkPos,\n"
+ " TELEPORT_WARM_RADIUS)"));
assertTrue(dimensions.contains("removeTicketWithRadius(\n"
+ " TELEPORT_WARM_TICKET,\n"
+ " chunkPos,\n"
+ " TELEPORT_WARM_RADIUS)"));
assertTrue(vision.contains("Math.floor(worldX)"));
assertTrue(vision.contains("Math.floor(worldZ)"));
assertTrue(vision.contains("if (opener != null)"));
assertTrue(vision.contains("ModdedDimensionManager.teleportAsync("));
assertFalse(vision.contains("level.getHeight("));
assertFalse(vision.contains("player.teleportTo("));
}
private static String source(String relative) throws IOException {
String root = System.getProperty(SOURCE_ROOT_PROPERTY);
if (root == null || root.isBlank()) {
throw new IllegalStateException("Missing test property " + SOURCE_ROOT_PROPERTY);
}
return Files.readString(Path.of(root).resolve("art/arcane/iris/modded").resolve(relative))
.replace("\r\n", "\n");
}
private static String method(String source, String signature) {
int start = source.indexOf(signature);
if (start < 0) {
throw new IllegalArgumentException("Missing source method " + signature);
}
int open = source.indexOf('{', start);
int depth = 0;
for (int index = open; index < source.length(); index++) {
char current = source.charAt(index);
if (current == '{') {
depth++;
} else if (current == '}' && --depth == 0) {
return source.substring(start, index + 1);
}
}
throw new IllegalArgumentException("Unclosed source method " + signature);
}
private static void assertBefore(String source, String first, String second) {
int firstIndex = source.indexOf(first);
int secondIndex = source.indexOf(second);
assertTrue("Missing source token " + first, firstIndex >= 0);
assertTrue("Missing source token " + second, secondIndex >= 0);
assertTrue(first + " must precede " + second, firstIndex < secondIndex);
}
}
@@ -2,6 +2,7 @@ package art.arcane.iris.modded.localization;
import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.core.localization.IrisMessages;
import art.arcane.iris.testsupport.ProjectPaths;
import art.arcane.volmlib.util.localization.MessageKey;
import art.arcane.volmlib.util.localization.VolmitLocales;
import com.google.gson.JsonObject;
@@ -27,7 +28,7 @@ public class ModdedMessageContributorTest {
@Test
public void completeLoaderCatalogStillMatchesEveryDownloadableLocale() throws Exception {
Path sourceRoot = Path.of("../../core/src/main/resources/languages");
Path sourceRoot = ProjectPaths.repositoryFile("core/src/main/resources/languages");
for (String locale : VolmitLocales.nonEnglish()) {
JsonObject document = JsonParser.parseString(Files.readString(sourceRoot.resolve(locale + ".json")))
.getAsJsonObject();
+1 -1
View File
@@ -132,6 +132,7 @@ configurations.testRuntimeClasspath.extendsFrom(configurations.runtimeClasspath,
dependencies {
testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(testFixtures('art.arcane:core'))
testImplementation(libs.spigot) {
transitive = false
}
@@ -222,7 +223,6 @@ tasks.named('compileJava', JavaCompile).configure {
tasks.named('test').configure {
useJUnitPlatform()
systemProperty('iris.moddedCommonSources', file('../modded-common/src/main/java').absolutePath)
}
processResources {
+1 -1
View File
@@ -164,6 +164,7 @@ nmsBindings.each { key, value ->
testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(project(':core'))
testImplementation(testFixtures(project(':core')))
testCompileOnly(volmLibCoordinate) {
transitive = false
}
@@ -599,7 +600,6 @@ List<String> requiredBukkitArtifactEntries = [
'art/arcane/iris/engine/platform/PlatformChunkGenerator.class',
'art/arcane/iris/core/lifecycle/WorldLifecycleStaging.class',
'art/arcane/iris/util/simd/VectorSimdKernels.class',
'art/arcane/iris/util/simd/VectorNoiseKernels2D.class',
'art/arcane/iris/util/project/agent/Agent.class',
'art/arcane/iris/util/common/misc/getHardware.class',
'art/arcane/volmlib/util/io/JarScanner.class',
@@ -35,6 +35,16 @@ public final class GenerationRevisionScope {
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPregen.java",
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java",
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsaw.java",
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawAdopt.java",
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawConnector.java",
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawExports.java",
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawOpen.java",
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawPiece.java",
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawPool.java",
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawPreview.java",
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawRules.java",
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawVariant.java",
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawWorkcell.java",
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java",
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java",
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/PaperCommandRegistrar.java",
+6 -11
View File
@@ -29,6 +29,7 @@ import java.nio.charset.StandardCharsets
plugins {
id 'java'
id 'java-library'
id 'java-test-fixtures'
alias(libs.plugins.shadow)
alias(libs.plugins.slimjar)
}
@@ -130,6 +131,10 @@ dependencies {
slim(libs.dom4j)
slim(libs.jaxen)
testFixturesImplementation('junit:junit:4.13.2')
testFixturesImplementation('org.mockito:mockito-core:5.23.0')
testFixturesCompileOnly(libs.paper.api)
testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(libs.paper.api)
@@ -232,7 +237,7 @@ tasks.named('compileJava', JavaCompile).configure {
tasks.named('test', Test).configure {
jvmArgs('--add-modules', 'jdk.incubator.vector')
dependsOn(languageSourceManifestTask)
classpath = files(layout.buildDirectory.dir('generated/language-source')) + files('src/main/resources') + classpath
classpath = files(layout.buildDirectory.dir('generated/language-source')) + files('src/main/resources') + sourceSets.main.output + classpath
}
configurations.matching { it.name.startsWith('slim') }.all { }
@@ -264,8 +269,6 @@ List<String> supersededVolmLibPackages = [
'art/arcane/volmlib/util/reflect/**',
'art/arcane/volmlib/util/documentation/**',
'art/arcane/iris/core/report/**',
'art/arcane/iris/util/common/inventorygui/**',
'art/arcane/iris/util/common/board/**',
'art/arcane/volmlib/integration/VaultEconomy*.class'
]
@@ -318,8 +321,6 @@ List<String> unusedVolmLibClasses = [
'art/arcane/volmlib/util/director/DirectorNodeBase',
'art/arcane/volmlib/util/director/DirectorParameterBase',
'art/arcane/volmlib/util/director/theme/DirectorThemes',
'art/arcane/volmlib/util/interpolation/InterpolationMethod',
'art/arcane/volmlib/util/interpolation/IrisInterpolation',
'art/arcane/volmlib/util/math/INode',
'art/arcane/volmlib/util/math/IrisMathHelper',
'art/arcane/volmlib/util/math/KochanekBartelsInterpolation',
@@ -329,11 +330,6 @@ List<String> unusedVolmLibClasses = [
'art/arcane/volmlib/util/board/BoardUpdateTask',
'art/arcane/volmlib/util/bukkit/Placeholders',
'art/arcane/volmlib/util/hunk/HunkFactory',
'art/arcane/volmlib/util/hunk/storage/ArrayHunk',
'art/arcane/volmlib/util/hunk/storage/AtomicDoubleHunk',
'art/arcane/volmlib/util/hunk/storage/AtomicIntegerHunk',
'art/arcane/volmlib/util/hunk/storage/AtomicLongHunk',
'art/arcane/volmlib/util/hunk/storage/SynchronizedArrayHunk',
'art/arcane/volmlib/util/hunk/view/BiomeForceBridge',
'art/arcane/volmlib/util/hunk/view/BiomeGridForceSupport',
'art/arcane/volmlib/util/hunk/view/BiomeGridHunkHolder',
@@ -393,7 +389,6 @@ List<String> unusedVolmLibClasses = [
'art/arcane/iris/engine/object/IPostBlockAccess',
'art/arcane/iris/engine/object/IrisPotionEffect',
'art/arcane/iris/engine/platform/DummyChunkGenerator',
'art/arcane/iris/util/common/math/VectorMath',
'art/arcane/iris/util/common/parallel/GridLock',
'art/arcane/iris/util/common/reflect/W',
'art/arcane/iris/util/slimjar/BuildConstants',
+26 -3
View File
@@ -62,11 +62,37 @@ art/arcane/iris/core/service/BoardSVC.java
art/arcane/iris/core/service/EntityRiseSVC.java
art/arcane/iris/core/service/ExternalDataSVC.java
art/arcane/iris/core/service/GlobalCacheSVC.java
art/arcane/iris/core/service/JigsawStudioAutosaveScheduler.java
art/arcane/iris/core/service/JigsawStudioCapture.java
art/arcane/iris/core/service/JigsawStudioCaptureScheduler.java
art/arcane/iris/core/service/JigsawStudioChunkWriter.java
art/arcane/iris/core/service/JigsawStudioDetailsMenu.java
art/arcane/iris/core/service/JigsawStudioDirtyTracker.java
art/arcane/iris/core/service/JigsawStudioGraphMutations.java
art/arcane/iris/core/service/JigsawStudioMainMenu.java
art/arcane/iris/core/service/JigsawStudioMarkerParser.java
art/arcane/iris/core/service/JigsawStudioMaterializer.java
art/arcane/iris/core/service/JigsawStudioMenuActions.java
art/arcane/iris/core/service/JigsawStudioMenuController.java
art/arcane/iris/core/service/JigsawStudioMenuFormat.java
art/arcane/iris/core/service/JigsawStudioPlayerContext.java
art/arcane/iris/core/service/JigsawStudioPreviewRenderer.java
art/arcane/iris/core/service/JigsawStudioProtection.java
art/arcane/iris/core/service/JigsawStudioProtectionListener.java
art/arcane/iris/core/service/JigsawStudioRegistry.java
art/arcane/iris/core/service/JigsawStudioSaveLifecycle.java
art/arcane/iris/core/service/JigsawStudioService.java
art/arcane/iris/core/service/JigsawStudioStructureMenu.java
art/arcane/iris/core/service/JigsawStudioTileWatcher.java
art/arcane/iris/core/service/JigsawStudioToolCodec.java
art/arcane/iris/core/service/JigsawStudioToolbelt.java
art/arcane/iris/core/service/JigsawStudioToolboxMenu.java
art/arcane/iris/core/service/JigsawStudioVariantEditor.java
art/arcane/iris/core/service/JigsawStudioVariantMenu.java
art/arcane/iris/core/service/JigsawStudioVariantProperties.java
art/arcane/iris/core/service/JigsawStudioVisualization.java
art/arcane/iris/core/service/JigsawStudioWorkcellEditor.java
art/arcane/iris/core/service/JigsawStudioWorkcellMenu.java
art/arcane/iris/core/service/MultiverseSVC.java
art/arcane/iris/core/service/ObjectSVC.java
art/arcane/iris/core/service/ObjectStudioSaveService.java
@@ -162,9 +188,6 @@ art/arcane/iris/util/common/director/handlers/VectorHandler.java
art/arcane/iris/util/common/director/handlers/WorldHandler.java
art/arcane/iris/util/common/director/specialhandlers/NullablePlayerHandler.java
art/arcane/iris/util/common/format/C.java
art/arcane/iris/util/common/inventorygui/UIStaticDecorator.java
art/arcane/iris/util/common/inventorygui/Window.java
art/arcane/iris/util/common/inventorygui/WindowResolution.java
art/arcane/iris/util/common/math/AxisAlignedBB.java
art/arcane/iris/util/common/math/Direction.java
art/arcane/iris/util/common/math/VectorMath.java
@@ -107,6 +107,8 @@ public class IrisSettings {
try {
IO.writeAll(s, new JSONObject(new Gson().toJson(loaded)).toString(4));
} catch (IOException e) {
IrisLogging.reportError("Could not rewrite " + s.getAbsolutePath()
+ "; settings changes made in game will not survive a restart.", e);
}
} catch (Throwable ee) {
// IrisLogging.reportError(ee); causes a self-reference & stackoverflow
@@ -306,6 +308,7 @@ public class IrisSettings {
public boolean debug = false;
public boolean dumpMantleOnError = false;
public boolean disableNMS = false;
public boolean eagerRuntimeInjection = false;
public boolean splashLogoStartup = true;
public boolean useConsoleCustomColors = true;
public boolean useCustomColorsIngame = true;
@@ -158,6 +158,16 @@ public class ServerConfigurator {
}
}
public static void resetLoadedDatapackRuntime() {
synchronized (DATAPACK_INSTALL_LOCK) {
loadedDatapackRuntimeReady = false;
loadedDatapackCompilerInputFingerprint = "";
loadedDatapackRegistryRequirements = Map.of();
loadedDatapackRuntimeGeneration++;
loadedDatapackRestartRequired = false;
}
}
public static LoadedDatapackRuntimeInvalidation invalidateLoadedDatapackRuntime() {
synchronized (DATAPACK_INSTALL_LOCK) {
boolean wasReady = loadedDatapackRuntimeReady;
@@ -0,0 +1,37 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
final class CoordinatorDirectory {
String target;
String pending;
String backup;
boolean hadTarget;
String originalHash;
String desiredHash;
String originalMarkerHash;
String desiredMarkerHash;
String originalIdentity;
String desiredIdentity;
String targetRoot;
String scratchRoot;
String targetRootIdentity;
String scratchRootIdentity;
}
@@ -16,12 +16,11 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.util.project.interpolation;
package art.arcane.iris.core.datapack;
public enum InterpolationType {
LINEAR,
PARAMETRIC_2,
PARAMETRIC_4,
BEZIER,
NONE
}
final class CoordinatorEditable {
String packRoot;
String transactionId;
String claimId;
}
@@ -0,0 +1,37 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import java.util.ArrayList;
import java.util.List;
final class CoordinatorJournal {
int schemaVersion;
String transactionId;
CoordinatorOperation operation;
CoordinatorPhase phase;
String id;
String url;
String versionId;
String versionNumber;
String sha1;
boolean manifestAlreadyMatched;
List<CoordinatorDirectory> directories = new ArrayList<>();
List<CoordinatorEditable> editables = new ArrayList<>();
}
@@ -0,0 +1,25 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
enum CoordinatorOperation {
INSTALL,
REMOVE
}
@@ -0,0 +1,31 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
enum CoordinatorPhase {
PREPARED,
PUBLISHING,
PUBLISHED,
COMMITTED;
boolean published() {
return this == PUBLISHED || this == COMMITTED;
}
}
@@ -0,0 +1,415 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.core.datapack.ModrinthResolver.ResolvedDatapack;
import art.arcane.volmlib.util.io.IO;
import art.arcane.volmlib.util.io.ZipUtils;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UncheckedIOException;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
final class DatapackArchive {
static final String USER_AGENT = "VolmitSoftware/Iris (datapack-ingest)";
static final int MAX_REDIRECTS = 5;
static final int MAX_ARCHIVE_ENTRIES = 100_000;
static final int MAX_CACHE_FILES = 32;
static final long MAX_DOWNLOAD_BYTES = 256L * 1024L * 1024L;
static final long MAX_EXPANDED_BYTES = 1024L * 1024L * 1024L;
static final long MAX_ENTRY_BYTES = 256L * 1024L * 1024L;
static final long MAX_CACHE_BYTES = 1024L * 1024L * 1024L;
static final Set<String> RESERVED_IDS = Set.of("iris");
private DatapackArchive() {
}
static File extractArchive(File zip, File stagingRoot, Entry entry) throws IOException {
DatapackSupport.ensureScratchDirectory(stagingRoot, "datapack staging");
File pending = new File(stagingRoot, ".pending-" + entry.id + "-" + UUID.randomUUID());
try {
if (!pending.mkdirs() && !pending.isDirectory()) {
throw new IOException("Couldn't create datapack extraction directory " + pending.getPath());
}
ZipUtils.unzipFile(zip, pending, MAX_ARCHIVE_ENTRIES, MAX_EXPANDED_BYTES, MAX_ENTRY_BYTES);
flattenIfWrapped(pending);
DatapackPackMetadata.validatePackMetadata(pending);
PackResources resources = DatapackOwnership.scanPackResources(pending);
entry.structureKeys = resources.structureKeys();
entry.templateKeys = resources.templateKeys();
DatapackOwnership.writeOwnership(pending, entry);
return pending;
} catch (UncheckedIOException e) {
IOException failure = e.getCause();
cleanupFailedExtraction(pending, failure);
throw failure;
} catch (IOException | RuntimeException e) {
cleanupFailedExtraction(pending, e);
throw e;
}
}
static void cleanupFailedExtraction(File pending, Throwable failure) {
try {
DatapackInstall.deleteInstallScratch(pending, "failed datapack extraction");
} catch (IOException cleanupFailure) {
failure.addSuppressed(cleanupFailure);
}
}
static void cleanupExtractedStaging(File extractedDir) {
try {
DatapackInstall.deleteInstallScratch(extractedDir, "extracted datapack staging");
} catch (IOException e) {
IrisLogging.warn("Preserving extracted datapack staging for restart cleanup: "
+ extractedDir.getPath() + " (" + e.getMessage() + ")");
}
}
static void flattenIfWrapped(File dir) throws IOException {
if (new File(dir, "pack.mcmeta").isFile()) {
return;
}
File[] children = dir.listFiles();
if (children == null) {
return;
}
File singleDir = null;
int dirCount = 0;
int fileCount = 0;
for (File child : children) {
if (child.isDirectory()) {
dirCount++;
singleDir = child;
} else {
fileCount++;
}
}
if (dirCount != 1 || fileCount != 0 || singleDir == null || !new File(singleDir, "pack.mcmeta").isFile()) {
return;
}
File[] inner = singleDir.listFiles();
if (inner != null) {
for (File item : inner) {
File moved = new File(dir, item.getName());
if (item.renameTo(moved)) {
continue;
}
if (item.isDirectory()) {
IO.copyDirectory(item.toPath(), moved.toPath());
} else {
Files.copy(item.toPath(), moved.toPath(), StandardCopyOption.REPLACE_EXISTING);
}
}
}
DatapackInstall.deleteInstallScratch(singleDir, "wrapped datapack extraction");
}
static DownloadResult download(String url, File dest, String etag, String lastModified) throws IOException {
URI current = DatapackStartupValidation.parseSourceUri(url);
if (current == null) {
throw new IOException("Empty datapack URL");
}
if ("file".equalsIgnoreCase(current.getScheme())) {
return copyLocalDatapack(current, dest);
}
for (int attempt = 0; attempt < MAX_REDIRECTS; attempt++) {
if (!"http".equalsIgnoreCase(current.getScheme()) && !"https".equalsIgnoreCase(current.getScheme())) {
throw new IOException("Datapack URL must use HTTP or HTTPS: " + current);
}
URL target = current.toURL();
HttpURLConnection connection = (HttpURLConnection) target.openConnection();
connection.setRequestMethod("GET");
connection.setRequestProperty("User-Agent", USER_AGENT);
if (etag != null && !etag.isBlank()) {
connection.setRequestProperty("If-None-Match", etag);
}
if (lastModified != null && !lastModified.isBlank()) {
connection.setRequestProperty("If-Modified-Since", lastModified);
}
connection.setConnectTimeout(20000);
connection.setReadTimeout(60000);
connection.setInstanceFollowRedirects(false);
try {
int code = connection.getResponseCode();
if (code == HttpURLConnection.HTTP_NOT_MODIFIED) {
String responseEtag = headerOrFallback(connection, "ETag", etag);
String responseLastModified = headerOrFallback(connection, "Last-Modified", lastModified);
return new DownloadResult(true, responseEtag, responseLastModified);
}
if (code / 100 == 3) {
String location = connection.getHeaderField("Location");
if (location == null || location.isBlank()) {
throw new IOException("Redirect without a location header from " + current);
}
try {
current = current.resolve(new URI(location));
} catch (URISyntaxException e) {
throw new IOException("Invalid redirect location from " + current + ": " + location, e);
}
continue;
}
if (code != 200) {
throw new IOException("HTTP " + code + " downloading " + current);
}
long declaredLength = connection.getContentLengthLong();
if (declaredLength > MAX_DOWNLOAD_BYTES) {
throw new IOException("Datapack download exceeds " + MAX_DOWNLOAD_BYTES + " bytes");
}
File parent = dest.getParentFile();
Path parentPath = parent == null ? Path.of(".").toAbsolutePath().normalize() : parent.toPath();
DatapackSupport.ensureScratchDirectory(parentPath.toFile(), "datapack download cache");
Path temporary = Files.createTempFile(parentPath, dest.getName() + "-", ".part");
try {
long downloaded = 0;
String responseEtag = connection.getHeaderField("ETag");
String responseLastModified = connection.getHeaderField("Last-Modified");
try (InputStream in = connection.getInputStream();
OutputStream out = Files.newOutputStream(temporary)) {
byte[] buffer = new byte[8192];
int length;
while ((length = in.read(buffer)) > 0) {
downloaded += length;
if (downloaded > MAX_DOWNLOAD_BYTES) {
throw new IOException("Datapack download exceeds " + MAX_DOWNLOAD_BYTES + " bytes");
}
out.write(buffer, 0, length);
}
}
DatapackSupport.move(temporary, dest.toPath());
return new DownloadResult(false, responseEtag, responseLastModified);
} finally {
Files.deleteIfExists(temporary);
}
} finally {
connection.disconnect();
}
}
throw new IOException("Too many redirects downloading " + url);
}
static DownloadResult copyLocalDatapack(URI source, File destination) throws IOException {
Path sourcePath = requireLocalDatapackPath(source);
Path destinationPath = destination.toPath().toAbsolutePath().normalize();
if (Files.exists(destinationPath, LinkOption.NOFOLLOW_LINKS)
&& Files.isSameFile(sourcePath, destinationPath)) {
throw new IOException("Local datapack source and cache destination are the same file: " + sourcePath);
}
Path parent = destinationPath.getParent();
if (parent == null) {
throw new IOException("Local datapack cache destination has no parent: " + destinationPath);
}
DatapackSupport.ensureScratchDirectory(parent.toFile(), "datapack download cache");
BasicFileAttributes before = Files.readAttributes(
sourcePath,
BasicFileAttributes.class,
LinkOption.NOFOLLOW_LINKS);
Path temporary = Files.createTempFile(parent, destination.getName() + "-", ".part");
try {
long copied = 0;
try (InputStream input = Files.newInputStream(
sourcePath,
StandardOpenOption.READ,
LinkOption.NOFOLLOW_LINKS);
OutputStream output = Files.newOutputStream(temporary)) {
byte[] buffer = new byte[DatapackOwnership.HASH_BUFFER_BYTES];
int length;
while ((length = input.read(buffer)) > 0) {
copied += length;
if (copied > MAX_DOWNLOAD_BYTES) {
throw new IOException("Local datapack exceeds " + MAX_DOWNLOAD_BYTES + " bytes: " + sourcePath);
}
output.write(buffer, 0, length);
}
}
BasicFileAttributes after = Files.readAttributes(
sourcePath,
BasicFileAttributes.class,
LinkOption.NOFOLLOW_LINKS);
if (!after.isRegularFile()
|| before.size() != after.size()
|| !before.lastModifiedTime().equals(after.lastModifiedTime())
|| !Objects.equals(before.fileKey(), after.fileKey())
|| copied != after.size()) {
throw new IOException("Local datapack changed while Iris was copying it: " + sourcePath);
}
DatapackSupport.move(temporary, destinationPath);
return new DownloadResult(false, null, null);
} finally {
Files.deleteIfExists(temporary);
}
}
static Path requireLocalDatapackPath(URI source) throws IOException {
if (source == null
|| !"file".equalsIgnoreCase(source.getScheme())
|| source.isOpaque()
|| source.getRawAuthority() != null
|| source.getRawQuery() != null
|| source.getRawFragment() != null) {
throw new IOException("Datapack file URL must be an absolute local file URI without authority, query, or fragment: " + source);
}
Path path;
try {
path = Path.of(source).toAbsolutePath().normalize();
} catch (IllegalArgumentException exception) {
throw new IOException("Invalid local datapack file URL: " + source, exception);
}
if (Files.isSymbolicLink(path)
|| !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Local datapack is not a regular non-symbolic-link file: " + path);
}
long size = Files.size(path);
if (size > MAX_DOWNLOAD_BYTES) {
throw new IOException("Local datapack exceeds " + MAX_DOWNLOAD_BYTES + " bytes: " + path);
}
return path;
}
static String headerOrFallback(HttpURLConnection connection, String name, String fallback) {
String value = connection.getHeaderField(name);
return value == null || value.isBlank() ? fallback : value;
}
static String sha1(File file) throws IOException {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-1");
try (InputStream in = new FileInputStream(file)) {
byte[] buffer = new byte[8192];
int length;
while ((length = in.read(buffer)) > 0) {
digest.update(buffer, 0, length);
}
}
byte[] hash = digest.digest();
StringBuilder builder = new StringBuilder(hash.length * 2);
for (byte value : hash) {
builder.append(String.format("%02x", value));
}
return builder.toString();
} catch (NoSuchAlgorithmException e) {
throw new IOException("SHA-1 algorithm unavailable", e);
}
}
static String deriveId(ResolvedDatapack resolved) {
String base = resolved.getProjectSlug();
if (base == null || base.isBlank()) {
base = resolved.getFileName();
int dot = base.lastIndexOf('.');
if (dot > 0) {
base = base.substring(0, dot);
}
}
String id = sanitizeId(base);
if (resolved.isDirect()) {
return id + "-" + ModrinthResolver.directIdentity(resolved.getDownloadUrl());
}
return id;
}
static String sanitizeId(String value) {
if (value == null) {
return "datapack";
}
String lower = value.toLowerCase(Locale.ROOT).trim();
StringBuilder builder = new StringBuilder(lower.length());
for (int i = 0; i < lower.length(); i++) {
char c = lower.charAt(i);
if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.') {
builder.append(c);
} else if (c == ' ' || c == '/' || c == '\\') {
builder.append('-');
}
}
String cleaned = builder.toString().replaceAll("-+", "-");
cleaned = cleaned.replaceAll("^[-_.]+", "").replaceAll("[-_.]+$", "");
return cleaned.isBlank() ? "datapack" : cleaned;
}
static boolean isValidManagedId(String value) {
return value != null && !value.isBlank() && value.equals(sanitizeId(value))
&& !RESERVED_IDS.contains(value);
}
static void pruneCache(File cacheDir) {
File[] files = cacheDir.listFiles(File::isFile);
if (files == null) {
return;
}
List<File> archives = new ArrayList<>();
long totalBytes = 0;
for (File file : files) {
if (file.getName().endsWith(".part")) {
IO.delete(file);
continue;
}
if (file.getName().endsWith(".zip")) {
archives.add(file);
totalBytes += Math.max(0, file.length());
}
}
archives.sort(Comparator.comparingLong(File::lastModified));
int remaining = archives.size();
for (File archive : archives) {
if (remaining <= MAX_CACHE_FILES && totalBytes <= MAX_CACHE_BYTES) {
break;
}
long size = Math.max(0, archive.length());
IO.delete(archive);
if (!archive.exists()) {
remaining--;
totalBytes -= size;
}
}
}
}
@@ -16,25 +16,26 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.util.common.inventorygui;
package art.arcane.iris.core.datapack;
import art.arcane.volmlib.util.inventorygui.WindowDecorator;
import java.io.IOException;
import java.nio.file.Path;
import art.arcane.volmlib.util.inventorygui.Element;
final class DatapackCoordinator {
private final Path transactionRoot;
final CoordinatorJournal journal;
import art.arcane.volmlib.util.data.MaterialBlock;
import org.bukkit.Material;
@SuppressWarnings("ClassCanBeRecord")
public class UIStaticDecorator implements WindowDecorator {
private final Element element;
public UIStaticDecorator(Element element) {
this.element = element == null ? new art.arcane.volmlib.util.inventorygui.UIElement("bg").setMaterial(new MaterialBlock(Material.AIR)) : element;
DatapackCoordinator(Path transactionRoot, CoordinatorJournal journal) {
this.transactionRoot = transactionRoot;
this.journal = journal;
}
@Override
public Element onDecorateBackground(art.arcane.volmlib.util.inventorygui.Window window, int position, int row) {
return element;
void phase(CoordinatorPhase phase) throws IOException {
journal.phase = phase;
DatapackTransactions.writeCoordinatorJournal(transactionRoot, journal);
}
void finish() throws IOException {
DatapackTransactions.deleteCoordinatorTransaction(transactionRoot);
}
}
@@ -0,0 +1,438 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import art.arcane.volmlib.util.io.IO;
import java.io.File;
import java.io.IOException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
final class DatapackCoordinatorResolution {
private DatapackCoordinatorResolution() {
}
static Set<Path> authoritativeEditableRoots(
Manifest committedManifest,
CoordinatorJournal journal
) throws IOException {
Set<Path> roots = new HashSet<>();
Entry committed = committedManifest.findById(journal.id);
if (committed != null) {
if (!Objects.equals(committed.url, journal.url)) {
throw new IOException("Datapack transaction conflicts with the committed editable pack owner");
}
addExistingPackRoots(roots, committed.importedTargets.keySet());
addExistingPackRoots(roots, committed.importAttempts.keySet());
addExistingPackRoots(roots, committed.importedBundles.keySet());
return roots;
}
if (journal.operation != CoordinatorOperation.REMOVE || !journal.phase.published()) {
throw new IOException("Datapack transaction has no committed editable pack authority");
}
for (CoordinatorEditable editable : journal.editables) {
Path packRoot = DatapackTransactions.coordinatorPath(editable.packRoot, "editable pack root");
if (Files.isSymbolicLink(packRoot) || !Files.isDirectory(packRoot, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid editable pack root in datapack transaction: " + packRoot);
}
Path realPackRoot = packRoot.toRealPath();
if (!packRoot.equals(realPackRoot)) {
throw new IOException("Editable pack root changed before datapack recovery: " + packRoot);
}
roots.add(realPackRoot);
}
return roots;
}
static void addExistingPackRoots(Set<Path> roots, Set<String> paths) throws IOException {
for (String path : paths) {
try {
Path root = Path.of(path).toAbsolutePath().normalize();
if (Files.isDirectory(root)) {
roots.add(root.toRealPath());
}
} catch (RuntimeException e) {
throw new IOException("Invalid editable pack root in the datapack manifest: " + path, e);
}
}
}
static void validateCoordinatorDirectory(
CoordinatorDirectory directory,
CoordinatorOperation operation,
Set<Path> allowedTargets,
Set<Path> seenTargets,
Set<Path> seenTargetIdentities
) throws IOException {
if (directory == null || directory.target == null || directory.backup == null
|| directory.originalHash == null || directory.desiredHash == null
|| directory.originalMarkerHash == null || directory.desiredMarkerHash == null
|| directory.originalIdentity == null || directory.desiredIdentity == null
|| directory.targetRoot == null || directory.scratchRoot == null
|| directory.targetRootIdentity == null || directory.scratchRootIdentity == null) {
throw new IOException("Incomplete directory participant in datapack transaction");
}
Path target = DatapackTransactions.coordinatorPath(directory.target, "target");
if (!allowedTargets.contains(target) || !seenTargets.add(target)) {
throw new IOException("Datapack transaction target is outside its configured roots: " + target);
}
File targetFile = target.toFile();
File targetParent = targetFile.getParentFile();
Path targetRootIdentity = DatapackTransactions.coordinatorPath(directory.targetRoot, "target root");
if (!seenTargetIdentities.add(targetRootIdentity.resolve(target.getFileName()).normalize())) {
throw new IOException("Aliased datapack transaction target " + target);
}
validateRecoveryContainer(
targetParent.toPath(),
targetRootIdentity,
true,
"datapack target root"
);
DatapackInstallPlanner.verifyDirectoryContainerIdentity(
targetParent, directory.targetRoot, directory.targetRootIdentity, "datapack target root");
File scratchRoot = new File(
targetParent.getParentFile() == null ? targetParent : targetParent.getParentFile(),
operation == CoordinatorOperation.INSTALL ? ".iris-datapack-install" : ".iris-datapack-remove"
);
Path expectedScratch = scratchRoot.toPath().toAbsolutePath().normalize();
Path backup = DatapackTransactions.coordinatorPath(directory.backup, "backup");
if (!Objects.equals(backup.getParent(), expectedScratch) || Files.isSymbolicLink(backup)) {
throw new IOException("Invalid datapack transaction backup path " + backup);
}
Path pending = null;
if (operation == CoordinatorOperation.INSTALL) {
if (directory.pending == null || directory.pending.isBlank()) {
throw new IOException("Install transaction is missing its pending directory");
}
pending = DatapackTransactions.coordinatorPath(directory.pending, "pending directory");
if (!Objects.equals(pending.getParent(), expectedScratch) || Files.isSymbolicLink(pending)) {
throw new IOException("Invalid datapack transaction pending path " + pending);
}
} else if (directory.pending != null && !directory.pending.isBlank()) {
throw new IOException("Removal transaction unexpectedly contains a pending directory");
}
boolean scratchRequired = Files.exists(backup, LinkOption.NOFOLLOW_LINKS)
|| pending != null && Files.exists(pending, LinkOption.NOFOLLOW_LINKS);
validateRecoveryContainer(
expectedScratch,
DatapackTransactions.coordinatorPath(directory.scratchRoot, "scratch root"),
scratchRequired,
"datapack transaction scratch root"
);
if (scratchRequired) {
DatapackInstallPlanner.verifyDirectoryContainerIdentity(
expectedScratch.toFile(), directory.scratchRoot,
directory.scratchRootIdentity, "datapack transaction scratch root");
}
}
static void validateRecoveryContainer(
Path container,
Path expectedRealPath,
boolean required,
String purpose
) throws IOException {
Path normalized = container.toAbsolutePath().normalize();
Path normalizedExpected = expectedRealPath.toAbsolutePath().normalize();
if (!Files.exists(normalized, LinkOption.NOFOLLOW_LINKS)) {
if (required) {
throw new IOException("Missing " + purpose + " " + normalized);
}
return;
}
if (Files.isSymbolicLink(normalized)
|| !Files.isDirectory(normalized, LinkOption.NOFOLLOW_LINKS)
|| !Objects.equals(normalized.toRealPath(), normalizedExpected)) {
throw new IOException("Changed or unsafe " + purpose + " " + normalized);
}
}
static boolean coordinatorCommitDecision(Manifest manifest, CoordinatorJournal journal) throws IOException {
Entry committed = manifest.findById(journal.id);
if (journal.operation == CoordinatorOperation.REMOVE) {
if (committed == null) {
if (!journal.phase.published()) {
throw new IOException("Datapack removal manifest changed before every participant was published");
}
return true;
}
if (!Objects.equals(committed.url, journal.url) || journal.phase == CoordinatorPhase.COMMITTED) {
throw new IOException("Datapack removal journal conflicts with the committed manifest");
}
return false;
}
boolean matches = committed != null
&& Objects.equals(committed.url, journal.url)
&& Objects.equals(committed.versionId, journal.versionId)
&& Objects.equals(committed.versionNumber, journal.versionNumber)
&& Objects.equals(committed.sha1, journal.sha1);
if (journal.manifestAlreadyMatched) {
if (!matches) {
throw new IOException("Committed datapack changed while an install transaction was incomplete");
}
return journal.phase.published();
}
if (matches) {
if (!journal.phase.published()) {
throw new IOException("Datapack install manifest changed before every target was published");
}
return true;
}
if (journal.phase == CoordinatorPhase.COMMITTED) {
throw new IOException("Committed datapack install journal conflicts with the manifest");
}
return false;
}
static void resolveCoordinatorDirectories(CoordinatorJournal journal, boolean commit) throws IOException {
List<CoordinatorDirectory> directories = new ArrayList<>(journal.directories);
if (!commit) {
Collections.reverse(directories);
}
IOException failure = null;
for (CoordinatorDirectory directory : directories) {
try {
if (journal.operation == CoordinatorOperation.INSTALL) {
resolveInstallDirectory(journal, directory, commit);
} else {
resolveRemovalDirectory(directory, commit);
}
} catch (IOException | RuntimeException e) {
IOException participantFailure = e instanceof IOException ioFailure
? ioFailure : new IOException("Failed resolving datapack directory participant", e);
if (failure == null) {
failure = participantFailure;
} else {
failure.addSuppressed(participantFailure);
}
}
}
if (failure != null) {
throw failure;
}
}
static void resolveInstallDirectory(
CoordinatorJournal journal,
CoordinatorDirectory directory,
boolean commit
) throws IOException {
File target = new File(directory.target);
File pending = new File(directory.pending);
File backup = new File(directory.backup);
File targetRoot = new File(directory.targetRoot);
if (commit) {
verifyDesiredDirectory(
target, targetRoot, journal, directory.desiredHash,
directory.desiredMarkerHash, directory.desiredIdentity);
deleteOriginalBackupIfPresent(
backup, targetRoot, directory.originalHash,
directory.originalMarkerHash, directory.originalIdentity);
deleteDesiredDirectoryIfPresent(
pending, targetRoot, journal, directory.desiredHash,
directory.desiredMarkerHash, directory.desiredIdentity);
cleanupScratchParent(backup);
return;
}
if (directory.hadTarget) {
if (Files.exists(backup.toPath(), LinkOption.NOFOLLOW_LINKS)) {
DatapackInstallPlanner.verifyDirectorySnapshot(
backup, targetRoot, directory.originalHash,
directory.originalMarkerHash, directory.originalIdentity,
"datapack install backup");
deleteDesiredDirectoryIfPresent(
target, targetRoot, journal, directory.desiredHash,
directory.desiredMarkerHash, directory.desiredIdentity);
DatapackSupport.moveNew(backup.toPath(), target.toPath());
DatapackSupport.forceDirectoryIfSupported(target.getParentFile().toPath());
DatapackSupport.forceDirectoryIfSupported(backup.getParentFile().toPath());
} else {
DatapackInstallPlanner.verifyDirectorySnapshot(
target, targetRoot, directory.originalHash,
directory.originalMarkerHash, directory.originalIdentity,
"original datapack target");
}
} else {
if (Files.exists(backup.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unexpected backup for newly-installed datapack " + backup.getPath());
}
deleteDesiredDirectoryIfPresent(
target, targetRoot, journal, directory.desiredHash,
directory.desiredMarkerHash, directory.desiredIdentity);
}
deleteDesiredDirectoryIfPresent(
pending, targetRoot, journal, directory.desiredHash,
directory.desiredMarkerHash, directory.desiredIdentity);
cleanupScratchParent(backup);
}
static void resolveRemovalDirectory(CoordinatorDirectory directory, boolean commit) throws IOException {
File target = new File(directory.target);
File backup = new File(directory.backup);
File targetRoot = new File(directory.targetRoot);
if (commit) {
if (Files.exists(target.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Removed datapack target reappeared before transaction cleanup: " + target.getPath());
}
deleteOriginalBackupIfPresent(
backup, targetRoot, directory.originalHash,
directory.originalMarkerHash, directory.originalIdentity);
cleanupScratchParent(backup);
return;
}
if (Files.exists(backup.toPath(), LinkOption.NOFOLLOW_LINKS)) {
DatapackInstallPlanner.verifyDirectorySnapshot(
backup, targetRoot, directory.originalHash,
directory.originalMarkerHash, directory.originalIdentity,
"datapack removal backup");
if (Files.exists(target.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Datapack removal target was concurrently recreated: " + target.getPath());
}
DatapackSupport.moveNew(backup.toPath(), target.toPath());
DatapackSupport.forceDirectoryIfSupported(target.getParentFile().toPath());
DatapackSupport.forceDirectoryIfSupported(backup.getParentFile().toPath());
} else {
DatapackInstallPlanner.verifyDirectorySnapshot(
target, targetRoot, directory.originalHash,
directory.originalMarkerHash, directory.originalIdentity,
"original datapack target");
}
cleanupScratchParent(backup);
}
static void resolveCoordinatorEditables(
CoordinatorJournal journal,
Path transactionRoot,
boolean commit
) throws IOException {
IOException failure = null;
for (CoordinatorEditable editable : journal.editables) {
try {
Path packRoot = DatapackTransactions.coordinatorPath(editable.packRoot, "editable pack root");
StructureTransactionWriter writer = new StructureTransactionWriter(packRoot);
writer.resolvePreparedRemoval(
new StructureTransactionWriter.PreparedRemovalToken(
packRoot,
UUID.fromString(editable.transactionId)
),
new StructureTransactionWriter.RecoveryOwner(
transactionRoot,
UUID.fromString(journal.transactionId),
UUID.fromString(editable.claimId)
),
commit
);
IrisData.invalidateLoadedStructureResources(packRoot.toFile());
} catch (IOException | RuntimeException e) {
IOException participantFailure = e instanceof IOException ioFailure
? ioFailure : new IOException("Failed resolving editable structure participant", e);
if (failure == null) {
failure = participantFailure;
} else {
failure.addSuppressed(participantFailure);
}
}
}
if (failure != null) {
throw failure;
}
}
static void verifyDesiredDirectory(
File directory,
File targetRoot,
CoordinatorJournal journal,
String expectedHash,
String expectedMarkerHash,
String expectedIdentity
) throws IOException {
DatapackInstallPlanner.verifyDirectorySnapshot(
directory, targetRoot, expectedHash, expectedMarkerHash,
expectedIdentity, "installed datapack target");
Ownership ownership = DatapackOwnership.readOwnership(directory);
if (!Objects.equals(ownership.id, journal.id) || !Objects.equals(ownership.url, journal.url)
|| !Objects.equals(ownership.contentHash, expectedHash)) {
throw new IOException("Installed datapack ownership does not match its transaction at " + directory.getPath());
}
}
static void deleteDesiredDirectoryIfPresent(
File directory,
File targetRoot,
CoordinatorJournal journal,
String expectedHash,
String expectedMarkerHash,
String expectedIdentity
) throws IOException {
if (!Files.exists(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) {
return;
}
verifyDesiredDirectory(
directory, targetRoot, journal, expectedHash,
expectedMarkerHash, expectedIdentity);
deleteVerifiedDirectory(directory);
}
static void deleteOriginalBackupIfPresent(
File backup,
File targetRoot,
String expectedHash,
String expectedMarkerHash,
String expectedIdentity
) throws IOException {
if (!Files.exists(backup.toPath(), LinkOption.NOFOLLOW_LINKS)) {
return;
}
DatapackInstallPlanner.verifyDirectorySnapshot(
backup, targetRoot, expectedHash, expectedMarkerHash,
expectedIdentity, "datapack transaction backup");
deleteVerifiedDirectory(backup);
}
static void deleteVerifiedDirectory(File directory) throws IOException {
File parent = Objects.requireNonNull(directory.getParentFile(), "datapack transaction parent");
DatapackInstall.validateInstallTree(directory, parent, "Datapack transaction directory");
IO.delete(directory);
if (Files.exists(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Could not delete datapack transaction directory " + directory.getPath());
}
}
static void cleanupScratchParent(File participant) {
File parent = participant.getParentFile();
if (parent != null) {
parent.delete();
}
}
}
@@ -0,0 +1,146 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.volmlib.util.collection.KList;
import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Stream;
final class DatapackImportSources {
static final String LOCAL_IMPORT_DIRECTORY = "imports";
private DatapackImportSources() {
}
static Set<String> mergeConfiguredImports(
Iterable<String> explicit,
Iterable<String> discovered
) {
LinkedHashSet<String> sources = new LinkedHashSet<>();
if (explicit != null) {
addImports(explicit, sources);
}
if (discovered != null) {
addImports(discovered, sources);
}
return sources.isEmpty() ? Set.of() : Set.copyOf(sources);
}
static List<String> discoverLocalDatapackImports(File directory) throws IOException {
Path root = directory.toPath().toAbsolutePath().normalize();
if (Files.notExists(root, LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(root);
}
if (Files.isSymbolicLink(root)
|| !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Local datapack import path is not a safe directory: " + root);
}
try (Stream<Path> stream = Files.list(root)) {
return stream
.filter(path -> !Files.isSymbolicLink(path))
.filter(path -> Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS))
.filter(DatapackImportSources::isLocalDatapackArchive)
.sorted(Comparator.comparing(
(Path path) -> path.getFileName().toString(),
String.CASE_INSENSITIVE_ORDER)
.thenComparing((Path path) -> path.getFileName().toString()))
.map(path -> path.toAbsolutePath().normalize().toUri().toASCIIString())
.toList();
}
}
static boolean isLocalDatapackArchive(Path path) {
String name = path.getFileName().toString();
return !name.startsWith(".") && name.toLowerCase(Locale.ROOT).endsWith(".zip");
}
static List<String> localDatapackImports() {
File directory = IrisPlatforms.get().dataFolder("datapacks", LOCAL_IMPORT_DIRECTORY);
try {
return discoverLocalDatapackImports(directory);
} catch (IOException exception) {
IrisLogging.reportError("Could not discover local datapack imports under "
+ directory.getPath() + ".", exception);
return List.of();
}
}
static void collectImports(IrisData data, LinkedHashSet<String> urls) {
if (data == null || data.getDimensionLoader() == null) {
return;
}
for (IrisDimension dimension : data.getDimensionLoader().loadAll(data.getDimensionLoader().getPossibleKeys())) {
if (dimension == null) {
continue;
}
KList<String> imports = dimension.getDatapackImports();
if (imports == null) {
continue;
}
addImports(imports, urls);
}
}
static void addImports(Iterable<String> imports, LinkedHashSet<String> sources) {
for (String source : imports) {
if (source != null && !source.isBlank()) {
sources.add(normalizeConfiguredSource(source));
}
}
}
static String normalizeConfiguredSource(String source) {
String normalized = source.trim();
try {
URI uri = new URI(normalized);
if ("file".equalsIgnoreCase(uri.getScheme())) {
return Path.of(uri).toAbsolutePath().normalize().toUri().toASCIIString();
}
} catch (IllegalArgumentException | URISyntaxException ignored) {
}
return normalized;
}
static Set<String> configuredImports(IrisData data) {
LinkedHashSet<String> urls = new LinkedHashSet<>();
collectImports(data, urls);
urls.addAll(localDatapackImports());
return urls;
}
static boolean hasImports(IrisData data) {
return !configuredImports(data).isEmpty();
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,331 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.io.IO;
import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
final class DatapackInstall {
static final int MAX_SCRATCH_DELETE_ATTEMPTS = 3;
private DatapackInstall() {
}
static InstallResult install(File stagedDir, KList<File> worldFolders, Entry entry, boolean stripOverrides) throws IOException {
File root = inferDatapackRoot(stagedDir);
DatapackScratchRecovery.recoverTransactions(root, worldFolders);
InstallExecution execution = prepareInstallExecution(
stagedDir,
worldFolders,
entry,
stripOverrides,
root
);
try {
verifyInstallExecution(execution);
} catch (IOException failure) {
rollbackInstallExecutions(List.of(execution), failure);
throw failure;
}
finishInstallExecution(execution);
return execution.result();
}
static InstallExecution prepareInstallExecution(
File stagedDir,
KList<File> worldFolders,
Entry entry,
boolean stripOverrides,
File root
) throws IOException {
return prepareInstallExecution(
stagedDir, worldFolders, entry, stripOverrides, root, null);
}
static InstallExecution prepareInstallExecution(
File stagedDir,
KList<File> worldFolders,
Entry entry,
boolean stripOverrides,
File root,
VerifiedStagingInstall verifiedStagingInstall
) throws IOException {
DatapackOwnership.validateManagedDirectory(stagedDir, entry.id);
Ownership stagedOwnership = DatapackOwnership.readOwnership(stagedDir);
String stagedHash = DatapackOwnership.directoryHash(stagedDir);
if (!ownershipMetadataMatches(stagedOwnership, entry, stagedHash)) {
throw new IOException("Iris datapack staging does not match the committed manifest entry for " + entry.id);
}
DatapackStagingGuard.validateInstallParticipants(worldFolders, verifiedStagingInstall, entry);
List<InstallPlan> plans = new ArrayList<>();
try {
for (File worldFolder : worldFolders) {
plans.add(DatapackInstallPlanner.prepareInstall(
stagedDir, worldFolder, entry, stagedHash, stripOverrides, verifiedStagingInstall));
}
if (verifiedStagingInstall != null) {
plans.add(DatapackInstallPlanner.prepareInstall(
stagedDir,
verifiedStagingInstall.stagingRoot().toFile(),
entry,
stagedHash,
false,
verifiedStagingInstall));
}
} catch (IOException | RuntimeException preparationFailure) {
for (InstallPlan plan : plans) {
try {
DatapackInstallPlanner.cleanupInstallPlan(plan, false);
} catch (IOException cleanupFailure) {
preparationFailure.addSuppressed(cleanupFailure);
}
}
if (preparationFailure instanceof UncheckedIOException unchecked) {
throw unchecked.getCause();
}
if (preparationFailure instanceof IOException ioFailure) {
throw ioFailure;
}
throw preparationFailure;
}
boolean changed = false;
List<InstallPlan> publishPlans = new ArrayList<>();
List<InstallPlan> unchangedPlans = new ArrayList<>();
try {
for (InstallPlan plan : plans) {
changed |= plan.contentChanged();
if (plan.publishRequired()) {
publishPlans.add(plan);
} else {
unchangedPlans.add(plan);
DatapackInstallPlanner.cleanupInstallPlan(plan, true);
}
}
} catch (IOException cleanupFailure) {
for (InstallPlan plan : plans) {
try {
DatapackInstallPlanner.cleanupInstallPlan(plan, false);
} catch (IOException additionalFailure) {
cleanupFailure.addSuppressed(additionalFailure);
}
}
throw cleanupFailure;
}
if (publishPlans.isEmpty()) {
return new InstallExecution(
new InstallResult(changed),
null,
stagedDir,
entry,
stagedHash,
verifiedStagingInstall == null,
publishPlans,
unchangedPlans);
}
Manifest committedManifest = DatapackManifestStore.readCommittedManifest(root);
boolean manifestAlreadyMatched = manifestEntryMatches(committedManifest.findById(entry.id), entry);
DatapackCoordinator coordinator;
try {
coordinator = DatapackTransactions.createInstallCoordinator(root, entry, publishPlans, manifestAlreadyMatched);
} catch (IOException | RuntimeException coordinatorFailure) {
for (InstallPlan plan : publishPlans) {
try {
DatapackInstallPlanner.cleanupInstallPlan(plan, false);
} catch (IOException cleanupFailure) {
coordinatorFailure.addSuppressed(cleanupFailure);
}
}
if (coordinatorFailure instanceof IOException ioFailure) {
throw ioFailure;
}
throw coordinatorFailure;
}
try {
coordinator.phase(CoordinatorPhase.PUBLISHING);
for (InstallPlan plan : publishPlans) {
DatapackInstallPlanner.publishInstallPlan(plan);
}
coordinator.phase(CoordinatorPhase.PUBLISHED);
} catch (IOException | RuntimeException publishFailure) {
try {
DatapackCoordinatorResolution.resolveCoordinatorDirectories(coordinator.journal, false);
coordinator.finish();
} catch (IOException rollbackFailure) {
publishFailure.addSuppressed(rollbackFailure);
}
if (publishFailure instanceof IOException ioFailure) {
throw ioFailure;
}
throw publishFailure;
}
return new InstallExecution(
new InstallResult(changed),
coordinator,
stagedDir,
entry,
stagedHash,
verifiedStagingInstall == null,
publishPlans,
unchangedPlans);
}
static void verifyInstallExecution(InstallExecution execution) throws IOException {
if (execution.verified()) {
return;
}
if (execution.verifyStagedSource()) {
DatapackOwnership.validateManagedDirectory(execution.stagedDir(), execution.entry().id);
Ownership stagedOwnership = DatapackOwnership.readOwnership(execution.stagedDir());
String stagedHash = DatapackOwnership.directoryHash(execution.stagedDir());
if (!Objects.equals(stagedHash, execution.stagedHash())
|| !ownershipMetadataMatches(stagedOwnership, execution.entry(), stagedHash)) {
throw new IOException("Iris datapack staging changed before installation commit for "
+ execution.entry().id);
}
}
for (InstallPlan plan : execution.publishedPlans()) {
DatapackInstallPlanner.verifyDesiredInstallSnapshot(plan.target(), plan, "published datapack target");
}
for (InstallPlan plan : execution.unchangedPlans()) {
DatapackInstallPlanner.verifyOriginalInstallSnapshot(plan.target(), plan, "unchanged datapack target");
}
execution.markVerified();
}
static void finishInstallExecution(InstallExecution execution) throws IOException {
if (!execution.verified()) {
throw new IOException("Datapack install cannot commit before final verification");
}
if (execution.coordinator() == null) {
return;
}
execution.coordinator().phase(CoordinatorPhase.COMMITTED);
DatapackCoordinatorResolution.resolveCoordinatorDirectories(execution.coordinator().journal, true);
execution.coordinator().finish();
}
static void rollbackInstallExecutions(List<InstallExecution> executions, Throwable failure) {
List<InstallExecution> reversed = new ArrayList<>(executions);
Collections.reverse(reversed);
for (InstallExecution execution : reversed) {
if (execution.coordinator() == null) {
continue;
}
try {
DatapackCoordinatorResolution.resolveCoordinatorDirectories(execution.coordinator().journal, false);
execution.coordinator().finish();
} catch (IOException rollbackFailure) {
failure.addSuppressed(rollbackFailure);
}
}
}
static File inferDatapackRoot(File stagedDir) {
File parent = stagedDir.getParentFile();
if (parent != null && "staging".equals(parent.getName()) && parent.getParentFile() != null) {
return parent.getParentFile();
}
return parent == null ? stagedDir : parent;
}
static boolean manifestEntryMatches(Entry committed, Entry desired) {
return committed != null
&& Objects.equals(committed.id, desired.id)
&& Objects.equals(committed.url, desired.url)
&& Objects.equals(committed.versionId, desired.versionId)
&& Objects.equals(committed.versionNumber, desired.versionNumber)
&& Objects.equals(committed.sha1, desired.sha1);
}
static void validateInstallTree(File directory, File storeAnchor, String purpose) throws IOException {
DatapackScratchRecovery.validateScratchTree(directory.toPath());
if (!DatapackScratchRecovery.sameScratchVolume(directory.toPath(), storeAnchor.toPath())) {
throw new IOException(purpose + " crosses a filesystem boundary at " + directory.getPath());
}
}
static File installScratchRoot(File targetFolder) {
File parent = targetFolder.getParentFile();
return new File(parent == null ? targetFolder : parent, ".iris-datapack-install");
}
static void cleanupPreparedInstall(File pending, File pendingRoot, Throwable failure) {
try {
deleteInstallScratch(pending, "prepared datapack install");
} catch (IOException cleanupFailure) {
failure.addSuppressed(cleanupFailure);
}
pendingRoot.delete();
}
static boolean ownershipMetadataMatches(Ownership ownership, Entry entry, String contentHash) {
return ownership.schemaVersion == DatapackOwnership.OWNERSHIP_SCHEMA
&& Objects.equals(ownership.id, entry.id)
&& Objects.equals(ownership.url, entry.url)
&& Objects.equals(ownership.versionId, entry.versionId)
&& Objects.equals(ownership.versionNumber, entry.versionNumber)
&& Objects.equals(ownership.sha1, entry.sha1)
&& Objects.equals(ownership.contentHash, contentHash)
&& DatapackManifestStore.copyList(ownership.structureKeys).equals(DatapackManifestStore.copyList(entry.structureKeys))
&& DatapackManifestStore.copyList(ownership.templateKeys).equals(DatapackManifestStore.copyList(entry.templateKeys));
}
static boolean ownershipSourceMatches(Ownership ownership, Entry entry) {
return ownership.schemaVersion == DatapackOwnership.OWNERSHIP_SCHEMA
&& Objects.equals(ownership.id, entry.id)
&& Objects.equals(ownership.url, entry.url);
}
static void deleteInstallScratch(File scratch, String purpose) throws IOException {
Path scratchPath = scratch.toPath();
File parent = Objects.requireNonNull(scratch.getParentFile(), "datapack scratch parent");
for (int attempt = 0; attempt < MAX_SCRATCH_DELETE_ATTEMPTS; attempt++) {
if (Files.notExists(scratchPath, LinkOption.NOFOLLOW_LINKS)) {
return;
}
if (!Files.exists(scratchPath, LinkOption.NOFOLLOW_LINKS)
|| Files.isSymbolicLink(scratchPath)
|| !Files.isDirectory(scratchPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing to remove unsafe " + purpose + " " + scratch.getPath());
}
validateInstallTree(scratch, parent, purpose);
DatapackOwnership.removeFinderMetadata(scratch);
IO.delete(scratch);
if (Files.notExists(scratchPath, LinkOption.NOFOLLOW_LINKS)) {
return;
}
}
throw new IOException("Could not remove " + purpose + " " + scratch.getPath());
}
}
@@ -0,0 +1,394 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.util.io.IO;
import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;
import java.util.UUID;
final class DatapackInstallPlanner {
private DatapackInstallPlanner() {
}
static InstallPlan prepareInstall(
File stagedDir,
File worldFolder,
Entry entry,
String stagedHash,
boolean stripOverrides,
VerifiedStagingInstall verifiedStagingInstall
) throws IOException {
DatapackStagingGuard.ensureInstallTargetRoot(worldFolder);
File target = new File(worldFolder, entry.id);
InstallPlan unchanged = tryPrepareUnchangedManagedInstall(
stagedDir,
worldFolder,
target,
entry,
stagedHash,
stripOverrides,
verifiedStagingInstall);
if (unchanged != null) {
return unchanged;
}
boolean canonicalStagingInstall = verifiedStagingInstall != null
&& verifiedStagingInstall.isCanonicalInstall(worldFolder, target);
boolean legacyReplacementAuthorized = canonicalStagingInstall
&& verifiedStagingInstall.consume(stagedDir, worldFolder, target, entry, stagedHash);
VerifiedStagingInstall legacyWorldAuthorization = null;
File pendingRoot = DatapackInstall.installScratchRoot(worldFolder);
File pending = new File(pendingRoot, entry.id + "-" + UUID.randomUUID());
File backup = new File(pendingRoot, entry.id + "-backup-" + UUID.randomUUID());
try {
DatapackSupport.ensureScratchDirectory(pendingRoot, "datapack install staging");
String targetRootIdentity = DatapackSupport.realDirectoryPath(worldFolder, "datapack target root");
String scratchRootIdentity = DatapackSupport.realDirectoryPath(pendingRoot, "datapack install scratch root");
String targetRootFileIdentity = DatapackSupport.directoryIdentity(worldFolder);
String scratchRootFileIdentity = DatapackSupport.directoryIdentity(pendingRoot);
IO.copyDirectory(stagedDir.toPath(), pending.toPath());
Files.deleteIfExists(new File(pending, DatapackOwnership.OWNERSHIP_MARKER).toPath());
String copiedHash = DatapackOwnership.directoryHash(pending);
if (!Objects.equals(stagedHash, copiedHash)) {
throw new IOException("Datapack staging changed or copied incompletely while preparing " + entry.id);
}
boolean removedOverrideMarker = Files.deleteIfExists(
new File(pending, DatapackPackMetadata.OVERRIDES_STRIPPED_MARKER).toPath());
if (stripOverrides) {
DatapackPackMetadata.stripVanillaStructureOverrides(pending);
DatapackPackMetadata.writeMarker(new File(pending, DatapackPackMetadata.OVERRIDES_STRIPPED_MARKER));
}
DatapackPackMetadata.validatePackMetadata(pending);
if (!stripOverrides && !removedOverrideMarker) {
DatapackOwnership.writeOwnership(pending, entry, copiedHash);
} else {
DatapackOwnership.writeOwnership(pending, entry);
}
DatapackInstall.validateInstallTree(pending, worldFolder, "Prepared datapack install");
Ownership desiredOwnership = DatapackOwnership.readOwnership(pending);
String desiredHash = desiredOwnership.contentHash;
String desiredMarkerHash = DatapackOwnership.ownershipMarkerFingerprint(pending);
String desiredIdentity = DatapackSupport.directoryIdentity(pending);
boolean hadTarget = DatapackSupport.pathExists(target.toPath(), "datapack install target");
String originalHash = "";
String originalMarkerHash = "absent";
String originalIdentity = "";
boolean contentChanged = !hadTarget;
boolean publishRequired = !hadTarget;
if (hadTarget) {
if (Files.isSymbolicLink(target.toPath())
|| !Files.isDirectory(target.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing to replace non-directory or symbolic-link datapack " + target.getPath());
}
DatapackInstall.validateInstallTree(target, worldFolder, "Existing datapack install");
Ownership ownership = DatapackOwnership.readOwnershipOrNull(target);
if (ownership != null) {
DatapackOwnership.removeFinderMetadata(target);
}
String currentHash = DatapackOwnership.directoryHash(target);
originalHash = currentHash;
originalMarkerHash = DatapackOwnership.ownershipMarkerFingerprint(target);
originalIdentity = DatapackSupport.directoryIdentity(target);
if (ownership == null) {
boolean differingLegacyTarget = !Objects.equals(currentHash, desiredHash);
if (differingLegacyTarget && !legacyReplacementAuthorized
&& verifiedStagingInstall != null
&& verifiedStagingInstall.authorizeLegacyWorldReplacement(
stagedDir,
worldFolder,
target,
entry,
stagedHash,
currentHash,
originalMarkerHash)) {
legacyReplacementAuthorized = true;
legacyWorldAuthorization = verifiedStagingInstall;
}
if (differingLegacyTarget && !legacyReplacementAuthorized) {
throw new IOException("Refusing to replace unmanaged datapack at " + target.getPath());
}
if (differingLegacyTarget && (originalIdentity.isEmpty()
|| targetRootFileIdentity.isEmpty()
|| scratchRootFileIdentity.isEmpty()
|| verifiedStagingInstall == null
|| !verifiedStagingInstall.hasStablePathIdentities())) {
throw new IOException("Cannot safely identify legacy datapack staging at " + target.getPath());
}
publishRequired = true;
} else {
if (!entry.id.equals(ownership.id)) {
throw new IOException("Datapack ownership mismatch at " + target.getPath());
}
publishRequired = !Objects.equals(ownership.contentHash, currentHash)
|| !DatapackInstall.ownershipMetadataMatches(ownership, entry, desiredHash);
}
contentChanged = !Objects.equals(currentHash, desiredHash);
publishRequired |= contentChanged;
if (ownership != null && !Objects.equals(ownership.contentHash, currentHash)) {
IrisLogging.warn("Repairing modified or corrupt Iris-managed datapack at " + target.getPath());
}
}
return new InstallPlan(
target,
pending,
backup,
pendingRoot,
hadTarget,
publishRequired,
contentChanged,
originalHash,
desiredHash,
originalMarkerHash,
desiredMarkerHash,
originalIdentity,
desiredIdentity,
targetRootIdentity,
scratchRootIdentity,
targetRootFileIdentity,
scratchRootFileIdentity,
entry.id,
entry.url,
legacyWorldAuthorization
);
} catch (UncheckedIOException e) {
IOException cause = e.getCause();
DatapackInstall.cleanupPreparedInstall(pending, pendingRoot, cause);
throw cause;
} catch (IOException e) {
DatapackInstall.cleanupPreparedInstall(pending, pendingRoot, e);
throw e;
} catch (RuntimeException e) {
DatapackInstall.cleanupPreparedInstall(pending, pendingRoot, e);
throw e;
}
}
static InstallPlan tryPrepareUnchangedManagedInstall(
File stagedDir,
File worldFolder,
File target,
Entry entry,
String stagedHash,
boolean stripOverrides,
VerifiedStagingInstall verifiedStagingInstall
) throws IOException {
if (verifiedStagingInstall != null
|| stripOverrides
|| DatapackSupport.pathExists(new File(stagedDir, DatapackPackMetadata.OVERRIDES_STRIPPED_MARKER).toPath(),
"staged datapack override marker")
|| !DatapackSupport.pathExists(target.toPath(), "datapack install target")) {
return null;
}
if (Files.isSymbolicLink(target.toPath())
|| !Files.isDirectory(target.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing to replace non-directory or symbolic-link datapack " + target.getPath());
}
Ownership ownership = DatapackOwnership.readOwnershipOrNull(target);
if (ownership == null) {
return null;
}
if (!entry.id.equals(ownership.id)) {
throw new IOException("Datapack ownership mismatch at " + target.getPath());
}
if (!DatapackInstall.ownershipMetadataMatches(ownership, entry, stagedHash)) {
return null;
}
DatapackInstall.validateInstallTree(target, worldFolder, "Existing datapack install");
DatapackOwnership.removeFinderMetadata(target);
String currentHash = DatapackOwnership.directoryHash(target);
if (!Objects.equals(currentHash, stagedHash)) {
return null;
}
String markerHash = DatapackOwnership.ownershipMarkerFingerprint(target);
String identity = DatapackSupport.directoryIdentity(target);
File pendingRoot = DatapackInstall.installScratchRoot(worldFolder);
return new InstallPlan(
target,
new File(pendingRoot, entry.id + "-" + UUID.randomUUID()),
new File(pendingRoot, entry.id + "-backup-" + UUID.randomUUID()),
pendingRoot,
true,
false,
false,
currentHash,
currentHash,
markerHash,
markerHash,
identity,
identity,
DatapackSupport.realDirectoryPath(worldFolder, "datapack target root"),
"",
DatapackSupport.directoryIdentity(worldFolder),
"",
entry.id,
entry.url,
null
);
}
static void publishInstallPlan(InstallPlan plan) throws IOException {
verifyDirectoryContainerIdentity(
plan.target().getParentFile(), plan.targetRootIdentity(),
plan.targetRootFileIdentity(), "datapack target root");
verifyDirectoryContainerIdentity(
plan.pendingRoot(), plan.scratchRootIdentity(),
plan.scratchRootFileIdentity(), "datapack install scratch root");
verifyDesiredInstallSnapshot(plan.pending(), plan, "prepared datapack install");
if (plan.hadTarget()) {
verifyOriginalInstallSnapshot(plan.target(), plan, "original datapack target");
} else if (DatapackSupport.pathExists(plan.target().toPath(), "new datapack target")) {
throw new IOException("Datapack install target was concurrently created at " + plan.target().getPath());
}
if (plan.legacyWorldAuthorization() != null) {
plan.legacyWorldAuthorization().verifyLegacyWorldSnapshot();
}
try {
if (plan.hadTarget()) {
DatapackSupport.moveNew(plan.target().toPath(), plan.backup().toPath());
verifyOriginalInstallSnapshot(plan.backup(), plan, "datapack install backup");
if (DatapackSupport.pathExists(plan.target().toPath(), "moved datapack target")) {
throw new IOException("Datapack install target reappeared after backup at " + plan.target().getPath());
}
forceInstallMoveDirectories(plan);
}
DatapackSupport.moveNew(plan.pending().toPath(), plan.target().toPath());
verifyDesiredInstallSnapshot(plan.target(), plan, "installed datapack target");
forceInstallMoveDirectories(plan);
} catch (IOException publishFailure) {
if (plan.hadTarget()
&& Files.exists(plan.backup().toPath(), LinkOption.NOFOLLOW_LINKS)
&& Files.exists(plan.pending().toPath(), LinkOption.NOFOLLOW_LINKS)
&& Files.notExists(plan.target().toPath(), LinkOption.NOFOLLOW_LINKS)) {
try {
verifyOriginalInstallSnapshot(plan.backup(), plan, "datapack install backup");
DatapackSupport.moveNew(plan.backup().toPath(), plan.target().toPath());
forceInstallMoveDirectories(plan);
} catch (IOException restoreFailure) {
publishFailure.addSuppressed(restoreFailure);
}
}
throw publishFailure;
}
}
static void forceInstallMoveDirectories(InstallPlan plan) throws IOException {
DatapackSupport.forceDirectoryIfSupported(plan.target().getParentFile().toPath());
DatapackSupport.forceDirectoryIfSupported(plan.pendingRoot().toPath());
}
static void verifyDirectoryContainerIdentity(
File directory,
String expectedRealPath,
String expectedFileIdentity,
String purpose
) throws IOException {
Path normalized = directory.toPath().toAbsolutePath().normalize();
if (Files.isSymbolicLink(normalized)
|| !Files.isDirectory(normalized, LinkOption.NOFOLLOW_LINKS)
|| !Objects.equals(normalized.toRealPath(), Path.of(expectedRealPath))
|| (!expectedFileIdentity.isEmpty()
&& !Objects.equals(DatapackSupport.directoryIdentity(directory), expectedFileIdentity))) {
throw new IOException("Changed or unsafe " + purpose + " " + normalized);
}
}
static void verifyOriginalInstallSnapshot(
File directory,
InstallPlan plan,
String purpose
) throws IOException {
verifyDirectorySnapshot(
directory,
plan.target().getParentFile(),
plan.originalHash(),
plan.originalMarkerHash(),
plan.originalIdentity(),
purpose
);
}
static void verifyDesiredInstallSnapshot(
File directory,
InstallPlan plan,
String purpose
) throws IOException {
verifyDirectorySnapshot(
directory,
plan.target().getParentFile(),
plan.desiredHash(),
plan.desiredMarkerHash(),
plan.desiredIdentity(),
purpose
);
Ownership ownership = DatapackOwnership.readOwnership(directory);
if (!Objects.equals(ownership.id, plan.id())
|| !Objects.equals(ownership.url, plan.url())
|| !Objects.equals(ownership.contentHash, plan.desiredHash())) {
throw new IOException("Datapack ownership changed in " + purpose + " at " + directory.getPath());
}
}
static void verifyDirectorySnapshot(
File directory,
File storeAnchor,
String expectedHash,
String expectedMarkerHash,
String expectedIdentity,
String purpose
) throws IOException {
if (!DatapackSupport.pathExists(directory.toPath(), purpose)
|| Files.isSymbolicLink(directory.toPath())
|| !Files.isDirectory(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Missing or unsafe " + purpose + " at " + directory.getPath());
}
DatapackInstall.validateInstallTree(directory, storeAnchor, purpose);
if (Files.exists(new File(directory, DatapackOwnership.OWNERSHIP_MARKER).toPath(), LinkOption.NOFOLLOW_LINKS)) {
DatapackOwnership.removeFinderMetadata(directory);
}
if (!expectedIdentity.isEmpty()
&& !Objects.equals(DatapackSupport.directoryIdentity(directory), expectedIdentity)) {
throw new IOException("Datapack directory identity changed in " + purpose + " at " + directory.getPath());
}
if (!Objects.equals(expectedHash, DatapackOwnership.directoryHash(directory))
|| !Objects.equals(expectedMarkerHash, DatapackOwnership.ownershipMarkerFingerprint(directory))) {
throw new IOException("Datapack content changed in " + purpose + " at " + directory.getPath());
}
}
static void cleanupInstallPlan(InstallPlan plan, boolean committed) throws IOException {
DatapackInstall.deleteInstallScratch(plan.pending(), "datapack install pending directory");
if (committed) {
DatapackInstall.deleteInstallScratch(plan.backup(), "datapack install backup");
}
if (Files.exists(plan.backup().toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Preserving prior datapack backup after incomplete install: "
+ plan.backup().getPath());
}
plan.pendingRoot().delete();
}
}
@@ -0,0 +1,316 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.spi.IrisLogging;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.TreeMap;
final class DatapackManifestStore {
static final long MAX_MANIFEST_BYTES = 16L * 1024L * 1024L;
private DatapackManifestStore() {
}
static List<String> copyList(List<String> values) {
return values == null ? List.of() : List.copyOf(values);
}
static Entry copyEntry(Entry source) {
Entry resolved = Objects.requireNonNull(source, "Datapack manifest entry must not be null");
Entry copy = new Entry();
copy.url = resolved.url;
copy.id = resolved.id;
copy.versionId = resolved.versionId;
copy.versionNumber = resolved.versionNumber;
copy.sha1 = resolved.sha1;
copy.filename = resolved.filename;
copy.etag = resolved.etag;
copy.lastModified = resolved.lastModified;
copy.installedEpoch = resolved.installedEpoch;
copy.structuresImported = resolved.structuresImported;
copy.stagingMetadata = resolved.stagingMetadata;
copy.structureKeys = new ArrayList<>(copyList(resolved.structureKeys));
copy.templateKeys = new ArrayList<>(copyList(resolved.templateKeys));
copy.installMetadata = new HashMap<>(Objects.requireNonNullElseGet(
resolved.installMetadata, Map::of));
copy.importedTargets = new HashMap<>(Objects.requireNonNullElseGet(
resolved.importedTargets, Map::of));
copy.importAttempts = new HashMap<>(Objects.requireNonNullElseGet(
resolved.importAttempts, Map::of));
copy.importedBundles = new HashMap<>();
if (resolved.importedBundles != null) {
for (Map.Entry<String, Map<String, String>> bundle : resolved.importedBundles.entrySet()) {
copy.importedBundles.put(bundle.getKey(), new HashMap<>(bundle.getValue()));
}
}
return copy;
}
static Manifest readManifest(File root) {
File file = new File(root, "manifest.json");
Manifest manifest = null;
boolean recoverFromStaging = false;
Path path = file.toPath();
if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
manifest = new Manifest();
} else if (Files.isSymbolicLink(path) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
IrisLogging.error("Unreadable datapack manifest " + file.getPath()
+ "; moving the non-regular path aside before recovering Iris-managed staging");
recoverFromStaging = quarantine(path);
} else {
try {
String json = DatapackSupport.readBoundedUtf8(path, MAX_MANIFEST_BYTES, "Datapack manifest");
manifest = DatapackSupport.GSON.fromJson(json, Manifest.class);
if (manifest == null) {
throw new IOException("Datapack manifest is empty");
}
} catch (Exception e) {
IrisLogging.reportError("Unreadable datapack manifest " + file.getPath()
+ "; moving it aside before recovering Iris-managed staging", e);
recoverFromStaging = quarantine(file.toPath());
}
}
if (manifest == null) {
manifest = new Manifest();
}
normalizeManifest(manifest);
if (recoverFromStaging) {
recoverManifestFromStaging(root, manifest);
}
return manifest;
}
static boolean quarantine(Path file) {
try {
DatapackSupport.move(file, file.resolveSibling(file.getFileName().toString() + ".corrupt-" + System.currentTimeMillis()));
return true;
} catch (IOException e) {
IrisLogging.reportError("Failed to move aside corrupt datapack manifest " + file, e);
return false;
}
}
static void normalizeManifest(Manifest manifest) {
if (manifest.entries == null) {
manifest.entries = new ArrayList<>();
return;
}
List<Entry> normalized = new ArrayList<>();
Set<String> urls = new HashSet<>();
Set<String> ids = new HashSet<>();
for (Entry entry : manifest.entries) {
if (entry == null || entry.url == null || entry.url.isBlank() || entry.id == null || entry.id.isBlank()) {
continue;
}
entry.url = entry.url.trim();
if (!DatapackArchive.isValidManagedId(entry.id)) {
IrisLogging.warn("Ignoring datapack manifest entry with invalid id '" + entry.id + "'");
continue;
}
entry.structureKeys = normalizeKeys(entry.structureKeys);
entry.templateKeys = normalizeKeys(entry.templateKeys);
entry.stagingMetadata = entry.stagingMetadata == null ? "" : entry.stagingMetadata.trim();
entry.installMetadata = normalizeImportedTargets(entry.installMetadata);
entry.importedTargets = normalizeImportedTargets(entry.importedTargets);
entry.importAttempts = normalizeImportedTargets(entry.importAttempts);
entry.importedBundles = normalizeImportedBundles(entry.importedBundles);
if (!urls.add(entry.url) || !ids.add(entry.id)) {
IrisLogging.warn("Ignoring duplicate datapack manifest entry for id '" + entry.id + "' and url " + entry.url);
continue;
}
normalized.add(entry);
}
manifest.entries = normalized;
}
static void recoverManifestFromStaging(File root, Manifest manifest) {
File staging = new File(root, "staging");
File[] directories = staging.listFiles(File::isDirectory);
if (directories == null) {
return;
}
for (File directory : directories) {
try {
Ownership ownership = DatapackOwnership.readOwnershipOrNull(directory);
if (ownership == null || !directory.getName().equals(ownership.id)
|| manifest.find(ownership.url) != null || manifest.findById(ownership.id) != null) {
continue;
}
DatapackOwnership.validateManagedDirectory(directory, ownership.id);
if (!Objects.equals(ownership.contentHash, DatapackOwnership.directoryHash(directory))) {
IrisLogging.warn("Ignoring corrupt Iris-managed datapack staging at " + directory.getPath());
continue;
}
Entry recovered = ownership.toEntry();
recovered.installedEpoch = directory.lastModified();
manifest.put(recovered);
IrisLogging.warn("Recovered Iris-managed datapack manifest entry '" + recovered.id + "' from staging.");
} catch (IOException e) {
IrisLogging.warn("Ignoring orphan datapack staging at " + directory.getPath() + ": " + e.getMessage());
}
}
}
static List<String> normalizeKeys(List<String> keys) {
TreeSet<String> normalized = new TreeSet<>();
if (keys != null) {
for (String key : keys) {
if (key != null && !key.isBlank()) {
normalized.add(key.trim().toLowerCase(Locale.ROOT));
}
}
}
return new ArrayList<>(normalized);
}
static Map<String, String> normalizeImportedTargets(Map<String, String> targets) {
Map<String, String> normalized = new HashMap<>();
if (targets == null) {
return normalized;
}
for (Map.Entry<String, String> target : targets.entrySet()) {
if (target.getKey() != null && !target.getKey().isBlank()
&& target.getValue() != null && !target.getValue().isBlank()) {
normalized.put(target.getKey(), target.getValue());
}
}
return normalized;
}
static Map<String, Map<String, String>> normalizeImportedBundles(
Map<String, Map<String, String>> targets
) {
Map<String, Map<String, String>> normalized = new HashMap<>();
if (targets == null) {
return normalized;
}
for (Map.Entry<String, Map<String, String>> target : targets.entrySet()) {
if (target.getKey() == null || target.getKey().isBlank() || target.getValue() == null) {
continue;
}
Map<String, String> bundles = new TreeMap<>();
for (Map.Entry<String, String> bundle : target.getValue().entrySet()) {
if (bundle.getKey() != null && !bundle.getKey().isBlank()
&& bundle.getValue() != null && !bundle.getValue().isBlank()) {
bundles.put(bundle.getKey(), bundle.getValue());
}
}
normalized.put(target.getKey(), bundles);
}
return normalized;
}
static void writeManifest(File root, Manifest manifest) {
try {
writeManifestChecked(root, manifest);
} catch (IOException e) {
IrisLogging.reportError("Failed to write datapack manifest "
+ new File(root, "manifest.json").toPath(), e);
}
}
static void writeManifestChecked(File root, Manifest manifest) throws IOException {
try (ManifestWrite write = prepareManifestWrite(root, manifest)) {
write.publish();
}
}
static ManifestWrite prepareManifestWrite(File root, Manifest manifest) throws IOException {
Path file = new File(root, "manifest.json").toPath();
Path parent = file.getParent();
Files.createDirectories(parent);
Path temp = Files.createTempFile(parent, "manifest", ".json.tmp");
Path rollback = null;
try {
byte[] content = DatapackSupport.GSON.toJson(manifest).getBytes(StandardCharsets.UTF_8);
if (content.length > MAX_MANIFEST_BYTES) {
throw new IOException("Datapack manifest exceeds " + MAX_MANIFEST_BYTES + " bytes");
}
Files.write(temp, content, StandardOpenOption.TRUNCATE_EXISTING);
DatapackSupport.forceFile(temp);
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(file)) {
throw new IOException("Datapack manifest is not a regular file: " + file);
}
rollback = Files.createTempFile(parent, "manifest-rollback", ".json.tmp");
Files.copy(file, rollback, StandardCopyOption.REPLACE_EXISTING);
DatapackSupport.forceFile(rollback);
}
return new ManifestWrite(temp, file, rollback);
} catch (IOException | RuntimeException e) {
try {
Files.deleteIfExists(temp);
} catch (IOException cleanupFailure) {
e.addSuppressed(cleanupFailure);
}
if (rollback != null) {
try {
Files.deleteIfExists(rollback);
} catch (IOException cleanupFailure) {
e.addSuppressed(cleanupFailure);
}
}
throw e;
}
}
static Manifest readCommittedManifest(File root) throws IOException {
Path manifestPath = new File(root, "manifest.json").toPath();
if (!Files.exists(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
return new Manifest();
}
if (Files.isSymbolicLink(manifestPath)
|| !Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Datapack manifest is not a regular file: " + manifestPath);
}
Manifest manifest;
try {
manifest = DatapackSupport.GSON.fromJson(
DatapackSupport.readBoundedUtf8(manifestPath, MAX_MANIFEST_BYTES, "Datapack manifest"),
Manifest.class
);
} catch (RuntimeException e) {
throw new IOException("Invalid datapack manifest " + manifestPath, e);
}
if (manifest == null) {
throw new IOException("Empty datapack manifest " + manifestPath);
}
normalizeManifest(manifest);
return manifest;
}
}
@@ -0,0 +1,379 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.volmlib.util.io.IO;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileStore;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Stream;
final class DatapackOwnership {
static final String FINDER_METADATA = ".DS_Store";
static final String OWNERSHIP_MARKER = ".iris-managed.json";
static final int OWNERSHIP_SCHEMA = 1;
static final int MAX_MANAGED_PATHS = DatapackArchive.MAX_ARCHIVE_ENTRIES + 16;
static final long MAX_OWNERSHIP_BYTES = 1024L * 1024L;
static final int HASH_BUFFER_BYTES = 64 * 1024;
private DatapackOwnership() {
}
static void writeOwnership(File directory, Entry entry) throws IOException {
writeOwnership(directory, entry, directoryHash(directory));
}
static void writeOwnership(File directory, Entry entry, String contentHash) throws IOException {
if (!DatapackArchive.isValidManagedId(entry.id) || entry.url == null || entry.url.isBlank()) {
throw new IOException("Invalid Iris datapack ownership identity for " + directory.getPath());
}
if (contentHash == null || contentHash.isBlank()) {
throw new IOException("Missing Iris datapack ownership content hash for " + directory.getPath());
}
Ownership ownership = new Ownership(
OWNERSHIP_SCHEMA,
entry.id,
entry.url,
entry.versionId,
entry.versionNumber,
entry.sha1,
contentHash,
DatapackManifestStore.copyList(entry.structureKeys),
DatapackManifestStore.copyList(entry.templateKeys)
);
Path marker = new File(directory, OWNERSHIP_MARKER).toPath();
Path temporary = Files.createTempFile(directory.toPath(), OWNERSHIP_MARKER, ".tmp");
try {
Files.writeString(
temporary,
DatapackSupport.GSON.toJson(ownership),
StandardCharsets.UTF_8,
StandardOpenOption.TRUNCATE_EXISTING
);
DatapackSupport.move(temporary, marker);
} finally {
Files.deleteIfExists(temporary);
}
}
static Ownership readOwnership(File directory) throws IOException {
Ownership ownership = readOwnershipOrNull(directory);
if (ownership == null) {
throw new IOException("Missing Iris datapack ownership marker in " + directory.getPath());
}
return ownership;
}
static Ownership readOwnershipOrNull(File directory) throws IOException {
File marker = new File(directory, OWNERSHIP_MARKER);
Path markerPath = marker.toPath();
if (Files.notExists(markerPath, LinkOption.NOFOLLOW_LINKS)) {
return null;
}
if (!Files.exists(markerPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Cannot determine Iris datapack ownership marker state in " + directory.getPath());
}
if (Files.isSymbolicLink(markerPath)
|| !Files.isRegularFile(markerPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid Iris datapack ownership marker in " + directory.getPath());
}
if (marker.length() > MAX_OWNERSHIP_BYTES) {
throw new IOException("Oversized Iris datapack ownership marker in " + directory.getPath());
}
try {
Ownership ownership = DatapackSupport.GSON.fromJson(DatapackSupport.readBoundedUtf8(
marker.toPath(), MAX_OWNERSHIP_BYTES, "Iris datapack ownership marker"), Ownership.class);
if (ownership == null || ownership.schemaVersion != OWNERSHIP_SCHEMA
|| !DatapackArchive.isValidManagedId(ownership.id) || ownership.url == null || ownership.url.isBlank()
|| ownership.contentHash == null || ownership.contentHash.isBlank()) {
throw new IOException("Invalid Iris datapack ownership marker in " + directory.getPath());
}
return ownership;
} catch (RuntimeException e) {
throw new IOException("Invalid Iris datapack ownership marker in " + directory.getPath(), e);
}
}
static String ownershipMarkerFingerprint(File directory) throws IOException {
Path marker = new File(directory, OWNERSHIP_MARKER).toPath();
if (Files.notExists(marker, LinkOption.NOFOLLOW_LINKS)) {
return "absent";
}
if (!Files.exists(marker, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Cannot determine Iris datapack ownership marker state in " + directory.getPath());
}
if (Files.isSymbolicLink(marker)
|| !Files.isRegularFile(marker, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid Iris datapack ownership marker in " + directory.getPath());
}
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return "sha256:" + DatapackSupport.hex(digest.digest(DatapackSupport.readBoundedBytes(
marker, MAX_OWNERSHIP_BYTES, "Iris datapack ownership marker")));
} catch (NoSuchAlgorithmException e) {
throw new IOException("SHA-256 algorithm unavailable", e);
}
}
static void validateManagedDirectory(File directory, String id) throws IOException {
Path path = directory.toPath();
if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Missing datapack directory " + directory.getPath());
}
if (Files.isSymbolicLink(path)) {
throw new IOException("Refusing symbolic-link datapack directory " + directory.getPath());
}
DatapackPackMetadata.validatePackMetadata(directory);
rejectSymbolicLinks(directory);
Ownership ownership = readOwnershipOrNull(directory);
if (ownership == null) {
throw new IOException("Datapack is not Iris-managed: " + directory.getPath());
}
if (!id.equals(ownership.id)) {
throw new IOException("Datapack ownership mismatch at " + directory.getPath());
}
removeFinderMetadata(directory);
}
static void rejectSymbolicLinks(File root) throws IOException {
try (Stream<Path> paths = Files.walk(root.toPath())) {
Path symbolicLink = paths.filter(Files::isSymbolicLink).findFirst().orElse(null);
if (symbolicLink != null) {
throw new IOException("Datapack contains a symbolic link: " + symbolicLink);
}
}
}
static boolean sameDatapackVolume(
Path root,
FileStore rootStore,
Path entry
) throws IOException {
return DatapackScratchRecovery.sameScratchVolume(root, rootStore, entry, Files.getFileStore(entry));
}
static String directoryHash(File root) throws IOException {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
Path rootPath = root.toPath().toAbsolutePath().normalize();
Path rootMarker = rootPath.resolve(OWNERSHIP_MARKER);
FileStore rootStore = Files.getFileStore(rootPath);
List<Path> entries = new ArrayList<>();
try (Stream<Path> paths = Files.walk(rootPath)) {
Iterator<Path> iterator = paths.iterator();
int pathCount = 0;
while (iterator.hasNext()) {
Path path = iterator.next();
if (path.equals(rootPath) || path.equals(rootMarker)) {
continue;
}
if (isFinderMetadata(path)) {
if (Files.isSymbolicLink(path)
|| !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Suspicious Finder metadata in datapack: " + path);
}
continue;
}
pathCount++;
if (pathCount > MAX_MANAGED_PATHS) {
throw new IOException("Datapack contains more than " + MAX_MANAGED_PATHS + " paths");
}
if (Files.isSymbolicLink(path)) {
throw new IOException("Datapack contains a symbolic link: " + path);
}
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)
&& !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Datapack contains an unsupported filesystem entry: " + path);
}
entries.add(path);
}
}
entries.sort(Comparator.comparing(path -> rootPath.relativize(path).toString()));
byte[] buffer = new byte[HASH_BUFFER_BYTES];
long totalBytes = 0;
for (Path entry : entries) {
String relative = rootPath.relativize(entry).toString().replace(File.separatorChar, '/');
byte[] relativeBytes = relative.getBytes(StandardCharsets.UTF_8);
BasicFileAttributes attributes = Files.readAttributes(
entry,
BasicFileAttributes.class,
LinkOption.NOFOLLOW_LINKS
);
if (attributes.isSymbolicLink()
|| attributes.isOther()
|| !attributes.isDirectory() && !attributes.isRegularFile()) {
throw new IOException("Datapack entry changed while hashing: " + relative);
}
if (!DatapackScratchRecovery.sameScratchVolume(rootPath, rootStore, entry, Files.getFileStore(entry))) {
throw new IOException("Datapack entry crosses a filesystem boundary: " + entry);
}
boolean directory = attributes.isDirectory();
digest.update((byte) (directory ? 1 : 2));
DatapackSupport.updateDigestInt(digest, relativeBytes.length);
digest.update(relativeBytes);
if (!directory) {
long expectedBytes = attributes.size();
if (expectedBytes > DatapackArchive.MAX_ENTRY_BYTES) {
throw new IOException("Datapack file exceeds " + DatapackArchive.MAX_ENTRY_BYTES + " bytes: " + relative);
}
totalBytes += expectedBytes;
if (totalBytes > DatapackArchive.MAX_EXPANDED_BYTES) {
throw new IOException("Datapack contents exceed " + DatapackArchive.MAX_EXPANDED_BYTES + " bytes");
}
DatapackSupport.updateDigestLong(digest, expectedBytes);
long entryBytes = 0;
try (InputStream input = Files.newInputStream(
entry,
StandardOpenOption.READ,
LinkOption.NOFOLLOW_LINKS
)) {
int length;
while ((length = input.read(buffer)) > 0) {
entryBytes += length;
if (entryBytes > expectedBytes) {
throw new IOException("Datapack file changed while hashing: " + relative);
}
digest.update(buffer, 0, length);
}
}
if (entryBytes != expectedBytes) {
throw new IOException("Datapack file changed while hashing: " + relative);
}
}
}
return DatapackSupport.hex(digest.digest());
} catch (NoSuchAlgorithmException e) {
throw new IOException("SHA-256 algorithm unavailable", e);
}
}
static void removeFinderMetadata(File root) throws IOException {
List<Path> entries;
try (Stream<Path> paths = Files.walk(root.toPath())) {
entries = paths.limit(MAX_MANAGED_PATHS + 1L).toList();
}
if (entries.size() > MAX_MANAGED_PATHS) {
throw new IOException("Datapack contains more than " + MAX_MANAGED_PATHS + " paths");
}
for (Path path : entries) {
if (!isFinderMetadata(path)) {
continue;
}
if (Files.isSymbolicLink(path)
|| !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Suspicious Finder metadata in datapack: " + path);
}
Files.delete(path);
}
}
static boolean isFinderMetadata(Path path) {
Path fileName = path.getFileName();
return fileName != null && FINDER_METADATA.equals(fileName.toString());
}
static PackResources scanPackResources(File root) throws IOException {
TreeSet<String> structureKeys = new TreeSet<>();
TreeSet<String> structureSetKeys = new TreeSet<>();
TreeSet<String> templateKeys = new TreeSet<>();
Path dataRoot = new File(root, "data").toPath();
if (!Files.isDirectory(dataRoot, LinkOption.NOFOLLOW_LINKS)) {
return new PackResources(new ArrayList<>(), new ArrayList<>(), new ArrayList<>());
}
try (Stream<Path> paths = Files.walk(dataRoot)) {
for (Path path : paths.filter(file -> Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)).toList()) {
Path relative = dataRoot.relativize(path);
if (relative.getNameCount() < 3) {
continue;
}
String namespace = relative.getName(0).toString().toLowerCase(Locale.ROOT);
String normalized = relative.subpath(1, relative.getNameCount()).toString().replace(File.separatorChar, '/');
addResourceKey(structureKeys, namespace, normalized, "worldgen/structure/", ".json");
addResourceKey(structureKeys, namespace, normalized, "worldgen/structures/", ".json");
addResourceKey(structureSetKeys, namespace, normalized, "worldgen/structure_set/", ".json");
addResourceKey(structureSetKeys, namespace, normalized, "worldgen/structure_sets/", ".json");
addResourceKey(templateKeys, namespace, normalized, "structure/", ".nbt");
addResourceKey(templateKeys, namespace, normalized, "structures/", ".nbt");
}
}
return new PackResources(
new ArrayList<>(structureKeys),
new ArrayList<>(structureSetKeys),
new ArrayList<>(templateKeys));
}
static void addResourceKey(Set<String> keys, String namespace, String path, String prefix, String suffix) {
if (!path.startsWith(prefix) || !path.endsWith(suffix)) {
return;
}
String resourcePath = path.substring(prefix.length(), path.length() - suffix.length());
if (!resourcePath.isBlank()) {
keys.add(namespace + ":" + resourcePath);
}
}
static boolean deleteOwnedDirectory(File directory, String id) throws IOException {
Path path = directory.toPath();
if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
return false;
}
if (Files.isSymbolicLink(path) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing non-directory or symbolic-link target " + directory.getPath());
}
Ownership ownership = readOwnership(directory);
if (!id.equals(ownership.id)) {
throw new IOException("Ownership marker belongs to '" + ownership.id + "'");
}
removeFinderMetadata(directory);
if (!Objects.equals(ownership.contentHash, directoryHash(directory))) {
throw new IOException("Refusing to delete modified or corrupt Iris-managed datapack " + directory.getPath());
}
File parent = Objects.requireNonNull(directory.getParentFile(), "managed datapack parent");
DatapackInstall.validateInstallTree(directory, parent, "Managed datapack deletion");
IO.delete(directory);
if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Could not delete " + directory.getPath());
}
return true;
}
}
@@ -0,0 +1,155 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisImportedStructureControl;
import art.arcane.volmlib.util.io.IO;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.util.stream.Stream;
final class DatapackPackMetadata {
static final String OVERRIDES_STRIPPED_MARKER = ".iris-overrides-stripped";
static final long MAX_METADATA_BYTES = 1024L * 1024L;
private DatapackPackMetadata() {
}
static boolean resolveStripOverrides() {
try (Stream<IrisData> stream = ServerConfigurator.allPacks()) {
return stream.anyMatch(DatapackPackMetadata::packDisablesOverrides);
}
}
static boolean packDisablesOverrides(IrisData data) {
if (data == null || data.getDimensionLoader() == null) {
return false;
}
for (IrisDimension dimension : data.getDimensionLoader().loadAll(data.getDimensionLoader().getPossibleKeys())) {
if (dimension == null) {
continue;
}
IrisImportedStructureControl control = dimension.getImportedStructures();
if (control != null && !control.isDatapackOverrides()) {
return true;
}
}
return false;
}
static void stripVanillaStructureOverrides(File datapackRoot) throws IOException {
stripVanillaStructureOverrides(datapackRoot, IO::delete);
}
static void stripVanillaStructureOverrides(
File datapackRoot,
DirectoryDeleter deleter
) throws IOException {
File minecraftData = new File(new File(datapackRoot, "data"), "minecraft");
if (!minecraftData.isDirectory()) {
return;
}
String[] relativeTrees = {
"worldgen" + File.separator + "structure_set",
"worldgen" + File.separator + "structure",
"worldgen" + File.separator + "template_pool",
"structure"
};
for (String tree : relativeTrees) {
File dir = new File(minecraftData, tree);
if (dir.exists()) {
deleter.delete(dir);
if (Files.exists(dir.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Could not remove vanilla structure override tree " + dir.getPath());
}
}
}
}
static void writeMarker(File marker) throws IOException {
Files.writeString(marker.toPath(), "stripped", StandardCharsets.UTF_8);
}
static void validatePackMetadata(File datapackRoot) throws IOException {
File metadata = new File(datapackRoot, "pack.mcmeta");
if (!metadata.isFile() || Files.isSymbolicLink(metadata.toPath())) {
throw new IOException("Datapack is missing a regular pack.mcmeta");
}
if (metadata.length() > MAX_METADATA_BYTES) {
throw new IOException("Datapack pack.mcmeta exceeds 1 MiB");
}
JsonElement parsed;
try {
parsed = JsonParser.parseString(DatapackSupport.readBoundedUtf8(
metadata.toPath(), MAX_METADATA_BYTES, "Datapack pack.mcmeta"));
} catch (RuntimeException e) {
throw new IOException("Datapack pack.mcmeta is not valid JSON", e);
}
if (!parsed.isJsonObject()) {
throw new IOException("Datapack pack.mcmeta root must be an object");
}
JsonObject root = parsed.getAsJsonObject();
if (!root.has("pack") || !root.get("pack").isJsonObject()) {
throw new IOException("Datapack pack.mcmeta must contain a pack object");
}
JsonObject pack = root.getAsJsonObject("pack");
boolean hasPackFormat = pack.has("pack_format") && pack.get("pack_format").isJsonPrimitive()
&& pack.getAsJsonPrimitive("pack_format").isNumber();
boolean hasRange = validFormatValue(pack.get("min_format")) && validFormatValue(pack.get("max_format"));
if (!hasPackFormat && !hasRange) {
throw new IOException("Datapack pack.mcmeta must declare numeric pack_format or valid min_format/max_format");
}
if (!pack.has("description") || pack.get("description").isJsonNull()) {
throw new IOException("Datapack pack.mcmeta must declare a description");
}
File data = new File(datapackRoot, "data");
if (data.exists() && (!data.isDirectory() || Files.isSymbolicLink(data.toPath()))) {
throw new IOException("Datapack data path is not a regular directory");
}
}
static boolean validFormatValue(JsonElement value) {
if (value == null || value.isJsonNull()) {
return false;
}
if (value.isJsonPrimitive() && value.getAsJsonPrimitive().isNumber()) {
return true;
}
if (!value.isJsonArray() || value.getAsJsonArray().size() != 2) {
return false;
}
for (JsonElement component : value.getAsJsonArray()) {
if (!component.isJsonPrimitive() || !component.getAsJsonPrimitive().isNumber()) {
return false;
}
}
return true;
}
}
@@ -0,0 +1,468 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.structure.authoring.StructureKey;
import art.arcane.iris.core.structure.authoring.StructureSource;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import art.arcane.iris.util.common.format.C;
import art.arcane.iris.util.common.plugin.VolmitSender;
import java.io.File;
import java.io.IOException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.TreeMap;
import java.util.UUID;
final class DatapackRemoval {
private DatapackRemoval() {
}
static RemoveOutcome removeOutcomeLocked(VolmitSender sender, String id) {
File root = IrisPlatforms.get().dataFolder("datapacks");
return removeOutcomeLocked(sender, id, root, ServerConfigurator.getDatapacksFolder());
}
static boolean removeLocked(VolmitSender sender, String id, File root, List<File> worldFolders) {
return removeOutcomeLocked(sender, id, root, worldFolders) == RemoveOutcome.REMOVED;
}
static RemoveOutcome removeOutcomeLocked(VolmitSender sender, String id, File root, List<File> worldFolders) {
String requested = id == null ? "" : id.trim().toLowerCase(Locale.ROOT);
String cleaned = DatapackArchive.sanitizeId(id);
if (requested.isBlank() || !requested.equals(cleaned) || DatapackArchive.RESERVED_IDS.contains(cleaned)) {
DatapackSupport.message(sender, C.RED + "Invalid Iris-managed datapack id '" + requested + "'. Run /iris datapack list and use the exact listed id.");
return RemoveOutcome.REJECTED;
}
try {
DatapackScratchRecovery.recoverTransactions(root, worldFolders);
} catch (IOException e) {
DatapackSupport.message(sender, C.RED + "Datapack removal blocked by incomplete transaction recovery: " + e.getMessage());
IrisLogging.reportError(e);
return RemoveOutcome.REJECTED;
}
Manifest manifest = DatapackManifestStore.readManifest(root);
Entry ownedEntry = manifest.findById(cleaned);
if (ownedEntry == null) {
DatapackSupport.message(sender, C.YELLOW + "No Iris-managed datapack named '" + cleaned + "'. Unmanaged world datapacks are never removed by Iris.");
return RemoveOutcome.REJECTED;
}
List<File> targets;
try {
targets = preflightRemovalTargets(root, worldFolders, ownedEntry);
} catch (IOException e) {
DatapackSupport.message(sender, C.RED + "Refused to remove datapack '" + cleaned + "': " + e.getMessage());
IrisLogging.reportError(e);
return RemoveOutcome.REJECTED;
}
EditableImportRemoval editableRemoval = null;
DirectoryRemoval directoryRemoval = null;
ManifestWrite manifestWrite = null;
DatapackCoordinator coordinator = null;
try {
editableRemoval = prepareEntryImportRemoval(ownedEntry, manifest.entries);
directoryRemoval = prepareOwnedDirectories(targets);
if (!manifest.removeById(cleaned)) {
throw new IOException("Datapack manifest entry disappeared during removal");
}
manifestWrite = DatapackManifestStore.prepareManifestWrite(root, manifest);
coordinator = DatapackTransactions.createRemovalCoordinator(root, ownedEntry, directoryRemoval, editableRemoval);
coordinator.phase(CoordinatorPhase.PUBLISHING);
directoryRemoval.prepare();
coordinator.phase(CoordinatorPhase.PUBLISHED);
manifestWrite.publish();
} catch (IOException | RuntimeException removalFailure) {
boolean manifestCommitted = manifestWrite != null && manifestWrite.published();
if (manifestCommitted) {
finishCommittedRemoval(cleaned, manifestWrite, directoryRemoval, editableRemoval, coordinator,
removalFailure);
DatapackSupport.message(sender, C.GREEN + "Removed datapack '" + C.WHITE + cleaned + C.GREEN
+ "'. Restart for it to stop generating, and delete its URL from the pack's datapackImports to keep it gone.");
return RemoveOutcome.REMOVED;
}
boolean restored = rollbackRemoval(manifestWrite, directoryRemoval, editableRemoval, removalFailure);
if (restored && coordinator != null) {
try {
coordinator.finish();
} catch (IOException cleanupFailure) {
removalFailure.addSuppressed(cleanupFailure);
}
}
DatapackSupport.message(sender, C.RED + "Failed to remove datapack '" + cleaned
+ "'; Iris attempted to restore every prior location: " + removalFailure.getMessage());
IrisLogging.reportError(removalFailure);
// A mutation was attempted; even a successful rollback is not provably identical
// (the fingerprint does not cover datapacks/), so stay conservatively invalidated.
return RemoveOutcome.FAILED_AFTER_MUTATION;
}
finishCommittedRemoval(cleaned, manifestWrite, directoryRemoval, editableRemoval, coordinator, null);
DatapackSupport.message(sender, C.GREEN + "Removed datapack '" + C.WHITE + cleaned + C.GREEN
+ "'. Restart for it to stop generating, and delete its URL from the pack's datapackImports to keep it gone.");
return RemoveOutcome.REMOVED;
}
static void finishCommittedRemoval(
String id,
ManifestWrite manifestWrite,
DirectoryRemoval directoryRemoval,
EditableImportRemoval editableRemoval,
DatapackCoordinator coordinator,
Throwable priorFailure
) {
if (priorFailure != null) {
IOException recoveryFailure = new IOException(
"Datapack manifest committed before publication durability was confirmed",
priorFailure
);
if (editableRemoval != null) {
try {
editableRemoval.leaveForRecovery();
} catch (IOException releaseFailure) {
recoveryFailure.addSuppressed(releaseFailure);
}
}
try {
manifestWrite.discard();
} catch (IOException cleanupFailure) {
recoveryFailure.addSuppressed(cleanupFailure);
}
IrisLogging.reportError("Datapack '" + id
+ "' was removed but transaction cleanup requires restart recovery.", recoveryFailure);
return;
}
IOException failure = null;
if (coordinator != null) {
try {
coordinator.phase(CoordinatorPhase.COMMITTED);
} catch (IOException phaseFailure) {
failure = DatapackSupport.appendIOException(failure, phaseFailure);
if (editableRemoval != null) {
try {
editableRemoval.leaveForRecovery();
} catch (IOException releaseFailure) {
failure = DatapackSupport.appendIOException(failure, releaseFailure);
}
}
try {
manifestWrite.discard();
} catch (IOException cleanupFailure) {
failure = DatapackSupport.appendIOException(failure, cleanupFailure);
}
IrisLogging.reportError("Datapack '" + id
+ "' was removed but transaction cleanup requires restart recovery.", failure);
return;
}
}
if (editableRemoval != null) {
try {
editableRemoval.markCommitted();
editableRemoval.finishCommit();
} catch (IOException | RuntimeException cleanupFailure) {
failure = DatapackSupport.appendIOException(failure, cleanupFailure);
try {
editableRemoval.leaveForRecovery();
} catch (IOException releaseFailure) {
failure = DatapackSupport.appendIOException(failure, releaseFailure);
}
}
}
if (directoryRemoval != null) {
try {
directoryRemoval.finishCommit();
} catch (IOException | RuntimeException cleanupFailure) {
failure = DatapackSupport.appendIOException(failure, cleanupFailure);
}
}
try {
manifestWrite.discard();
} catch (IOException cleanupFailure) {
failure = DatapackSupport.appendIOException(failure, cleanupFailure);
}
if (failure == null && coordinator != null) {
try {
coordinator.finish();
} catch (IOException cleanupFailure) {
failure = cleanupFailure;
}
}
if (failure != null) {
IrisLogging.reportError("Datapack '" + id
+ "' was removed but transaction cleanup requires restart recovery.", failure);
}
}
static List<File> preflightRemovalTargets(File root, List<File> worldFolders, Entry entry) throws IOException {
List<File> targets = new ArrayList<>();
Set<Path> seen = new HashSet<>();
File staging = new File(root, "staging");
DatapackSupport.verifyDirectoryContainerIfPresent(staging, "datapack staging");
File stagedDir = new File(staging, entry.id);
verifyAndCollectRemovalTarget(stagedDir, entry, targets, seen);
for (File worldFolder : worldFolders) {
DatapackSupport.verifyDirectoryContainerIfPresent(worldFolder, "world datapacks");
verifyAndCollectRemovalTarget(new File(worldFolder, entry.id), entry, targets, seen);
}
return targets;
}
static void verifyAndCollectRemovalTarget(
File target,
Entry entry,
List<File> targets,
Set<Path> seen
) throws IOException {
verifyOwnedDirectoryIfPresent(target, entry);
Path normalized = target.toPath().toAbsolutePath().normalize();
if (DatapackSupport.pathExists(normalized, "datapack removal target")) {
Path identity = normalized.toRealPath();
if (!seen.add(identity)) {
throw new IOException("Aliased datapack removal target " + normalized);
}
targets.add(target);
}
}
static EditableImportRemoval prepareEntryImportRemoval(
Entry entry,
List<Entry> manifestEntries
) throws IOException {
List<PreparedEditableImport> prepared = new ArrayList<>();
Set<String> targetIdSet = new TreeSet<>(entry.importedBundles.keySet());
targetIdSet.addAll(entry.importedTargets.keySet());
targetIdSet.addAll(entry.importAttempts.keySet());
List<String> targetIds = new ArrayList<>(targetIdSet);
targetIds.sort(String::compareTo);
try {
for (String targetId : targetIds) {
Set<String> retainedKeys = invalidateRetainedImportClaims(
entry, targetId, manifestEntries);
File dataFolder = new File(targetId);
if (!dataFolder.isDirectory()) {
continue;
}
StructureTransactionWriter writer = new StructureTransactionWriter(dataFolder.toPath());
List<StructureTransactionWriter.OwnedRemoval> removals = ownedImportRemovals(
writer,
entry,
targetId,
retainedKeys
);
StructureTransactionWriter.PreparedRemoval removal =
writer.prepareMatchingOwnedRemovals(removals);
prepared.add(new PreparedEditableImport(dataFolder, removal));
}
return new EditableImportRemoval(prepared);
} catch (IOException | RuntimeException preparationFailure) {
IOException failure = preparationFailure instanceof IOException ioFailure
? ioFailure
: new IOException("Failed preparing editable datapack import cleanup", preparationFailure);
for (int i = prepared.size() - 1; i >= 0; i--) {
try {
prepared.get(i).removal().rollback();
} catch (IOException rollbackFailure) {
failure.addSuppressed(rollbackFailure);
}
}
throw failure;
}
}
static List<StructureTransactionWriter.OwnedRemoval> ownedImportRemovals(
StructureTransactionWriter writer,
Entry removingEntry,
String targetId,
Set<String> retainedKeys
) throws IOException {
List<StructureTransactionWriter.OwnedRemoval> removals = new ArrayList<>();
Map<String, Set<String>> removingClaims = importBundleClaims(removingEntry, targetId);
for (Map.Entry<String, Set<String>> bundle : removingClaims.entrySet()) {
try {
StructureKey targetKey = StructureKey.parse(bundle.getKey());
if (retainedKeys.contains(bundle.getKey())) {
continue;
}
Optional<StructureSource> ownedSource = writer.ownedSource(targetKey);
if (ownedSource.isEmpty() || !sourceClaimsContain(bundle.getValue(), ownedSource.get())) {
continue;
}
removals.add(StructureTransactionWriter.OwnedRemoval.managedDatapack(
targetKey,
ownedSource.get().kind(),
ownedSource.get().key()
));
} catch (RuntimeException e) {
throw new IOException("Invalid editable structure import inventory entry '"
+ bundle.getKey() + "' -> '" + bundle.getValue() + "'", e);
}
}
return List.copyOf(removals);
}
static Set<String> invalidateRetainedImportClaims(
Entry removingEntry,
String targetId,
List<Entry> manifestEntries
) {
Set<String> removingKeys = importBundleClaims(removingEntry, targetId).keySet();
Set<String> retainedKeys = new TreeSet<>();
for (Entry candidate : manifestEntries) {
if (candidate == removingEntry) {
continue;
}
Set<String> candidateKeys = importBundleClaims(candidate, targetId).keySet();
boolean candidateRetained = false;
for (String candidateKey : candidateKeys) {
if (removingKeys.contains(candidateKey)) {
retainedKeys.add(candidateKey);
candidateRetained = true;
}
}
if (candidateRetained) {
candidate.importedTargets.remove(targetId);
candidate.importAttempts.remove(targetId);
candidate.structuresImported = false;
}
}
return Set.copyOf(retainedKeys);
}
static Map<String, Set<String>> importBundleClaims(Entry entry, String targetId) {
Map<String, Set<String>> claims = new TreeMap<>();
addImportBundleClaims(claims, entry.importedBundles.getOrDefault(targetId, Map.of()));
if (entry.importedBundles.containsKey(targetId) || entry.importedTargets.containsKey(targetId)) {
addImportBundleClaims(claims, DatapackStructureImports.importBundleInventory(entry));
}
return claims;
}
static void addImportBundleClaims(
Map<String, Set<String>> claims,
Map<String, String> inventory
) {
for (Map.Entry<String, String> bundle : inventory.entrySet()) {
claims.computeIfAbsent(bundle.getKey(), ignored -> new TreeSet<>()).add(bundle.getValue());
}
}
static boolean sourceClaimsContain(Set<String> claims, StructureSource source) throws IOException {
for (String claimedKey : claims) {
try {
StructureKey sourceKey = StructureKey.parse(claimedKey);
StructureSource.Kind sourceKind = sourceKey.namespace().equals("minecraft")
? StructureSource.Kind.VANILLA : StructureSource.Kind.DATAPACK;
if (source.kind() == sourceKind && source.key().equals(sourceKey)) {
return true;
}
} catch (RuntimeException e) {
throw new IOException("Invalid editable structure source key '" + claimedKey + "'", e);
}
}
return false;
}
static DirectoryRemoval prepareOwnedDirectories(List<File> targets) throws IOException {
List<DirectoryMove> planned = new ArrayList<>();
for (File target : targets) {
File parent = target.getParentFile();
File backupRoot = new File(parent.getParentFile() == null ? parent : parent.getParentFile(), ".iris-datapack-remove");
File backup = new File(backupRoot, target.getName() + "-" + UUID.randomUUID());
DatapackSupport.ensureScratchDirectory(backupRoot, "datapack removal backup");
DatapackInstall.validateInstallTree(target, parent, "Datapack removal target");
planned.add(new DirectoryMove(
target,
backup,
DatapackOwnership.directoryHash(target),
DatapackOwnership.ownershipMarkerFingerprint(target),
DatapackSupport.directoryIdentity(target),
DatapackSupport.realDirectoryPath(parent, "datapack target root"),
DatapackSupport.realDirectoryPath(backupRoot, "datapack removal scratch root"),
DatapackSupport.directoryIdentity(parent),
DatapackSupport.directoryIdentity(backupRoot)
));
}
return new DirectoryRemoval(planned);
}
static boolean rollbackRemoval(
ManifestWrite manifestWrite,
DirectoryRemoval directoryRemoval,
EditableImportRemoval editableRemoval,
Throwable removalFailure
) {
if (manifestWrite != null) {
try {
manifestWrite.discard();
} catch (IOException discardFailure) {
removalFailure.addSuppressed(discardFailure);
}
}
if (directoryRemoval != null) {
try {
directoryRemoval.rollback();
} catch (IOException rollbackFailure) {
removalFailure.addSuppressed(rollbackFailure);
}
}
if (editableRemoval != null) {
try {
editableRemoval.rollback();
} catch (IOException rollbackFailure) {
removalFailure.addSuppressed(rollbackFailure);
}
}
return removalFailure.getSuppressed().length == 0;
}
static void verifyOwnedDirectoryIfPresent(File directory, Entry entry) throws IOException {
if (!Files.exists(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) {
return;
}
if (Files.isSymbolicLink(directory.toPath())
|| !Files.isDirectory(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing non-directory or symbolic-link target " + directory.getPath());
}
Ownership ownership = DatapackOwnership.readOwnership(directory);
if (!DatapackInstall.ownershipSourceMatches(ownership, entry)) {
throw new IOException("Ownership marker at " + directory.getPath() + " belongs to '" + ownership.id + "'");
}
DatapackOwnership.removeFinderMetadata(directory);
if (!Objects.equals(ownership.contentHash, DatapackOwnership.directoryHash(directory))) {
throw new IOException("Refusing to remove modified or corrupt Iris-managed datapack " + directory.getPath());
}
}
}
@@ -0,0 +1,436 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import java.io.File;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.FileStore;
import java.nio.file.FileSystems;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.TreeMap;
import java.util.UUID;
import java.util.stream.Stream;
final class DatapackScratchRecovery {
static final int WINDOWS_LEGACY_PATH_LIMIT = 247;
private DatapackScratchRecovery() {
}
static boolean recoverTransactions(File root, List<File> worldFolders) throws IOException {
boolean changed = recoverStagingScratch(new File(root, "staging"));
File transactionDirectory = new File(root, DatapackTransactions.TRANSACTION_DIRECTORY);
Path transactionPath = transactionDirectory.toPath();
if (!Files.exists(transactionPath, LinkOption.NOFOLLOW_LINKS)) {
return recoverInstallScratch(root, worldFolders) | changed;
}
DatapackSupport.verifyDirectoryContainerIfPresent(transactionDirectory, "datapack transaction");
Manifest committedManifest = DatapackManifestStore.readCommittedManifest(root);
List<Path> transactionRoots;
try (Stream<Path> paths = Files.list(transactionPath)) {
transactionRoots = paths.limit(DatapackTransactions.MAX_TRANSACTION_COUNT + 2L).sorted().toList();
}
if (transactionRoots.size() == DatapackTransactions.MAX_TRANSACTION_COUNT + 2) {
throw new IOException("Datapack transaction directory contains too many entries");
}
int transactionCount = 0;
for (Path transactionRoot : transactionRoots) {
if (isHarmlessRecoveryArtifact(transactionRoot)) {
continue;
}
transactionCount++;
if (transactionCount > DatapackTransactions.MAX_TRANSACTION_COUNT) {
throw new IOException("Datapack transaction count exceeds " + DatapackTransactions.MAX_TRANSACTION_COUNT);
}
}
for (Path transactionRoot : transactionRoots) {
if (isHarmlessRecoveryArtifact(transactionRoot)) {
changed |= Files.deleteIfExists(transactionRoot);
continue;
}
recoverTransaction(root, worldFolders, committedManifest, transactionPath, transactionRoot);
changed = true;
}
changed |= transactionDirectory.delete();
return recoverInstallScratch(root, worldFolders) | changed;
}
static boolean recoverInstallScratch(File root, List<File> worldFolders) throws IOException {
Set<Path> scratchRoots = new TreeSet<>();
scratchRoots.add(DatapackInstall.installScratchRoot(new File(root, "staging")).toPath().toAbsolutePath().normalize());
for (File worldFolder : worldFolders) {
scratchRoots.add(DatapackInstall.installScratchRoot(worldFolder).toPath().toAbsolutePath().normalize());
}
boolean changed = false;
for (Path scratchRoot : scratchRoots) {
changed |= recoverInstallScratchRoot(scratchRoot);
}
return changed;
}
static boolean recoverInstallScratchRoot(Path scratchRoot) throws IOException {
if (!Files.exists(scratchRoot, LinkOption.NOFOLLOW_LINKS)) {
return false;
}
DatapackSupport.verifyDirectoryContainerIfPresent(scratchRoot.toFile(), "datapack install scratch");
List<Path> children;
try (Stream<Path> paths = Files.list(scratchRoot)) {
children = paths.limit(DatapackOwnership.MAX_MANAGED_PATHS + 2L).sorted().toList();
}
int managedEntries = 0;
for (Path child : children) {
if (!isHarmlessRecoveryArtifact(child)) {
managedEntries++;
}
if (managedEntries > DatapackOwnership.MAX_MANAGED_PATHS) {
throw new IOException("Datapack install scratch contains too many entries");
}
}
List<StagingScratch> pending = new ArrayList<>();
List<StagingScratch> backups = new ArrayList<>();
boolean changed = false;
for (Path child : children) {
if (isHarmlessRecoveryArtifact(child)) {
changed |= Files.deleteIfExists(child);
continue;
}
StagingScratch scratch = parseInstallScratch(scratchRoot, child);
if (scratch == null) {
throw new IOException("Unexpected datapack install scratch artifact " + child);
}
if (Files.isSymbolicLink(child) || !Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid datapack install scratch artifact " + child);
}
validateScratchTree(child);
if (scratch.kind() == StagingScratchKind.BACKUP) {
backups.add(scratch);
} else {
pending.add(scratch);
}
}
if (!backups.isEmpty()) {
throw new IOException("Preserving unjournaled datapack install backup "
+ backups.getFirst().path());
}
for (StagingScratch scratch : pending) {
DatapackInstall.deleteInstallScratch(scratch.path().toFile(), "orphan datapack install pending directory");
changed = true;
}
changed |= scratchRoot.toFile().delete();
return changed;
}
static StagingScratch parseInstallScratch(Path scratchRoot, Path child) throws IOException {
Path normalized = child.toAbsolutePath().normalize();
if (!Objects.equals(normalized.getParent(), scratchRoot)) {
throw new IOException("Datapack install scratch artifact escapes its root: " + child);
}
String name = normalized.getFileName().toString();
int uuidStart = name.length() - 36;
if (uuidStart <= 1 || name.charAt(uuidStart - 1) != '-') {
return null;
}
try {
UUID.fromString(name.substring(uuidStart));
} catch (IllegalArgumentException e) {
return null;
}
String stem = name.substring(0, uuidStart - 1);
StagingScratchKind kind = stem.endsWith("-backup")
? StagingScratchKind.BACKUP : StagingScratchKind.PENDING;
String id = kind == StagingScratchKind.BACKUP
? stem.substring(0, stem.length() - "-backup".length()) : stem;
if (id.isBlank() || !id.equals(DatapackArchive.sanitizeId(id)) || DatapackArchive.RESERVED_IDS.contains(id)) {
return null;
}
return new StagingScratch(kind, id, normalized);
}
static boolean recoverStagingScratch(File stagingDirectory) throws IOException {
Path stagingRoot = stagingDirectory.toPath().toAbsolutePath().normalize();
if (!Files.exists(stagingRoot, LinkOption.NOFOLLOW_LINKS)) {
return false;
}
DatapackSupport.verifyDirectoryContainerIfPresent(stagingDirectory, "datapack staging");
List<Path> children;
try (Stream<Path> paths = Files.list(stagingRoot)) {
children = paths.limit(DatapackOwnership.MAX_MANAGED_PATHS + 1L).sorted().toList();
}
if (children.size() > DatapackOwnership.MAX_MANAGED_PATHS) {
throw new IOException("Datapack staging contains too many entries");
}
List<StagingScratch> pending = new ArrayList<>();
Map<String, List<StagingScratch>> backups = new TreeMap<>();
for (Path child : children) {
StagingScratch scratch = parseStagingScratch(stagingRoot, child);
if (scratch == null) {
continue;
}
if (Files.isSymbolicLink(child) || !Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid datapack staging scratch artifact " + child);
}
if (scratch.kind() == StagingScratchKind.PENDING) {
validateScratchTree(child);
pending.add(scratch);
} else {
backups.computeIfAbsent(scratch.id(), ignored -> new ArrayList<>()).add(scratch);
}
}
for (Map.Entry<String, List<StagingScratch>> entry : backups.entrySet()) {
if (entry.getValue().size() != 1) {
throw new IOException("Ambiguous datapack staging backups for '" + entry.getKey() + "'");
}
StagingScratch backup = entry.getValue().getFirst();
Ownership backupOwnership = verifyManagedScratchDirectory(backup.path().toFile(), backup.id());
Path target = stagingRoot.resolve(backup.id()).normalize();
if (!Objects.equals(target.getParent(), stagingRoot)) {
throw new IOException("Datapack staging backup target escapes its root");
}
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
Ownership targetOwnership = verifyManagedScratchDirectory(target.toFile(), backup.id());
if (!Objects.equals(backupOwnership.url, targetOwnership.url)) {
throw new IOException("Datapack staging backup source does not match its target: " + target);
}
}
}
boolean changed = false;
for (List<StagingScratch> matches : backups.values()) {
StagingScratch backup = matches.getFirst();
Path target = stagingRoot.resolve(backup.id()).normalize();
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
DatapackCoordinatorResolution.deleteVerifiedDirectory(backup.path().toFile());
} else {
DatapackSupport.moveNew(backup.path(), target);
}
changed = true;
}
for (StagingScratch scratch : pending) {
DatapackCoordinatorResolution.deleteVerifiedDirectory(scratch.path().toFile());
changed = true;
}
DatapackSupport.forceDirectoryIfSupported(stagingRoot);
return changed;
}
static StagingScratch parseStagingScratch(Path stagingRoot, Path child) throws IOException {
Path normalized = child.toAbsolutePath().normalize();
if (!Objects.equals(normalized.getParent(), stagingRoot)) {
throw new IOException("Datapack staging artifact escapes its root: " + child);
}
String name = normalized.getFileName().toString();
StagingScratchKind kind;
String prefix;
if (name.startsWith(".pending-")) {
kind = StagingScratchKind.PENDING;
prefix = ".pending-";
} else if (name.startsWith(".backup-")) {
kind = StagingScratchKind.BACKUP;
prefix = ".backup-";
} else {
return null;
}
int uuidStart = name.length() - 36;
if (uuidStart <= prefix.length() || name.charAt(uuidStart - 1) != '-') {
return null;
}
String id = name.substring(prefix.length(), uuidStart - 1);
if (!id.equals(DatapackArchive.sanitizeId(id)) || DatapackArchive.RESERVED_IDS.contains(id)) {
return null;
}
try {
UUID.fromString(name.substring(uuidStart));
} catch (IllegalArgumentException e) {
return null;
}
return new StagingScratch(kind, id, normalized);
}
static void validateScratchTree(Path root) throws IOException {
FileStore rootStore = Files.getFileStore(root);
int[] pathCount = new int[]{0};
Files.walkFileTree(root, new SimpleFileVisitor<>() {
private FileVisitResult inspect(Path entry, BasicFileAttributes attributes) throws IOException {
pathCount[0]++;
if (pathCount[0] > DatapackOwnership.MAX_MANAGED_PATHS) {
throw new IOException("Datapack scratch contains too many paths: " + root);
}
if (attributes.isSymbolicLink()
|| attributes.isOther()
|| (!attributes.isDirectory() && !attributes.isRegularFile())) {
throw new IOException("Datapack scratch contains an unsupported file: " + entry);
}
if (!DatapackOwnership.sameDatapackVolume(root, rootStore, entry)) {
throw new IOException("Datapack scratch crosses a filesystem boundary: " + entry);
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult preVisitDirectory(
Path directory,
BasicFileAttributes attributes
) throws IOException {
return inspect(directory, attributes);
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
return inspect(file, attributes);
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException {
throw new IOException("Unable to inspect datapack scratch entry: " + file, failure);
}
});
}
static boolean sameScratchVolume(Path first, Path second) throws IOException {
return sameScratchVolume(first, Files.getFileStore(first), second, Files.getFileStore(second));
}
static boolean sameScratchVolume(
Path first,
FileStore firstStore,
Path second,
FileStore secondStore
) {
if (Objects.equals(firstStore, secondStore)) {
return true;
}
if (!isDefaultWindowsPath(first) || !isDefaultWindowsPath(second)) {
return false;
}
Path firstAbsolute = first.toAbsolutePath().normalize();
Path secondAbsolute = second.toAbsolutePath().normalize();
if ((firstAbsolute.toString().length() > WINDOWS_LEGACY_PATH_LIMIT)
== (secondAbsolute.toString().length() > WINDOWS_LEGACY_PATH_LIMIT)) {
return false;
}
Path firstRoot = firstAbsolute.getRoot();
Path secondRoot = secondAbsolute.getRoot();
if (firstRoot == null || secondRoot == null) {
return false;
}
return sameWindowsVolume(
firstStore, firstRoot.toString(), secondStore, secondRoot.toString());
}
static boolean sameWindowsVolume(
FileStore firstStore,
String firstRoot,
FileStore secondStore,
String secondRoot
) {
if (firstRoot == null || secondRoot == null || !firstRoot.equalsIgnoreCase(secondRoot)) {
return false;
}
try {
Object firstSerial = firstStore.getAttribute("volume:vsn");
Object secondSerial = secondStore.getAttribute("volume:vsn");
return firstSerial != null && firstSerial.equals(secondSerial);
} catch (IOException | RuntimeException exception) {
return false;
}
}
static boolean isSupportedScratchDirectory(BasicFileAttributes attributes) {
return attributes != null
&& attributes.isDirectory()
&& !attributes.isSymbolicLink()
&& !attributes.isOther();
}
static boolean isDefaultWindowsPath(Path path) {
return File.separatorChar == '\\'
&& path.getFileSystem().equals(FileSystems.getDefault());
}
static Ownership verifyManagedScratchDirectory(File directory, String id) throws IOException {
DatapackOwnership.validateManagedDirectory(directory, id);
Ownership ownership = DatapackOwnership.readOwnership(directory);
if (!Objects.equals(ownership.contentHash, DatapackOwnership.directoryHash(directory))) {
throw new IOException("Datapack staging backup is modified or corrupt: " + directory.getPath());
}
return ownership;
}
static boolean isHarmlessRecoveryArtifact(Path path) throws IOException {
if (!DatapackOwnership.isFinderMetadata(path)) {
return false;
}
if (Files.isSymbolicLink(path) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Suspicious datapack recovery artifact " + path);
}
return true;
}
static void recoverTransaction(
File root,
List<File> worldFolders,
Manifest committedManifest,
Path transactionDirectory,
Path transactionRoot
) throws IOException {
Path normalizedRoot = transactionRoot.toAbsolutePath().normalize();
if (!Objects.equals(normalizedRoot.getParent(), transactionDirectory.toAbsolutePath().normalize())
|| Files.isSymbolicLink(normalizedRoot)
|| !Files.isDirectory(normalizedRoot, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid datapack transaction directory " + transactionRoot);
}
try {
UUID.fromString(normalizedRoot.getFileName().toString());
} catch (IllegalArgumentException e) {
throw new IOException("Invalid datapack transaction directory name " + transactionRoot, e);
}
CoordinatorJournal journal = DatapackTransactions.readCoordinatorJournal(normalizedRoot);
if (journal == null) {
DatapackTransactions.deleteCoordinatorTransaction(normalizedRoot);
return;
}
DatapackTransactions.validateCoordinatorJournal(root, worldFolders, committedManifest, normalizedRoot, journal);
boolean commit = DatapackCoordinatorResolution.coordinatorCommitDecision(committedManifest, journal);
DatapackCoordinatorResolution.resolveCoordinatorDirectories(journal, commit);
DatapackCoordinatorResolution.resolveCoordinatorEditables(journal, normalizedRoot, commit);
DatapackTransactions.deleteCoordinatorTransaction(normalizedRoot);
}
enum StagingScratchKind {
PENDING,
BACKUP
}
record StagingScratch(StagingScratchKind kind, String id, Path path) {
}
}
@@ -0,0 +1,256 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Report;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.format.C;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.volmlib.util.collection.KList;
import java.io.File;
import java.io.IOException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
final class DatapackStagingGuard {
private DatapackStagingGuard() {
}
static VerifiedStagingInstall authorizeVerifiedStagingInstall(
File root,
File stagingRoot,
File verifiedSource,
Entry entry
) throws IOException {
Path normalizedRoot = DatapackSupport.requireDirectoryIdentity(root, "datapack storage root");
Path normalizedStagingRoot = DatapackSupport.requireDirectoryIdentity(stagingRoot, "datapack staging root");
DatapackSupport.requireNoSymbolicLinkComponents(normalizedRoot, "datapack storage root");
DatapackSupport.requireNoSymbolicLinkComponents(normalizedStagingRoot, "datapack staging root");
Path expectedStagingRoot = normalizedRoot.resolve("staging").normalize();
if (!normalizedStagingRoot.equals(expectedStagingRoot)
|| !Files.isSameFile(normalizedStagingRoot, expectedStagingRoot)) {
throw new IOException("Verified datapack staging root is not Iris's canonical staging directory");
}
Path normalizedSource = DatapackSupport.requireDirectoryIdentity(verifiedSource, "verified datapack extraction");
DatapackSupport.requireNoSymbolicLinkComponents(normalizedSource, "verified datapack extraction");
if (!Objects.equals(normalizedSource.getParent(), normalizedStagingRoot)
|| !Files.isSameFile(normalizedSource.getParent(), normalizedStagingRoot)) {
throw new IOException("Verified datapack extraction is outside Iris's canonical staging directory");
}
verifyPendingExtractionName(normalizedSource.getFileName().toString(), entry.id);
DatapackOwnership.validateManagedDirectory(verifiedSource, entry.id);
DatapackScratchRecovery.validateScratchTree(normalizedSource);
if (!DatapackScratchRecovery.sameScratchVolume(normalizedSource, normalizedStagingRoot)) {
throw new IOException("Verified datapack extraction crosses a filesystem boundary");
}
String desiredHash = DatapackOwnership.directoryHash(verifiedSource);
Ownership ownership = DatapackOwnership.readOwnership(verifiedSource);
if (!DatapackInstall.ownershipMetadataMatches(ownership, entry, desiredHash)) {
throw new IOException("Verified datapack extraction does not match the resolved archive metadata");
}
Manifest committedManifest = DatapackManifestStore.readCommittedManifest(root);
Entry committed = committedManifest.findById(entry.id);
boolean legacyReplacementAuthorized = committed != null
&& Objects.equals(committed.id, entry.id)
&& Objects.equals(committed.url, entry.url);
LegacyStagingSnapshot legacyStagingSnapshot = captureLegacyStagingSnapshot(
normalizedStagingRoot, entry.id, legacyReplacementAuthorized);
return new VerifiedStagingInstall(
normalizedRoot,
normalizedStagingRoot,
normalizedSource,
entry,
desiredHash,
committed,
legacyReplacementAuthorized,
legacyStagingSnapshot
);
}
static LegacyStagingSnapshot captureLegacyStagingSnapshot(
Path normalizedStagingRoot,
String id,
boolean legacyReplacementAuthorized
) throws IOException {
if (!legacyReplacementAuthorized) {
return null;
}
Path target = normalizedStagingRoot.resolve(id).normalize();
if (!DatapackSupport.pathExists(target, "legacy datapack staging target")) {
return null;
}
if (Files.isSymbolicLink(target)
|| !Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid legacy datapack staging target " + target);
}
DatapackInstall.validateInstallTree(target.toFile(), normalizedStagingRoot.toFile(), "Legacy datapack staging");
String markerHash = DatapackOwnership.ownershipMarkerFingerprint(target.toFile());
if (!"absent".equals(markerHash)) {
return null;
}
return new LegacyStagingSnapshot(
target,
target.toRealPath(),
DatapackSupport.directoryIdentity(target.toFile()),
DatapackOwnership.directoryHash(target.toFile()),
markerHash
);
}
static void verifyPendingExtractionName(String name, String id) throws IOException {
String prefix = ".pending-" + id + "-";
if (!name.startsWith(prefix)) {
throw new IOException("Verified datapack extraction has an invalid staging name " + name);
}
try {
UUID.fromString(name.substring(prefix.length()));
} catch (IllegalArgumentException e) {
throw new IOException("Verified datapack extraction has an invalid staging identity " + name, e);
}
}
static void validateInstallParticipants(
List<File> worldFolders,
VerifiedStagingInstall verifiedStagingInstall,
Entry entry
) throws IOException {
List<File> roots = new ArrayList<>(worldFolders);
if (verifiedStagingInstall != null) {
roots.add(verifiedStagingInstall.stagingRoot().toFile());
}
Set<Path> normalizedTargets = new HashSet<>();
Set<Path> realRoots = new HashSet<>();
Set<Path> targetIdentities = new HashSet<>();
List<Path> existingTargets = new ArrayList<>();
for (File folder : roots) {
ensureInstallTargetRoot(folder);
Path normalizedRoot = folder.toPath().toAbsolutePath().normalize();
Path realRoot = normalizedRoot.toRealPath();
Path normalizedTarget = normalizedRoot.resolve(entry.id).normalize();
if (!realRoots.add(realRoot) || !normalizedTargets.add(normalizedTarget)) {
throw new IOException("Duplicate datapack install target " + normalizedTarget);
}
Path targetIdentity = realRoot.resolve(entry.id).normalize();
if (Files.exists(normalizedTarget, LinkOption.NOFOLLOW_LINKS)) {
if (Files.isSymbolicLink(normalizedTarget)
|| !Files.isDirectory(normalizedTarget, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing invalid datapack install target " + normalizedTarget);
}
targetIdentity = normalizedTarget.toRealPath();
for (Path existingTarget : existingTargets) {
if (Files.isSameFile(normalizedTarget, existingTarget)) {
throw new IOException("Aliased datapack install target " + normalizedTarget);
}
}
existingTargets.add(normalizedTarget);
}
if (!targetIdentities.add(targetIdentity)) {
throw new IOException("Aliased datapack install target " + normalizedTarget);
}
}
if (verifiedStagingInstall != null) {
verifiedStagingInstall.verifyStagingRoot();
}
}
static void ensureInstallTargetRoot(File directory) throws IOException {
if (!Files.exists(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(directory.toPath());
}
DatapackSupport.requireDirectoryIdentity(directory, "datapack install root");
}
static void recordInstallResult(
VolmitSender sender,
Report report,
File stagedDir,
KList<File> worldFolders,
Entry entry,
InstallResult result,
String versionNumber
) {
DatapackStagingReapply.recordInstallMetadata(stagedDir, worldFolders, entry);
if (result.changed()) {
report.updated.add(entry.id + " (" + DatapackSupport.safe(versionNumber) + ")");
report.requiresRestart = true;
DatapackSupport.message(sender, C.GREEN + " Repaired " + C.WHITE + entry.id + C.GREEN + " " + DatapackSupport.safe(versionNumber));
return;
}
report.upToDate.add(entry.id + " (" + DatapackSupport.safe(versionNumber) + ")");
DatapackSupport.message(sender, C.GRAY + " Up to date: " + C.WHITE + entry.id + C.GRAY + " " + DatapackSupport.safe(versionNumber));
}
static boolean isUsableStaging(File stagedDir, Entry entry) {
return inspectUsableStaging(stagedDir, entry).usable();
}
static StagingInspection inspectUsableStaging(File stagedDir, Entry entry) {
try {
DatapackOwnership.validateManagedDirectory(stagedDir, entry.id);
Ownership ownership = DatapackOwnership.readOwnership(stagedDir);
String contentHash = DatapackOwnership.directoryHash(stagedDir);
if (!DatapackInstall.ownershipSourceMatches(ownership, entry)
|| !Objects.equals(ownership.versionId, entry.versionId)
|| !Objects.equals(ownership.versionNumber, entry.versionNumber)
|| !Objects.equals(ownership.sha1, entry.sha1)
|| !Objects.equals(ownership.contentHash, contentHash)) {
return new StagingInspection(false, false, false);
}
PackResources resources = DatapackOwnership.scanPackResources(stagedDir);
List<String> previousStructureKeys = DatapackManifestStore.copyList(entry.structureKeys);
List<String> previousTemplateKeys = DatapackManifestStore.copyList(entry.templateKeys);
boolean ownershipCorrected = false;
if (!DatapackManifestStore.copyList(resources.structureKeys()).equals(DatapackManifestStore.copyList(ownership.structureKeys))
|| !DatapackManifestStore.copyList(resources.templateKeys()).equals(DatapackManifestStore.copyList(ownership.templateKeys))) {
Entry corrected = DatapackManifestStore.copyEntry(entry);
corrected.structureKeys = resources.structureKeys();
corrected.templateKeys = resources.templateKeys();
DatapackOwnership.writeOwnership(stagedDir, corrected);
ownershipCorrected = true;
}
entry.structureKeys = resources.structureKeys();
entry.templateKeys = resources.templateKeys();
boolean manifestChanged = !previousStructureKeys.equals(DatapackManifestStore.copyList(entry.structureKeys))
|| !previousTemplateKeys.equals(DatapackManifestStore.copyList(entry.templateKeys));
return new StagingInspection(true, ownershipCorrected, manifestChanged);
} catch (IOException e) {
IrisLogging.warn("Ignoring unusable Iris datapack staging at " + stagedDir.getPath() + ": " + e.getMessage());
return new StagingInspection(false, false, false);
}
}
record LegacyStagingSnapshot(
Path normalizedTarget,
Path realTarget,
String targetIdentity,
String contentHash,
String markerHash
) {
}
}
@@ -0,0 +1,312 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.ReapplyOutcome;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.volmlib.util.collection.KList;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Stream;
final class DatapackStagingReapply {
private DatapackStagingReapply() {
}
static ReapplyOutcome reapplyFromStagingLocked(KList<File> worldFolders) {
File root = IrisPlatforms.get().dataFolder("datapacks");
ReapplyOutcome recovery = recoverBeforeReapplyOutcome(root, worldFolders);
if (!recovery.succeeded()) {
return reportReapplyFailure(recovery);
}
File stagingDir = IrisPlatforms.get().dataFolderNoCreate("datapacks", "staging");
ReapplyOutcome repair = reapplyStagingRootOutcome(
root,
stagingDir,
worldFolders,
DatapackPackMetadata.resolveStripOverrides());
if (!repair.succeeded()) {
return reportReapplyFailure(repair);
}
return ReapplyOutcome.success(recovery.recovered(), repair.repaired());
}
static boolean reapplyStagingRoot(
File root,
File stagingDir,
KList<File> worldFolders,
boolean stripOverrides
) {
return reportReapplyFailure(reapplyStagingRootOutcome(
root,
stagingDir,
worldFolders,
stripOverrides)).succeeded();
}
static ReapplyOutcome reapplyStagingRootOutcome(
File root,
File stagingDir,
KList<File> worldFolders,
boolean stripOverrides
) {
Manifest manifest = DatapackManifestStore.readManifest(root);
if (stagingDir == null
|| !Files.exists(stagingDir.toPath(), LinkOption.NOFOLLOW_LINKS)) {
if (manifest.entries.isEmpty()) {
return ReapplyOutcome.success(false, false);
}
File missing = stagingDir == null ? new File(root, "staging") : stagingDir;
return ReapplyOutcome.failed(new IOException(
"Managed datapack staging is missing at " + missing.getPath()));
}
if (Files.isSymbolicLink(stagingDir.toPath())
|| !Files.isDirectory(stagingDir.toPath(), LinkOption.NOFOLLOW_LINKS)) {
return ReapplyOutcome.failed(new IOException(
"Managed datapack staging is not a safe directory at " + stagingDir.getPath()));
}
return reapplyStagedDirectoriesOutcome(
root, stagingDir, worldFolders, stripOverrides, manifest);
}
static boolean reapplyStagedDirectories(
File root,
File stagingDir,
KList<File> worldFolders,
boolean stripOverrides
) {
if (Files.isSymbolicLink(stagingDir.toPath())
|| !Files.isDirectory(stagingDir.toPath(), LinkOption.NOFOLLOW_LINKS)) {
return reportReapplyFailure(ReapplyOutcome.failed(new IOException(
"Managed datapack staging is not a safe directory at "
+ stagingDir.getPath()))).succeeded();
}
return reportReapplyFailure(reapplyStagedDirectoriesOutcome(
root,
stagingDir,
worldFolders,
stripOverrides,
DatapackManifestStore.readManifest(root))).succeeded();
}
static ReapplyOutcome reapplyStagedDirectoriesOutcome(
File root,
File stagingDir,
KList<File> worldFolders,
boolean stripOverrides,
Manifest manifest
) {
File[] staged = stagingDir.listFiles(File::isDirectory);
if (staged == null) {
return ReapplyOutcome.failed(new IOException(
"Unable to enumerate managed datapack staging at " + stagingDir.getPath()));
}
boolean repaired = false;
IOException failure = null;
for (Entry entry : manifest.entries) {
File stagedDir = new File(stagingDir, entry.id);
if (isRecordedUnchangedInstall(stagedDir, worldFolders, entry, stripOverrides)) {
continue;
}
if (!DatapackStagingGuard.isUsableStaging(stagedDir, entry)) {
forgetInstallMetadata(entry);
failure = DatapackSupport.appendFailure(failure, new IOException(
"Managed datapack staging is unusable for '" + entry.id
+ "' at " + stagedDir.getPath()));
continue;
}
try {
InstallResult result = DatapackInstall.install(stagedDir, worldFolders, entry, stripOverrides);
if (result.changed()) {
repaired = true;
IrisLogging.warn("Repaired installed datapack '" + entry.id
+ "' from Iris staging before datapack compilation.");
}
recordInstallMetadata(stagedDir, worldFolders, entry);
} catch (IOException e) {
forgetInstallMetadata(entry);
failure = DatapackSupport.appendFailure(failure, e);
}
}
DatapackManifestStore.writeManifest(root, manifest);
return failure == null
? ReapplyOutcome.success(false, repaired)
: ReapplyOutcome.failed(failure);
}
static ReapplyOutcome reportReapplyFailure(ReapplyOutcome outcome) {
if (!outcome.succeeded()) {
IrisLogging.reportError(
"External datapack recovery or staging repair failed.",
outcome.failure().orElseThrow());
}
return outcome;
}
static boolean isRecordedUnchangedInstall(
File stagedDir,
KList<File> worldFolders,
Entry entry,
boolean stripOverrides
) {
if (entry.stagingMetadata == null || entry.stagingMetadata.isBlank()
|| entry.installMetadata == null || entry.installMetadata.size() != worldFolders.size()) {
return false;
}
try {
if (!isRecordedManagedDirectory(stagedDir, entry)
|| !entry.stagingMetadata.equals(metadataDigest(stagedDir))) {
return false;
}
for (File worldFolder : worldFolders) {
File target = new File(worldFolder, entry.id);
if (!isRecordedManagedDirectory(target, entry)
|| new File(target, DatapackPackMetadata.OVERRIDES_STRIPPED_MARKER).isFile() != stripOverrides) {
return false;
}
String recorded = entry.installMetadata.get(installMetadataKey(target));
if (recorded == null || !recorded.equals(metadataDigest(target))) {
return false;
}
}
return true;
} catch (IOException e) {
IrisLogging.reportError("Managed datapack '" + entry.id
+ "' requires full verification", e);
return false;
}
}
static boolean isRecordedManagedDirectory(File directory, Entry entry) throws IOException {
Path path = directory.toPath();
if (!Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(path)) {
return false;
}
if (!new File(directory, "pack.mcmeta").isFile()) {
return false;
}
Ownership ownership = DatapackOwnership.readOwnershipOrNull(directory);
return ownership != null
&& DatapackInstall.ownershipSourceMatches(ownership, entry)
&& Objects.equals(ownership.versionId, entry.versionId)
&& Objects.equals(ownership.versionNumber, entry.versionNumber)
&& Objects.equals(ownership.sha1, entry.sha1);
}
static void recordInstallMetadata(File stagedDir, KList<File> worldFolders, Entry entry) {
try {
Map<String, String> recorded = new HashMap<>();
for (File worldFolder : worldFolders) {
File target = new File(worldFolder, entry.id);
recorded.put(installMetadataKey(target), metadataDigest(target));
}
entry.stagingMetadata = metadataDigest(stagedDir);
entry.installMetadata = recorded;
} catch (IOException e) {
IrisLogging.reportError("Unable to record managed datapack metadata for '" + entry.id + "'", e);
forgetInstallMetadata(entry);
}
}
static void forgetInstallMetadata(Entry entry) {
entry.stagingMetadata = "";
entry.installMetadata = new HashMap<>();
}
static String installMetadataKey(File target) {
return target.toPath().toAbsolutePath().normalize().toString();
}
static String metadataDigest(File root) throws IOException {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
Path rootPath = root.toPath().toAbsolutePath().normalize();
List<MetadataEntry> entries = new ArrayList<>();
try (Stream<Path> paths = Files.walk(rootPath)) {
Iterator<Path> iterator = paths.iterator();
int pathCount = 0;
while (iterator.hasNext()) {
Path path = iterator.next();
if (path.equals(rootPath) || DatapackOwnership.isFinderMetadata(path)) {
continue;
}
pathCount++;
if (pathCount > DatapackOwnership.MAX_MANAGED_PATHS) {
throw new IOException("Datapack contains more than " + DatapackOwnership.MAX_MANAGED_PATHS + " paths");
}
if (Files.isSymbolicLink(path)) {
throw new IOException("Datapack contains a symbolic link: " + path);
}
String relativePath = rootPath.relativize(path).toString();
entries.add(new MetadataEntry(path, relativePath));
}
}
entries.sort(Comparator.comparing(MetadataEntry::relativePath));
for (MetadataEntry entry : entries) {
String relative = entry.relativePath().replace(File.separatorChar, '/');
byte[] relativeBytes = relative.getBytes(StandardCharsets.UTF_8);
BasicFileAttributes attributes = Files.readAttributes(
entry.path(), BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
if (!attributes.isDirectory() && !attributes.isRegularFile()) {
throw new IOException("Datapack contains an unsupported filesystem entry: " + entry.path());
}
digest.update((byte) (attributes.isDirectory() ? 1 : 2));
DatapackSupport.updateDigestInt(digest, relativeBytes.length);
digest.update(relativeBytes);
if (!attributes.isDirectory()) {
DatapackSupport.updateDigestLong(digest, attributes.size());
DatapackSupport.updateDigestLong(digest, attributes.lastModifiedTime().toMillis());
}
}
return DatapackSupport.hex(digest.digest());
} catch (NoSuchAlgorithmException e) {
throw new IOException("SHA-256 algorithm unavailable", e);
}
}
static boolean recoverBeforeReapply(File root, List<File> worldFolders) {
return reportReapplyFailure(recoverBeforeReapplyOutcome(root, worldFolders)).succeeded();
}
static ReapplyOutcome recoverBeforeReapplyOutcome(File root, List<File> worldFolders) {
try {
return ReapplyOutcome.success(DatapackScratchRecovery.recoverTransactions(root, worldFolders), false);
} catch (IOException e) {
return ReapplyOutcome.failed(e);
}
}
}
@@ -0,0 +1,385 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.core.IrisStartupValidation;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.volmlib.util.collection.KList;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
final class DatapackStartupValidation {
static final String STARTUP_VALIDATION_CACHE = "startup-validation.json";
static final int STARTUP_VALIDATION_SCHEMA = 1;
static volatile StartupValidationCache activeStartupValidation;
private DatapackStartupValidation() {
}
static StartupValidationCache cacheStartupValidation(
File root,
KList<File> worldFolders,
Path cacheFile,
String mcVersion,
int irisVersion,
boolean autoIngest,
boolean stripOverrides,
List<String> urls
) {
try {
StartupValidationCache cache = createStartupValidationCache(
mcVersion,
irisVersion,
autoIngest,
stripOverrides,
urls,
startupValidationFingerprint(root, worldFolders, urls));
writeStartupValidationCache(cacheFile, cache);
return cache;
} catch (IOException | RuntimeException exception) {
IrisLogging.warn("Could not persist external datapack startup validation: "
+ DatapackSupport.failureMessage(exception));
return null;
}
}
static void refreshStartupValidationAfterMaintenance(boolean maintenanceChanged) {
if (!maintenanceChanged) {
return;
}
StartupValidationCache validated = activeStartupValidation;
if (validated == null || !IrisStartupValidation.isReady()) {
return;
}
KList<String> configured = DatapackIngestService.collectConfiguredImports();
List<String> urls = configured.stream().sorted().toList();
boolean autoIngest = IrisSettings.get().getGeneral().autoIngestDatapacks;
boolean stripOverrides = DatapackPackMetadata.resolveStripOverrides();
String mcVersion = DatapackIngestService.serverMcVersion();
int irisVersion = IrisPlatforms.get().irisVersionNumber();
if (!startupValidationContextMatches(
validated, mcVersion, irisVersion, autoIngest, stripOverrides, urls)) {
return;
}
File root = IrisPlatforms.get().dataFolder("datapacks");
KList<File> worldFolders = ServerConfigurator.getDatapacksFolder();
Path cacheFile = new File(root, STARTUP_VALIDATION_CACHE).toPath();
DatapackTransactions.TRANSACTION_LOCK.lock();
try {
DatapackScratchRecovery.recoverTransactions(root, worldFolders);
StartupValidationCache refreshed = refreshStartupValidationCache(
validated, root, worldFolders);
writeStartupValidationCache(cacheFile, refreshed);
activeStartupValidation = refreshed;
} catch (IOException | RuntimeException exception) {
IrisLogging.warn("Could not refresh external datapack validation after startup maintenance: "
+ DatapackSupport.failureMessage(exception));
} finally {
DatapackTransactions.TRANSACTION_LOCK.unlock();
}
}
static StartupValidationCache refreshStartupValidationCache(
StartupValidationCache validated,
File root,
KList<File> worldFolders
) throws IOException {
Objects.requireNonNull(validated, "Validated external datapack startup state");
return createStartupValidationCache(
validated.minecraftVersion,
validated.irisVersion,
validated.autoIngest,
validated.stripOverrides,
validated.urls,
startupValidationFingerprint(root, worldFolders, validated.urls));
}
static StartupValidationCache createStartupValidationCache(
String mcVersion,
int irisVersion,
boolean autoIngest,
boolean stripOverrides,
List<String> urls,
String localFingerprint
) {
StartupValidationCache cache = new StartupValidationCache();
cache.schemaVersion = STARTUP_VALIDATION_SCHEMA;
cache.minecraftVersion = Objects.requireNonNullElse(mcVersion, "");
cache.irisVersion = irisVersion;
cache.autoIngest = autoIngest;
cache.stripOverrides = stripOverrides;
cache.urls = List.copyOf(urls);
cache.localFingerprint = localFingerprint;
return cache;
}
static boolean startupValidationContextMatches(
StartupValidationCache cache,
String mcVersion,
int irisVersion,
boolean autoIngest,
boolean stripOverrides,
List<String> urls
) {
return cache != null
&& cache.schemaVersion == STARTUP_VALIDATION_SCHEMA
&& Objects.equals(cache.minecraftVersion, Objects.requireNonNullElse(mcVersion, ""))
&& cache.irisVersion == irisVersion
&& cache.autoIngest == autoIngest
&& cache.stripOverrides == stripOverrides
&& Objects.equals(cache.urls, urls);
}
static boolean startupValidationCacheMatches(
StartupValidationCache cache,
String mcVersion,
int irisVersion,
boolean autoIngest,
boolean stripOverrides,
List<String> urls,
String localFingerprint
) {
return startupValidationContextMatches(
cache, mcVersion, irisVersion, autoIngest, stripOverrides, urls)
&& localFingerprint != null && !localFingerprint.isBlank()
&& Objects.equals(cache.localFingerprint, localFingerprint);
}
static StartupValidationCache readStartupValidationCache(Path cacheFile) {
if (cacheFile == null
|| Files.isSymbolicLink(cacheFile)
|| !Files.isRegularFile(cacheFile, LinkOption.NOFOLLOW_LINKS)) {
return null;
}
try {
if (Files.size(cacheFile) > DatapackPackMetadata.MAX_METADATA_BYTES) {
return null;
}
StartupValidationCache cache = DatapackSupport.GSON.fromJson(
DatapackSupport.readBoundedUtf8(cacheFile, DatapackPackMetadata.MAX_METADATA_BYTES, "External datapack startup validation"),
StartupValidationCache.class);
if (cache == null || cache.urls == null) {
return null;
}
List<String> sortedUrls = cache.urls.stream()
.filter(Objects::nonNull)
.sorted()
.toList();
if (sortedUrls.size() != cache.urls.size()
|| new HashSet<>(sortedUrls).size() != sortedUrls.size()
|| !sortedUrls.equals(cache.urls)) {
return null;
}
cache.urls = sortedUrls;
return cache;
} catch (IOException | RuntimeException exception) {
return null;
}
}
static void writeStartupValidationCache(Path cacheFile, StartupValidationCache cache) throws IOException {
Path absolute = cacheFile.toAbsolutePath().normalize();
Path parent = Objects.requireNonNull(absolute.getParent(), "External datapack startup validation parent");
Files.createDirectories(parent);
Path staged = Files.createTempFile(parent, ".startup-validation-", ".tmp");
try {
byte[] content = DatapackSupport.GSON.toJson(cache).getBytes(StandardCharsets.UTF_8);
if (content.length > DatapackPackMetadata.MAX_METADATA_BYTES) {
throw new IOException("External datapack startup validation exceeds " + DatapackPackMetadata.MAX_METADATA_BYTES + " bytes");
}
Files.write(staged, content, StandardOpenOption.TRUNCATE_EXISTING);
DatapackSupport.forceFile(staged);
try {
Files.move(staged, absolute, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(staged, absolute, StandardCopyOption.REPLACE_EXISTING);
}
DatapackSupport.forceDirectoryIfSupported(parent);
} finally {
Files.deleteIfExists(staged);
}
}
static String startupValidationFingerprint(File root, KList<File> worldFolders) throws IOException {
return startupValidationFingerprint(root, worldFolders, List.of());
}
static String startupValidationFingerprint(
File root,
KList<File> worldFolders,
Iterable<String> sources
) throws IOException {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
Path manifestPath = new File(root, "manifest.json").toPath();
updateFingerprintValue(digest, "manifest");
if (Files.exists(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
if (Files.isSymbolicLink(manifestPath)
|| !Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid datapack manifest path " + manifestPath);
}
byte[] manifest = DatapackSupport.readBoundedBytes(
manifestPath, DatapackManifestStore.MAX_MANIFEST_BYTES, "Datapack manifest fingerprint");
DatapackSupport.updateDigestLong(digest, manifest.length);
digest.update(manifest);
} else if (Files.notExists(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
DatapackSupport.updateDigestLong(digest, -1L);
} else {
throw new IOException("Cannot determine datapack manifest state at " + manifestPath);
}
Manifest manifest = DatapackManifestStore.readCommittedManifest(root);
updateDirectoryFingerprint(digest, "staging", new File(root, "staging"));
updateDirectoryFingerprint(digest, "transactions", new File(root, DatapackTransactions.TRANSACTION_DIRECTORY));
updateDirectoryFingerprint(
digest,
"storage-install-scratch",
DatapackInstall.installScratchRoot(new File(root, "staging")));
List<Entry> entries = new ArrayList<>(manifest.entries);
entries.sort(Comparator.comparing(entry -> entry.id));
List<File> targets = new ArrayList<>(worldFolders == null ? List.of() : worldFolders);
targets.sort(Comparator.comparing(file -> file.toPath().toAbsolutePath().normalize().toString()));
for (File worldFolder : targets) {
String worldIdentity = worldFolder.toPath().toAbsolutePath().normalize().toString();
updateFingerprintValue(digest, "world:" + worldIdentity);
updateDirectoryFingerprint(
digest,
"world-install-scratch:" + worldIdentity,
DatapackInstall.installScratchRoot(worldFolder));
for (Entry entry : entries) {
updateDirectoryFingerprint(
digest,
"world-pack:" + worldIdentity + ":" + entry.id,
new File(worldFolder, entry.id));
}
}
updateLocalSourceFingerprint(digest, sources);
return DatapackSupport.hex(digest.digest());
} catch (NoSuchAlgorithmException exception) {
throw new IOException("SHA-256 algorithm unavailable", exception);
}
}
static void updateLocalSourceFingerprint(
MessageDigest digest,
Iterable<String> sources
) throws IOException {
if (sources == null) {
return;
}
List<String> localSources = new ArrayList<>();
for (String source : sources) {
URI uri = parseSourceUri(source);
if (uri != null && "file".equalsIgnoreCase(uri.getScheme())) {
localSources.add(uri.normalize().toASCIIString());
}
}
localSources.sort(String::compareTo);
byte[] buffer = new byte[DatapackOwnership.HASH_BUFFER_BYTES];
for (String source : localSources) {
Path path = DatapackArchive.requireLocalDatapackPath(parseSourceUri(source));
updateFingerprintValue(digest, "local-source:" + source);
try (InputStream input = Files.newInputStream(
path,
StandardOpenOption.READ,
LinkOption.NOFOLLOW_LINKS)) {
long bytes = 0;
int length;
while ((length = input.read(buffer)) > 0) {
bytes += length;
if (bytes > DatapackArchive.MAX_DOWNLOAD_BYTES) {
throw new IOException("Local datapack exceeds " + DatapackArchive.MAX_DOWNLOAD_BYTES + " bytes: " + path);
}
digest.update(buffer, 0, length);
}
DatapackSupport.updateDigestLong(digest, bytes);
}
}
}
static URI parseSourceUri(String source) throws IOException {
if (source == null || source.isBlank()) {
return null;
}
try {
return new URI(source.trim());
} catch (URISyntaxException exception) {
throw new IOException("Invalid datapack URL " + source, exception);
}
}
static void updateDirectoryFingerprint(
MessageDigest digest,
String identity,
File directory
) throws IOException {
updateFingerprintValue(digest, identity);
Path path = directory.toPath();
if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) {
updateFingerprintValue(digest, "missing");
return;
}
if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)
|| Files.isSymbolicLink(path)
|| !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid external datapack validation directory " + path);
}
updateFingerprintValue(digest, DatapackOwnership.directoryHash(directory));
Path ownership = new File(directory, DatapackOwnership.OWNERSHIP_MARKER).toPath();
if (Files.isRegularFile(ownership, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(ownership)) {
byte[] marker = DatapackSupport.readBoundedBytes(
ownership, DatapackOwnership.MAX_OWNERSHIP_BYTES, "External datapack ownership fingerprint");
DatapackSupport.updateDigestLong(digest, marker.length);
digest.update(marker);
} else {
DatapackSupport.updateDigestLong(digest, -1L);
}
}
static void updateFingerprintValue(MessageDigest digest, String value) {
byte[] bytes = Objects.requireNonNullElse(value, "").getBytes(StandardCharsets.UTF_8);
DatapackSupport.updateDigestInt(digest, bytes.length);
digest.update(bytes);
}
}
@@ -0,0 +1,489 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.structure.BulkStructureImporter;
import art.arcane.iris.core.structure.StructureImporter;
import art.arcane.iris.core.structure.authoring.StructureKey;
import art.arcane.iris.core.structure.authoring.StructureSource;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import java.io.File;
import java.io.IOException;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.TreeMap;
import java.util.stream.Stream;
final class DatapackStructureImports {
static final int STRUCTURE_IMPORT_FORMAT_REVISION = 3;
private DatapackStructureImports() {
}
static boolean autoImportDatapackStructures() {
DatapackTransactions.TRANSACTION_LOCK.lock();
try {
return autoImportDatapackStructuresLocked();
} finally {
DatapackTransactions.TRANSACTION_LOCK.unlock();
}
}
static boolean autoImportDatapackStructuresLocked() {
boolean autoImportEnabled = IrisSettings.get().getGeneral().autoImportDatapackStructures;
File root = IrisPlatforms.get().dataFolder("datapacks");
boolean recovered;
try {
recovered = DatapackScratchRecovery.recoverTransactions(root, ServerConfigurator.getDatapacksFolder());
} catch (IOException e) {
IrisLogging.reportError("Automatic datapack structure import blocked by incomplete transaction recovery.", e);
return false;
}
Manifest manifest = DatapackManifestStore.readManifest(root);
if (manifest.entries.isEmpty()) {
return recovered;
}
Map<String, Entry> manifestEntriesByUrl = new HashMap<>();
for (Entry entry : manifest.entries) {
if (entry.url != null) {
manifestEntriesByUrl.put(entry.url, entry);
}
}
List<IrisData> packs;
try (Stream<IrisData> stream = ServerConfigurator.allPacks()) {
packs = stream.filter(Objects::nonNull).toList();
}
if (!autoImportEnabled && !hasRemovedImportState(packs, manifest.entries)) {
return recovered;
}
Map<String, Entry> entriesByUrl = new HashMap<>();
File stagingRoot = new File(root, "staging");
boolean stagingStateChanged = false;
boolean manifestStagingMetadataChanged = false;
for (Entry entry : manifest.entries) {
if (entry.url == null) {
continue;
}
StagingInspection inspection = DatapackStagingGuard.inspectUsableStaging(new File(stagingRoot, entry.id), entry);
stagingStateChanged |= inspection.ownershipCorrected();
manifestStagingMetadataChanged |= inspection.manifestChanged();
if (inspection.usable()) {
entriesByUrl.put(entry.url, entry);
}
}
int attemptedPacks = 0;
int completedPacks = 0;
int cleanupTargets = 0;
Set<String> completedUrls = new HashSet<>();
Set<String> failedUrls = new HashSet<>();
for (IrisData data : packs) {
Set<String> configured = DatapackImportSources.configuredImports(data);
String targetId = data.getDataFolder().toPath().toAbsolutePath().normalize().toString();
for (Entry entry : manifest.entries) {
if (!configured.contains(entry.url) && hasImportState(entry, targetId)) {
cleanupTargets++;
}
}
if (!cleanupRemovedImports(data, targetId, configured, manifest.entries, manifestEntriesByUrl)) {
for (Entry entry : manifest.entries) {
if (!configured.contains(entry.url) && hasImportState(entry, targetId)) {
failedUrls.add(entry.url);
}
}
}
if (!autoImportEnabled) {
continue;
}
Set<String> pendingUrls = new HashSet<>();
for (String url : configured) {
Entry entry = entriesByUrl.get(url);
if (entry != null && importPending(entry, targetId)) {
pendingUrls.add(url);
}
}
if (pendingUrls.isEmpty()) {
continue;
}
for (String pendingUrl : pendingUrls) {
Entry entry = entriesByUrl.get(pendingUrl);
prepareImportRecoveryInventory(entry, targetId);
}
try {
DatapackManifestStore.writeManifestChecked(root, manifest);
} catch (IOException e) {
failedUrls.addAll(pendingUrls);
IrisLogging.reportError("Datapack structure import for pack '"
+ data.getDataFolder().getPath()
+ "' was blocked because its recovery inventory could not be persisted.", e);
continue;
}
Set<String> structureKeys = new TreeSet<>();
Set<String> templateKeys = new TreeSet<>();
for (Entry entry : manifest.entries) {
if (!configured.contains(entry.url)) {
continue;
}
structureKeys.addAll(DatapackManifestStore.copyList(entry.structureKeys));
templateKeys.addAll(DatapackManifestStore.copyList(entry.templateKeys));
}
attemptedPacks++;
try {
BulkStructureImporter.Report report = BulkStructureImporter.importManagedDatapackStructures(
data,
StructureImporter.Mode.OVERWRITE,
BukkitPlatform.console(),
structureKeys,
templateKeys
);
Set<String> successfulPendingUrls = new HashSet<>(pendingUrls);
Set<String> incompleteUrls = new HashSet<>();
for (String pendingUrl : pendingUrls) {
Entry entry = entriesByUrl.get(pendingUrl);
Map<String, String> desired = importBundleInventory(entry);
if (!report.successfulBundles().entrySet().containsAll(desired.entrySet())) {
successfulPendingUrls.remove(pendingUrl);
incompleteUrls.add(pendingUrl);
failedUrls.add(pendingUrl);
}
}
if (report.failed() > 0 && incompleteUrls.isEmpty()) {
successfulPendingUrls.clear();
incompleteUrls.addAll(pendingUrls);
failedUrls.addAll(pendingUrls);
}
if (!incompleteUrls.isEmpty()) {
boolean reconciled = reconcileFailedImportInventories(
root, manifest, data, targetId, incompleteUrls, entriesByUrl);
if (report.retryRequired() || !reconciled) {
IrisLogging.error("Datapack structure import for pack '%s' reported %d incomplete source(s) and remains pending because a retryable runtime failure occurred.",
data.getDataFolder().getPath(), incompleteUrls.size());
} else if (recordDeterministicImportAttempts(
root, manifest, targetId, incompleteUrls, entriesByUrl)) {
IrisLogging.warn("Datapack structure import for pack '"
+ data.getDataFolder().getPath() + "' left " + incompleteUrls.size()
+ " source(s) incomplete after deterministic validation failures. Iris will retain the partial editable imports without retrying until the datapack source, importer format, or target pack changes.");
}
}
Map<String, String> sharedBundles = desiredBundles(configured, entriesByUrl);
boolean packCompleted = false;
for (String pendingUrl : successfulPendingUrls) {
Entry entry = entriesByUrl.get(pendingUrl);
Map<String, String> desired = importBundleInventory(entry);
Map<String, String> previous = entry.importedBundles.getOrDefault(targetId, Map.of());
Map<String, String> stale = new TreeMap<>(previous);
stale.keySet().removeAll(desired.keySet());
stale.keySet().removeAll(sharedBundles.keySet());
Map<String, String> remaining = cleanupImportedBundles(data, stale);
if (!remaining.isEmpty()) {
failedUrls.add(pendingUrl);
continue;
}
entry.importedBundles.put(targetId, desired);
recordSuccessfulImport(entry, targetId);
completedUrls.add(pendingUrl);
packCompleted = true;
}
if (packCompleted) {
completedPacks++;
}
} catch (RuntimeException e) {
failedUrls.addAll(pendingUrls);
IrisLogging.reportError("Datapack structure import failed for pack '"
+ data.getDataFolder().getPath() + "'; the manifest remains pending for retry.", e);
reconcileFailedImportInventories(root, manifest, data, targetId, pendingUrls, entriesByUrl);
}
}
for (Entry entry : manifest.entries) {
if (failedUrls.contains(entry.url)) {
entry.structuresImported = false;
} else if (completedUrls.contains(entry.url)) {
entry.structuresImported = true;
}
}
if (attemptedPacks == 0 && cleanupTargets == 0) {
if (manifestStagingMetadataChanged) {
DatapackManifestStore.writeManifest(root, manifest);
}
return recovered || stagingStateChanged || manifestStagingMetadataChanged;
}
DatapackManifestStore.writeManifest(root, manifest);
if (attemptedPacks == 0) {
IrisLogging.info("Datapack editable-import cleanup reconciled " + cleanupTargets + " removed source target(s).");
return true;
}
IrisLogging.info("Datapack structure import refreshed " + completedUrls.size() + " source(s) across "
+ completedPacks + "/" + attemptedPacks
+ " pack(s). Reference the imported keys from a 'structures' placement to position them manually.");
return true;
}
static boolean hasRemovedImportState(List<IrisData> packs, List<Entry> entries) {
for (IrisData data : packs) {
Set<String> configured = DatapackImportSources.configuredImports(data);
String targetId = data.getDataFolder().toPath().toAbsolutePath().normalize().toString();
for (Entry entry : entries) {
if (!configured.contains(entry.url) && hasImportState(entry, targetId)) {
return true;
}
}
}
return false;
}
static String importRevision(Entry entry) {
return importRevision(entry, STRUCTURE_IMPORT_FORMAT_REVISION);
}
static String importRevision(Entry entry, int importerFormatRevision) {
return "v" + importerFormatRevision + ":" + DatapackSupport.safe(entry.versionId) + ":" + DatapackSupport.safe(entry.sha1);
}
static boolean importPending(Entry entry, String targetId) {
String revision = importRevision(entry);
return !revision.equals(entry.importedTargets.get(targetId))
&& !revision.equals(entry.importAttempts.get(targetId));
}
static void recordDeterministicImportAttempt(Entry entry, String targetId) {
entry.importedTargets.remove(targetId);
entry.importAttempts.put(targetId, importRevision(entry));
entry.structuresImported = false;
}
static void recordSuccessfulImport(Entry entry, String targetId) {
entry.importedTargets.put(targetId, importRevision(entry));
entry.importAttempts.remove(targetId);
}
static void prepareImportRecoveryInventory(Entry entry, String targetId) {
Map<String, String> desired = importBundleInventory(entry);
Map<String, String> recovery = new TreeMap<>(desired);
recovery.putAll(entry.importedBundles.getOrDefault(targetId, Map.of()));
entry.importedBundles.put(targetId, recovery);
entry.importedTargets.remove(targetId);
entry.importAttempts.remove(targetId);
entry.structuresImported = false;
}
static boolean reconcileFailedImportInventories(
File root,
Manifest manifest,
IrisData data,
String targetId,
Set<String> pendingUrls,
Map<String, Entry> entriesByUrl
) {
boolean reconciled = true;
for (String pendingUrl : pendingUrls) {
Entry entry = entriesByUrl.get(pendingUrl);
try {
reconcileFailedImportInventory(data, entry, targetId);
} catch (IOException | RuntimeException e) {
reconciled = false;
IrisLogging.reportError("Could not reconcile partial editable structure imports for '"
+ pendingUrl + "' in pack '" + data.getDataFolder().getPath()
+ "'; the conservative recovery inventory remains pending.", e);
}
}
try {
DatapackManifestStore.writeManifestChecked(root, manifest);
} catch (IOException e) {
reconciled = false;
IrisLogging.reportError("Could not persist reconciled partial editable structure imports for pack '"
+ data.getDataFolder().getPath() + "'; the earlier recovery inventory remains durable.", e);
}
return reconciled;
}
static boolean recordDeterministicImportAttempts(
File root,
Manifest manifest,
String targetId,
Set<String> incompleteUrls,
Map<String, Entry> entriesByUrl
) {
for (String incompleteUrl : incompleteUrls) {
recordDeterministicImportAttempt(entriesByUrl.get(incompleteUrl), targetId);
}
try {
DatapackManifestStore.writeManifestChecked(root, manifest);
return true;
} catch (IOException e) {
for (String incompleteUrl : incompleteUrls) {
entriesByUrl.get(incompleteUrl).importAttempts.remove(targetId);
}
IrisLogging.reportError("Could not persist deterministic editable structure import attempts; the sources remain pending for retry.", e);
return false;
}
}
static void reconcileFailedImportInventory(
IrisData data,
Entry entry,
String targetId
) throws IOException {
Map<String, Set<String>> claims = DatapackRemoval.importBundleClaims(entry, targetId);
Map<String, String> reconciled = new TreeMap<>();
StructureTransactionWriter writer = new StructureTransactionWriter(data.getDataFolder().toPath());
for (Map.Entry<String, Set<String>> bundle : claims.entrySet()) {
StructureKey targetKey;
try {
targetKey = StructureKey.parse(bundle.getKey());
} catch (RuntimeException e) {
throw new IOException("Invalid editable structure target key '" + bundle.getKey() + "'", e);
}
Optional<StructureSource> source = writer.ownedSource(targetKey);
if (source.isPresent() && DatapackRemoval.sourceClaimsContain(bundle.getValue(), source.get())) {
reconciled.put(bundle.getKey(), source.get().key().value());
}
}
if (reconciled.isEmpty()) {
entry.importedBundles.remove(targetId);
} else {
entry.importedBundles.put(targetId, reconciled);
}
entry.importedTargets.remove(targetId);
entry.importAttempts.remove(targetId);
entry.structuresImported = false;
}
static boolean cleanupRemovedImports(
IrisData data,
String targetId,
Set<String> configured,
List<Entry> entries,
Map<String, Entry> entriesByUrl
) {
boolean successful = true;
Map<String, String> retainedBundles = desiredBundles(configured, entriesByUrl);
for (Entry entry : entries) {
if (configured.contains(entry.url)) {
continue;
}
Map<String, String> inventory = entry.importedBundles.get(targetId);
if (inventory == null && !hasImportState(entry, targetId)) {
continue;
}
Map<String, String> resolvedInventory = inventory == null ? Map.of() : inventory;
for (Entry retainedEntry : entries) {
if (configured.contains(retainedEntry.url)) {
retainedEntry.importedTargets.remove(targetId);
retainedEntry.importAttempts.remove(targetId);
retainedEntry.structuresImported = false;
}
}
Map<String, String> removable = new TreeMap<>(resolvedInventory);
removable.keySet().removeAll(retainedBundles.keySet());
Map<String, String> remaining = cleanupImportedBundles(data, removable);
if (!remaining.isEmpty()) {
Map<String, String> retained = new TreeMap<>();
for (Map.Entry<String, String> bundle : resolvedInventory.entrySet()) {
if (retainedBundles.containsKey(bundle.getKey()) || remaining.containsKey(bundle.getKey())) {
retained.put(bundle.getKey(), bundle.getValue());
}
}
entry.importedBundles.put(targetId, retained);
entry.importedTargets.remove(targetId);
entry.importAttempts.remove(targetId);
entry.structuresImported = false;
successful = false;
continue;
}
entry.importedBundles.remove(targetId);
entry.importedTargets.remove(targetId);
entry.importAttempts.remove(targetId);
}
return successful;
}
static boolean hasImportState(Entry entry, String targetId) {
return entry.importedBundles.containsKey(targetId)
|| entry.importedTargets.containsKey(targetId)
|| entry.importAttempts.containsKey(targetId);
}
static Map<String, String> cleanupImportedBundles(IrisData data, Map<String, String> inventory) {
Map<String, String> remaining = new TreeMap<>();
StructureTransactionWriter writer = new StructureTransactionWriter(data.getDataFolder().toPath());
boolean removed = false;
for (Map.Entry<String, String> bundle : inventory.entrySet()) {
StructureKey sourceKey;
try {
sourceKey = StructureKey.parse(bundle.getValue());
StructureSource.Kind sourceKind = sourceKey.namespace().equals("minecraft")
? StructureSource.Kind.VANILLA : StructureSource.Kind.DATAPACK;
removed |= writer.removeManagedDatapackOwned(
StructureKey.parse(bundle.getKey()),
sourceKind,
sourceKey
);
} catch (IOException | RuntimeException e) {
remaining.put(bundle.getKey(), bundle.getValue());
IrisLogging.reportError("Preserving imported structure bundle '" + bundle.getKey()
+ "' in pack '" + data.getDataFolder().getPath()
+ "' because ownership-safe cleanup failed.", e);
}
}
if (removed) {
data.invalidateStructureResources();
}
return remaining;
}
static Map<String, String> desiredBundles(Set<String> configured, Map<String, Entry> entriesByUrl) {
Map<String, String> bundles = new TreeMap<>();
for (String url : configured) {
Entry entry = entriesByUrl.get(url);
if (entry != null) {
bundles.putAll(importBundleInventory(entry));
}
}
return bundles;
}
static Map<String, String> importBundleInventory(Entry entry) {
Map<String, String> bundles = new TreeMap<>();
for (String structureKey : DatapackManifestStore.copyList(entry.structureKeys)) {
bundles.put("iris:" + StructureImporter.deriveName(structureKey), structureKey);
}
for (String templateKey : DatapackManifestStore.copyList(entry.templateKeys)) {
bundles.put("iris:" + BulkStructureImporter.templateNameFor(templateKey), templateKey);
}
return bundles;
}
}
@@ -0,0 +1,265 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.io.Durability;
import art.arcane.iris.util.common.plugin.VolmitSender;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.MessageDigest;
final class DatapackSupport {
static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private DatapackSupport() {
}
static String failureMessage(Throwable exception) {
if (exception == null) {
return "unknown failure";
}
String message = exception.getMessage();
return message == null || message.isBlank() ? exception.getClass().getSimpleName() : message;
}
static IOException appendFailure(IOException current, IOException additional) {
if (current == null) {
return additional;
}
current.addSuppressed(additional);
return current;
}
static IOException appendIOException(IOException current, Throwable failure) {
IOException next = failure instanceof IOException ioFailure
? ioFailure : new IOException("Datapack transaction participant failed", failure);
if (current == null) {
return next;
}
current.addSuppressed(next);
return current;
}
static Path requireDirectoryIdentity(File directory, String purpose) throws IOException {
Path normalized = directory.toPath().toAbsolutePath().normalize();
BasicFileAttributes attributes = Files.readAttributes(
normalized, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
if (!DatapackScratchRecovery.isSupportedScratchDirectory(attributes)) {
throw new IOException("Invalid " + purpose + " " + normalized);
}
normalized.toRealPath();
return normalized;
}
static void requireNoSymbolicLinkComponents(Path normalized, String purpose) throws IOException {
Path current = normalized.getRoot();
for (Path component : normalized) {
current = current == null ? component : current.resolve(component);
BasicFileAttributes attributes = Files.readAttributes(
current, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
if (attributes.isSymbolicLink()) {
throw new IOException("Refusing symbolic-link component in " + purpose + " " + normalized);
}
if (attributes.isOther()) {
throw new IOException("Refusing special filesystem component in " + purpose + " " + normalized);
}
if (!DatapackScratchRecovery.isSupportedScratchDirectory(attributes)) {
throw new IOException("Refusing unsupported component in " + purpose + " " + normalized);
}
}
}
static String directoryIdentity(File directory) throws IOException {
BasicFileAttributes attributes = Files.readAttributes(
directory.toPath(), BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
if (!attributes.isDirectory() || attributes.isSymbolicLink()) {
throw new IOException("Invalid datapack directory identity at " + directory.getPath());
}
Object fileKey = attributes.fileKey();
if (fileKey != null) {
return "key:" + fileKey;
}
// Windows exposes no inode-equivalent for a directory, and returning nothing here fails
// the identity gate closed, which disables legacy staging replacement on that platform
// outright. Creation time is the one stable discriminator the filesystem still offers: a
// directory swapped in at the same path brings its own, so a swap is still caught. It is
// a weaker guarantee than an inode, so it is only ever the fallback.
return "created:" + attributes.creationTime();
}
static boolean pathExists(Path path, String purpose) throws IOException {
if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
return true;
}
if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) {
return false;
}
throw new IOException("Cannot determine " + purpose + " state at " + path);
}
static String safe(String value) {
return value == null || value.isBlank() ? "?" : value;
}
static String safeFile(String value) {
return value == null || value.isBlank() ? "unknown" : value.replaceAll("[^a-zA-Z0-9._-]", "_");
}
static String hex(byte[] hash) {
StringBuilder builder = new StringBuilder(hash.length * 2);
for (byte value : hash) {
builder.append(String.format("%02x", value));
}
return builder.toString();
}
static void updateDigestInt(MessageDigest digest, int value) {
digest.update((byte) (value >>> 24));
digest.update((byte) (value >>> 16));
digest.update((byte) (value >>> 8));
digest.update((byte) value);
}
static void updateDigestLong(MessageDigest digest, long value) {
digest.update((byte) (value >>> 56));
digest.update((byte) (value >>> 48));
digest.update((byte) (value >>> 40));
digest.update((byte) (value >>> 32));
digest.update((byte) (value >>> 24));
digest.update((byte) (value >>> 16));
digest.update((byte) (value >>> 8));
digest.update((byte) value);
}
static void message(VolmitSender sender, String text) {
if (sender != null) {
sender.sendMessage(text);
return;
}
IrisLogging.info(text);
}
static void move(Path source, Path target) throws IOException {
try {
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
}
}
static void moveNew(Path source, Path target) throws IOException {
if (pathExists(target, "move target")) {
throw new IOException("Refusing to replace concurrently-created path " + target);
}
Files.move(source, target);
}
static void ensureScratchDirectory(File directory, String purpose) throws IOException {
Path path = directory.toPath();
if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
if (Files.isSymbolicLink(path) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing invalid " + purpose + " directory " + directory.getPath());
}
return;
}
Files.createDirectories(path);
if (Files.isSymbolicLink(path) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Could not create a safe " + purpose + " directory " + directory.getPath());
}
}
static void verifyDirectoryContainerIfPresent(File directory, String purpose) throws IOException {
Path path = directory.toPath();
if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
return;
}
if (Files.isSymbolicLink(path) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing invalid " + purpose + " directory " + directory.getPath());
}
}
static String normalizedPath(File file) {
return file.toPath().toAbsolutePath().normalize().toString();
}
static String realDirectoryPath(File directory, String purpose) throws IOException {
Path path = directory.toPath().toAbsolutePath().normalize();
if (Files.isSymbolicLink(path) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid " + purpose + " " + path);
}
return path.toRealPath().toString();
}
static String readBoundedUtf8(Path path, long maxBytes, String purpose) throws IOException {
return new String(readBoundedBytes(path, maxBytes, purpose), StandardCharsets.UTF_8);
}
static byte[] readBoundedBytes(Path path, long maxBytes, String purpose) throws IOException {
int readLimit = Math.toIntExact(maxBytes + 1);
byte[] content;
try (InputStream input = Files.newInputStream(
path,
StandardOpenOption.READ,
LinkOption.NOFOLLOW_LINKS
)) {
content = input.readNBytes(readLimit);
}
if (content.length > maxBytes) {
throw new IOException(purpose + " exceeds " + maxBytes + " bytes");
}
return content;
}
static void forceFile(Path file) throws IOException {
if (!Durability.enabled()) {
return;
}
try (FileChannel channel = FileChannel.open(file, StandardOpenOption.WRITE)) {
Durability.force(channel);
}
}
static void forceDirectoryIfSupported(Path directory) throws IOException {
if (!Durability.enabled()) {
return;
}
if (!Files.getFileStore(directory).supportsFileAttributeView("posix")) {
return;
}
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
Durability.force(channel);
}
}
}
@@ -0,0 +1,342 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Stream;
final class DatapackTransactions {
static final String TRANSACTION_DIRECTORY = ".iris-datapack-transactions";
static final String TRANSACTION_JOURNAL = "journal.json";
static final String TRANSACTION_JOURNAL_NEXT = "journal.next.json";
static final int TRANSACTION_SCHEMA = 2;
static final long MAX_TRANSACTION_JOURNAL_BYTES = 4L * 1024L * 1024L;
static final int MAX_TRANSACTION_COUNT = 1_024;
static final ReentrantLock TRANSACTION_LOCK = new ReentrantLock();
private DatapackTransactions() {
}
static DatapackCoordinator createInstallCoordinator(
File root,
Entry entry,
List<InstallPlan> plans,
boolean manifestAlreadyMatched
) throws IOException {
CoordinatorJournal journal = newCoordinatorJournal(
CoordinatorOperation.INSTALL,
entry,
manifestAlreadyMatched
);
for (InstallPlan plan : plans) {
if (!plan.publishRequired()) {
continue;
}
CoordinatorDirectory directory = new CoordinatorDirectory();
directory.target = DatapackSupport.normalizedPath(plan.target());
directory.pending = DatapackSupport.normalizedPath(plan.pending());
directory.backup = DatapackSupport.normalizedPath(plan.backup());
directory.hadTarget = plan.hadTarget();
directory.originalHash = plan.originalHash();
directory.desiredHash = plan.desiredHash();
directory.originalMarkerHash = plan.originalMarkerHash();
directory.desiredMarkerHash = plan.desiredMarkerHash();
directory.originalIdentity = plan.originalIdentity();
directory.desiredIdentity = plan.desiredIdentity();
directory.targetRoot = plan.targetRootIdentity();
directory.scratchRoot = plan.scratchRootIdentity();
directory.targetRootIdentity = plan.targetRootFileIdentity();
directory.scratchRootIdentity = plan.scratchRootFileIdentity();
journal.directories.add(directory);
}
return createCoordinator(root, journal);
}
static DatapackCoordinator createRemovalCoordinator(
File root,
Entry entry,
DirectoryRemoval directoryRemoval,
EditableImportRemoval editableRemoval
) throws IOException {
CoordinatorJournal journal = newCoordinatorJournal(CoordinatorOperation.REMOVE, entry, true);
for (DirectoryMove move : directoryRemoval.moves()) {
CoordinatorDirectory directory = new CoordinatorDirectory();
directory.target = DatapackSupport.normalizedPath(move.target());
directory.pending = "";
directory.backup = DatapackSupport.normalizedPath(move.backup());
directory.hadTarget = true;
directory.originalHash = move.originalHash();
directory.desiredHash = "";
directory.originalMarkerHash = move.originalMarkerHash();
directory.desiredMarkerHash = "";
directory.originalIdentity = move.originalIdentity();
directory.desiredIdentity = "";
directory.targetRoot = move.targetRootIdentity();
directory.scratchRoot = move.scratchRootIdentity();
directory.targetRootIdentity = move.targetRootFileIdentity();
directory.scratchRootIdentity = move.scratchRootFileIdentity();
journal.directories.add(directory);
}
for (StructureTransactionWriter.PreparedRemovalToken token : editableRemoval.recoveryTokens()) {
CoordinatorEditable editable = new CoordinatorEditable();
editable.packRoot = token.packRoot().toString();
editable.transactionId = token.transactionId().toString();
editable.claimId = UUID.randomUUID().toString();
journal.editables.add(editable);
}
Path transactionRoot = coordinatorTransactionRoot(root, journal);
try {
editableRemoval.claimRecoveryOwners(transactionRoot, journal);
writeCoordinatorJournal(transactionRoot, journal);
return new DatapackCoordinator(transactionRoot, journal);
} catch (IOException | RuntimeException creationFailure) {
try {
DatapackInstall.deleteInstallScratch(transactionRoot.toFile(), "incomplete datapack transaction");
} catch (IOException cleanupFailure) {
creationFailure.addSuppressed(cleanupFailure);
}
if (creationFailure instanceof IOException ioFailure) {
throw ioFailure;
}
throw creationFailure;
}
}
static CoordinatorJournal newCoordinatorJournal(
CoordinatorOperation operation,
Entry entry,
boolean manifestAlreadyMatched
) {
CoordinatorJournal journal = new CoordinatorJournal();
journal.schemaVersion = TRANSACTION_SCHEMA;
journal.transactionId = UUID.randomUUID().toString();
journal.operation = operation;
journal.phase = CoordinatorPhase.PREPARED;
journal.id = entry.id;
journal.url = entry.url;
journal.versionId = entry.versionId;
journal.versionNumber = entry.versionNumber;
journal.sha1 = entry.sha1;
journal.manifestAlreadyMatched = manifestAlreadyMatched;
return journal;
}
static DatapackCoordinator createCoordinator(File root, CoordinatorJournal journal) throws IOException {
Path transactionRoot = coordinatorTransactionRoot(root, journal);
writeCoordinatorJournal(transactionRoot, journal);
return new DatapackCoordinator(transactionRoot, journal);
}
static Path coordinatorTransactionRoot(File root, CoordinatorJournal journal) throws IOException {
File transactionDirectory = new File(root, TRANSACTION_DIRECTORY);
DatapackSupport.ensureScratchDirectory(transactionDirectory, "datapack transaction");
Path transactionRoot = new File(transactionDirectory, journal.transactionId).toPath().toAbsolutePath().normalize();
if (Files.exists(transactionRoot, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Datapack transaction id already exists: " + journal.transactionId);
}
return transactionRoot;
}
static CoordinatorJournal readCoordinatorJournal(Path transactionRoot) throws IOException {
Path committed = transactionRoot.resolve(TRANSACTION_JOURNAL);
Path next = transactionRoot.resolve(TRANSACTION_JOURNAL_NEXT);
if (Files.exists(committed, LinkOption.NOFOLLOW_LINKS)) {
return parseCoordinatorJournal(committed);
}
if (!Files.exists(next, LinkOption.NOFOLLOW_LINKS)) {
try (Stream<Path> entries = Files.list(transactionRoot)) {
if (entries.findAny().isEmpty()) {
return null;
}
}
throw new IOException("Missing datapack transaction journal " + committed);
}
try {
return parseCoordinatorJournal(next);
} catch (IOException firstWriteFailure) {
try (Stream<Path> entries = Files.list(transactionRoot)) {
List<Path> contents = entries.limit(2).toList();
if (contents.size() == 1 && Objects.equals(contents.getFirst(), next)
&& !Files.isSymbolicLink(next)) {
return null;
}
}
throw firstWriteFailure;
}
}
static CoordinatorJournal parseCoordinatorJournal(Path journalPath) throws IOException {
if (Files.isSymbolicLink(journalPath)
|| !Files.isRegularFile(journalPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid datapack transaction journal " + journalPath);
}
try {
CoordinatorJournal journal = DatapackSupport.GSON.fromJson(
DatapackSupport.readBoundedUtf8(
journalPath,
MAX_TRANSACTION_JOURNAL_BYTES,
"Datapack transaction journal"
),
CoordinatorJournal.class
);
if (journal == null) {
throw new IOException("Empty datapack transaction journal " + journalPath);
}
return journal;
} catch (RuntimeException e) {
throw new IOException("Invalid datapack transaction journal " + journalPath, e);
}
}
static void validateCoordinatorJournal(
File root,
List<File> worldFolders,
Manifest committedManifest,
Path transactionRoot,
CoordinatorJournal journal
) throws IOException {
if (journal.schemaVersion != TRANSACTION_SCHEMA || journal.transactionId == null
|| journal.operation == null || journal.phase == null || journal.id == null
|| journal.url == null || journal.directories == null || journal.editables == null) {
throw new IOException("Incomplete datapack transaction journal at " + transactionRoot);
}
UUID transactionId;
try {
transactionId = UUID.fromString(journal.transactionId);
} catch (IllegalArgumentException e) {
throw new IOException("Invalid datapack transaction id " + journal.transactionId, e);
}
if (!transactionId.toString().equals(transactionRoot.getFileName().toString())) {
throw new IOException("Datapack transaction id does not match its directory");
}
if (!journal.id.equals(DatapackArchive.sanitizeId(journal.id)) || DatapackArchive.RESERVED_IDS.contains(journal.id)) {
throw new IOException("Invalid datapack transaction entry id " + journal.id);
}
if (journal.directories.size() > worldFolders.size() + 1 || journal.editables.size() > 10_000) {
throw new IOException("Datapack transaction journal contains too many participants");
}
if (journal.operation == CoordinatorOperation.INSTALL && !journal.editables.isEmpty()) {
throw new IOException("Datapack install transaction unexpectedly contains editable participants");
}
Set<Path> allowedTargets = new HashSet<>();
allowedTargets.add(new File(new File(root, "staging"), journal.id).toPath().toAbsolutePath().normalize());
for (File worldFolder : worldFolders) {
allowedTargets.add(new File(worldFolder, journal.id).toPath().toAbsolutePath().normalize());
}
Set<Path> seenTargets = new HashSet<>();
Set<Path> seenTargetIdentities = new HashSet<>();
for (CoordinatorDirectory directory : journal.directories) {
DatapackCoordinatorResolution.validateCoordinatorDirectory(
directory, journal.operation, allowedTargets, seenTargets, seenTargetIdentities);
}
Set<String> seenEditables = new HashSet<>();
Set<Path> allowedEditableRoots = journal.editables.isEmpty()
? Set.of() : DatapackCoordinatorResolution.authoritativeEditableRoots(committedManifest, journal);
for (CoordinatorEditable editable : journal.editables) {
if (editable == null || editable.packRoot == null || editable.transactionId == null
|| editable.claimId == null
|| !seenEditables.add(editable.packRoot)) {
throw new IOException("Invalid duplicate editable participant in datapack transaction");
}
Path packRoot = coordinatorPath(editable.packRoot, "editable pack root");
Path realPackRoot = Files.isDirectory(packRoot, LinkOption.NOFOLLOW_LINKS)
? packRoot.toRealPath() : packRoot;
if (Files.isSymbolicLink(packRoot) || !Files.isDirectory(packRoot, LinkOption.NOFOLLOW_LINKS)
|| !packRoot.equals(realPackRoot) || !allowedEditableRoots.contains(realPackRoot)) {
throw new IOException("Invalid editable pack root in datapack transaction: " + packRoot);
}
try {
UUID.fromString(editable.transactionId);
UUID.fromString(editable.claimId);
} catch (IllegalArgumentException e) {
throw new IOException("Invalid editable transaction recovery identity", e);
}
StructureTransactionWriter writer = new StructureTransactionWriter(packRoot);
boolean recoveryDataPresent = writer.verifyRecoveryOwner(
new StructureTransactionWriter.PreparedRemovalToken(
packRoot,
UUID.fromString(editable.transactionId)
),
new StructureTransactionWriter.RecoveryOwner(
transactionRoot,
UUID.fromString(journal.transactionId),
UUID.fromString(editable.claimId)
),
committedManifest.findById(journal.id) == null
&& journal.phase == CoordinatorPhase.PUBLISHED
);
if (!recoveryDataPresent && journal.phase != CoordinatorPhase.COMMITTED) {
throw new IOException("Editable structure recovery data disappeared before commit");
}
}
}
static void deleteCoordinatorTransaction(Path transactionRoot) throws IOException {
DatapackInstall.deleteInstallScratch(transactionRoot.toFile(), "completed datapack transaction");
DatapackSupport.forceDirectoryIfSupported(Objects.requireNonNull(transactionRoot.getParent(), "transaction parent"));
}
static void writeCoordinatorJournal(Path transactionRoot, CoordinatorJournal journal) throws IOException {
byte[] content = DatapackSupport.GSON.toJson(journal).getBytes(StandardCharsets.UTF_8);
if (content.length > MAX_TRANSACTION_JOURNAL_BYTES) {
throw new IOException("Datapack transaction journal exceeds " + MAX_TRANSACTION_JOURNAL_BYTES + " bytes");
}
Files.createDirectories(transactionRoot);
Path next = transactionRoot.resolve(TRANSACTION_JOURNAL_NEXT);
if (Files.isSymbolicLink(next)) {
throw new IOException("Datapack transaction journal cannot be a symbolic link: " + next);
}
Files.deleteIfExists(next);
Files.write(next, content, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
DatapackSupport.forceFile(next);
DatapackSupport.move(next, transactionRoot.resolve(TRANSACTION_JOURNAL));
DatapackSupport.forceDirectoryIfSupported(transactionRoot);
DatapackSupport.forceDirectoryIfSupported(Objects.requireNonNull(transactionRoot.getParent(), "transaction parent"));
}
static Path coordinatorPath(String value, String purpose) throws IOException {
try {
return Path.of(value).toAbsolutePath().normalize();
} catch (RuntimeException e) {
throw new IOException("Invalid datapack transaction " + purpose + " path", e);
}
}
}
@@ -0,0 +1,26 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import java.io.File;
@FunctionalInterface
interface DirectoryDeleter {
void delete(File directory);
}
@@ -0,0 +1,34 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import java.io.File;
record DirectoryMove(
File target,
File backup,
String originalHash,
String originalMarkerHash,
String originalIdentity,
String targetRootIdentity,
String scratchRootIdentity,
String targetRootFileIdentity,
String scratchRootFileIdentity
) {
}
@@ -0,0 +1,179 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import java.io.File;
import java.io.IOException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.util.List;
final class DirectoryRemoval {
private final List<DirectoryMove> moved;
private int prepared;
private boolean closed;
DirectoryRemoval(List<DirectoryMove> moved) {
this.moved = List.copyOf(moved);
}
List<DirectoryMove> moves() {
return moved;
}
void prepare() throws IOException {
if (closed || prepared > 0) {
throw new IllegalStateException("Datapack directory removal transaction is not new");
}
try {
for (DirectoryMove directoryMove : moved) {
DatapackInstallPlanner.verifyDirectoryContainerIdentity(
directoryMove.target().getParentFile(),
directoryMove.targetRootIdentity(),
directoryMove.targetRootFileIdentity(),
"datapack removal target root"
);
DatapackInstallPlanner.verifyDirectoryContainerIdentity(
directoryMove.backup().getParentFile(),
directoryMove.scratchRootIdentity(),
directoryMove.scratchRootFileIdentity(),
"datapack removal scratch root"
);
DatapackInstallPlanner.verifyDirectorySnapshot(
directoryMove.target(),
new File(directoryMove.targetRootIdentity()),
directoryMove.originalHash(),
directoryMove.originalMarkerHash(),
directoryMove.originalIdentity(),
"datapack removal target"
);
DatapackSupport.moveNew(directoryMove.target().toPath(), directoryMove.backup().toPath());
prepared++;
DatapackSupport.forceDirectoryIfSupported(directoryMove.target().getParentFile().toPath());
DatapackSupport.forceDirectoryIfSupported(directoryMove.backup().getParentFile().toPath());
DatapackInstallPlanner.verifyDirectorySnapshot(
directoryMove.backup(),
new File(directoryMove.targetRootIdentity()),
directoryMove.originalHash(),
directoryMove.originalMarkerHash(),
directoryMove.originalIdentity(),
"datapack removal backup"
);
}
} catch (IOException removalFailure) {
try {
rollback();
} catch (IOException restoreFailure) {
removalFailure.addSuppressed(restoreFailure);
}
throw removalFailure;
}
}
void rollback() throws IOException {
if (closed) {
return;
}
IOException failure = null;
for (int i = prepared - 1; i >= 0; i--) {
DirectoryMove directoryMove = moved.get(i);
if (!Files.exists(directoryMove.backup().toPath(), LinkOption.NOFOLLOW_LINKS)) {
continue;
}
try {
DatapackInstallPlanner.verifyDirectoryContainerIdentity(
directoryMove.target().getParentFile(),
directoryMove.targetRootIdentity(),
directoryMove.targetRootFileIdentity(),
"datapack removal target root"
);
DatapackInstallPlanner.verifyDirectoryContainerIdentity(
directoryMove.backup().getParentFile(),
directoryMove.scratchRootIdentity(),
directoryMove.scratchRootFileIdentity(),
"datapack removal scratch root"
);
DatapackInstallPlanner.verifyDirectorySnapshot(
directoryMove.backup(),
new File(directoryMove.targetRootIdentity()),
directoryMove.originalHash(),
directoryMove.originalMarkerHash(),
directoryMove.originalIdentity(),
"datapack removal backup"
);
if (DatapackSupport.pathExists(directoryMove.target().toPath(), "datapack removal target")) {
throw new IOException("Datapack removal target was concurrently recreated at "
+ directoryMove.target().getPath());
}
DatapackSupport.moveNew(
directoryMove.backup().toPath(),
directoryMove.target().toPath()
);
DatapackSupport.forceDirectoryIfSupported(directoryMove.target().getParentFile().toPath());
DatapackSupport.forceDirectoryIfSupported(directoryMove.backup().getParentFile().toPath());
} catch (IOException restoreFailure) {
if (failure == null) {
failure = restoreFailure;
} else {
failure.addSuppressed(restoreFailure);
}
}
}
for (int i = 0; i < prepared; i++) {
DirectoryMove directoryMove = moved.get(i);
directoryMove.backup().getParentFile().delete();
}
closed = true;
if (failure != null) {
throw failure;
}
}
void finishCommit() throws IOException {
if (closed) {
return;
}
IOException failure = null;
for (int i = 0; i < prepared; i++) {
DirectoryMove directoryMove = moved.get(i);
try {
DatapackInstallPlanner.verifyDirectoryContainerIdentity(
directoryMove.backup().getParentFile(),
directoryMove.scratchRootIdentity(),
directoryMove.scratchRootFileIdentity(),
"datapack removal scratch root"
);
DatapackCoordinatorResolution.deleteOriginalBackupIfPresent(
directoryMove.backup(),
new File(directoryMove.targetRootIdentity()),
directoryMove.originalHash(),
directoryMove.originalMarkerHash(),
directoryMove.originalIdentity()
);
} catch (IOException cleanupFailure) {
failure = DatapackSupport.appendIOException(failure, cleanupFailure);
}
directoryMove.backup().getParentFile().delete();
}
closed = true;
if (failure != null) {
throw failure;
}
}
}
@@ -0,0 +1,23 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
record DownloadResult(boolean notModified, String etag, String lastModified) {
}
@@ -0,0 +1,171 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
final class EditableImportRemoval {
private final List<PreparedEditableImport> prepared;
private boolean closed;
EditableImportRemoval(List<PreparedEditableImport> prepared) {
this.prepared = List.copyOf(prepared);
}
List<StructureTransactionWriter.PreparedRemovalToken> recoveryTokens() {
List<StructureTransactionWriter.PreparedRemovalToken> tokens = new ArrayList<>();
for (PreparedEditableImport editableImport : prepared) {
editableImport.removal().recoveryToken().ifPresent(tokens::add);
}
return List.copyOf(tokens);
}
void claimRecoveryOwners(Path transactionRoot, CoordinatorJournal journal) throws IOException {
int ownerIndex = 0;
for (PreparedEditableImport editableImport : prepared) {
Optional<StructureTransactionWriter.PreparedRemovalToken> token =
editableImport.removal().recoveryToken();
if (token.isEmpty()) {
continue;
}
if (ownerIndex >= journal.editables.size()) {
throw new IOException("Missing datapack coordinator claim for editable structure removal");
}
CoordinatorEditable editable = journal.editables.get(ownerIndex++);
StructureTransactionWriter.PreparedRemovalToken recoveryToken = token.get();
if (!Objects.equals(recoveryToken.packRoot().toString(), editable.packRoot)
|| !Objects.equals(recoveryToken.transactionId().toString(), editable.transactionId)) {
throw new IOException("Datapack coordinator editable claim order changed during preparation");
}
editableImport.removal().claimRecoveryOwner(
new StructureTransactionWriter.RecoveryOwner(
transactionRoot,
UUID.fromString(journal.transactionId),
UUID.fromString(editable.claimId)
)
);
}
if (ownerIndex != journal.editables.size()) {
throw new IOException("Unexpected datapack coordinator editable recovery claim");
}
}
void markCommitted() throws IOException {
if (closed) {
throw new IllegalStateException("Editable import removal transaction is closed");
}
for (PreparedEditableImport editableImport : prepared) {
editableImport.removal().markCommitted();
}
}
void finishCommit() throws IOException {
if (closed) {
return;
}
IOException failure = null;
for (PreparedEditableImport editableImport : prepared) {
try {
editableImport.removal().finishCommit();
} catch (IOException | RuntimeException cleanupFailure) {
IOException transactionFailure = cleanupFailure instanceof IOException ioFailure
? ioFailure
: new IOException("Failed finalizing editable import removal", cleanupFailure);
if (failure == null) {
failure = transactionFailure;
} else {
failure.addSuppressed(transactionFailure);
}
}
if (editableImport.removal().changed()) {
try {
IrisData.getLoaded(editableImport.dataFolder())
.ifPresent(IrisData::invalidateStructureResources);
} catch (RuntimeException invalidationFailure) {
IOException transactionFailure = new IOException(
"Failed invalidating editable import structure resources",
invalidationFailure
);
if (failure == null) {
failure = transactionFailure;
} else {
failure.addSuppressed(transactionFailure);
}
}
}
}
closed = true;
if (failure != null) {
throw failure;
}
}
void rollback() throws IOException {
if (closed) {
return;
}
IOException failure = null;
for (int i = prepared.size() - 1; i >= 0; i--) {
try {
prepared.get(i).removal().rollback();
} catch (IOException rollbackFailure) {
if (failure == null) {
failure = rollbackFailure;
} else {
failure.addSuppressed(rollbackFailure);
}
}
}
closed = true;
if (failure != null) {
throw failure;
}
}
void leaveForRecovery() throws IOException {
if (closed) {
return;
}
IOException failure = null;
for (PreparedEditableImport editableImport : prepared) {
try {
editableImport.removal().leaveForRecovery();
} catch (IOException releaseFailure) {
if (failure == null) {
failure = releaseFailure;
} else {
failure.addSuppressed(releaseFailure);
}
}
}
closed = true;
if (failure != null) {
throw failure;
}
}
}
@@ -0,0 +1,96 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import java.io.File;
import java.util.List;
final class InstallExecution {
private final InstallResult result;
private final DatapackCoordinator coordinator;
private final File stagedDir;
private final Entry entry;
private final String stagedHash;
private final boolean verifyStagedSource;
private final List<InstallPlan> publishedPlans;
private final List<InstallPlan> unchangedPlans;
private boolean verified;
InstallExecution(
InstallResult result,
DatapackCoordinator coordinator,
File stagedDir,
Entry entry,
String stagedHash,
boolean verifyStagedSource,
List<InstallPlan> publishedPlans,
List<InstallPlan> unchangedPlans
) {
this.result = result;
this.coordinator = coordinator;
this.stagedDir = stagedDir;
this.entry = DatapackManifestStore.copyEntry(entry);
this.stagedHash = stagedHash;
this.verifyStagedSource = verifyStagedSource;
this.publishedPlans = List.copyOf(publishedPlans);
this.unchangedPlans = List.copyOf(unchangedPlans);
}
InstallResult result() {
return result;
}
DatapackCoordinator coordinator() {
return coordinator;
}
File stagedDir() {
return stagedDir;
}
Entry entry() {
return entry;
}
String stagedHash() {
return stagedHash;
}
boolean verifyStagedSource() {
return verifyStagedSource;
}
List<InstallPlan> publishedPlans() {
return publishedPlans;
}
List<InstallPlan> unchangedPlans() {
return unchangedPlans;
}
boolean verified() {
return verified;
}
void markVerified() {
verified = true;
}
}
@@ -0,0 +1,45 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import java.io.File;
record InstallPlan(
File target,
File pending,
File backup,
File pendingRoot,
boolean hadTarget,
boolean publishRequired,
boolean contentChanged,
String originalHash,
String desiredHash,
String originalMarkerHash,
String desiredMarkerHash,
String originalIdentity,
String desiredIdentity,
String targetRootIdentity,
String scratchRootIdentity,
String targetRootFileIdentity,
String scratchRootFileIdentity,
String id,
String url,
VerifiedStagingInstall legacyWorldAuthorization
) {
}
@@ -0,0 +1,23 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
record InstallResult(boolean changed) {
}
@@ -0,0 +1,61 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import java.util.ArrayList;
import java.util.List;
final class Manifest {
List<Entry> entries = new ArrayList<>();
Entry find(String url) {
for (Entry entry : entries) {
if (entry.url != null && entry.url.equals(url)) {
return entry;
}
}
return null;
}
Entry findById(String id) {
for (Entry entry : entries) {
if (entry.id != null && entry.id.equals(id)) {
return entry;
}
}
return null;
}
void put(Entry entry) {
for (int i = 0; i < entries.size(); i++) {
Entry current = entries.get(i);
if (current.url != null && current.url.equals(entry.url)) {
entries.set(i, entry);
return;
}
}
entries.add(entry);
}
boolean removeById(String id) {
return entries.removeIf(entry -> id.equals(entry.id));
}
}
@@ -0,0 +1,96 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;
final class ManifestWrite implements AutoCloseable {
private final Path staged;
private final Path target;
private final Path rollback;
private boolean published;
ManifestWrite(Path staged, Path target, Path rollback) {
this.staged = staged;
this.target = target;
this.rollback = rollback;
}
void publish() throws IOException {
if (published) {
throw new IllegalStateException("Datapack manifest write was already published");
}
try {
DatapackSupport.move(staged, target);
published = true;
} catch (IOException publishFailure) {
try {
restoreOriginal();
} catch (IOException restoreFailure) {
publishFailure.addSuppressed(restoreFailure);
}
throw publishFailure;
}
DatapackSupport.forceDirectoryIfSupported(Objects.requireNonNull(target.getParent(), "manifest parent"));
}
boolean published() {
return published;
}
void discard() throws IOException {
IOException failure = null;
try {
Files.deleteIfExists(staged);
} catch (IOException cleanupFailure) {
failure = cleanupFailure;
}
if (rollback != null) {
try {
Files.deleteIfExists(rollback);
} catch (IOException cleanupFailure) {
if (failure == null) {
failure = cleanupFailure;
} else {
failure.addSuppressed(cleanupFailure);
}
}
}
if (failure != null) {
throw failure;
}
}
@Override
public void close() throws IOException {
discard();
}
private void restoreOriginal() throws IOException {
if (rollback == null) {
Files.deleteIfExists(target);
} else {
DatapackSupport.move(rollback, target);
}
DatapackSupport.forceDirectoryIfSupported(Objects.requireNonNull(target.getParent(), "manifest parent"));
}
}
@@ -0,0 +1,24 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import java.nio.file.Path;
record MetadataEntry(Path path, String relativePath) {
}
@@ -0,0 +1,69 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import java.util.List;
final class Ownership {
final int schemaVersion;
final String id;
final String url;
final String versionId;
final String versionNumber;
final String sha1;
final String contentHash;
final List<String> structureKeys;
final List<String> templateKeys;
Ownership(
int schemaVersion,
String id,
String url,
String versionId,
String versionNumber,
String sha1,
String contentHash,
List<String> structureKeys,
List<String> templateKeys
) {
this.schemaVersion = schemaVersion;
this.id = id;
this.url = url;
this.versionId = versionId;
this.versionNumber = versionNumber;
this.sha1 = sha1;
this.contentHash = contentHash;
this.structureKeys = List.copyOf(structureKeys);
this.templateKeys = List.copyOf(templateKeys);
}
Entry toEntry() {
Entry entry = new Entry();
entry.id = id;
entry.url = url;
entry.versionId = versionId;
entry.versionNumber = versionNumber;
entry.sha1 = sha1;
entry.structureKeys = DatapackManifestStore.normalizeKeys(structureKeys);
entry.templateKeys = DatapackManifestStore.normalizeKeys(templateKeys);
return entry;
}
}
@@ -0,0 +1,28 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import java.util.List;
record PackResources(
List<String> structureKeys,
List<String> structureSetKeys,
List<String> templateKeys
) {
}
@@ -0,0 +1,29 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import java.io.File;
record PreparedEditableImport(
File dataFolder,
StructureTransactionWriter.PreparedRemoval removal
) {
}
@@ -0,0 +1,26 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
enum RemoveOutcome {
REMOVED,
REJECTED,
FAILED_AFTER_MUTATION
}
@@ -0,0 +1,27 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
record StagingInspection(
boolean usable,
boolean ownershipCorrected,
boolean manifestChanged
) {
}
@@ -0,0 +1,31 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import java.util.List;
final class StartupValidationCache {
int schemaVersion;
String minecraftVersion;
int irisVersion;
boolean autoIngest;
boolean stripOverrides;
List<String> urls;
String localFingerprint;
}
@@ -0,0 +1,248 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import java.io.File;
import java.io.IOException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;
final class VerifiedStagingInstall {
private final Path normalizedRoot;
private final Path realRoot;
private final String rootIdentity;
private final Path normalizedStagingRoot;
private final Path realStagingRoot;
private final String stagingRootIdentity;
private final Path normalizedSource;
private final Path realSource;
private final String sourceIdentity;
private final String id;
private final String url;
private final String versionId;
private final String versionNumber;
private final String sha1;
private final String desiredHash;
private final Entry committedEntrySnapshot;
private final boolean legacyReplacementAuthorized;
private final DatapackStagingGuard.LegacyStagingSnapshot legacyStagingSnapshot;
private boolean consumed;
VerifiedStagingInstall(
Path normalizedRoot,
Path normalizedStagingRoot,
Path normalizedSource,
Entry entry,
String desiredHash,
Entry committedEntrySnapshot,
boolean legacyReplacementAuthorized,
DatapackStagingGuard.LegacyStagingSnapshot legacyStagingSnapshot
) throws IOException {
this.normalizedRoot = normalizedRoot;
this.realRoot = normalizedRoot.toRealPath();
this.rootIdentity = DatapackSupport.directoryIdentity(normalizedRoot.toFile());
this.normalizedStagingRoot = normalizedStagingRoot;
this.realStagingRoot = normalizedStagingRoot.toRealPath();
this.stagingRootIdentity = DatapackSupport.directoryIdentity(normalizedStagingRoot.toFile());
this.normalizedSource = normalizedSource;
this.realSource = normalizedSource.toRealPath();
this.sourceIdentity = DatapackSupport.directoryIdentity(normalizedSource.toFile());
this.id = entry.id;
this.url = entry.url;
this.versionId = entry.versionId;
this.versionNumber = entry.versionNumber;
this.sha1 = entry.sha1;
this.desiredHash = desiredHash;
this.committedEntrySnapshot = committedEntrySnapshot == null ? null : DatapackManifestStore.copyEntry(committedEntrySnapshot);
this.legacyReplacementAuthorized = legacyReplacementAuthorized;
this.legacyStagingSnapshot = legacyStagingSnapshot;
}
Path stagingRoot() {
return normalizedStagingRoot;
}
boolean hasStablePathIdentities() {
return !rootIdentity.isEmpty()
&& !stagingRootIdentity.isEmpty()
&& !sourceIdentity.isEmpty();
}
boolean isCanonicalInstall(File installRoot, File target) {
Path suppliedRoot = installRoot.toPath().toAbsolutePath().normalize();
Path suppliedTarget = target.toPath().toAbsolutePath().normalize();
return suppliedRoot.equals(normalizedStagingRoot)
&& suppliedTarget.equals(normalizedStagingRoot.resolve(id).normalize());
}
void verifyStagingRoot() throws IOException {
verifyPathIdentity(normalizedRoot, realRoot, rootIdentity, "datapack storage root");
verifyPathIdentity(
normalizedStagingRoot, realStagingRoot, stagingRootIdentity, "datapack staging root");
if (!Objects.equals(normalizedStagingRoot.getParent(), normalizedRoot)
|| !Files.isSameFile(normalizedStagingRoot.getParent(), normalizedRoot)) {
throw new IOException("Verified datapack staging root changed identity");
}
}
boolean consume(
File source,
File installRoot,
File target,
Entry entry,
String stagedHash
) throws IOException {
if (consumed) {
throw new IOException("Verified datapack staging authorization was already consumed");
}
consumed = true;
Path suppliedRoot = installRoot.toPath().toAbsolutePath().normalize();
Path suppliedTarget = target.toPath().toAbsolutePath().normalize();
if (!suppliedRoot.equals(normalizedStagingRoot)
|| !suppliedTarget.equals(normalizedStagingRoot.resolve(id).normalize())) {
throw new IOException("Verified datapack staging authorization was paired with a different path");
}
verifyAuthority(source, entry, stagedHash);
return legacyReplacementAuthorized;
}
boolean authorizeLegacyWorldReplacement(
File source,
File installRoot,
File target,
Entry entry,
String stagedHash,
String currentHash,
String currentMarkerHash
) throws IOException {
if (legacyStagingSnapshot == null
|| !"absent".equals(currentMarkerHash)
|| !Objects.equals(currentHash, legacyStagingSnapshot.contentHash())
|| legacyStagingSnapshot.targetIdentity().isEmpty()) {
return false;
}
Path suppliedRoot = installRoot.toPath().toAbsolutePath().normalize();
Path suppliedTarget = target.toPath().toAbsolutePath().normalize();
if (!Objects.equals(suppliedTarget.getParent(), suppliedRoot)
|| !Objects.equals(suppliedTarget.getFileName().toString(), id)
|| Files.isSameFile(suppliedRoot, normalizedStagingRoot)
|| Files.isSameFile(suppliedTarget, legacyStagingSnapshot.normalizedTarget())) {
return false;
}
verifyAuthority(source, entry, stagedHash);
verifyLegacyWorldSnapshot();
return true;
}
private void verifyAuthority(File source, Entry entry, String stagedHash) throws IOException {
verifyAuthorityState();
Path suppliedSource = source.toPath().toAbsolutePath().normalize();
if (!suppliedSource.equals(normalizedSource)) {
throw new IOException("Verified datapack staging authorization was paired with a different path");
}
if (!Objects.equals(entry.id, id)
|| !Objects.equals(entry.url, url)
|| !Objects.equals(entry.versionId, versionId)
|| !Objects.equals(entry.versionNumber, versionNumber)
|| !Objects.equals(entry.sha1, sha1)
|| !Objects.equals(stagedHash, desiredHash)) {
throw new IOException("Verified datapack staging authorization was paired with different metadata");
}
}
private void verifyAuthorityState() throws IOException {
verifyStagingRoot();
verifyPathIdentity(normalizedSource, realSource, sourceIdentity, "verified datapack extraction");
Manifest committedManifest = DatapackManifestStore.readCommittedManifest(normalizedRoot.toFile());
Entry committed = committedManifest.findById(id);
if (!manifestEntrySnapshotMatches(committed, committedEntrySnapshot)) {
throw new IOException("Committed datapack staging authority changed before installation");
}
if (committedEntrySnapshot != null && !legacyReplacementAuthorized) {
throw new IOException("Committed datapack staging authority conflicts with " + id);
}
}
private static boolean manifestEntrySnapshotMatches(Entry current, Entry expected) {
if (current == null || expected == null) {
return current == expected;
}
return Objects.equals(current.id, expected.id)
&& Objects.equals(current.url, expected.url)
&& Objects.equals(current.versionId, expected.versionId)
&& Objects.equals(current.versionNumber, expected.versionNumber)
&& Objects.equals(current.sha1, expected.sha1)
&& Objects.equals(current.filename, expected.filename)
&& Objects.equals(current.etag, expected.etag)
&& Objects.equals(current.lastModified, expected.lastModified)
&& current.installedEpoch == expected.installedEpoch
&& current.structuresImported == expected.structuresImported
&& DatapackManifestStore.copyList(current.structureKeys).equals(DatapackManifestStore.copyList(expected.structureKeys))
&& DatapackManifestStore.copyList(current.templateKeys).equals(DatapackManifestStore.copyList(expected.templateKeys))
&& Objects.equals(current.importedTargets, expected.importedTargets)
&& Objects.equals(current.importAttempts, expected.importAttempts)
&& Objects.equals(current.importedBundles, expected.importedBundles);
}
void verifyLegacyWorldSnapshot() throws IOException {
verifyAuthorityState();
if (legacyStagingSnapshot == null || legacyStagingSnapshot.targetIdentity().isEmpty()) {
throw new IOException("Missing stable canonical legacy datapack staging snapshot for " + id);
}
if (!Objects.equals(legacyStagingSnapshot.normalizedTarget().getParent(), normalizedStagingRoot)
|| !Files.isSameFile(
legacyStagingSnapshot.normalizedTarget().getParent(), normalizedStagingRoot)
|| !DatapackScratchRecovery.sameScratchVolume(
legacyStagingSnapshot.normalizedTarget(), normalizedStagingRoot)) {
throw new IOException("Changed or unsafe canonical legacy datapack staging target for " + id);
}
DatapackInstallPlanner.verifyDirectorySnapshot(
legacyStagingSnapshot.normalizedTarget().toFile(),
normalizedStagingRoot.toFile(),
legacyStagingSnapshot.contentHash(),
legacyStagingSnapshot.markerHash(),
legacyStagingSnapshot.targetIdentity(),
"canonical legacy datapack staging target"
);
if (!Objects.equals(
legacyStagingSnapshot.normalizedTarget().toRealPath(),
legacyStagingSnapshot.realTarget())) {
throw new IOException("Changed canonical legacy datapack staging target identity for " + id);
}
}
private static void verifyPathIdentity(
Path normalized,
Path expectedReal,
String expectedIdentity,
String purpose
) throws IOException {
if (Files.isSymbolicLink(normalized)
|| !Files.isDirectory(normalized, LinkOption.NOFOLLOW_LINKS)
|| !Objects.equals(normalized.toRealPath(), expectedReal)
|| (!expectedIdentity.isEmpty()
&& !Objects.equals(DatapackSupport.directoryIdentity(normalized.toFile()), expectedIdentity))) {
throw new IOException("Changed or unsafe " + purpose + " " + normalized);
}
}
}

Some files were not shown because too many files have changed in this diff Show More