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,10 +8,33 @@ import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
import java.util.zip.ZipInputStream 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( class WorldManager(
private val plugin: SpeedHG 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 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" )!!
@@ -31,7 +54,9 @@ 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() fun prepareRandomWorld()
{ {
@@ -49,74 +74,214 @@ class WorldManager(
val randomMapName = maps.random() val randomMapName = maps.random()
val mapsFolder = File( plugin.dataFolder, plugin.config.getString( "map-system.zip-folder", "maps" )!! ) val mapsFolder = File( plugin.dataFolder, plugin.config.getString( "map-system.zip-folder", "maps" )!! )
val zipFile = File( mapsFolder, randomMapName ) 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 return
} }
plugin.logger.info( "[WorldManager] Ausgewählte Map: $randomMapName. Entpacke..." ) plugin.logger.info( "[WorldManager] Ausgewählte Map: $randomMapName. Entpacke..." )
// Target folder must exist before extraction writes into it
targetWorldFolder.mkdirs() targetWorldFolder.mkdirs()
if (zipFile.endsWith( ".zip" )) // BUG FIX 1: Use archiveFile.name (String) not archiveFile (File) for
unzip( zipFile, targetWorldFolder.parentFile ) // extension checks. File.endsWith() matches path *components*,
else if (zipFile.endsWith( ".gz" )) // not the trailing characters of the filename string.
untar( zipFile, targetWorldFolder.parentFile ) when
{
plugin.logger.info( "[WorldManager] Map erfolgreich entpackt!" ) 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( private fun unzip(
zipFile: File, zipFile: File,
targetDir: File targetDir: File
) { ) {
ZipInputStream( FileInputStream( zipFile ) ).use { zis -> ZipInputStream( FileInputStream( zipFile ) ).use { zis ->
var entry = zis.nextEntry var entry = zis.nextEntry
while ( entry != null ) 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 )) 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}" ) throw SecurityException( "Ungültiger ZIP-Eintrag (Zip Slip): ${entry.name}" )
if ( entry.isDirectory ) if ( entry.isDirectory )
{ {
newFile.mkdirs() outFile.mkdirs()
} }
else else
{ {
File( newFile.parent ).mkdirs() outFile.parentFile?.mkdirs()
FileOutputStream( newFile ).use { fos -> FileOutputStream( outFile ).use { fos ->
zis.copyTo( fos ) zis.copyTo( fos )
} }
} }
}
zis.closeEntry() zis.closeEntry()
entry = zis.nextEntry 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( private fun untar(
tarGzFile: File, tarGzFile: File,
targetDir: 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 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 ) if ( entry.isDirectory )
newFile.mkdirs() {
outFile.mkdirs()
}
else else
{ {
newFile.parentFile.mkdirs() outFile.parentFile?.mkdirs()
FileOutputStream( newFile ).use { fos -> tais.copyTo( fos ) } 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()
} }
} }
} }