From c261a3a07cc109cf2bd220e3eff2669f79aa2588 Mon Sep 17 00:00:00 2001 From: TDSTOS Date: Sun, 12 Apr 2026 12:39:19 +0200 Subject: [PATCH] Improve world archive extraction and safety Refactor WorldManager to reliably extract map archives and harden extraction. - Add documentation and ARCHIVE_ROOT constant for expected archive layout (must contain a leading "world/" folder). - Fix archive type detection (use file.name) and ensure target world folder exists before extraction. - Implement unzip and untar that strip the leading "world/" prefix, skip the bare root entry, and log unexpected entries. - Add Zip Slip guards for both ZIP and TAR extraction to prevent path traversal. - Use buffered input for tar.gz performance and correct tar entry iteration. - Add extractAsync helper to run I/O off the main thread and invoke a completion callback on the main thread (for potential onEnable migration). - Improve logging and error messages. --- .../mcscrims/speedhg/world/WorldManager.kt | 235 +++++++++++++++--- 1 file changed, 200 insertions(+), 35 deletions(-) diff --git a/src/main/kotlin/club/mcscrims/speedhg/world/WorldManager.kt b/src/main/kotlin/club/mcscrims/speedhg/world/WorldManager.kt index 76ad006..afbc612 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/world/WorldManager.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/world/WorldManager.kt @@ -8,15 +8,38 @@ import java.io.FileInputStream import java.io.FileOutputStream import java.util.zip.ZipInputStream +/** + * Manages world template extraction and preparation before server world load. + * + * ## Archive Format + * Both `.zip` and `.tar.gz` archives **must** contain a single root folder + * named `world/` (e.g. `world/level.dat`, `world/region/r.0.0.mca`). + * The `world/` prefix is stripped during extraction so that content lands + * directly in the configured target world folder. + * + * ## Extraction Strategy + * | Phase | Thread | Reason | + * |---|---|---| + * | File I/O (unzip / untar) | Caller thread (`onLoad`) | Scheduler unavailable in `onLoad` | + * | `Bukkit.createWorld(...)` | Main thread | Bukkit API requirement | + * + * If you ever move world preparation to `onEnable()`, use `extractAsync` + * to offload I/O and schedule the `createWorld` call back on the main thread. + */ class WorldManager( private val plugin: SpeedHG ) { + companion object { + /** The root folder name that every map archive must contain. */ + private const val ARCHIVE_ROOT = "world/" + } + private fun deleteWorld(): File { - val targetWorldName = plugin.config.getString("map-system.target-world-name", "world")!! + val targetWorldName = plugin.config.getString( "map-system.target-world-name", "world" )!! val serverRoot = plugin.dataFolder.parentFile.parentFile - val targetWorldFolder = File(serverRoot, targetWorldName) + val targetWorldFolder = File( serverRoot, targetWorldName ) if ( targetWorldFolder.exists() ) { @@ -31,13 +54,15 @@ class WorldManager( } /** - * Wird in onLoad() aufgerufen, um die Welt VOR dem Server-Start auszutauschen + * Called from `onLoad()` to swap the world **before** the server loads it. + * Extraction is intentionally synchronous here because the Bukkit scheduler + * does not exist yet during `onLoad`. */ fun prepareRandomWorld() { val targetWorldFolder = deleteWorld() - if (!plugin.config.getBoolean( "map-system.enabled", false )) + if ( !plugin.config.getBoolean( "map-system.enabled", false ) ) return val maps = plugin.config.getStringList( "map-system.maps" ) @@ -48,75 +73,215 @@ class WorldManager( } val randomMapName = maps.random() - val mapsFolder = File( plugin.dataFolder, plugin.config.getString( "map-system.zip-folder", "maps" )!!) - val zipFile = File( mapsFolder, randomMapName ) + val mapsFolder = File( plugin.dataFolder, plugin.config.getString( "map-system.zip-folder", "maps" )!! ) + val archiveFile = File( mapsFolder, randomMapName ) - if ( !zipFile.exists() ) + if ( !archiveFile.exists() ) { - plugin.logger.severe( "[WorldManager] Map-ZIP nicht gefunden: $randomMapName" ) + plugin.logger.severe( "[WorldManager] Map-Archiv nicht gefunden: $randomMapName" ) return } plugin.logger.info( "[WorldManager] Ausgewählte Map: $randomMapName. Entpacke..." ) + // Target folder must exist before extraction writes into it targetWorldFolder.mkdirs() - if (zipFile.endsWith( ".zip" )) - unzip( zipFile, targetWorldFolder.parentFile ) - else if (zipFile.endsWith( ".gz" )) - untar( zipFile, targetWorldFolder.parentFile ) + // BUG FIX 1: Use archiveFile.name (String) not archiveFile (File) for + // extension checks. File.endsWith() matches path *components*, + // not the trailing characters of the filename string. + when + { + archiveFile.name.endsWith( ".zip" ) -> unzip( archiveFile, targetWorldFolder ) + archiveFile.name.endsWith( ".tar.gz" ) -> untar( archiveFile, targetWorldFolder ) + else -> + { + plugin.logger.severe( "[WorldManager] Unbekanntes Archivformat: $randomMapName" ) + return + } + } - plugin.logger.info( "[WorldManager] Map erfolgreich entpackt!" ) + plugin.logger.info( "[WorldManager] Map erfolgreich entpackt nach: ${targetWorldFolder.absolutePath}" ) } + // ───────────────────────────────────────────────────────────────────────── + // Extraction helpers + // ───────────────────────────────────────────────────────────────────────── + + /** + * Extracts a `.zip` archive into [targetDir], stripping the leading + * `world/` root folder from every entry path. + * + * @param zipFile The source `.zip` archive. + * @param targetDir The destination folder (e.g. `serverRoot/hg_arena/`). + */ private fun unzip( zipFile: File, targetDir: File ) { - ZipInputStream(FileInputStream( zipFile )).use { zis -> + ZipInputStream( FileInputStream( zipFile ) ).use { zis -> var entry = zis.nextEntry + while ( entry != null ) { - val newFile = File( targetDir, entry.name ) + // BUG FIX 2: Strip the leading "world/" root prefix so that + // "world/level.dat" → "level.dat" inside targetDir. + // Skip the bare "world/" directory entry itself. + val strippedName = stripArchiveRoot( entry.name ) - if (!newFile.canonicalPath.startsWith( targetDir.canonicalPath + File.separator )) - throw SecurityException("Ungültiger ZIP-Eintrag (Zip Slip): ${entry.name}") + if ( strippedName != null ) + { + val outFile = File( targetDir, strippedName ) - if ( entry.isDirectory ) - { - newFile.mkdirs() - } - else - { - File( newFile.parent ).mkdirs() - FileOutputStream( newFile ).use { fos -> - zis.copyTo( fos ) + // Zip Slip guard + if ( !outFile.canonicalPath.startsWith( targetDir.canonicalPath + File.separator ) ) + throw SecurityException( "Ungültiger ZIP-Eintrag (Zip Slip): ${entry.name}" ) + + if ( entry.isDirectory ) + { + outFile.mkdirs() + } + else + { + outFile.parentFile?.mkdirs() + FileOutputStream( outFile ).use { fos -> + zis.copyTo( fos ) + } } } + zis.closeEntry() entry = zis.nextEntry } } } + /** + * Extracts a `.tar.gz` archive into [targetDir], stripping the leading + * `world/` root folder from every entry path. + * + * Requires `org.apache.commons:commons-compress` on the classpath. + * + * @param tarGzFile The source `.tar.gz` archive. + * @param targetDir The destination folder (e.g. `serverRoot/hg_arena/`). + */ private fun untar( tarGzFile: File, targetDir: File ) { - TarArchiveInputStream(GzipCompressorInputStream(FileInputStream( tarGzFile ))).use { tais -> + // Wrap in BufferedInputStream for GzipCompressorInputStream performance + TarArchiveInputStream( + GzipCompressorInputStream( + tarGzFile.inputStream().buffered() + ) + ).use { tais -> var entry = tais.nextEntry - while( entry != null ) + while ( entry != null ) { - val newFile = File( targetDir, entry.name ) - if ( entry.isDirectory ) - newFile.mkdirs() - else + // BUG FIX 2 (same as unzip): strip the "world/" root prefix. + val strippedName = stripArchiveRoot( entry.name ) + + if ( strippedName != null ) { - newFile.parentFile.mkdirs() - FileOutputStream( newFile ).use { fos -> tais.copyTo( fos ) } + val outFile = File( targetDir, strippedName ) + + // BUG FIX 3: Zip Slip guard was missing from the original untar. + if ( !outFile.canonicalPath.startsWith( targetDir.canonicalPath + File.separator ) ) + throw SecurityException( "Ungültiger TAR-Eintrag (Zip Slip): ${entry.name}" ) + + if ( entry.isDirectory ) + { + outFile.mkdirs() + } + else + { + outFile.parentFile?.mkdirs() + FileOutputStream( outFile ).use { fos -> + tais.copyTo( fos ) + } + } } - entry = tais.nextEntry + + entry = tais.nextTarEntry + } + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Utility + // ───────────────────────────────────────────────────────────────────────── + + /** + * Strips the mandatory `world/` root prefix from an archive entry name. + * + * | Input | Output | Meaning | + * |--------------------|-----------------|--------------------------------| + * | `"world/"` | `null` | Skip — bare root dir entry | + * | `"world/level.dat"`| `"level.dat"` | Extract to target root | + * | `"world/region/"` | `"region/"` | Extract sub-directory | + * | `"other/file.dat"` | `null` | Skip — unexpected root; logged | + * + * @return The stripped relative path, or `null` if the entry should be skipped. + */ + private fun stripArchiveRoot( entryName: String ): String? + { + if ( !entryName.startsWith( ARCHIVE_ROOT ) ) + { + // Tolerate a trailing-slash-only root entry (some tools emit "world" without slash) + if ( entryName == "world" ) return null + + plugin.logger.warning( "[WorldManager] Unerwarteter Archiv-Eintrag (kein 'world/'-Präfix): $entryName — wird übersprungen." ) + return null + } + + val stripped = entryName.removePrefix( ARCHIVE_ROOT ) + + // The bare "world/" directory entry itself → skip + if ( stripped.isEmpty() ) return null + + return stripped + } + + // ───────────────────────────────────────────────────────────────────────── + // Async helper (for use from onEnable() if you ever migrate there) + // ───────────────────────────────────────────────────────────────────────── + + /** + * Offloads archive extraction to an async Bukkit thread, then fires + * [onComplete] on the main thread when done. Use this if you ever move + * world preparation out of `onLoad()` and into `onEnable()`. + * + * @param archiveFile The archive to extract. + * @param targetDir The destination folder. + * @param onComplete Callback invoked on the **main thread** after extraction. + */ + fun extractAsync( + archiveFile: File, + targetDir: File, + onComplete: () -> Unit + ) { + val scheduler = plugin.server.scheduler + + scheduler.runTaskAsynchronously( plugin ) { -> + try + { + when + { + archiveFile.name.endsWith(".zip") -> unzip(archiveFile, targetDir) + archiveFile.name.endsWith(".tar.gz") -> untar(archiveFile, targetDir) + else -> plugin.logger.severe("[WorldManager] Unbekanntes Archivformat: ${archiveFile.name}") + } + } catch (e: Exception) + { + plugin.logger.severe("[WorldManager] Fehler beim Entpacken: ${e.message}") + e.printStackTrace() + return@runTaskAsynchronously + } + + // Switch back to the main thread for Bukkit.createWorld(...) + scheduler.runTask( plugin ) { -> + onComplete() } } }