Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,35 @@ import java.nio.file.StandardCopyOption
import java.util.concurrent.CompletionStage

class VanillaPlayerDataMigrationProvider(
private val worldRoots: List<Path>,
worldRoots: List<Path>,
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<IdentityMigrationInspection> =
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) ||
Expand All @@ -46,7 +66,12 @@ class VanillaPlayerDataMigrationProvider(

override fun migrate(context: IdentityMigrationContext): CompletionStage<IdentityMigrationOperationResult> =
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) {
Expand Down Expand Up @@ -87,7 +112,12 @@ class VanillaPlayerDataMigrationProvider(

override fun rollback(context: IdentityMigrationContext): CompletionStage<IdentityMigrationOperationResult> =
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)
Expand All @@ -114,7 +144,37 @@ class VanillaPlayerDataMigrationProvider(
IdentityMigrationOperationResult.Success
}

private fun mappings(context: IdentityMigrationContext): List<FileMapping> {
private fun resolveWorldRoots(): List<Path> {
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<Path> {
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<Path>): List<FileMapping> {
val source = context.sourceMinecraftUuid.toString()
val target = context.targetMinecraftUuid.toString()
val relativePairs = listOf(
Expand All @@ -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 ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<IdentityMigrationOperationResult.Success>(
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()
}
}
}
Loading