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:
@@ -8,15 +8,38 @@ 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" )!!
|
||||||
val serverRoot = plugin.dataFolder.parentFile.parentFile
|
val serverRoot = plugin.dataFolder.parentFile.parentFile
|
||||||
val targetWorldFolder = File(serverRoot, targetWorldName)
|
val targetWorldFolder = File( serverRoot, targetWorldName )
|
||||||
|
|
||||||
if ( targetWorldFolder.exists() )
|
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()
|
fun prepareRandomWorld()
|
||||||
{
|
{
|
||||||
val targetWorldFolder = deleteWorld()
|
val targetWorldFolder = deleteWorld()
|
||||||
|
|
||||||
if (!plugin.config.getBoolean( "map-system.enabled", false ))
|
if ( !plugin.config.getBoolean( "map-system.enabled", false ) )
|
||||||
return
|
return
|
||||||
|
|
||||||
val maps = plugin.config.getStringList( "map-system.maps" )
|
val maps = plugin.config.getStringList( "map-system.maps" )
|
||||||
@@ -48,75 +73,215 @@ 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 )
|
||||||
throw SecurityException("Ungültiger ZIP-Eintrag (Zip Slip): ${entry.name}")
|
{
|
||||||
|
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 )
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user