From 77ab9b49fae7cbd658c93062bbb3619972b90c0f Mon Sep 17 00:00:00 2001 From: WieszczY Date: Sat, 3 Oct 2026 07:52:44 +0200 Subject: [PATCH 1/2] fix: resolve vanilla world roots lazily for migrations --- .../VanillaPlayerDataMigrationProvider.kt | 72 +++++++++++++++++-- 1 file changed, 66 insertions(+), 6 deletions(-) diff --git a/authgatewayx-paper/src/main/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/VanillaPlayerDataMigrationProvider.kt b/authgatewayx-paper/src/main/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/VanillaPlayerDataMigrationProvider.kt index cc15f6d..fb6919d 100644 --- a/authgatewayx-paper/src/main/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/VanillaPlayerDataMigrationProvider.kt +++ b/authgatewayx-paper/src/main/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/VanillaPlayerDataMigrationProvider.kt @@ -13,15 +13,35 @@ import java.nio.file.StandardCopyOption import java.util.concurrent.CompletionStage class VanillaPlayerDataMigrationProvider( - private val worldRoots: List, + worldRoots: List, private val backupRoot: Path, private val executor: BoundedTaskExecutor, + private val worldContainer: Path = Path.of("").toAbsolutePath().normalize(), ) : IdentityMigrationProvider { override val id: String = "authgatewayx:vanilla" + /** + * Paper plugins marked STARTUP may be constructed before Server#getWorlds exposes every + * loaded world. Keep the initial snapshot, but resolve filesystem world roots again for + * every migration operation so a startup-time empty list can never be interpreted as + * "this player has no vanilla data". + */ + private val initialWorldRoots = worldRoots + .map { it.toAbsolutePath().normalize() } + .distinct() + .sortedBy { it.toString() } + override fun inspect(context: IdentityMigrationContext): CompletionStage = executor.submit { - val mappings = mappings(context) + val roots = resolveWorldRoots() + if (roots.isEmpty()) { + return@submit IdentityMigrationInspection( + IdentityMigrationInspectionStatus.BLOCKED, + "VANILLA_WORLD_ROOTS_UNAVAILABLE", + ) + } + + val mappings = mappings(context, roots) val source = mappings.filter { Files.exists(it.source, LinkOption.NOFOLLOW_LINKS) } val unsafe = source.firstOrNull { Files.isSymbolicLink(it.source) || @@ -46,7 +66,12 @@ class VanillaPlayerDataMigrationProvider( override fun migrate(context: IdentityMigrationContext): CompletionStage = executor.submit { - val mappings = mappings(context).filter { Files.exists(it.source, LinkOption.NOFOLLOW_LINKS) } + val roots = resolveWorldRoots() + if (roots.isEmpty()) { + return@submit IdentityMigrationOperationResult.Failure("VANILLA_WORLD_ROOTS_UNAVAILABLE") + } + + val mappings = mappings(context, roots).filter { Files.exists(it.source, LinkOption.NOFOLLOW_LINKS) } if (mappings.isEmpty()) return@submit IdentityMigrationOperationResult.NoData for (mapping in mappings) { @@ -87,7 +112,12 @@ class VanillaPlayerDataMigrationProvider( override fun rollback(context: IdentityMigrationContext): CompletionStage = executor.submit { - for (mapping in mappings(context)) { + val roots = resolveWorldRoots() + if (roots.isEmpty()) { + return@submit IdentityMigrationOperationResult.Failure("VANILLA_WORLD_ROOTS_UNAVAILABLE") + } + + for (mapping in mappings(context, roots)) { when { Files.exists(mapping.targetBackup, LinkOption.NOFOLLOW_LINKS) -> { requireRegularFile(mapping.targetBackup) @@ -114,7 +144,37 @@ class VanillaPlayerDataMigrationProvider( IdentityMigrationOperationResult.Success } - private fun mappings(context: IdentityMigrationContext): List { + private fun resolveWorldRoots(): List { + val discovered = discoverWorldRoots(worldContainer) + return (initialWorldRoots + discovered) + .map { it.toAbsolutePath().normalize() } + .filter { Files.isDirectory(it, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(it) } + .distinct() + .sortedBy { it.toString() } + } + + private fun discoverWorldRoots(container: Path): List { + val root = container.toAbsolutePath().normalize() + if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(root)) { + return emptyList() + } + return runCatching { + Files.list(root).use { children -> + children + .filter { Files.isDirectory(it, LinkOption.NOFOLLOW_LINKS) } + .filter { !Files.isSymbolicLink(it) } + .filter { + val levelDat = it.resolve("level.dat") + Files.isRegularFile(levelDat, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(levelDat) + } + .map { it.toAbsolutePath().normalize() } + .sorted() + .toList() + } + }.getOrDefault(emptyList()) + } + + private fun mappings(context: IdentityMigrationContext, roots: List): List { val source = context.sourceMinecraftUuid.toString() val target = context.targetMinecraftUuid.toString() val relativePairs = listOf( @@ -123,7 +183,7 @@ class VanillaPlayerDataMigrationProvider( "stats/$source.json" to "stats/$target.json", "advancements/$source.json" to "advancements/$target.json", ) - val uniqueRoots = worldRoots.map { it.toAbsolutePath().normalize() }.distinct() + val uniqueRoots = roots.map { it.toAbsolutePath().normalize() }.distinct().sortedBy { it.toString() } return buildList { uniqueRoots.forEachIndexed { index, root -> relativePairs.forEachIndexed { pairIndex, pair -> From d8a72384a493fd7e9e8829c34cf30e4652c7700a Mon Sep 17 00:00:00 2001 From: WieszczY Date: Sat, 3 Oct 2026 07:53:10 +0200 Subject: [PATCH 2/2] test: cover lazy vanilla world discovery --- .../VanillaPlayerDataMigrationProviderTest.kt | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/authgatewayx-paper/src/test/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/VanillaPlayerDataMigrationProviderTest.kt b/authgatewayx-paper/src/test/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/VanillaPlayerDataMigrationProviderTest.kt index 59f6606..9a2f3b3 100644 --- a/authgatewayx-paper/src/test/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/VanillaPlayerDataMigrationProviderTest.kt +++ b/authgatewayx-paper/src/test/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/VanillaPlayerDataMigrationProviderTest.kt @@ -83,4 +83,68 @@ class VanillaPlayerDataMigrationProviderTest { backups.toFile().deleteRecursively() } } + + @Test + fun `startup empty world snapshot discovers world from filesystem later`() { + val serverRoot = Files.createTempDirectory("agx-server-") + val world = serverRoot.resolve("world") + val backups = Files.createTempDirectory("agx-backups-") + val executor = BoundedTaskExecutor(1, 8, "migration-test") + try { + Files.createDirectories(world) + world.resolve("level.dat").writeText("world-marker") + val source = world.resolve("playerdata").resolve("${context.sourceMinecraftUuid}.dat") + val target = world.resolve("playerdata").resolve("${context.targetMinecraftUuid}.dat") + Files.createDirectories(source.parent) + source.writeText("inventory-from-offline-profile") + + val provider = VanillaPlayerDataMigrationProvider( + emptyList(), + backups, + executor, + worldContainer = serverRoot, + ) + + val inspection = provider.inspect(context).toCompletableFuture().get() + assertEquals(IdentityMigrationInspectionStatus.READY, inspection.status) + assertEquals("VANILLA_FILES_1", inspection.reasonCode) + + assertIs( + provider.migrate(context).toCompletableFuture().get(), + ) + assertEquals("inventory-from-offline-profile", target.readText()) + } finally { + executor.close() + serverRoot.toFile().deleteRecursively() + backups.toFile().deleteRecursively() + } + } + + @Test + fun `missing world roots block migration instead of reporting no player data`() { + val serverRoot = Files.createTempDirectory("agx-empty-server-") + val backups = Files.createTempDirectory("agx-backups-") + val executor = BoundedTaskExecutor(1, 8, "migration-test") + try { + val provider = VanillaPlayerDataMigrationProvider( + emptyList(), + backups, + executor, + worldContainer = serverRoot, + ) + + val inspection = provider.inspect(context).toCompletableFuture().get() + + assertEquals(IdentityMigrationInspectionStatus.BLOCKED, inspection.status) + assertEquals("VANILLA_WORLD_ROOTS_UNAVAILABLE", inspection.reasonCode) + assertEquals( + IdentityMigrationOperationResult.Failure("VANILLA_WORLD_ROOTS_UNAVAILABLE"), + provider.migrate(context).toCompletableFuture().get(), + ) + } finally { + executor.close() + serverRoot.toFile().deleteRecursively() + backups.toFile().deleteRecursively() + } + } }