diff --git a/libraries/libasyncio/FileOrDirectoryCompressTask.php b/libraries/libasyncio/FileOrDirectoryCompressTask.php index 7de459e..53ce1af 100644 --- a/libraries/libasyncio/FileOrDirectoryCompressTask.php +++ b/libraries/libasyncio/FileOrDirectoryCompressTask.php @@ -23,7 +23,12 @@ namespace libasyncio; +use GlobalLogger; +use InvalidArgumentException; +use libasyncio\compression\CompressionFormat; +use libasyncio\compression\Compressor; use pocketmine\Server; +use RuntimeException; class FileOrDirectoryCompressTask extends FileOperationTask { @@ -31,9 +36,11 @@ class FileOrDirectoryCompressTask extends FileOperationTask /** @var string */ private string $input; /** @var string */ - private $output; - /** @var int */ - private $compressionLevel; + private string $output; + /** @var int|null */ + private ?int $compressionLevel; + /** @var Compressor */ + private Compressor $compressor; /** * FileOrDirectoryCompressTask constructor. @@ -41,12 +48,18 @@ class FileOrDirectoryCompressTask extends FileOperationTask * @param string $input * @param string $output * @param callable $callable - * @param int $compressionLevel + * @param int|null $compressionLevel + * @param CompressionFormat|null $format */ - public function __construct(string $input, string $output, callable $callable, int $compressionLevel = ZstdRecursiveCompressor::COMPRESSION_LEVEL) + public function __construct(string $input, string $output, callable $callable, ?int $compressionLevel = null, ?CompressionFormat $format = null) { + if ($format !== null && !$format->isCompatible()) { + throw new InvalidArgumentException('Compression format ' . $format->name . ' is not compatible'); + } + + $this->compressor = ($format ?? CompressionFormat::auto())->getCompressor(); $this->input = $input; - $this->output = str_replace('.' . ZstdRecursiveCompressor::COMPRESSION_FORMAT, '', $output); + $this->output = ($outputFormat = CompressionFormat::fromPath($output)) !== null ? substr($output, 0, -strlen('.' . $outputFormat->getFileExtension())) : $output; $this->compressionLevel = $compressionLevel; parent::__construct($input, $callable); } @@ -57,12 +70,18 @@ public function __construct(string $input, string $output, callable $callable, i public function onRun(): void { parent::onRun(); - $this->setSuccess(ZstdRecursiveCompressor::compress($this->input, $this->output, $this->compressionLevel)); + try { + $this->setSuccess(RecursiveCompressor::compress($this->input, $this->output, $this->compressionLevel, $this->compressor->getFormat())); + } catch (RuntimeException $e) { + GlobalLogger::get()->critical("Compression failed for {$this->input}: " . $e->getMessage()); + GlobalLogger::get()->logException($e); + $this->setSuccess(false); + } } protected function checkSuccess(): void { - $outputLocation = $this->output . '.' . ZstdRecursiveCompressor::COMPRESSION_FORMAT; + $outputLocation = $this->output . '.' . $this->compressor->getFormat()->getFileExtension(); if ($this->getSuccess()) { Server::getInstance()->getLogger()->debug("Compressed directory/file {$this->input} to {$outputLocation}"); } else { @@ -70,4 +89,4 @@ protected function checkSuccess(): void } } -} \ No newline at end of file +} diff --git a/libraries/libasyncio/FileOrDirectoryUncompressTask.php b/libraries/libasyncio/FileOrDirectoryUncompressTask.php index c7c0885..ecdea50 100644 --- a/libraries/libasyncio/FileOrDirectoryUncompressTask.php +++ b/libraries/libasyncio/FileOrDirectoryUncompressTask.php @@ -23,26 +23,40 @@ namespace libasyncio; +use GlobalLogger; +use InvalidArgumentException; +use libasyncio\compression\CompressionFormat; +use libasyncio\compression\Compressor; use pocketmine\Server; +use RuntimeException; +use function str_ends_with; class FileOrDirectoryUncompressTask extends FileOperationTask { /** @var string */ - private $input; + private string $input; /** @var string */ private string $output; + /** @var Compressor */ + private Compressor $compressor; /** - * FileOrDirectoryCompressTask constructor. + * FileOrDirectoryUncompressTask constructor. * * @param string $input * @param string $output * @param callable $callable + * @param CompressionFormat|null $format */ - public function __construct(string $input, string $output, callable $callable) + public function __construct(string $input, string $output, callable $callable, ?CompressionFormat $format = null) { - $this->input = str_replace('.' . ZstdRecursiveCompressor::COMPRESSION_FORMAT, '', $input); + if ($format !== null && !$format->isCompatible()) { + throw new InvalidArgumentException('Compression format ' . $format->name . ' is not compatible'); + } + + $this->compressor = ($format ?? CompressionFormat::fromPath($input) ?? CompressionFormat::auto())->getCompressor(); + $this->input = $input; $this->output = $output; parent::__construct($input, $callable); } @@ -53,12 +67,20 @@ public function __construct(string $input, string $output, callable $callable) public function onRun(): void { parent::onRun(); - $this->setSuccess(ZstdRecursiveCompressor::uncompress($this->input, $this->output)); + try { + $this->setSuccess(RecursiveCompressor::uncompress($this->input, $this->output, $this->compressor->getFormat())); + } catch (RuntimeException $e) { + GlobalLogger::get()->critical("Uncompression failed for {$this->input}: " . $e->getMessage()); + GlobalLogger::get()->logException($e); + $this->setSuccess(false); + } } protected function checkSuccess(): void { - $inputLocation = $this->input . '.' . ZstdRecursiveCompressor::COMPRESSION_FORMAT; + $extension = $this->compressor->getFormat()->getFileExtension(); + $inputLocation = str_ends_with($this->input, '.' . $extension) ? $this->input : $this->input . '.' . $extension; + if ($this->getSuccess()) { Server::getInstance()->getLogger()->debug("Uncompressed directory/file {$inputLocation} to {$this->output}"); } else { @@ -66,4 +88,4 @@ protected function checkSuccess(): void } } -} \ No newline at end of file +} diff --git a/libraries/libasyncio/ZstdRecursiveCompressor.php b/libraries/libasyncio/RecursiveCompressor.php similarity index 64% rename from libraries/libasyncio/ZstdRecursiveCompressor.php rename to libraries/libasyncio/RecursiveCompressor.php index aab594b..eb2f3fd 100644 --- a/libraries/libasyncio/ZstdRecursiveCompressor.php +++ b/libraries/libasyncio/RecursiveCompressor.php @@ -24,23 +24,22 @@ namespace libasyncio; use GlobalLogger; +use libasyncio\compression\CompressionFormat; +use libasyncio\compression\Compressor; use Phar; use PharData; use pocketmine\utils\Filesystem; use RuntimeException; use Throwable; use function is_dir; +use function is_file; use function mkdir; -use function sprintf; +use function str_ends_with; -class ZstdRecursiveCompressor +class RecursiveCompressor { - /** @var int */ - public const COMPRESSION_LEVEL = ZSTD_COMPRESS_LEVEL_MAX; - public const ARCHIVE_FORMAT = 'tar'; - public const COMPRESSION_FORMAT = 'ngzstd'; /** * Compress a directory. @@ -48,22 +47,18 @@ class ZstdRecursiveCompressor * like path. It's important you don't * use a file name for it. * - * Output format is COMPRESSION_FORMAT. + * Output format is the chosen compression format. * * @param string $input * @param string $output - * @param int $compressionLevel + * @param int|null $compressionLevel + * @param CompressionFormat|null $format * * @return bool */ - public static function compress(string $input, string $output, int $compressionLevel = self::COMPRESSION_LEVEL): bool + public static function compress(string $input, string $output, ?int $compressionLevel = null, ?CompressionFormat $format = null): bool { - if ($compressionLevel < ZSTD_COMPRESS_LEVEL_MIN || $compressionLevel > ZSTD_COMPRESS_LEVEL_MAX) { - throw new RuntimeException( - 'Compression level must cannot either lower than ' . ZSTD_COMPRESS_LEVEL_MIN . - ' or higher than ' . ZSTD_COMPRESS_LEVEL_MAX . ', ' . $compressionLevel . ' given' - ); - } + $compressor = self::resolveCompressor($format); $archive = new PharData($input . '.' . self::ARCHIVE_FORMAT); $archive->buildFromDirectory($input); @@ -73,12 +68,9 @@ public static function compress(string $input, string $output, int $compressionL throw new RuntimeException('Archive unreadable'); } - $compressedData = zstd_compress($data, $compressionLevel); - if (!is_string($compressedData)) { - throw new RuntimeException('Compression failed'); - } + $compressedData = $compressor->compress($data, $compressionLevel); - Filesystem::safeFilePutContents($output . '.' . self::COMPRESSION_FORMAT, $compressedData); + Filesystem::safeFilePutContents($output . '.' . $compressor->getFormat()->getFileExtension(), $compressedData); unset($archive); Phar::unlinkArchive($input . '.' . self::ARCHIVE_FORMAT); @@ -91,20 +83,27 @@ public static function compress(string $input, string $output, int $compressionL * like path. It's important you don't * use a file name for it. * - * Input format is COMPRESSION_FORMAT. + * Input format is the chosen compression format. * Output format is regular directory. * * @param string $input * @param string $output + * @param CompressionFormat|null $format * * @return bool */ - public static function uncompress(string $input, string $output): bool + public static function uncompress(string $input, string $output, ?CompressionFormat $format = null): bool { - $input .= '.' . self::COMPRESSION_FORMAT; + $compressor = self::resolveCompressor($format, $input); + + $extension = $compressor->getFormat()->getFileExtension(); + if (!str_ends_with($input, '.' . $extension)) { + $input .= '.' . $extension; + } + if (!is_file($input)) { throw new RuntimeException( - 'That file is not of type ' . self::COMPRESSION_FORMAT . ', cannot uncompress' + 'That file is not of type ' . $extension . ', cannot uncompress' ); } @@ -113,17 +112,14 @@ public static function uncompress(string $input, string $output): bool throw new RuntimeException('Compressed file unreadable'); } - $data = zstd_uncompress($compressedData); - if (!is_string($data)) { - throw new RuntimeException('Uncompression failed.'); - } + $data = $compressor->decompress($compressedData); Filesystem::safeFilePutContents($output . '.' . self::ARCHIVE_FORMAT, $data); $archive = new PharData($output . '.' . self::ARCHIVE_FORMAT); try { if (!is_dir($output) && !mkdir($output)) { - throw new RuntimeException(sprintf('Directory "%s" was not created', $output)); + throw new RuntimeException('Directory "' . $output . '" was not created'); } } catch (Throwable $exception) { GlobalLogger::get()->critical("Unhandled exception from a method that should never throw anything."); @@ -138,4 +134,19 @@ public static function uncompress(string $input, string $output): bool return true; } -} \ No newline at end of file + + /** + * @param CompressionFormat|null $format + * @param string|null $path + * + * @return Compressor + */ + private static function resolveCompressor(?CompressionFormat $format, ?string $path = null): Compressor + { + if ($format !== null) { + return $format->getCompressor(); + } + + return ($path !== null ? (CompressionFormat::fromPath($path) ?? CompressionFormat::auto()) : CompressionFormat::auto())->getCompressor(); + } +} diff --git a/libraries/libasyncio/composer.json b/libraries/libasyncio/composer.json index b9181e3..92aa2b5 100644 --- a/libraries/libasyncio/composer.json +++ b/libraries/libasyncio/composer.json @@ -5,9 +5,13 @@ "type": "project", "version": "dev-stable", "require": { - "php": "^8.0", + "php": "^8.1", "ext-igbinary": "*" }, + "suggest": { + "ext-zstd": "Required for ZSTD compression format", + "ext-libdeflate": "Faster DEFLATE compression" + }, "require-dev": { "phpstan/phpstan": "2.1.29", "nethergamesmc/pocketmine-mp": "dev-stable", diff --git a/libraries/libasyncio/compression/CompressionFormat.php b/libraries/libasyncio/compression/CompressionFormat.php new file mode 100644 index 0000000..3bc9fad --- /dev/null +++ b/libraries/libasyncio/compression/CompressionFormat.php @@ -0,0 +1,109 @@ + 'ngzstd', + self::DEFLATE => 'ngdeflate', + self::GZIP => 'nggzip', + }; + } + + /** + * Whether this format is compatible and can be used. + * + * @return bool + */ + public function isCompatible(): bool + { + return match ($this) { + self::ZSTD => extension_loaded('zstd'), + self::DEFLATE => extension_loaded('libdeflate') || extension_loaded('zlib'), + self::GZIP => extension_loaded('zlib'), + }; + } + + /** + * @return Compressor + */ + public function getCompressor(): Compressor + { + return match ($this) { + self::ZSTD => new Zstd(), + self::DEFLATE => new Deflate(), + self::GZIP => new Gzip(), + }; + } + + /** + * @param string $path + * @return self|null + */ + public static function fromPath(string $path): ?self + { + foreach (self::cases() as $format) { + if (str_ends_with($path, '.' . $format->getFileExtension())) { + return $format; + } + } + + return null; + } + + /** + * @return self + * @throws RuntimeException if no compatible format is found + */ + public static function auto(): self + { + foreach (self::cases() as $format) { + if ($format->isCompatible()) { + return $format; + } + } + + throw new RuntimeException('No compatible compression format found'); + } +} diff --git a/libraries/libasyncio/compression/Compressor.php b/libraries/libasyncio/compression/Compressor.php new file mode 100644 index 0000000..3fe45d7 --- /dev/null +++ b/libraries/libasyncio/compression/Compressor.php @@ -0,0 +1,46 @@ + self::LEVEL_MAX) { + throw new InvalidArgumentException( + 'Compression level must be between ' . self::LEVEL_MIN . ' and ' . self::LEVEL_MAX . ', ' . $level . ' given' + ); + } + + if (extension_loaded('libdeflate')) { + return libdeflate_deflate_compress($data, $level); + } + + // TODO: HACK! Fallback to core zlib when ext-libdeflate is not available. + // pmmp/ext-libdeflate only exposes compression bindings and may not be + // installed; zlib_encode with ZLIB_ENCODING_RAW produces compatible + // raw DEFLATE output. Level is clamped to 9 as zlib supports 0-9 + // while libdeflate supports 0-12. + $result = zlib_encode($data, ZLIB_ENCODING_RAW, $level > 9 ? 9 : $level); + if (!is_string($result)) { + throw new RuntimeException('Compression failed'); + } + + return $result; + } + + /** + * @param string $data + * @return string + */ + public function decompress(string $data): string + { + $context = inflate_init(ZLIB_ENCODING_RAW); + if ($context === false) { + throw new RuntimeException('Failed to initialize inflate context'); + } + + $result = inflate_add($context, $data, ZLIB_FINISH); + if (!is_string($result)) { + throw new RuntimeException('Uncompression failed'); + } + + return $result; + } + + /** + * @return CompressionFormat + */ + public function getFormat(): CompressionFormat + { + return CompressionFormat::DEFLATE; + } +} diff --git a/libraries/libasyncio/compression/Gzip.php b/libraries/libasyncio/compression/Gzip.php new file mode 100644 index 0000000..2ee708f --- /dev/null +++ b/libraries/libasyncio/compression/Gzip.php @@ -0,0 +1,80 @@ + self::LEVEL_MAX)) { + throw new InvalidArgumentException( + 'Compression level must be between ' . self::LEVEL_MIN . ' and ' . self::LEVEL_MAX . ', ' . $level . ' given' + ); + } + + $result = gzencode($data, $level ?? -1); + if (!is_string($result)) { + throw new RuntimeException('Compression failed'); + } + + return $result; + } + + /** + * @param string $data + * @return string + */ + public function decompress(string $data): string + { + $result = gzdecode($data); + if (!is_string($result)) { + throw new RuntimeException('Uncompression failed'); + } + + return $result; + } + + /** + * @return CompressionFormat + */ + public function getFormat(): CompressionFormat + { + return CompressionFormat::GZIP; + } +} diff --git a/libraries/libasyncio/compression/Zstd.php b/libraries/libasyncio/compression/Zstd.php new file mode 100644 index 0000000..4bb125e --- /dev/null +++ b/libraries/libasyncio/compression/Zstd.php @@ -0,0 +1,83 @@ + self::LEVEL_MAX) { + throw new InvalidArgumentException( + 'Compression level must be between ' . self::LEVEL_MIN . ' and ' . self::LEVEL_MAX . ', ' . $level . ' given' + ); + } + + $result = zstd_compress($data, $level); + if (!is_string($result)) { + throw new RuntimeException('Compression failed'); + } + + return $result; + } + + /** + * @param string $data + * @return string + */ + public function decompress(string $data): string + { + $result = zstd_uncompress($data); + if (!is_string($result)) { + throw new RuntimeException('Uncompression failed'); + } + + return $result; + } + + /** + * @return CompressionFormat + */ + public function getFormat(): CompressionFormat + { + return CompressionFormat::ZSTD; + } +} diff --git a/libraries/libasyncio/stub/LibDeflate/libdeflate.php b/libraries/libasyncio/stub/LibDeflate/libdeflate.php new file mode 100644 index 0000000..1921e45 --- /dev/null +++ b/libraries/libasyncio/stub/LibDeflate/libdeflate.php @@ -0,0 +1,23 @@ +