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.
This commit is contained in:
TDSTOS
2026-04-12 12:39:19 +02:00
parent 26a29e8ba9
commit c261a3a07c

View File

@@ -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 )
plugin.logger.info( "[WorldManager] Map erfolgreich entpackt!" )
// 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 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 )
// Zip Slip guard
if ( !outFile.canonicalPath.startsWith( targetDir.canonicalPath + File.separator ) )
throw SecurityException( "Ungültiger ZIP-Eintrag (Zip Slip): ${entry.name}" )
if ( entry.isDirectory )
{
newFile.mkdirs()
outFile.mkdirs()
}
else
{
File( newFile.parent ).mkdirs()
FileOutputStream( newFile ).use { fos ->
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 )
// BUG FIX 2 (same as unzip): strip the "world/" root prefix.
val strippedName = stripArchiveRoot( entry.name )
if ( strippedName != null )
{
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 )
newFile.mkdirs()
{
outFile.mkdirs()
}
else
{
newFile.parentFile.mkdirs()
FileOutputStream( newFile ).use { fos -> tais.copyTo( fos ) }
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()
}
}
}