diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessChunkLighting.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessChunkLighting.java new file mode 100644 index 000000000..d73a70998 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessChunkLighting.java @@ -0,0 +1,259 @@ +package art.arcane.iris.probe; + +import ca.spottedleaf.moonrise.patches.starlight.chunk.StarlightChunk; +import ca.spottedleaf.moonrise.patches.starlight.light.SWMRNibbleArray; +import net.minecraft.core.BlockPos; +import net.minecraft.core.SectionPos; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.DataLayer; +import net.minecraft.world.level.chunk.LightChunk; +import net.minecraft.world.level.chunk.LightChunkGetter; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.lighting.BlockLightEngine; +import net.minecraft.world.level.lighting.ChunkSkyLightSources; +import net.minecraft.world.level.lighting.LayerLightEventListener; +import net.minecraft.world.level.lighting.SkyLightEngine; +import net.minecraft.world.level.material.FluidState; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.BiConsumer; + +final class HeadlessChunkLighting { + private static final int MAXIMUM_WINDOW_CHUNKS = 256; + private static final int HALO = 2; + + private HeadlessChunkLighting() { + } + + static void light(Request request) { + if (Thread.currentThread().isInterrupted()) { + throw new IllegalStateException("Offline lighting interrupted before initialization"); + } + Window window = new Window(request); + BlockLightEngine block = new BlockLightEngine(window); + SkyLightEngine sky = request.skyLight() ? new SkyLightEngine(window) : null; + for (LightingChunk chunk : window.chunks.values()) { + ProtoChunk source = chunk.source; + for (int section = source.getMinSectionY(); section <= source.getMaxSectionY(); section++) { + SectionPos position = SectionPos.of(source.getPos(), section); + boolean empty = source.getSection(source.getSectionIndexFromSectionY(section)).hasOnlyAir(); + block.updateSectionStatus(position, empty); + if (sky != null) { + sky.updateSectionStatus(position, empty); + } + } + } + for (LightingChunk chunk : window.chunks.values()) { + block.propagateLightSources(chunk.source.getPos()); + if (sky != null) { + sky.propagateLightSources(chunk.source.getPos()); + } + } + drain(block); + if (sky != null) { + drain(sky); + } + for (ChunkPos position : request.targets()) { + ProtoChunk chunk = window.chunks.get(position.pack()).source; + StarlightChunk destination = (StarlightChunk) chunk; + destination.starlight$setBlockNibbles(copyLight(block, chunk)); + destination.starlight$setSkyNibbles(copyLight(sky, chunk)); + boolean[] emptySections = new boolean[chunk.getSectionsCount()]; + for (int index = 0; index < emptySections.length; index++) { + emptySections[index] = chunk.getSection(index).hasOnlyAir(); + } + destination.starlight$setBlockEmptinessMap(emptySections.clone()); + destination.starlight$setSkyEmptinessMap(emptySections); + chunk.setPersistedStatus(ChunkStatus.LIGHT); + chunk.setLightCorrect(true); + } + } + + private static void drain(LayerLightEventListener engine) { + while (engine.hasLightWork()) { + if (Thread.currentThread().isInterrupted()) { + throw new IllegalStateException("Offline lighting interrupted before completion"); + } + engine.runLightUpdates(); + } + } + + private static SWMRNibbleArray[] copyLight(LayerLightEventListener engine, ProtoChunk chunk) { + SWMRNibbleArray[] result = new SWMRNibbleArray[chunk.getSectionsCount() + 2]; + BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos(); + for (int index = 0; index < result.length; index++) { + int sectionY = chunk.getMinSectionY() + index - 1; + DataLayer layer = engine == null ? new DataLayer() : engine.getDataLayerData(SectionPos.of(chunk.getPos(), sectionY)); + if (layer == null) { + layer = new DataLayer(); + for (int y = 0; y < 16; y++) { + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + position.set(chunk.getPos().getMinBlockX() + x, (sectionY << 4) + y, + chunk.getPos().getMinBlockZ() + z); + layer.set(x, y, z, engine.getLightValue(position)); + } + } + } + } + result[index] = SWMRNibbleArray.fromVanilla(layer); + } + return result; + } + + record Request(List chunks, Set targets, boolean skyLight) { + Request { + chunks = List.copyOf(chunks); + targets = Set.copyOf(targets); + if (chunks.isEmpty() || chunks.size() > MAXIMUM_WINDOW_CHUNKS || targets.isEmpty()) { + throw new IllegalArgumentException("Offline lighting requires targets inside a bounded chunk window"); + } + } + } + + private static final class Window implements LightChunkGetter, BlockGetter { + private final Map chunks; + private final int minimumY; + private final int height; + + private Window(Request request) { + minimumY = request.chunks().getFirst().getMinY(); + height = request.chunks().getFirst().getHeight(); + chunks = new HashMap<>(request.chunks().size()); + for (ProtoChunk chunk : request.chunks()) { + if (chunk.getMinY() != minimumY || chunk.getHeight() != height + || chunk.getPersistedStatus() != ChunkStatus.FEATURES) { + throw new IllegalArgumentException("Offline lighting requires matching heights and completed FEATURES chunks"); + } + if (chunks.putIfAbsent(chunk.getPos().pack(), new LightingChunk(chunk)) != null) { + throw new IllegalArgumentException("Duplicate lighting chunk: " + chunk.getPos()); + } + } + for (ChunkPos target : request.targets()) { + for (int dz = -HALO; dz <= HALO; dz++) { + for (int dx = -HALO; dx <= HALO; dx++) { + if (!chunks.containsKey(ChunkPos.pack(Math.addExact(target.x(), dx), Math.addExact(target.z(), dz)))) { + throw new IllegalArgumentException("Offline lighting target lacks its completed neighbor halo: " + target); + } + } + } + } + } + + @Override + public LightChunk getChunkForLighting(int x, int z) { + return chunks.get(ChunkPos.pack(x, z)); + } + + @Override + public BlockGetter getLevel() { + return this; + } + + @Override + public int getMinY() { + return minimumY; + } + + @Override + public int getHeight() { + return height; + } + + @Override + public BlockEntity getBlockEntity(BlockPos position) { + return requireChunk(position).getBlockEntity(position); + } + + @Override + public BlockState getBlockState(BlockPos position) { + return requireChunk(position).getBlockState(position); + } + + @Override + public BlockState getBlockStateIfLoaded(BlockPos position) { + LightingChunk chunk = chunks.get(ChunkPos.pack(position.getX() >> 4, position.getZ() >> 4)); + return chunk == null ? null : chunk.getBlockState(position); + } + + @Override + public FluidState getFluidState(BlockPos position) { + return getBlockState(position).getFluidState(); + } + + @Override + public FluidState getFluidIfLoaded(BlockPos position) { + BlockState state = getBlockStateIfLoaded(position); + return state == null ? null : state.getFluidState(); + } + + private LightingChunk requireChunk(BlockPos position) { + return Objects.requireNonNull(chunks.get(ChunkPos.pack(position.getX() >> 4, position.getZ() >> 4)), + "Lighting query outside the supplied chunk window"); + } + } + + private static final class LightingChunk implements LightChunk { + private final ProtoChunk source; + private final ChunkSkyLightSources skySources; + + private LightingChunk(ProtoChunk source) { + this.source = source; + skySources = new ChunkSkyLightSources(source); + skySources.fillFrom(source); + } + + @Override + public void findBlockLightSources(BiConsumer consumer) { + source.findBlockLightSources(consumer); + } + + @Override + public ChunkSkyLightSources getSkyLightSources() { + return skySources; + } + + @Override + public BlockEntity getBlockEntity(BlockPos position) { + return source.getBlockEntity(position); + } + + @Override + public BlockState getBlockState(BlockPos position) { + return source.getBlockState(position); + } + + @Override + public BlockState getBlockStateIfLoaded(BlockPos position) { + return source.getBlockStateIfLoaded(position); + } + + @Override + public FluidState getFluidState(BlockPos position) { + return source.getFluidState(position); + } + + @Override + public FluidState getFluidIfLoaded(BlockPos position) { + return source.getFluidIfLoaded(position); + } + + @Override + public int getMinY() { + return source.getMinY(); + } + + @Override + public int getHeight() { + return source.getHeight(); + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessChunkLightingTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessChunkLightingTest.java new file mode 100644 index 000000000..f379416ff --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessChunkLightingTest.java @@ -0,0 +1,260 @@ +package art.arcane.iris.probe; + +import ca.spottedleaf.moonrise.patches.starlight.chunk.StarlightChunk; +import ca.spottedleaf.moonrise.patches.starlight.light.StarLightInterface; +import ca.spottedleaf.moonrise.patches.starlight.light.SWMRNibbleArray; +import ca.spottedleaf.moonrise.patches.starlight.util.SaveUtil; +import net.minecraft.core.BlockPos; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtAccounter; +import net.minecraft.nbt.NbtIo; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.repository.ServerPacksSource; +import net.minecraft.server.packs.resources.MultiPackResourceManager; +import net.minecraft.util.datafix.DataFixers; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.LevelHeightAccessor; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.LightChunk; +import net.minecraft.world.level.chunk.LightChunkGetter; +import net.minecraft.world.level.chunk.PalettedContainerFactory; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.chunk.UpgradeData; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.chunk.storage.SerializableChunkData; +import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; +import net.minecraft.world.level.storage.LevelStorageSource; +import org.bukkit.Bukkit; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertArrayEquals; +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; + +public final class HeadlessChunkLightingTest { + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + + @Test + public void nativeLightCrossesChunkEdgesAndRoundTripsSavedNibblesWithoutServer() throws Exception { + Fixture fixture = new Fixture(-1, -2); + int edgeX = fixture.target.x() * 16; + int z = fixture.target.z() * 16 + 8; + for (ProtoChunk chunk : fixture.chunks) { + for (int x = 0; x < 16; x++) { + for (int localZ = 0; localZ < 16; localZ++) { + chunk.setBlockState(new BlockPos(chunk.getPos().getMinBlockX() + x, 0, + chunk.getPos().getMinBlockZ() + localZ), Blocks.STONE.defaultBlockState(), 0); + } + } + } + fixture.set(new BlockPos(edgeX - 1, 0, z), Blocks.AIR.defaultBlockState()); + fixture.set(new BlockPos(edgeX - 1, -12, z), Blocks.GLOWSTONE.defaultBlockState()); + fixture.finishFeatures(); + assertNull(Bukkit.getServer()); + HeadlessChunkLighting.light(fixture.request(true)); + ProtoChunk target = fixture.chunk(fixture.target); + assertEquals(14, light(target, false, new BlockPos(edgeX, -12, z))); + assertEquals(13, light(target, false, new BlockPos(edgeX + 1, -12, z))); + assertEquals(14, light(target, true, new BlockPos(edgeX, -1, z))); + assertEquals(15, light(target, true, new BlockPos(edgeX, 1, z))); + assertEquals(0, light(target, true, new BlockPos(edgeX + 15, -1, z))); + assertEquals(ChunkStatus.LIGHT, target.getPersistedStatus()); + assertTrue(target.isLightCorrect()); + assertEquals(ChunkStatus.FEATURES, fixture.chunk(new ChunkPos(fixture.target.x() - 2, fixture.target.z())).getPersistedStatus()); + assertFalse(fixture.chunk(new ChunkPos(fixture.target.x() - 2, fixture.target.z())).isLightCorrect()); + + try (MultiPackResourceManager resources = new MultiPackResourceManager(PackType.SERVER_DATA, + List.of(ServerPacksSource.createVanillaPackSource().fullResources())); + LevelStorageSource.LevelStorageAccess storage = LevelStorageSource.createDefault( + temporary.newFolder().toPath()).createAccess("lighting")) { + StructureTemplateManager templates = new StructureTemplateManager(resources, storage, + DataFixers.getDataFixer(), BuiltInRegistries.BLOCK); + HeadlessChunkSerialization.Context context = new HeadlessChunkSerialization.Context( + new StructurePieceSerializationContext(resources, fixture.registries, templates), fixture.containers, 0L); + CompoundTag tag = HeadlessChunkSerialization.copyOf(target, context).write(); + assertEquals("minecraft:light", tag.getStringOr("Status", "")); + assertEquals(SaveUtil.STARLIGHT_LIGHT_VERSION, tag.getIntOr(SaveUtil.STARLIGHT_VERSION_TAG, -1)); + assertTrue(tag.contains("isLightOn")); + assertFalse(tag.getBooleanOr("isLightOn", true)); + assertThrows(ClassCastException.class, () -> SerializableChunkData.parse(target, fixture.containers, tag)); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + NbtIo.writeCompressed(tag, bytes); + CompoundTag restored = NbtIo.readCompressed(new ByteArrayInputStream(bytes.toByteArray()), NbtAccounter.unlimitedHeap()); + assertEquals(tag, restored); + List sections = restored.getListOrEmpty("sections").compoundStream().toList(); + assertEquals(target.getSectionsCount() + 2, sections.size()); + for (CompoundTag section : sections) { + int index = section.getByteOr("Y", (byte) 0) - target.getMinSectionY() + 1; + assertTrue(section.contains(SaveUtil.BLOCKLIGHT_STATE_TAG)); + assertTrue(section.contains(SaveUtil.SKYLIGHT_STATE_TAG)); + SWMRNibbleArray block = new SWMRNibbleArray(section.getByteArray("BlockLight").orElse(null), + section.getIntOr(SaveUtil.BLOCKLIGHT_STATE_TAG, 0)); + SWMRNibbleArray sky = new SWMRNibbleArray(section.getByteArray("SkyLight").orElse(null), + section.getIntOr(SaveUtil.SKYLIGHT_STATE_TAG, 0)); + assertNibbleEquals(((StarlightChunk) target).starlight$getBlockNibbles()[index], block); + assertNibbleEquals(((StarlightChunk) target).starlight$getSkyNibbles()[index], sky); + } + } + assertNull(Bukkit.getServer()); + } + + @Test + public void nativeOpacityAndShapeRulesApplyAcrossTheBoundaryWithoutSkylight() { + for (BlockState barrier : List.of(Blocks.GLASS.defaultBlockState(), Blocks.TINTED_GLASS.defaultBlockState(), + Blocks.STONE_SLAB.defaultBlockState(), Blocks.STONE.defaultBlockState())) { + Fixture fixture = new Fixture(0, 0); + for (int x = -2; x <= 3; x++) { + for (int y = -13; y <= -11; y++) { + for (int z = 7; z <= 9; z++) { + fixture.set(new BlockPos(x, y, z), Blocks.STONE.defaultBlockState()); + } + } + } + for (int x = -1; x <= 2; x++) { + fixture.set(new BlockPos(x, -12, 8), Blocks.AIR.defaultBlockState()); + } + fixture.set(new BlockPos(-1, -12, 8), Blocks.GLOWSTONE.defaultBlockState()); + fixture.set(new BlockPos(0, -12, 8), barrier); + fixture.finishFeatures(); + HeadlessChunkLighting.light(fixture.request(false)); + ProtoChunk target = fixture.chunk(fixture.target); + int expected = barrier.is(Blocks.GLASS) || barrier.is(Blocks.STONE_SLAB) ? 13 : 0; + assertEquals(barrier.toString(), expected, light(target, false, new BlockPos(1, -12, 8))); + assertEquals(0, light(target, true, new BlockPos(1, 20, 8))); + } + assertNull(Bukkit.getServer()); + } + + @Test + public void emptySectionsRetainImplicitSkyLightAcrossFullHeightAndPadding() { + for (boolean sky : List.of(true, false)) { + Fixture fixture = new Fixture(0, 0); + fixture.finishFeatures(); + HeadlessChunkLighting.light(fixture.request(sky)); + ProtoChunk target = fixture.chunk(fixture.target); + for (int y = target.getMinY() - 16; y <= target.getMaxY() + 16; y++) { + assertEquals("sky y=" + y, sky ? 15 : 0, light(target, true, new BlockPos(8, y, 8))); + assertEquals("block y=" + y, 0, light(target, false, new BlockPos(8, y, 8))); + } + } + } + + @Test + public void interruptionLeavesChunkUnlitAndRetainsInterrupt() { + Fixture fixture = new Fixture(0, 0); + fixture.finishFeatures(); + Thread.currentThread().interrupt(); + try { + assertThrows(IllegalStateException.class, () -> HeadlessChunkLighting.light(fixture.request(true))); + assertTrue(Thread.currentThread().isInterrupted()); + assertFalse(fixture.chunk(fixture.target).isLightCorrect()); + assertEquals(ChunkStatus.FEATURES, fixture.chunk(fixture.target).getPersistedStatus()); + } finally { + Thread.interrupted(); + } + } + + @Test + public void rejectsIncompleteNeighborsBeforePromotingOrMutatingLight() { + Fixture fixture = new Fixture(0, 0); + fixture.finishFeatures(); + fixture.chunks.removeFirst(); + ProtoChunk target = fixture.chunk(fixture.target); + SWMRNibbleArray[] previous = ((StarlightChunk) target).starlight$getBlockNibbles(); + assertThrows(IllegalArgumentException.class, () -> HeadlessChunkLighting.light(fixture.request(true))); + assertEquals(ChunkStatus.FEATURES, target.getPersistedStatus()); + assertFalse(target.isLightCorrect()); + assertArrayEquals(previous, ((StarlightChunk) target).starlight$getBlockNibbles()); + fixture.chunks.add(new ProtoChunk(new ChunkPos(-2, -2), UpgradeData.EMPTY, target, fixture.containers, null)); + assertThrows(IllegalArgumentException.class, () -> HeadlessChunkLighting.light(fixture.request(true))); + } + + @Test + public void directStarlightRequiresLevelButNativeLightEnginesDoNot() { + Fixture fixture = new Fixture(0, 0); + ProtoChunk chunk = fixture.chunk(fixture.target); + LightChunkGetter source = new LightChunkGetter() { + @Override + public LightChunk getChunkForLighting(int x, int z) { + return chunk; + } + + @Override + public BlockGetter getLevel() { + return chunk; + } + }; + assertThrows(ClassCastException.class, () -> new StarLightInterface(source, true, true, null)); + assertNull(Bukkit.getServer()); + } + + private static int light(ProtoChunk chunk, boolean sky, BlockPos position) { + StarlightChunk light = (StarlightChunk) chunk; + SWMRNibbleArray[] nibbles = sky ? light.starlight$getSkyNibbles() : light.starlight$getBlockNibbles(); + return nibbles[(position.getY() >> 4) - chunk.getMinSectionY() + 1] + .getVisible(position.getX(), position.getY(), position.getZ()); + } + + private static void assertNibbleEquals(SWMRNibbleArray expected, SWMRNibbleArray actual) { + for (int index = 0; index < 4096; index++) { + assertEquals(expected.getVisible(index), actual.getVisible(index)); + } + } + + private static final class Fixture { + private final RegistryAccess registries = HeadlessNativeTestRegistries.get(); + private final PalettedContainerFactory containers = PalettedContainerFactory.create(registries); + private final List chunks = new ArrayList<>(); + private final ChunkPos target; + + private Fixture(int x, int z) { + target = new ChunkPos(x, z); + LevelHeightAccessor height = LevelHeightAccessor.create(-64, 128); + for (int dz = -2; dz <= 2; dz++) { + for (int dx = -2; dx <= 2; dx++) { + chunks.add(new ProtoChunk(new ChunkPos(x + dx, z + dz), UpgradeData.EMPTY, height, containers, null)); + } + } + } + + private ProtoChunk chunk(ChunkPos position) { + for (ProtoChunk chunk : chunks) { + if (chunk.getPos().equals(position)) { + return chunk; + } + } + throw new IllegalArgumentException("Missing fixture chunk " + position); + } + + private void set(BlockPos position, BlockState state) { + chunk(new ChunkPos(position.getX() >> 4, position.getZ() >> 4)).setBlockState(position, state, 0); + } + + private void finishFeatures() { + for (ProtoChunk chunk : chunks) { + chunk.setPersistedStatus(ChunkStatus.FEATURES); + } + } + + private HeadlessChunkLighting.Request request(boolean sky) { + return new HeadlessChunkLighting.Request(chunks, Set.of(target), sky); + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/OfflineFeatureWorld.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/OfflineFeatureWorld.java new file mode 100644 index 000000000..d640366bf --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/OfflineFeatureWorld.java @@ -0,0 +1,443 @@ +package art.arcane.iris.probe; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.Holder; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.particles.ParticleOptions; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.RandomSource; +import net.minecraft.world.DifficultyInstance; +import net.minecraft.world.attribute.EnvironmentAttributeReader; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.flag.FeatureFlagSet; +import net.minecraft.world.flag.FeatureFlags; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.LightLayer; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeManager; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.EntityBlock; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.border.WorldBorder; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.ChunkSource; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.dimension.DimensionType; +import net.minecraft.world.level.entity.EntityTypeTest; +import net.minecraft.world.level.gameevent.GameEvent; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.lighting.LevelLightEngine; +import net.minecraft.world.level.material.Fluid; +import net.minecraft.world.level.material.FluidState; +import net.minecraft.world.level.storage.LevelData; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import net.minecraft.world.ticks.LevelTickAccess; +import net.minecraft.world.ticks.ScheduledTick; +import net.minecraft.world.ticks.TickContainerAccess; +import org.bukkit.event.entity.CreatureSpawnEvent; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Predicate; + +final class OfflineFeatureWorld implements WorldGenLevel { + private final Options options; + private final Map chunks = new HashMap<>(); + private final RandomSource random; + private final BiomeManager biomeManager; + private final WorldBorder border = new WorldBorder(); + private final LevelTickAccess blockTicks; + private final LevelTickAccess fluidTicks; + private long subTick; + + OfflineFeatureWorld(Options options, List chunks) { + this.options = options; + for (ProtoChunk chunk : chunks) { + if (chunk.getPersistedStatus() != ChunkStatus.TERRAIN) { + throw new IllegalArgumentException("Feature fixture requires TERRAIN chunks"); + } + if (this.chunks.put(chunk.getPos().pack(), chunk) != null) { + throw new IllegalArgumentException("Duplicate chunk"); + } + } + random = RandomSource.create(options.seed()); + biomeManager = new BiomeManager(this, BiomeManager.obfuscateSeed(options.seed())); + blockTicks = ticks(ProtoChunk::getBlockTicks); + fluidTicks = ticks(ProtoChunk::getFluidTicks); + } + + @Override + public long getGameTime() { + return 0L; + } + + @Override + public ServerLevel getMinecraftWorld() { + throw unsupported("ServerLevel"); + } + + @Override + public long getSeed() { + return options.seed(); + } + + @Override + public boolean ensureCanWrite(BlockPos position) { + return !isOutsideBuildHeight(position) && hasChunk(position.getX() >> 4, position.getZ() >> 4); + } + + @Override + public ServerLevel getLevel() { + throw unsupported("ServerLevel"); + } + + @Override + public DifficultyInstance getCurrentDifficultyAt(BlockPos position) { + throw unsupported("getCurrentDifficultyAt"); + } + + @Override + public long nextSubTickCount() { + return subTick++; + } + + @Override + public LevelTickAccess getBlockTicks() { + return blockTicks; + } + + @Override + public LevelTickAccess getFluidTicks() { + return fluidTicks; + } + + @Override + public LevelData getLevelData() { + throw unsupported("getLevelData"); + } + + @Override + public MinecraftServer getServer() { + throw unsupported("getServer"); + } + + @Override + public ChunkSource getChunkSource() { + throw unsupported("getChunkSource"); + } + + @Override + public RandomSource getRandom() { + return random; + } + + @Override + public void updateNeighborsAt(BlockPos position, Block block) { + throw unsupported("updateNeighborsAt"); + } + + @Override + public void neighborShapeChanged(Direction direction, BlockPos position, BlockPos neighbourPosition, BlockState neighbourState, int updateFlags, int updateLimit) { + throw unsupported("neighborShapeChanged"); + } + + @Override + public void playSound(Entity source, BlockPos position, SoundEvent sound, SoundSource soundSource, float volume, float pitch) { + throw unsupported("playSound"); + } + + @Override + public void addParticle(ParticleOptions particle, double x, double y, double z, double velocityX, double velocityY, double velocityZ) { + throw unsupported("addParticle"); + } + + @Override + public void levelEvent(Entity source, int eventId, BlockPos position, int data) { + throw unsupported("levelEvent"); + } + + @Override + public void gameEvent(Holder event, Vec3 position, GameEvent.Context context) { + throw unsupported("gameEvent"); + } + + @Override + public ChunkAccess getChunk(int chunkX, int chunkZ) { + return chunk(chunkX, chunkZ); + } + + @Override + public ChunkAccess getChunk(int chunkX, int chunkZ, ChunkStatus status, boolean create) { + ProtoChunk chunk = chunks.get(ChunkPos.pack(chunkX, chunkZ)); + if (chunk == null && !create) { + return null; + } + chunk = chunk(chunkX, chunkZ); + if (!chunk.getPersistedStatus().isOrAfter(status)) { + throw unsupported("chunk status " + status); + } + return chunk; + } + + @Override + public ChunkAccess getChunkIfLoadedImmediately(int chunkX, int chunkZ) { + return chunks.get(ChunkPos.pack(chunkX, chunkZ)); + } + + @Override + public boolean hasChunk(int chunkX, int chunkZ) { + return chunks.containsKey(ChunkPos.pack(chunkX, chunkZ)); + } + + @Override + public int getHeight(Heightmap.Types type, int x, int z) { + return chunk(x >> 4, z >> 4).getHeight(type, x & 15, z & 15) + 1; + } + + @Override + public int getSkyDarken() { + throw unsupported("getSkyDarken"); + } + + @Override + public BiomeManager getBiomeManager() { + return biomeManager; + } + + @Override + public Holder getNoiseBiome(int quartX, int quartY, int quartZ) { + return getUncachedNoiseBiome(quartX, quartY, quartZ); + } + + @Override + public Holder getUncachedNoiseBiome(int quartX, int quartY, int quartZ) { + return chunk(quartX >> 2, quartZ >> 2).getNoiseBiome(quartX, quartY, quartZ); + } + + @Override + public boolean isClientSide() { + return false; + } + + @Override + public int getSeaLevel() { + return options.seaLevel(); + } + + @Override + public DimensionType dimensionType() { + return options.dimension(); + } + + @Override + public int getMinY() { + return options.dimension().minY(); + } + + @Override + public int getHeight() { + return options.dimension().height(); + } + + @Override + public RegistryAccess registryAccess() { + return options.registries(); + } + + @Override + public FeatureFlagSet enabledFeatures() { + return FeatureFlags.DEFAULT_FLAGS; + } + + @Override + public EnvironmentAttributeReader environmentAttributes() { + throw unsupported("environmentAttributes"); + } + + @Override + public LevelLightEngine getLightEngine() { + throw unsupported("getLightEngine"); + } + + @Override + public int getBrightness(LightLayer layer, BlockPos position) { + throw unsupported("getBrightness"); + } + + @Override + public int getRawBrightness(BlockPos position, int ambientDarkening) { + throw unsupported("getRawBrightness"); + } + + @Override + public boolean canSeeSky(BlockPos position) { + throw unsupported("canSeeSky"); + } + + @Override + public WorldBorder getWorldBorder() { + return border; + } + + @Override + public BlockGetter getChunkForCollisions(int chunkX, int chunkZ) { + return chunk(chunkX, chunkZ); + } + + @Override + public BlockEntity getBlockEntity(BlockPos position) { + return chunk(position).getBlockEntity(position); + } + + @Override + public BlockState getBlockState(BlockPos position) { + return chunk(position).getBlockState(position); + } + + @Override + public BlockState getBlockStateIfLoaded(BlockPos position) { + return hasChunk(position.getX() >> 4, position.getZ() >> 4) ? getBlockState(position) : null; + } + + @Override + public FluidState getFluidState(BlockPos position) { + return getBlockState(position).getFluidState(); + } + + @Override + public FluidState getFluidIfLoaded(BlockPos position) { + BlockState state = getBlockStateIfLoaded(position); + return state == null ? null : state.getFluidState(); + } + + @Override + public List getEntities(Entity source, AABB area, Predicate predicate) { + throw unsupported("getEntities"); + } + + @Override + public List getEntities(EntityTypeTest type, AABB area, Predicate predicate) { + throw unsupported("getEntities"); + } + + @Override + public List players() { + return List.of(); + } + + @Override + public boolean isStateAtPosition(BlockPos position, Predicate predicate) { + return predicate.test(getBlockState(position)); + } + + @Override + public boolean isFluidAtPosition(BlockPos position, Predicate predicate) { + return predicate.test(getFluidState(position)); + } + + @Override + public BlockPos getHeightmapPos(Heightmap.Types type, BlockPos position) { + return position.atY(getHeight(type, position.getX(), position.getZ())); + } + + @Override + public boolean setBlock(BlockPos position, BlockState state, int updateFlags, int updateLimit) { + if (!ensureCanWrite(position)) { + throw new IllegalStateException("Feature write outside prepared bounds: " + position); + } + ProtoChunk chunk = chunk(position); + BlockState previous = chunk.setBlockState(position, state, updateFlags); + if (previous == state) { + return false; + } + chunk.removeBlockEntity(position); + if (state.getBlock() instanceof EntityBlock block) { + BlockEntity entity = block.newBlockEntity(position, state); + if (entity != null) { + chunk.setBlockEntity(entity); + } + } + BlockPos postProcess = state.getPostProcessPos(this, position); + if (postProcess != null) { + chunk(postProcess).markPosForPostProcessing(postProcess); + } + return true; + } + + @Override + public boolean removeBlock(BlockPos position, boolean move) { + return setBlock(position, Blocks.AIR.defaultBlockState(), 2, 0); + } + + @Override + public boolean destroyBlock(BlockPos position, boolean drop, Entity source, int updateLimit) { + throw unsupported("destroyBlock"); + } + + @Override + public boolean addFreshEntity(Entity entity) { + throw unsupported("addFreshEntity"); + } + + @Override + public boolean addFreshEntity(Entity entity, CreatureSpawnEvent.SpawnReason reason) { + throw unsupported("addFreshEntity"); + } + + private ProtoChunk chunk(BlockPos position) { + return chunk(position.getX() >> 4, position.getZ() >> 4); + } + + private ProtoChunk chunk(int x, int z) { + ProtoChunk chunk = chunks.get(ChunkPos.pack(x, z)); + if (chunk == null) { + throw new IllegalStateException("Feature escaped prepared chunk region: " + x + "," + z); + } + return chunk; + } + + private LevelTickAccess ticks(Function> containers) { + return new LevelTickAccess<>() { + @Override + public void schedule(ScheduledTick tick) { + containers.apply(chunk(tick.pos())).schedule(tick); + } + @Override + public boolean hasScheduledTick(BlockPos position, T type) { + return containers.apply(chunk(position)).hasScheduledTick(position, type); + } + @Override + public int count() { + int count = 0; + for (ProtoChunk chunk : chunks.values()) { + count += containers.apply(chunk).count(); + } + return count; + } + @Override + public boolean willTickThisTick(BlockPos position, T type) { + throw unsupported("live tick execution"); + } + }; + } + + private static UnsupportedOperationException unsupported(String capability) { + return new UnsupportedOperationException("Offline feature fixture has no " + capability); + } + + record Options(RegistryAccess registries, DimensionType dimension, long seed, int seaLevel) { + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/OfflineNativeFeatureFeasibilityTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/OfflineNativeFeatureFeasibilityTest.java new file mode 100644 index 000000000..b40093d09 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/OfflineNativeFeatureFeasibilityTest.java @@ -0,0 +1,227 @@ +package art.arcane.iris.probe; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.LevelHeightAccessor; +import net.minecraft.world.level.biome.Biomes; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.ChestBlockEntity; +import net.minecraft.world.level.chunk.PalettedContainerFactory; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.chunk.UpgradeData; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.dimension.DimensionType; +import net.minecraft.world.level.levelgen.placement.PlacedFeature; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.structures.StrongholdPieces; +import net.minecraft.world.level.levelgen.structure.structures.SwampHutPiece; +import net.minecraft.world.level.material.Fluids; +import org.bukkit.Bukkit; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public final class OfflineNativeFeatureFeasibilityTest { + private static HeadlessNativeRegistries loaded; + private static RegistryAccess registries; + private static DimensionType dimension; + + @BeforeClass + public static void loadImportedFeatures() throws Exception { + assertNull(Bukkit.getServer()); + loaded = HeadlessNativeRegistries.load(Map.of( + "data/iris/worldgen/placed_feature/offline_spring.json", + "{\"feature\":\"minecraft:spring_water\",\"placement\":[]}", + "data/iris/worldgen/placed_feature/offline_chest.json", + "{\"feature\":{\"type\":\"minecraft:simple_block\",\"to_place\":{\"id\":\"minecraft:chest\"}},\"placement\":[]}" + )); + registries = loaded.registries(); + dimension = registries.lookupOrThrow(Registries.DIMENSION_TYPE).getOrThrow(ResourceKey.create( + Registries.DIMENSION_TYPE, Identifier.parse("minecraft:overworld"))).value(); + } + + @AfterClass + public static void closeRegistries() throws Exception { + if (loaded != null) { + loaded.close(); + } + assertNull(Bukkit.getServer()); + } + + @Test + public void registryConfiguredSpringCapturesNativeFluidTickAcrossChunkBoundary() { + Fixture fixture = fixture(); + BlockPos spring = new BlockPos(-1, 20, 0); + for (Direction direction : Direction.values()) { + if (direction != Direction.EAST) { + fixture.world().setBlock(spring.relative(direction), Blocks.STONE.defaultBlockState(), 2); + } + } + assertTrue(feature("offline_spring").placement().isEmpty()); + assertTrue(feature("offline_spring").feature().value().place(fixture.world(), null, RandomSource.create(42L), spring)); + assertTrue(fixture.world().getFluidState(spring).is(Fluids.WATER)); + assertTrue(fixture.world().getFluidTicks().hasScheduledTick(spring, Fluids.WATER)); + assertEquals(1, fixture.world().getFluidTicks().count()); + ProtoChunk source = fixture.chunk(-1, 0); + assertEquals(1, source.getTicksForSerialization(0L).fluids().size()); + assertEquals(spring, source.getTicksForSerialization(0L).fluids().getFirst().pos()); + assertFalse(source.isLightCorrect()); + assertEquals(ChunkStatus.TERRAIN, source.getPersistedStatus()); + assertThrows(UnsupportedOperationException.class, + () -> fixture.world().getFluidTicks().willTickThisTick(spring, Fluids.WATER)); + assertNull(Bukkit.getServer()); + } + + @Test + public void registryConfiguredSimpleBlockCreatesRealNativeBlockEntity() { + Fixture fixture = fixture(); + BlockPos chest = new BlockPos(1, 20, 1); + assertTrue(feature("offline_chest").placement().isEmpty()); + assertTrue(feature("offline_chest").feature().value().place(fixture.world(), null, RandomSource.create(3L), chest)); + assertTrue(fixture.world().getBlockState(chest).is(Blocks.CHEST)); + assertTrue(fixture.world().getBlockEntity(chest) instanceof ChestBlockEntity); + CompoundTag saved = fixture.chunk(0, 0).getBlockEntityNbtForSaving(chest, registries); + assertNotNull(saved); + assertEquals("minecraft:chest", saved.getStringOr("id", "")); + assertEquals(1, saved.getIntOr("x", -1)); + assertEquals(20, saved.getIntOr("y", -1)); + assertEquals(ChunkStatus.TERRAIN, fixture.chunk(0, 0).getPersistedStatus()); + assertNull(Bukkit.getServer()); + } + + @Test + public void importedPlacedFeatureWrapperRequiresServerLevelEvenWithoutModifiers() { + Fixture fixture = fixture(); + BlockPos position = new BlockPos(1, 20, 1); + for (String key : List.of("offline_spring", "offline_chest")) { + PlacedFeature placed = feature(key); + assertTrue(placed.placement().isEmpty()); + UnsupportedOperationException failure = assertThrows(UnsupportedOperationException.class, + () -> placed.place(fixture.world(), null, RandomSource.create(42L), position)); + assertTrue(failure.getMessage(), failure.getMessage().contains("ServerLevel")); + assertTrue(hasFrame(failure, "net.minecraft.world.level.levelgen.placement.PlacementContext")); + } + assertTrue(fixture.world().getBlockState(position).isAir()); + assertEquals(0, fixture.world().getFluidTicks().count()); + assertEquals(ChunkStatus.TERRAIN, fixture.chunk(0, 0).getPersistedStatus()); + } + + @Test + public void realStrongholdPieceWritesAcrossPreparedChunksWithoutServer() { + Fixture fixture = fixture(); + BoundingBox box = new BoundingBox(-2, 20, -2, 2, 24, 4); + for (BlockPos position : BlockPos.betweenClosed(-2, 20, -2, 2, 24, 4)) { + fixture.world().setBlock(position, Blocks.STONE.defaultBlockState(), 2); + } + StrongholdPieces.Straight piece = new StrongholdPieces.Straight(0, RandomSource.create(4L), box, Direction.SOUTH); + piece.postProcess(fixture.world(), null, null, RandomSource.create(5L), + box, new ChunkPos(0, 0), BlockPos.ZERO); + assertTrue(fixture.world().getBlockState(new BlockPos(0, 22, 0)).isAir()); + assertFalse(fixture.world().getBlockState(new BlockPos(-2, 20, -2)).is(Blocks.STONE)); + assertFalse(fixture.world().getBlockState(new BlockPos(2, 20, 4)).is(Blocks.STONE)); + for (ProtoChunk chunk : fixture.chunks()) { + assertEquals(ChunkStatus.TERRAIN, chunk.getPersistedStatus()); + } + assertNull(Bukkit.getServer()); + } + + @Test + public void nativeLootChestRequiresCraftBukkitWorldServices() { + Fixture fixture = fixture(); + BoundingBox box = new BoundingBox(0, 20, 0, 4, 24, 6); + StrongholdPieces.ChestCorridor piece = new StrongholdPieces.ChestCorridor( + 0, RandomSource.create(4L), box, Direction.SOUTH); + RuntimeException failure = assertThrows(RuntimeException.class, + () -> piece.postProcess(fixture.world(), null, null, RandomSource.create(5L), + box, new ChunkPos(0, 0), BlockPos.ZERO)); + assertEquals("Failed to read non-placed BlockState", failure.getMessage()); + assertTrue(failure.getCause() instanceof NullPointerException); + assertTrue(hasFrame(failure.getCause(), "net.minecraft.world.level.storage.TagValueOutput")); + assertTrue(hasFrame(failure, "org.bukkit.craftbukkit.block.CraftBlockStates")); + assertEquals(ChunkStatus.TERRAIN, fixture.chunk(0, 0).getPersistedStatus()); + assertNull(Bukkit.getServer()); + } + + @Test + public void nativeHutPopulationRequiresServerLevelAndLeavesChunkUnpromoted() { + Fixture fixture = fixture(); + for (BlockPos position : BlockPos.betweenClosed(-16, 63, -16, 31, 63, 31)) { + fixture.world().setBlock(position, Blocks.STONE.defaultBlockState(), 2); + } + SwampHutPiece piece = new SwampHutPiece(RandomSource.create(9L), 0, 0); + UnsupportedOperationException failure = assertThrows(UnsupportedOperationException.class, + () -> piece.postProcess(fixture.world(), null, null, RandomSource.create(6L), + new BoundingBox(-16, dimension.minY(), -16, 31, dimension.minY() + dimension.height() - 1, 31), + new ChunkPos(0, 0), BlockPos.ZERO)); + assertTrue(failure.getMessage(), failure.getMessage().contains("ServerLevel")); + assertTrue(hasFrame(failure, SwampHutPiece.class.getName())); + for (ProtoChunk chunk : fixture.chunks()) { + assertEquals(ChunkStatus.TERRAIN, chunk.getPersistedStatus()); + assertTrue(chunk.getEntities().isEmpty()); + } + } + + @Test + public void finiteFixtureRejectsEscapedWritesAndUnavailableStatus() { + Fixture fixture = fixture(); + assertThrows(IllegalStateException.class, + () -> fixture.world().setBlock(new BlockPos(32, 20, 0), Blocks.STONE.defaultBlockState(), 2)); + assertThrows(UnsupportedOperationException.class, + () -> fixture.world().getChunk(0, 0, ChunkStatus.FULL, true)); + assertNull(fixture.world().getChunk(3, 3, ChunkStatus.TERRAIN, false)); + } + + private static PlacedFeature feature(String path) { + return registries.lookupOrThrow(Registries.PLACED_FEATURE).getOrThrow(ResourceKey.create( + Registries.PLACED_FEATURE, Identifier.fromNamespaceAndPath("iris", path))).value(); + } + + private static boolean hasFrame(Throwable failure, String className) { + for (StackTraceElement frame : failure.getStackTrace()) { + if (frame.getClassName().equals(className)) { + return true; + } + } + return false; + } + + private static Fixture fixture() { + List chunks = new ArrayList<>(); + for (int z = -1; z <= 1; z++) { + for (int x = -1; x <= 1; x++) { + ProtoChunk chunk = new ProtoChunk(new ChunkPos(x, z), UpgradeData.EMPTY, + LevelHeightAccessor.create(dimension.minY(), dimension.height()), + PalettedContainerFactory.create(registries), null); + chunk.fillBiomesFromNoise((qx, qy, qz) -> registries.lookupOrThrow(Registries.BIOME).getOrThrow(Biomes.PLAINS)); + chunk.setPersistedStatus(ChunkStatus.TERRAIN); + chunks.add(chunk); + } + } + return new Fixture(new OfflineFeatureWorld(new OfflineFeatureWorld.Options( + registries, dimension, 69420L, 63), chunks), List.copyOf(chunks)); + } + + private record Fixture(OfflineFeatureWorld world, List chunks) { + ProtoChunk chunk(int x, int z) { + return (ProtoChunk) world.getChunk(x, z); + } + } +} diff --git a/build.gradle b/build.gradle index 9aaeb9f8f..d42637e2f 100644 --- a/build.gradle +++ b/build.gradle @@ -1125,3 +1125,5 @@ commands: from(fixtureResources) } } + +apply from: file('gradle/packed-jar.gradle') diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index 67f86ee32..aadba42e8 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -31,6 +31,7 @@ tasks.named('test').configure { dependencies { implementation(gradleApi()) implementation('org.ow2.asm:asm:9.10.1') + implementation('org.tukaani:xz:1.10') implementation('org.apache.commons:commons-compress:1.28.0') implementation('io.papermc.paperweight:paperweight-userdev:2.0.0-beta.21') testImplementation('junit:junit:4.13.2') diff --git a/buildSrc/src/main/java/PackedJar.java b/buildSrc/src/main/java/PackedJar.java new file mode 100644 index 000000000..2d3405245 --- /dev/null +++ b/buildSrc/src/main/java/PackedJar.java @@ -0,0 +1,376 @@ +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.artifacts.component.ComponentIdentifier; +import org.gradle.api.artifacts.component.ModuleComponentIdentifier; +import org.gradle.api.artifacts.component.ProjectComponentIdentifier; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.MapProperty; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; +import org.gradle.api.tasks.TaskAction; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Label; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; +import org.tukaani.xz.LZMA2Options; +import org.tukaani.xz.XZOutputStream; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HexFormat; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.stream.Stream; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +public abstract class PackedJar extends DefaultTask { + static final String RUNTIME = "art/arcane/iris/platform/bootstrap/PackedRuntime"; + static final String PLUGIN_BASE = "art/arcane/iris/platform/bukkit/plugin/VolmitPlugin"; + static final String PAPER_LOADER = "art/arcane/iris/IrisPluginLoader"; + static final String PAPER_BOOTSTRAP = "art/arcane/iris/IrisBootstrap"; + static final String PAYLOAD = "META-INF/iris/runtime.jar.xz"; + static final String CHECKSUM = "META-INF/iris/runtime.sha256"; + static final String NATIVE_DIRECTORY = "META-INF/iris/native/"; + + @InputFile + @PathSensitive(PathSensitivity.NONE) + public abstract RegularFileProperty getInputJar(); + + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + public abstract ConfigurableFileCollection getBootstrapClasses(); + + @InputFiles + @PathSensitive(PathSensitivity.NONE) + public abstract ConfigurableFileCollection getDecoderJars(); + + @Internal + public abstract MapProperty getNativeArtifacts(); + + @Input + public List getNativeModules() { + return getNativeArtifacts().get().keySet().stream().sorted().toList(); + } + + @InputFiles + @PathSensitive(PathSensitivity.NONE) + public Collection getNativeProviderJars() { + return getNativeArtifacts().get().values(); + } + + @OutputFile + public abstract RegularFileProperty getOutputJar(); + + @TaskAction + public void pack() throws IOException, NoSuchAlgorithmException { + Map bootstrap = new TreeMap<>(); + for (File directory : getBootstrapClasses()) { + try (Stream paths = Files.walk(directory.toPath())) { + for (Path path : paths.filter(Files::isRegularFile).sorted().toList()) { + bootstrap.put(directory.toPath().relativize(path).toString().replace(File.separatorChar, '/'), + Files.readAllBytes(path)); + } + } + } + for (File decoder : getDecoderJars()) { + for (Map.Entry entry : readJar(decoder.toPath()).entrySet()) { + if (entry.getKey().startsWith("org/tukaani/xz/") && entry.getKey().endsWith(".class")) { + bootstrap.put(entry.getKey(), entry.getValue()); + } + } + } + if (!bootstrap.containsKey(RUNTIME + ".class")) { + throw new IOException("Packed runtime bootstrap class is missing."); + } + Path source = getInputJar().get().getAsFile().toPath(); + Path output = getOutputJar().get().getAsFile().toPath(); + PackResult result = writePackedJar(source, output, bootstrap, getNativeArtifacts().get()); + getLogger().lifecycle("Packed {} classes into XZ; retained {} bootstrap classes. {} -> {} bytes", + result.totalClasses(), result.bootstrapClasses(), Files.size(source), Files.size(output)); + } + + static PackResult writePackedJar(Path source, Path output, Map bootstrap, + Map nativeArtifacts) + throws IOException, NoSuchAlgorithmException { + Map contents = readJar(source); + embedNativeArtifacts(contents, nativeArtifacts); + int classCount = (int) contents.keySet().stream().filter(name -> name.endsWith(".class")).count(); + for (String name : List.of(PLUGIN_BASE, PAPER_LOADER, PAPER_BOOTSTRAP)) { + byte[] original = contents.get(name + ".class"); + if (original == null) { + throw new IOException("Required bootstrap entry is missing: " + name); + } + contents.put(name + ".class", instrument(original, name.equals(PLUGIN_BASE))); + } + Set retained = bootstrapClosure(contents); + contents.putAll(bootstrap); + byte[] payload = writeJar(contents, true); + ByteArrayOutputStream compressed = new ByteArrayOutputStream(); + LZMA2Options options = new LZMA2Options(9); + options.setDictSize(Math.min(options.getDictSize(), Math.max(4096, payload.length))); + try (XZOutputStream xz = new XZOutputStream(compressed, options)) { + xz.write(payload); + } + Map outer = new TreeMap<>(); + for (Map.Entry entry : contents.entrySet()) { + if (entry.getKey().startsWith(NATIVE_DIRECTORY)) { + continue; + } + if (entry.getKey().endsWith(".class")) { + if (!retained.contains(entry.getKey())) { + continue; + } + } + outer.put(entry.getKey(), entry.getValue()); + } + outer.putAll(bootstrap); + outer.put(PAYLOAD, compressed.toByteArray()); + outer.put(CHECKSUM, (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(payload)) + "\n") + .getBytes(StandardCharsets.US_ASCII)); + Files.createDirectories(output.toAbsolutePath().getParent()); + Files.write(output, writeJar(outer, false)); + return new PackResult(classCount, retained.size()); + } + + public static Map resolveNativeArtifacts(Map resolved) { + Map artifacts = new TreeMap<>(); + for (Map.Entry entry : resolved.entrySet()) { + String module; + if (entry.getKey() instanceof ModuleComponentIdentifier component + && component.getGroup().equals("com.github.VolmitSoftware.VolmLib")) { + module = component.getModule(); + } else if (entry.getKey() instanceof ProjectComponentIdentifier component) { + module = component.getProjectPath().substring(1).replace(':', '-'); + } else { + throw new GradleException("Unexpected native provider component: " + entry.getKey().getDisplayName()); + } + if (!module.matches("native-(common|v[0-9_]+R[0-9]+)")) { + throw new GradleException("Unexpected native provider module: " + module); + } + if (artifacts.put(module, entry.getValue()) != null) { + throw new GradleException("Duplicate native provider module: " + module); + } + } + return artifacts; + } + + private static void embedNativeArtifacts(Map contents, Map artifacts) + throws IOException, NoSuchAlgorithmException { + byte[] original = contents.get(NativeRuntimeArtifacts.MANIFEST); + if (original == null) { + throw new IOException("Missing native runtime dependency manifest"); + } + Properties manifest = new Properties(); + manifest.load(new ByteArrayInputStream(original)); + Set modules = new TreeSet<>(List.of(manifest.getProperty("modules", "").split(","))); + if (!modules.equals(artifacts.keySet()) || !modules.contains("native-common")) { + throw new IOException("Embedded native providers differ: " + artifacts.keySet() + "; expected " + modules); + } + List embeddedManifest = new ArrayList<>(); + embeddedManifest.add("modules=" + String.join(",", modules)); + embeddedManifest.add("storage=embedded"); + for (String module : modules) { + if (!module.matches("native-(common|v[0-9_]+R[0-9]+)")) { + throw new IOException("Invalid embedded native provider module: " + module); + } + byte[] bytes = Files.readAllBytes(artifacts.get(module).toPath()); + String digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + embeddedManifest.add(module + ".coordinate=com.github.VolmitSoftware.VolmLib:" + module + ":embedded-" + digest); + embeddedManifest.add(module + ".sha256=" + digest); + contents.put(NATIVE_DIRECTORY + module + ".jar", bytes); + } + contents.put(NativeRuntimeArtifacts.MANIFEST, + (String.join("\n", embeddedManifest) + "\n").getBytes(StandardCharsets.UTF_8)); + } + + static Map readJar(Path path) throws IOException { + Map contents = new TreeMap<>(); + try (ZipFile jar = new ZipFile(path.toFile())) { + for (ZipEntry entry : jar.stream().filter(entry -> !entry.isDirectory()).toList()) { + try (InputStream input = jar.getInputStream(entry)) { + contents.put(entry.getName(), input.readAllBytes()); + } + } + } + return contents; + } + + static byte[] writeJar(Map contents, boolean stored) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (JarOutputStream jar = new JarOutputStream(bytes)) { + jar.setLevel(9); + for (Map.Entry entry : contents.entrySet()) { + JarEntry zipEntry = new JarEntry(entry.getKey()); + zipEntry.setTime(0L); + if (stored || entry.getKey().equals(PAYLOAD)) { + CRC32 crc = new CRC32(); + crc.update(entry.getValue()); + zipEntry.setMethod(ZipEntry.STORED); + zipEntry.setSize(entry.getValue().length); + zipEntry.setCompressedSize(entry.getValue().length); + zipEntry.setCrc(crc.getValue()); + } + jar.putNextEntry(zipEntry); + jar.write(entry.getValue()); + jar.closeEntry(); + } + } + return bytes.toByteArray(); + } + + static Set bootstrapClosure(Map contents) { + List roots = List.of("art/arcane/iris/Iris", PLUGIN_BASE, PAPER_LOADER, + PAPER_BOOTSTRAP, "art/arcane/volmlib/integration/ReloadAware"); + ArrayDeque pending = new ArrayDeque<>(roots); + for (String root : roots) { + byte[] bytes = contents.get(root + ".class"); + if (bytes != null) { + pending.addAll(ClassReferences.read(bytes)); + } + } + for (String entry : contents.keySet()) { + if (entry.startsWith("art/arcane/iris/util/slimjar/") && entry.endsWith(".class")) { + pending.add(entry.substring(0, entry.length() - 6)); + } + } + Set retained = new LinkedHashSet<>(); + while (!pending.isEmpty()) { + String name = pending.removeFirst(); + byte[] bytes = contents.get(name + ".class"); + if (bytes == null || !retained.add(name + ".class")) { + continue; + } + ClassReader reader = new ClassReader(bytes); + if (reader.getSuperName() != null) { + pending.add(reader.getSuperName()); + } + for (String implemented : reader.getInterfaces()) { + pending.add(implemented); + } + } + return retained; + } + + static byte[] instrument(byte[] original, boolean rewriteJarAccessor) { + ClassReader reader = new ClassReader(original); + ClassWriter writer = new ClassWriter(reader, 0); + reader.accept(new ClassVisitor(Opcodes.ASM9, writer) { + private boolean hasInitializer; + + @Override + public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, + String[] exceptions) { + MethodVisitor method = super.visitMethod(access, name, descriptor, signature, exceptions); + if (reader.getClassName().equals(PAPER_LOADER) && name.equals("classloader") + && descriptor.equals("(Lio/papermc/paper/plugin/loader/PluginClasspathBuilder;)V")) { + return releaseTemporaryLoader(method); + } + if (rewriteJarAccessor && name.equals("getJarFile") && descriptor.equals("()Ljava/io/File;")) { + method.visitCode(); + method.visitMethodInsn(Opcodes.INVOKESTATIC, RUNTIME, "runtimeJar", "()Ljava/io/File;", false); + method.visitInsn(Opcodes.ARETURN); + method.visitMaxs(1, 1); + method.visitEnd(); + return null; + } + if (!name.equals("")) { + return method; + } + hasInitializer = true; + return new MethodVisitor(Opcodes.ASM9, method) { + @Override + public void visitCode() { + super.visitCode(); + visitMethodInsn(Opcodes.INVOKESTATIC, RUNTIME, "install", "()V", false); + } + }; + } + + @Override + public void visitEnd() { + if (!hasInitializer) { + MethodVisitor initializer = super.visitMethod(Opcodes.ACC_STATIC, "", "()V", null, null); + initializer.visitCode(); + initializer.visitMethodInsn(Opcodes.INVOKESTATIC, RUNTIME, "install", "()V", false); + initializer.visitInsn(Opcodes.RETURN); + initializer.visitMaxs(0, 0); + initializer.visitEnd(); + } + super.visitEnd(); + } + }, 0); + return writer.toByteArray(); + } + + private static MethodVisitor releaseTemporaryLoader(MethodVisitor method) { + return new MethodVisitor(Opcodes.ASM9, method) { + private final Label start = new Label(); + private final Label end = new Label(); + private final Label handler = new Label(); + private final Label completed = new Label(); + + @Override + public void visitCode() { + super.visitCode(); + super.visitLabel(start); + } + + @Override + public void visitInsn(int opcode) { + if (opcode == Opcodes.RETURN) { + super.visitJumpInsn(Opcodes.GOTO, completed); + } else { + super.visitInsn(opcode); + } + } + + @Override + public void visitMaxs(int maxStack, int maxLocals) { + super.visitLabel(end); + super.visitTryCatchBlock(start, end, handler, "java/lang/Throwable"); + super.visitLabel(handler); + super.visitFrame(Opcodes.F_FULL, 0, null, 1, new Object[]{"java/lang/Throwable"}); + super.visitInsn(Opcodes.DUP); + super.visitMethodInsn(Opcodes.INVOKESTATIC, RUNTIME, "releaseTemporary", "(Ljava/lang/Throwable;)V", false); + super.visitInsn(Opcodes.ATHROW); + super.visitLabel(completed); + super.visitFrame(Opcodes.F_FULL, 0, null, 0, null); + super.visitInsn(Opcodes.ACONST_NULL); + super.visitMethodInsn(Opcodes.INVOKESTATIC, RUNTIME, "releaseTemporary", "(Ljava/lang/Throwable;)V", false); + super.visitInsn(Opcodes.RETURN); + super.visitMaxs(Math.max(maxStack, 2), maxLocals); + } + }; + } + + record PackResult(int totalClasses, int bootstrapClasses) { + } +} diff --git a/buildSrc/src/test/java/PackedJarTest.java b/buildSrc/src/test/java/PackedJarTest.java new file mode 100644 index 000000000..c78fd2b76 --- /dev/null +++ b/buildSrc/src/test/java/PackedJarTest.java @@ -0,0 +1,399 @@ +import org.gradle.api.artifacts.component.ProjectComponentIdentifier; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Label; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; +import org.tukaani.xz.XZInputStream; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.jar.JarEntry; +import java.util.jar.JarInputStream; +import java.util.zip.ZipEntry; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class PackedJarTest { + @Rule + public TemporaryFolder directory = new TemporaryFolder(); + + @Test + public void preservesAllRuntimeEntriesAndPublishesTheirChecksum() throws Exception { + Path original = directory.newFile("original.jar").toPath(); + Path packed = directory.getRoot().toPath().resolve("packed.jar"); + Map contents = new LinkedHashMap<>(); + contents.put(PackedJar.PLUGIN_BASE + ".class", pluginBase()); + contents.put(PackedJar.PAPER_LOADER + ".class", simpleClass(PackedJar.PAPER_LOADER, "java/lang/Object")); + contents.put(PackedJar.PAPER_BOOTSTRAP + ".class", simpleClass(PackedJar.PAPER_BOOTSTRAP, "java/lang/Object")); + contents.put("art/arcane/iris/command/Example.class", simpleClass("art/arcane/iris/command/Example", "java/lang/Object")); + contents.put("plugin.yml", "main: art.arcane.iris.Iris\n".getBytes(StandardCharsets.UTF_8)); + List modules = List.of("native-common", "native-v26_2_R1", "native-v26_3_R1"); + contents.put(NativeRuntimeArtifacts.MANIFEST, + ("modules=" + String.join(",", modules) + "\nrepository=http://127.0.0.1:8765/\n") + .getBytes(StandardCharsets.UTF_8)); + Map nativeArtifacts = new LinkedHashMap<>(); + for (String module : modules) { + Path artifact = directory.newFile(module + ".jar").toPath(); + Files.write(artifact, PackedJar.writeJar(Map.of("provider.txt", module.getBytes(StandardCharsets.UTF_8)), false)); + nativeArtifacts.put(module, artifact.toFile()); + } + Files.write(original, PackedJar.writeJar(contents, false)); + + Map bootstrap = Map.of( + PackedJar.RUNTIME + ".class", simpleClass(PackedJar.RUNTIME, "java/lang/Object"), + PackedJar.RUNTIME + "$RuntimeArchive.class", simpleClass(PackedJar.RUNTIME + "$RuntimeArchive", "java/lang/Object"), + "org/tukaani/xz/XZInputStream.class", simpleClass("org/tukaani/xz/XZInputStream", "java/lang/Object")); + PackedJar.PackResult result = PackedJar.writePackedJar(original, packed, bootstrap, nativeArtifacts); + assertEquals(4, result.totalClasses()); + Map outer = PackedJar.readJar(packed); + assertFalse(outer.containsKey("art/arcane/iris/command/Example.class")); + assertArrayEquals(contents.get("plugin.yml"), outer.get("plugin.yml")); + byte[] payload; + try (XZInputStream xz = new XZInputStream(new ByteArrayInputStream(outer.get(PackedJar.PAYLOAD)))) { + payload = xz.readAllBytes(); + } + assertEquals(HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(payload)), + new String(outer.get(PackedJar.CHECKSUM), StandardCharsets.US_ASCII).trim()); + Map extracted = new LinkedHashMap<>(); + try (JarInputStream jar = new JarInputStream(new ByteArrayInputStream(payload))) { + for (JarEntry entry = jar.getNextJarEntry(); entry != null; entry = jar.getNextJarEntry()) { + assertEquals(ZipEntry.STORED, entry.getMethod()); + extracted.put(entry.getName(), jar.readAllBytes()); + } + } + Map expected = new LinkedHashMap<>(contents); + expected.putAll(bootstrap); + for (String module : modules) { + expected.put(PackedJar.NATIVE_DIRECTORY + module + ".jar", Files.readAllBytes(nativeArtifacts.get(module).toPath())); + } + assertEquals(expected.keySet(), extracted.keySet()); + Properties nativeManifest = new Properties(); + nativeManifest.load(new ByteArrayInputStream(extracted.get(NativeRuntimeArtifacts.MANIFEST))); + assertEquals(String.join(",", modules), nativeManifest.getProperty("modules")); + assertEquals("embedded", nativeManifest.getProperty("storage")); + assertNull(nativeManifest.getProperty("repository")); + assertFalse(new String(extracted.get(NativeRuntimeArtifacts.MANIFEST), StandardCharsets.UTF_8).contains("127.0.0.1")); + assertArrayEquals(extracted.get(NativeRuntimeArtifacts.MANIFEST), outer.get(NativeRuntimeArtifacts.MANIFEST)); + for (String module : modules) { + String path = PackedJar.NATIVE_DIRECTORY + module + ".jar"; + byte[] artifact = Files.readAllBytes(nativeArtifacts.get(module).toPath()); + String digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(artifact)); + assertArrayEquals(artifact, extracted.get(path)); + assertFalse(outer.containsKey(path)); + assertEquals(digest, nativeManifest.getProperty(module + ".sha256")); + assertEquals("com.github.VolmitSoftware.VolmLib:" + module + ":embedded-" + digest, + nativeManifest.getProperty(module + ".coordinate")); + } + for (String name : bootstrap.keySet()) { + assertArrayEquals(bootstrap.get(name), extracted.get(name)); + assertArrayEquals(bootstrap.get(name), outer.get(name)); + } + for (String name : contents.keySet()) { + if (!name.equals(NativeRuntimeArtifacts.MANIFEST) + && !List.of(PackedJar.PLUGIN_BASE, PackedJar.PAPER_LOADER, PackedJar.PAPER_BOOTSTRAP).contains( + name.replace(".class", ""))) { + assertArrayEquals(contents.get(name), extracted.get(name)); + } + } + for (String name : List.of(PackedJar.PLUGIN_BASE, PackedJar.PAPER_LOADER, PackedJar.PAPER_BOOTSTRAP)) { + assertArrayEquals(extracted.get(name + ".class"), outer.get(name + ".class")); + assertEquals(1, installationCalls(extracted.get(name + ".class"))); + } + } + + @Test + public void rejectsMissingOrUnexpectedEmbeddedNativeProviders() throws Exception { + Path original = directory.newFile("original.jar").toPath(); + Path packed = directory.getRoot().toPath().resolve("packed.jar"); + byte[] manifest = "modules=native-common,native-v26_2_R1,native-v26_3_R1\n".getBytes(StandardCharsets.UTF_8); + Files.write(original, PackedJar.writeJar(Map.of(NativeRuntimeArtifacts.MANIFEST, manifest), false)); + File common = directory.newFile("common.jar"); + assertThrows(IOException.class, () -> PackedJar.writePackedJar(original, packed, Map.of(), + Map.of("native-common", common, "native-v26_2_R1", common))); + assertThrows(IOException.class, () -> PackedJar.writePackedJar(original, packed, Map.of(), + Map.of("native-common", common, "native-v26_2_R1", common, "native-v26_3_R1", common, "native-other", common))); + assertFalse(Files.exists(packed)); + } + + @Test + public void resolvesCompositeNativeModulesWithoutParsingArtifactFileNames() { + File common = new File("arbitrary-common.jar"); + File provider = new File("arbitrary-provider.jar"); + assertEquals(Map.of("native-common", common, "native-v26_2_R1", provider), + PackedJar.resolveNativeArtifacts(Map.of(project(":native-common"), common, + project(":native:v26_2_R1"), provider))); + } + + private static ProjectComponentIdentifier project(String path) { + return (ProjectComponentIdentifier) Proxy.newProxyInstance(PackedJarTest.class.getClassLoader(), + new Class[]{ProjectComponentIdentifier.class}, (proxy, method, arguments) -> switch (method.getName()) { + case "getProjectPath", "getDisplayName", "toString" -> path; + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == arguments[0]; + default -> throw new UnsupportedOperationException(method.getName()); + }); + } + + @Test + public void installsBeforeExistingInitializationAndRedirectsTheScannerJar() { + List instructions = new ArrayList<>(); + new ClassReader(PackedJar.instrument(pluginBase(), true)).accept(new ClassVisitor(Opcodes.ASM9) { + @Override + public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, + String[] exceptions) { + return new MethodVisitor(Opcodes.ASM9) { + @Override + public void visitMethodInsn(int opcode, String owner, String methodName, String methodDescriptor, + boolean isInterface) { + instructions.add(name + ":" + owner + "." + methodName); + } + + @Override + public void visitLdcInsn(Object value) { + instructions.add(name + ":" + value); + } + }; + } + }, 0); + assertEquals(List.of(":" + PackedJar.RUNTIME + ".install", ":existing", + "getJarFile:" + PackedJar.RUNTIME + ".runtimeJar"), instructions); + } + + @Test + public void retainsEntrypointReferencesAndHierarchyWithoutRecursiveMemberReferences() { + Map contents = new LinkedHashMap<>(); + ClassWriter iris = new ClassWriter(0); + iris.visit(Opcodes.V25, Opcodes.ACC_PUBLIC, "art/arcane/iris/Iris", null, "example/Base", null); + iris.visitField(Opcodes.ACC_PUBLIC, "field", "Lexample/Signature;", null, null).visitEnd(); + MethodVisitor method = iris.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "later", "()V", null, null); + method.visitCode(); + method.visitMethodInsn(Opcodes.INVOKESTATIC, "example/Body", "later", "()V", false); + method.visitInsn(Opcodes.RETURN); + method.visitMaxs(0, 0); + method.visitEnd(); + iris.visitEnd(); + contents.put("art/arcane/iris/Iris.class", iris.toByteArray()); + for (String name : List.of("example/Base", "example/Signature", "example/Body")) { + contents.put(name + ".class", simpleClass(name, "java/lang/Object")); + } + ClassWriter signature = new ClassWriter(0); + signature.visit(Opcodes.V25, Opcodes.ACC_PUBLIC, "example/Signature", null, "example/Ancestor", null); + signature.visitField(Opcodes.ACC_PUBLIC, "field", "Lexample/Chained;", null, null).visitEnd(); + signature.visitEnd(); + contents.put("example/Signature.class", signature.toByteArray()); + contents.put("example/Ancestor.class", simpleClass("example/Ancestor", "java/lang/Object")); + contents.put("example/Chained.class", simpleClass("example/Chained", "java/lang/Object")); + Set retained = PackedJar.bootstrapClosure(contents); + assertTrue(retained.contains("example/Base.class")); + assertTrue(retained.contains("example/Signature.class")); + assertTrue(retained.contains("example/Body.class")); + assertTrue(retained.contains("example/Ancestor.class")); + assertFalse(retained.contains("example/Chained.class")); + } + + @Test + public void releasesTemporaryLoaderAfterSuccessAndPreservesThrownFailure() throws Exception { + assertLoaderCleanup(false); + } + + @Test + public void releasesTemporaryLoaderWithDiscardedParameterFrameLocals() throws Exception { + assertLoaderCleanup(true); + } + + private static void assertLoaderCleanup(boolean discardFrameLocals) throws Exception { + String builderName = "io/papermc/paper/plugin/loader/PluginClasspathBuilder"; + byte[] original = loaderFixture(builderName); + if (discardFrameLocals) { + original = discardFrameLocals(original); + } + FixtureLoader fixture = new FixtureLoader(Map.of( + builderName, simpleClass(builderName, "java/lang/Object"), + PackedJar.RUNTIME, runtimeFixture(), + PackedJar.PAPER_LOADER, PackedJar.instrument(original, false))); + Class runtime = fixture.loadClass(PackedJar.RUNTIME.replace('/', '.')); + Class loader = fixture.loadClass(PackedJar.PAPER_LOADER.replace('/', '.')); + Object instance = loader.getConstructor().newInstance(); + Method invoke = loader.getMethod("classloader", fixture.loadClass(builderName.replace('/', '.'))); + invoke.invoke(instance, new Object[]{null}); + assertEquals(1, runtime.getField("releases").getInt(null)); + assertNull(runtime.getField("released").get(null)); + Throwable failure = new IllegalStateException("load failed"); + runtime.getField("failure").set(null, failure); + try { + invoke.invoke(instance, new Object[]{null}); + fail("Expected the original loader failure"); + } catch (InvocationTargetException caught) { + assertSame(failure, caught.getCause()); + } + assertEquals(2, runtime.getField("releases").getInt(null)); + assertSame(failure, runtime.getField("released").get(null)); + } + + private static byte[] discardFrameLocals(byte[] original) { + ClassWriter writer = new ClassWriter(0); + new ClassReader(original).accept(new ClassVisitor(Opcodes.ASM9, writer) { + @Override + public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, + String[] exceptions) { + MethodVisitor method = super.visitMethod(access, name, descriptor, signature, exceptions); + if (!name.equals("classloader")) { + return method; + } + return new MethodVisitor(Opcodes.ASM9, method) { + @Override + public void visitFrame(int type, int numLocal, Object[] local, int numStack, Object[] stack) { + super.visitFrame(Opcodes.F_FULL, 0, null, numStack, stack); + } + }; + } + }, ClassReader.EXPAND_FRAMES); + return writer.toByteArray(); + } + + private static int installationCalls(byte[] bytes) { + List calls = new ArrayList<>(); + new ClassReader(bytes).accept(new ClassVisitor(Opcodes.ASM9) { + @Override + public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, + String[] exceptions) { + return new MethodVisitor(Opcodes.ASM9) { + @Override + public void visitMethodInsn(int opcode, String owner, String methodName, String methodDescriptor, + boolean isInterface) { + if (name.equals("") && owner.equals(PackedJar.RUNTIME) && methodName.equals("install")) { + calls.add(methodName); + } + } + }; + } + }, 0); + return calls.size(); + } + + private static byte[] runtimeFixture() { + ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); + writer.visit(Opcodes.V25, Opcodes.ACC_PUBLIC, PackedJar.RUNTIME, null, "java/lang/Object", null); + for (String field : List.of("failure", "released")) { + writer.visitField(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, field, "Ljava/lang/Throwable;", null, null).visitEnd(); + } + writer.visitField(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "releases", "I", null, null).visitEnd(); + MethodVisitor install = writer.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "install", "()V", null, null); + install.visitCode(); + install.visitInsn(Opcodes.RETURN); + install.visitMaxs(0, 0); + install.visitEnd(); + MethodVisitor release = writer.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "releaseTemporary", + "(Ljava/lang/Throwable;)V", null, null); + release.visitCode(); + release.visitVarInsn(Opcodes.ALOAD, 0); + release.visitFieldInsn(Opcodes.PUTSTATIC, PackedJar.RUNTIME, "released", "Ljava/lang/Throwable;"); + release.visitFieldInsn(Opcodes.GETSTATIC, PackedJar.RUNTIME, "releases", "I"); + release.visitInsn(Opcodes.ICONST_1); + release.visitInsn(Opcodes.IADD); + release.visitFieldInsn(Opcodes.PUTSTATIC, PackedJar.RUNTIME, "releases", "I"); + release.visitInsn(Opcodes.RETURN); + release.visitMaxs(0, 0); + release.visitEnd(); + writer.visitEnd(); + return writer.toByteArray(); + } + + private static byte[] loaderFixture(String builderName) { + ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); + writer.visit(Opcodes.V25, Opcodes.ACC_PUBLIC, PackedJar.PAPER_LOADER, null, "java/lang/Object", null); + MethodVisitor constructor = writer.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null); + constructor.visitCode(); + constructor.visitVarInsn(Opcodes.ALOAD, 0); + constructor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false); + constructor.visitInsn(Opcodes.RETURN); + constructor.visitMaxs(0, 0); + constructor.visitEnd(); + MethodVisitor method = writer.visitMethod(Opcodes.ACC_PUBLIC, "classloader", "(L" + builderName + ";)V", null, null); + method.visitCode(); + method.visitFieldInsn(Opcodes.GETSTATIC, PackedJar.RUNTIME, "failure", "Ljava/lang/Throwable;"); + method.visitInsn(Opcodes.DUP); + Label success = new Label(); + method.visitJumpInsn(Opcodes.IFNULL, success); + method.visitInsn(Opcodes.ATHROW); + method.visitLabel(success); + method.visitInsn(Opcodes.POP); + method.visitInsn(Opcodes.RETURN); + method.visitMaxs(0, 0); + method.visitEnd(); + writer.visitEnd(); + return writer.toByteArray(); + } + + private static byte[] simpleClass(String name, String parent) { + ClassWriter writer = new ClassWriter(0); + writer.visit(Opcodes.V25, Opcodes.ACC_PUBLIC, name, null, parent, null); + writer.visitEnd(); + return writer.toByteArray(); + } + + private static byte[] pluginBase() { + ClassWriter writer = new ClassWriter(0); + writer.visit(Opcodes.V25, Opcodes.ACC_PUBLIC, PackedJar.PLUGIN_BASE, null, "java/lang/Object", null); + MethodVisitor initializer = writer.visitMethod(Opcodes.ACC_STATIC, "", "()V", null, null); + initializer.visitCode(); + initializer.visitLdcInsn("existing"); + initializer.visitInsn(Opcodes.POP); + initializer.visitInsn(Opcodes.RETURN); + initializer.visitMaxs(1, 0); + initializer.visitEnd(); + MethodVisitor accessor = writer.visitMethod(Opcodes.ACC_PUBLIC, "getJarFile", "()Ljava/io/File;", null, null); + accessor.visitCode(); + accessor.visitInsn(Opcodes.ACONST_NULL); + accessor.visitInsn(Opcodes.ARETURN); + accessor.visitMaxs(1, 1); + accessor.visitEnd(); + writer.visitEnd(); + return writer.toByteArray(); + } + + private static final class FixtureLoader extends ClassLoader { + private final Map classes; + + private FixtureLoader(Map classes) { + super(PackedJarTest.class.getClassLoader()); + this.classes = classes; + } + + @Override + protected Class findClass(String name) throws ClassNotFoundException { + byte[] bytes = classes.get(name.replace('.', '/')); + if (bytes == null) { + throw new ClassNotFoundException(name); + } + return defineClass(name, bytes, 0, bytes.length); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/platform/bootstrap/NativeRuntimeLibraries.java b/core/src/main/java/art/arcane/iris/platform/bootstrap/NativeRuntimeLibraries.java index fa7d151f1..e66d980c6 100644 --- a/core/src/main/java/art/arcane/iris/platform/bootstrap/NativeRuntimeLibraries.java +++ b/core/src/main/java/art/arcane/iris/platform/bootstrap/NativeRuntimeLibraries.java @@ -26,6 +26,7 @@ import java.net.URL; import java.net.URLClassLoader; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; @@ -60,6 +61,11 @@ public final class NativeRuntimeLibraries { } static NativeRuntimeLibraries from(Properties manifest, String minecraftVersion) throws IOException { + String storage = manifest.getProperty("storage", "repository"); + if (!storage.equals("repository") && !storage.equals("embedded")) { + throw new IllegalStateException("Invalid native runtime storage: " + storage); + } + boolean embedded = storage.equals("embedded"); NativeVersion version = NativeVersion.resolve(minecraftVersion).orElseThrow( () -> new UnsupportedOperationException("No native provider for Minecraft " + minecraftVersion)); List modules = List.of(manifest.getProperty("modules", "").split(",")); @@ -71,13 +77,14 @@ public final class NativeRuntimeLibraries { String[] coordinate = manifest.getProperty(module + ".coordinate", "").split(":"); String digest = manifest.getProperty(module + ".sha256", ""); if (coordinate.length != 3 || !coordinate[0].equals("com.github.VolmitSoftware.VolmLib") - || !coordinate[1].equals(module) || coordinate[2].isBlank() || !digest.matches("[0-9a-f]{64}")) { + || !coordinate[1].equals(module) || coordinate[2].isBlank() || !digest.matches("[0-9a-f]{64}") + || embedded && !coordinate[2].equals("embedded-" + digest)) { throw new IllegalStateException("Invalid native dependency manifest entry: " + module); } selected.put(new Dependency(coordinate[0], coordinate[1], coordinate[2], null, List.of()), digest); } - return new NativeRuntimeLibraries(Map.copyOf(selected), - new Repository(URI.create(manifest.getProperty("repository")).toURL())); + Repository repository = embedded ? null : new Repository(URI.create(manifest.getProperty("repository")).toURL()); + return new NativeRuntimeLibraries(Map.copyOf(selected), repository); } public > T configure(T builder, Path downloads) { @@ -111,15 +118,7 @@ public final class NativeRuntimeLibraries { public URLClassLoader openProviderLoader(Path downloads) { try { - List urls = new ArrayList<>(dependencies.size()); - for (Map.Entry entry : dependencies.entrySet()) { - Dependency dependency = entry.getKey(); - Path artifact = downloads.resolve(dependency.groupId().replace('.', '/')) - .resolve(dependency.artifactId()).resolve(dependency.version()) - .resolve(dependency.artifactId() + "-" + dependency.version() + ".jar"); - verify(artifact.toFile(), entry.getValue()); - urls.add(artifact.toUri().toURL()); - } + List urls = providerUrls(downloads, NativeRuntimeLibraries.class.getClassLoader()); URLClassLoader loader = new URLClassLoader("Iris native providers", urls.toArray(URL[]::new), NativeAdapters.class.getClassLoader()); NativeAdapters.registerProviderLoader(loader); @@ -130,6 +129,9 @@ public final class NativeRuntimeLibraries { } DependencyData merge(DependencyData original) { + if (repository == null) { + return original; + } List combined = new ArrayList<>(original.dependencies()); combined.addAll(dependencies.keySet()); List repositories = new ArrayList<>(original.repositories()); @@ -139,14 +141,61 @@ public final class NativeRuntimeLibraries { return new DependencyData(original.mirrors(), repositories, combined, original.relocations()); } + List providerUrls(Path downloads, ClassLoader resources) throws IOException { + List urls = new ArrayList<>(dependencies.size()); + for (Map.Entry entry : dependencies.entrySet()) { + Dependency dependency = entry.getKey(); + Path artifact = downloads.resolve(dependency.groupId().replace('.', '/')) + .resolve(dependency.artifactId()).resolve(dependency.version()) + .resolve(dependency.artifactId() + "-" + dependency.version() + ".jar"); + if (repository == null) { + extractEmbedded(resources, dependency.artifactId(), artifact, entry.getValue()); + } else { + verify(artifact.toFile(), entry.getValue()); + } + urls.add(artifact.toUri().toURL()); + } + return urls; + } + + private static void extractEmbedded(ClassLoader resources, String module, Path artifact, String expected) throws IOException { + if (Files.isRegularFile(artifact) && digest(artifact).equals(expected)) { + return; + } + String resource = "META-INF/iris/native/" + module + ".jar"; + try (InputStream input = resources.getResourceAsStream(resource)) { + if (input == null) { + throw new IOException("Missing embedded native provider: " + resource); + } + Files.createDirectories(artifact.getParent()); + Path temporary = Files.createTempFile(artifact.getParent(), module + "-", ".tmp"); + try { + Files.copy(input, temporary, StandardCopyOption.REPLACE_EXISTING); + verify(temporary.toFile(), expected); + Files.move(temporary, artifact, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } finally { + Files.deleteIfExists(temporary); + } + } + } + + private static String digest(Path file) throws IOException { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(file))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + static void verify(File file, String expected) { try { - String actual = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(file.toPath()))); + String actual = digest(file.toPath()); if (!actual.equals(expected)) { throw new DownloaderException("Native dependency does not match this Iris build: " + file.getName()); } - } catch (IOException | NoSuchAlgorithmException exception) { + } catch (IOException exception) { throw new DownloaderException("Cannot verify native dependency: " + file, exception); } } + } diff --git a/core/src/test/java/art/arcane/iris/platform/bootstrap/NativeRuntimeLibrariesTest.java b/core/src/test/java/art/arcane/iris/platform/bootstrap/NativeRuntimeLibrariesTest.java index 2518a2cfc..e41353fd8 100644 --- a/core/src/test/java/art/arcane/iris/platform/bootstrap/NativeRuntimeLibrariesTest.java +++ b/core/src/test/java/art/arcane/iris/platform/bootstrap/NativeRuntimeLibrariesTest.java @@ -3,17 +3,39 @@ package art.arcane.iris.platform.bootstrap; import io.github.slimjar.exceptions.DownloaderException; import io.github.slimjar.resolver.data.Dependency; import io.github.slimjar.resolver.data.DependencyData; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URI; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.HexFormat; import java.util.List; import java.util.Properties; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.stream.Stream; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; public class NativeRuntimeLibrariesTest { + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + @Test public void selectsOnlyCommonAndMatchingProvider() throws Exception { Properties manifest = manifest(); @@ -39,6 +61,115 @@ public class NativeRuntimeLibrariesTest { } } + @Test + public void embeddedProvidersLoadWithoutRepositoryAndSelectOnlyCurrentVersion() throws Exception { + EmbeddedFixture fixture = embeddedFixture(); + NativeRuntimeLibraries libraries = NativeRuntimeLibraries.from(fixture.manifest(), "26.2.0"); + Dependency original = new Dependency("example", "library", "1", null, List.of()); + DependencyData data = new DependencyData(List.of(), List.of(), List.of(original), List.of()); + assertSame(data, libraries.merge(data)); + Path downloads = temporary.newFolder("downloads").toPath(); + try (URLClassLoader resources = new URLClassLoader(new URL[]{fixture.resources().toUri().toURL()}, null)) { + List providers = libraries.providerUrls(downloads, resources); + assertEquals(2, providers.size()); + assertEquals(List.of("native-common", "native-v26_2_R1"), providers.stream() + .map(url -> Path.of(URI.create(url.toString())).getParent().getParent().getFileName().toString()) + .sorted().toList()); + for (URL provider : providers) { + Path artifact = Path.of(provider.toURI()); + String module = artifact.getParent().getParent().getFileName().toString(); + assertArrayEquals(Files.readAllBytes(fixture.resources().resolve("META-INF/iris/native/" + module + ".jar")), + Files.readAllBytes(artifact)); + FileTime timestamp = FileTime.fromMillis(1000); + Files.setLastModifiedTime(artifact, timestamp); + libraries.providerUrls(downloads, resources); + assertEquals(timestamp, Files.getLastModifiedTime(artifact)); + } + } + } + + @Test + public void embeddedProvidersRepairCorruptCachedBytes() throws Exception { + EmbeddedFixture fixture = embeddedFixture(); + NativeRuntimeLibraries libraries = NativeRuntimeLibraries.from(fixture.manifest(), "26.2.0"); + Path downloads = temporary.newFolder("downloads").toPath(); + try (URLClassLoader resources = new URLClassLoader(new URL[]{fixture.resources().toUri().toURL()}, null)) { + List providers = libraries.providerUrls(downloads, resources); + Path corrupted = Path.of(providers.getFirst().toURI()); + byte[] expected = Files.readAllBytes(corrupted); + Files.writeString(corrupted, "truncated"); + libraries.providerUrls(downloads, resources); + assertArrayEquals(expected, Files.readAllBytes(corrupted)); + } + } + + @Test + public void embeddedProvidersRejectMissingResourceWithoutCachingIt() throws Exception { + EmbeddedFixture fixture = embeddedFixture(); + Files.delete(fixture.resources().resolve("META-INF/iris/native/native-v26_2_R1.jar")); + NativeRuntimeLibraries libraries = NativeRuntimeLibraries.from(fixture.manifest(), "26.2.0"); + Path downloads = temporary.newFolder("downloads").toPath(); + try (URLClassLoader resources = new URLClassLoader(new URL[]{fixture.resources().toUri().toURL()}, null)) { + IOException failure = assertThrows(IOException.class, () -> libraries.providerUrls(downloads, resources)); + assertTrue(failure.getMessage().contains("native-v26_2_R1")); + assertNoFailedExtract(downloads); + } + } + + @Test + public void embeddedProvidersRejectTruncatedResourceWithoutCachingIt() throws Exception { + EmbeddedFixture fixture = embeddedFixture(); + Path resource = fixture.resources().resolve("META-INF/iris/native/native-v26_2_R1.jar"); + byte[] bytes = Files.readAllBytes(resource); + Files.write(resource, Arrays.copyOf(bytes, bytes.length / 2)); + NativeRuntimeLibraries libraries = NativeRuntimeLibraries.from(fixture.manifest(), "26.2.0"); + Path downloads = temporary.newFolder("downloads").toPath(); + try (URLClassLoader resources = new URLClassLoader(new URL[]{fixture.resources().toUri().toURL()}, null)) { + DownloaderException failure = assertThrows(DownloaderException.class, () -> libraries.providerUrls(downloads, resources)); + assertTrue(failure.getMessage().contains("native-v26_2_R1")); + assertNoFailedExtract(downloads); + } + } + + @Test + public void embeddedCoordinatesMustMatchTheirContentHash() throws Exception { + EmbeddedFixture fixture = embeddedFixture(); + fixture.manifest().setProperty("native-common.coordinate", "com.github.VolmitSoftware.VolmLib:native-common:revision"); + assertThrows(IllegalStateException.class, () -> NativeRuntimeLibraries.from(fixture.manifest(), "26.2.0")); + } + + private EmbeddedFixture embeddedFixture() throws Exception { + Properties properties = manifest(); + properties.setProperty("storage", "embedded"); + properties.remove("repository"); + Path resources = temporary.newFolder("resources").toPath(); + Path providers = Files.createDirectories(resources.resolve("META-INF/iris/native")); + for (String module : properties.getProperty("modules").split(",")) { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (JarOutputStream jar = new JarOutputStream(output)) { + jar.putNextEntry(new JarEntry("provider.txt")); + jar.write(module.getBytes(StandardCharsets.UTF_8)); + jar.closeEntry(); + } + byte[] bytes = output.toByteArray(); + String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + properties.setProperty(module + ".coordinate", "com.github.VolmitSoftware.VolmLib:" + module + ":embedded-" + hash); + properties.setProperty(module + ".sha256", hash); + Files.write(providers.resolve(module + ".jar"), bytes); + } + return new EmbeddedFixture(properties, resources); + } + + private static void assertNoFailedExtract(Path downloads) throws IOException { + try (Stream files = Files.walk(downloads)) { + assertFalse(files.anyMatch(path -> Files.isRegularFile(path) + && (path.getFileName().toString().endsWith(".tmp") || path.getFileName().toString().startsWith("native-v26_2_R1-")))); + } + } + + private record EmbeddedFixture(Properties manifest, Path resources) { + } + private static Properties manifest() { Properties properties = new Properties(); List modules = List.of("native-common", "native-v26_2_R1", "native-v26_3_R1"); diff --git a/gradle/packed-jar.gradle b/gradle/packed-jar.gradle new file mode 100644 index 000000000..b7a55318d --- /dev/null +++ b/gradle/packed-jar.gradle @@ -0,0 +1,59 @@ +import org.gradle.api.tasks.compile.JavaCompile +import org.gradle.api.artifacts.component.ComponentIdentifier + +configurations { + packedXz +} + +dependencies { + packedXz('org.tukaani:xz:1.10') +} + +def originalJar = tasks.named('jar', Jar) +sourceSets { + packedBootstrap { + java.srcDir('packaging/xz/src/main/java') + compileClasspath = files(originalJar.flatMap { it.archiveFile }) + configurations.packedXz + } + packedBootstrapTest { + java.srcDir('packaging/xz/src/test/java') + compileClasspath += sourceSets.packedBootstrap.output + sourceSets.packedBootstrap.compileClasspath + runtimeClasspath += sourceSets.packedBootstrap.output + sourceSets.packedBootstrap.compileClasspath + } +} + +dependencies { + packedBootstrapTestImplementation('junit:junit:4.13.2') +} + +tasks.named('compilePackedBootstrapJava', JavaCompile).configure { + dependsOn(originalJar) +} + +tasks.register('packedBootstrapTest', Test) { + group = 'verification' + testClassesDirs = sourceSets.packedBootstrapTest.output.classesDirs + classpath = sourceSets.packedBootstrapTest.runtimeClasspath +} + +tasks.register('packedJar', PackedJar) { + group = 'build' + description = 'Builds a Bukkit jar with an embedded XZ runtime payload.' + dependsOn(tasks.named('verifyBukkitArtifact'), tasks.named('packedBootstrapClasses'), configurations.nativeRuntime) + inputJar.set(originalJar.flatMap { it.archiveFile }) + bootstrapClasses.from(sourceSets.packedBootstrap.output.classesDirs) + decoderJars.from(configurations.packedXz) + nativeArtifacts.set(providers.provider { + Map resolved = new LinkedHashMap<>() + configurations.nativeRuntime.incoming.artifacts.artifacts.each { artifact -> + resolved.put(artifact.id.componentIdentifier, artifact.file) + } + PackedJar.resolveNativeArtifacts(resolved) + }) + outputJar.set(layout.buildDirectory.file(originalJar.flatMap { it.archiveFileName } + .map { String name -> "libs/${name.replaceFirst(/\.jar$/, '-xz.jar')}" })) +} + +tasks.named('check').configure { + dependsOn(tasks.named('packedBootstrapTest')) +} diff --git a/packaging/xz/src/main/java/art/arcane/iris/platform/bootstrap/PackedRuntime.java b/packaging/xz/src/main/java/art/arcane/iris/platform/bootstrap/PackedRuntime.java new file mode 100644 index 000000000..2aea88f42 --- /dev/null +++ b/packaging/xz/src/main/java/art/arcane/iris/platform/bootstrap/PackedRuntime.java @@ -0,0 +1,213 @@ +package art.arcane.iris.platform.bootstrap; + +import art.arcane.iris.util.slimjar.injector.loader.factory.InjectableFactory; +import org.tukaani.xz.XZInputStream; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.reflect.Field; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.List; +import java.util.jar.JarFile; +import java.util.logging.Logger; + +public final class PackedRuntime { + private static final long MAXIMUM_BYTES = 256L * 1024 * 1024; + private static final int DECODER_MEMORY_KIB = 128 * 1024; + private static Path runtime; + private static RuntimeArchive archive; + private static Field archiveField; + + private PackedRuntime() { + } + + public static synchronized void install() { + if (runtime != null) { + return; + } + try { + Path pluginJar = Path.of(PackedRuntime.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + Path cache = pluginJar.getParent().resolve("Iris/cache/runtime"); + String hash; + try (InputStream input = resource("runtime.sha256")) { + hash = new String(input.readNBytes(128), StandardCharsets.US_ASCII).strip(); + } + if (!hash.matches("[0-9a-f]{64}")) { + throw new IOException("Invalid embedded runtime checksum"); + } + Path payload = cache.resolve(hash + ".jar"); + if (Files.exists(payload)) { + verify(payload, hash); + } else { + try (InputStream input = resource("runtime.jar.xz")) { + extract(input, payload, hash); + } + } + InjectableFactory.UNSAFE.create(cache, List.of(), PackedRuntime.class.getClassLoader()) + .inject(payload.toUri().toURL()); + ClassLoader loader = PackedRuntime.class.getClassLoader(); + archiveField = archiveOwner(loader).getDeclaredField("jar"); + archive = replaceArchive(loader, archiveField, payload, !temporaryLoader(loader)); + runtime = payload; + Logger.getLogger("Iris").info("Loaded embedded XZ runtime: " + payload.getFileName()); + } catch (IOException | URISyntaxException | ReflectiveOperationException failure) { + throw new IllegalStateException("Unable to load the embedded Iris runtime", failure); + } + } + + public static synchronized void releaseTemporary(Throwable failure) { + ClassLoader loader = PackedRuntime.class.getClassLoader(); + if (!temporaryLoader(loader) || archive == null) { + return; + } + RuntimeArchive released = archive; + archive = null; + try (released) { + archiveField.set(loader, released.original); + } catch (IOException | IllegalAccessException cleanupFailure) { + if (failure != null) { + failure.addSuppressed(cleanupFailure); + } else { + throw new IllegalStateException("Unable to close the Iris runtime loader archive", cleanupFailure); + } + } + } + + public static File runtimeJar() { + install(); + return runtime.toFile(); + } + + static RuntimeArchive replaceArchive(ClassLoader loader, Field field, Path payload, boolean closeOriginal) + throws IOException, IllegalAccessException { + if (field.getType() != JarFile.class || !field.getDeclaringClass().isInstance(loader)) { + throw new IllegalArgumentException("Unsupported plugin archive field: " + field); + } + field.setAccessible(true); + JarFile original = (JarFile) field.get(loader); + RuntimeArchive replacement = new RuntimeArchive(new ArchiveOptions(payload, original, closeOriginal)); + try { + field.set(loader, replacement); + } catch (IllegalAccessException | RuntimeException failure) { + try { + replacement.closePayload(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + throw failure; + } + return replacement; + } + + static void extract(InputStream compressed, Path target, String expectedHash) throws IOException { + Files.createDirectories(target.getParent()); + Path temporary = Files.createTempFile(target.getParent(), ".runtime-", ".jar"); + try { + MessageDigest digest = digest(); + try (InputStream input = new DigestInputStream(new XZInputStream(compressed, DECODER_MEMORY_KIB), digest); + OutputStream output = Files.newOutputStream(temporary)) { + byte[] buffer = new byte[65536]; + long size = 0; + int count; + while ((count = input.read(buffer)) != -1) { + size += count; + if (size > MAXIMUM_BYTES) { + throw new IOException("Embedded Iris runtime exceeds the size limit"); + } + output.write(buffer, 0, count); + } + } + requireHash(digest.digest(), expectedHash); + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } finally { + Files.deleteIfExists(temporary); + } + } + + static void verify(Path payload, String expectedHash) throws IOException { + if (Files.size(payload) > MAXIMUM_BYTES) { + throw new IOException("Embedded Iris runtime exceeds the size limit"); + } + MessageDigest digest = digest(); + try (InputStream input = new DigestInputStream(Files.newInputStream(payload), digest)) { + input.transferTo(OutputStream.nullOutputStream()); + } + requireHash(digest.digest(), expectedHash); + } + + private static InputStream resource(String name) throws IOException { + InputStream input = PackedRuntime.class.getResourceAsStream("/META-INF/iris/" + name); + if (input == null) { + throw new IOException("Missing embedded Iris runtime resource: " + name); + } + return input; + } + + private static Class archiveOwner(ClassLoader loader) { + for (Class type = loader.getClass(); type != null; type = type.getSuperclass()) { + if (type.getName().equals("org.bukkit.plugin.java.PluginClassLoader") + || type.getName().equals("io.papermc.paper.plugin.entrypoint.classloader.PaperSimplePluginClassLoader")) { + return type; + } + } + throw new IllegalStateException("Unsupported Iris plugin classloader: " + loader.getClass().getName()); + } + + private static boolean temporaryLoader(ClassLoader loader) { + return loader.getClass().getName() + .equals("io.papermc.paper.plugin.entrypoint.classloader.PaperSimplePluginClassLoader"); + } + + private static MessageDigest digest() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException failure) { + throw new IllegalStateException("SHA-256 is unavailable", failure); + } + } + + private static void requireHash(byte[] actual, String expected) throws IOException { + if (!HexFormat.of().formatHex(actual).equals(expected)) { + throw new IOException("Embedded Iris runtime checksum does not match"); + } + } + + static final class RuntimeArchive extends JarFile { + private final JarFile original; + private final boolean closeOriginal; + + RuntimeArchive(ArchiveOptions options) throws IOException { + super(options.payload().toFile()); + this.original = options.original(); + this.closeOriginal = options.closeOriginal(); + } + + @Override + public void close() throws IOException { + if (closeOriginal) { + try (original) { + super.close(); + } + } else { + super.close(); + } + } + + private void closePayload() throws IOException { + super.close(); + } + } + + private record ArchiveOptions(Path payload, JarFile original, boolean closeOriginal) { + } +} diff --git a/packaging/xz/src/test/java/art/arcane/iris/platform/bootstrap/PackedRuntimeTest.java b/packaging/xz/src/test/java/art/arcane/iris/platform/bootstrap/PackedRuntimeTest.java new file mode 100644 index 000000000..295e8626f --- /dev/null +++ b/packaging/xz/src/test/java/art/arcane/iris/platform/bootstrap/PackedRuntimeTest.java @@ -0,0 +1,171 @@ +package art.arcane.iris.platform.bootstrap; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tukaani.xz.LZMA2Options; +import org.tukaani.xz.XZOutputStream; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.JarOutputStream; +import java.util.stream.Stream; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertSame; + +public class PackedRuntimeTest { + @Rule + public TemporaryFolder directory = new TemporaryFolder(); + + @Test + public void extractsAndVerifiesTheExactPayload() throws Exception { + byte[] content = "Iris runtime contents".getBytes(StandardCharsets.UTF_8); + String hash = hash(content); + Path target = directory.getRoot().toPath().resolve("runtime/" + hash + ".jar"); + PackedRuntime.extract(new ByteArrayInputStream(compress(content)), target, hash); + PackedRuntime.verify(target, hash); + assertArrayEquals(content, Files.readAllBytes(target)); + } + + @Test + public void rejectsAnIncorrectHashWithoutPublishingOrLeavingTemporaryFiles() throws Exception { + byte[] content = "Iris runtime contents".getBytes(StandardCharsets.UTF_8); + Path target = directory.getRoot().toPath().resolve("runtime/runtime.jar"); + assertThrows(IOException.class, () -> PackedRuntime.extract( + new ByteArrayInputStream(compress(content)), target, "0".repeat(64))); + assertFalse(Files.exists(target)); + try (Stream files = Files.list(target.getParent())) { + assertEquals(0L, files.count()); + } + } + + @Test + public void rejectsTruncatedCompressedData() throws Exception { + byte[] content = "Iris runtime contents".getBytes(StandardCharsets.UTF_8); + byte[] compressed = compress(content); + Path target = directory.getRoot().toPath().resolve("runtime/runtime.jar"); + assertThrows(IOException.class, () -> PackedRuntime.extract( + new ByteArrayInputStream(compressed, 0, compressed.length / 2), target, hash(content))); + assertFalse(Files.exists(target)); + } + + @Test + public void detectsModifiedCachedRuntime() throws Exception { + byte[] content = "Iris runtime contents".getBytes(StandardCharsets.UTF_8); + Path target = directory.newFile("runtime.jar").toPath(); + Files.writeString(target, "changed"); + assertThrows(IOException.class, () -> PackedRuntime.verify(target, hash(content))); + } + + @Test + public void nativeArchiveReplacementKeepsClassIdentityAndClosesOwnedHandles() throws Exception { + Path outer = createArchive("outer.jar", false); + Path payload = createArchive("payload.jar", true); + JarFile original = new JarFile(outer.toFile()); + PackedRuntime.RuntimeArchive replacement; + try (ArchiveLoader loader = new ArchiveLoader(original)) { + assertThrows(ClassNotFoundException.class, () -> loader.loadClass(Fixture.class.getName())); + replacement = PackedRuntime.replaceArchive(loader, ArchiveLoader.class.getDeclaredField("jar"), payload, true); + Class loaded = loader.loadClass(Fixture.class.getName()); + assertSame(loader, loaded.getClassLoader()); + assertEquals("runtime", loaded.getMethod("value").invoke(null)); + assertEquals(1, loader.definitions); + } + assertThrows(IllegalStateException.class, original::size); + assertThrows(IllegalStateException.class, replacement::size); + } + + @Test + public void temporaryArchiveCloseLeavesTheSharedOriginalOpen() throws Exception { + Path outer = createArchive("outer.jar", false); + Path payload = createArchive("payload.jar", true); + try (JarFile original = new JarFile(outer.toFile()); + ArchiveLoader loader = new ArchiveLoader(original)) { + PackedRuntime.RuntimeArchive replacement = PackedRuntime.replaceArchive( + loader, ArchiveLoader.class.getDeclaredField("jar"), payload, false); + replacement.close(); + assertEquals(0, original.size()); + assertThrows(IllegalStateException.class, replacement::size); + } + } + + private Path createArchive(String name, boolean withFixture) throws IOException { + Path path = directory.getRoot().toPath().resolve(name); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(path))) { + if (withFixture) { + String entry = Fixture.class.getName().replace('.', '/') + ".class"; + output.putNextEntry(new JarEntry(entry)); + try (InputStream bytes = getClass().getClassLoader().getResourceAsStream(entry)) { + bytes.transferTo(output); + } + output.closeEntry(); + } + } + return path; + } + + private static byte[] compress(byte[] content) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (XZOutputStream output = new XZOutputStream(bytes, new LZMA2Options(1))) { + output.write(content); + } + return bytes.toByteArray(); + } + + private static String hash(byte[] content) throws Exception { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content)); + } + + public static class Fixture { + public static String value() { + return "runtime"; + } + } + + private static final class ArchiveLoader extends URLClassLoader { + private final JarFile jar; + private int definitions; + + private ArchiveLoader(JarFile jar) { + super(new URL[0], ClassLoader.getPlatformClassLoader()); + this.jar = jar; + } + + @Override + protected Class findClass(String name) throws ClassNotFoundException { + JarEntry entry = jar.getJarEntry(name.replace('.', '/') + ".class"); + if (entry == null) { + throw new ClassNotFoundException(name); + } + try (InputStream input = jar.getInputStream(entry)) { + byte[] bytes = input.readAllBytes(); + definitions++; + return defineClass(name, bytes, 0, bytes.length); + } catch (IOException failure) { + throw new ClassNotFoundException(name, failure); + } + } + + @Override + public void close() throws IOException { + try (jar) { + super.close(); + } + } + } +}