diff --git a/README.md b/README.md index 7c00071..9b575cd 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,11 @@ PHP library to allow [Zstandard](https://facebook.github.io/zstd/) compression a ## Dependencies -ZSTD, example installation on Ubuntu: +ZSTD must be installed on your system. Example installation on Ubuntu: ```bash sudo apt install zstd -```` +``` ## Installation @@ -36,9 +36,21 @@ use Appoly\ZstdPhp\ZSTD; ZSTD::compress('path/to/file', 'path/to/output/file.zst'); ``` +If no output path is provided, the compressed file will be saved with the `.zst` extension added: + +```php +ZSTD::compress('path/to/file'); // Creates path/to/file.zst +``` + +For decompression, if no output path is provided, the file will be decompressed in place: + +```php +ZSTD::decompress('path/to/file.zst'); // Creates path/to/file +``` + ### Advanced -Decompress from a stream, and handle the output yourself in chunks. Can be used with custom filesystems etc to minimise memory usage. +Decompress from a stream, and handle the output yourself in chunks. Can be used with custom filesystems etc to minimize memory usage. ```php use Appoly\ZstdPhp\ZSTD; @@ -57,7 +69,7 @@ ZSTD::decompressDataFromStream( fclose($inputStream); ``` -Compress from a stream, and handle the output yourself in chunks. Can be used with custom filesystems etc to minimise memory usage. +Compress from a stream, and handle the output yourself in chunks. Can be used with custom filesystems etc to minimize memory usage. ```php use Appoly\ZstdPhp\ZSTD; @@ -68,7 +80,7 @@ $outputCallback = function ($outputChunk) { echo $outputChunk; }; -ZSTD::compressDataToStream( +ZSTD::compressDataFromStream( inputStream: $inputStream, outputCallback: $outputCallback ); @@ -76,13 +88,30 @@ ZSTD::compressDataToStream( fclose($inputStream); ``` +You can also specify a timeout for stream operations: + +```php +ZSTD::compressDataFromStream( + inputStream: $inputStream, + outputCallback: $outputCallback, + timeout: 60.0 // Timeout in seconds +); +``` + +## Exception Handling + +The library throws exceptions when operations fail: + +- `\Exception` if ZSTD is not installed on the system +- `\RuntimeException` if compression or decompression processes fail + ## License MIT License -Copyright (c) 2023 Appoly Ltd +Copyright (c) 2025 Appoly Ltd Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/composer.json b/composer.json index 5a2f68a..00a3977 100644 --- a/composer.json +++ b/composer.json @@ -2,6 +2,7 @@ "name": "appoly/zstd-php", "description": "PHP wrapper for zstd", "require": { + "php": "^8.0", "symfony/process": "^6.0" }, "license": "MIT", @@ -14,6 +15,10 @@ { "name": "John Wedgbury", "email": "john@appoly.co.uk" + }, + { + "name": "James Merrix", + "email": "james@appoly.co.uk" } ] -} +} \ No newline at end of file diff --git a/composer.lock b/composer.lock index c4c452f..729b949 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6f25804bbc74070ad2fd2c72d4f4f6b1", + "content-hash": "6ae2faa18193fbb4ac5cccc9028fc32e", "packages": [ { "name": "symfony/process", - "version": "v6.4.0", + "version": "v6.4.15", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "191703b1566d97a5425dc969e4350d32b8ef17aa" + "reference": "3cb242f059c14ae08591c5c4087d1fe443564392" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/191703b1566d97a5425dc969e4350d32b8ef17aa", - "reference": "191703b1566d97a5425dc969e4350d32b8ef17aa", + "url": "https://api.github.com/repos/symfony/process/zipball/3cb242f059c14ae08591c5c4087d1fe443564392", + "reference": "3cb242f059c14ae08591c5c4087d1fe443564392", "shasum": "" }, "require": { @@ -49,7 +49,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.4.0" + "source": "https://github.com/symfony/process/tree/v6.4.15" }, "funding": [ { @@ -65,16 +65,18 @@ "type": "tidelift" } ], - "time": "2023-11-17T21:06:49+00:00" + "time": "2024-11-06T14:19:14+00:00" } ], "packages-dev": [], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, - "platform": [], - "platform-dev": [], - "plugin-api-version": "2.3.0" + "platform": { + "php": "^8.0" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" } diff --git a/src/ZSTD.php b/src/ZSTD.php index e82e2cc..c04b112 100644 --- a/src/ZSTD.php +++ b/src/ZSTD.php @@ -3,88 +3,119 @@ namespace Appoly\ZstdPhp; use Symfony\Component\Process\Process; +use Symfony\Component\Process\ExecutableFinder; +use Symfony\Component\Process\Exception\ProcessFailedException; class ZSTD { - public static function compress(string $inputPath, ?string $outputPath = null): false|string + /** + * Compress a file using zstd. + * + * @param string $inputPath Path to the input file. + * @param string|null $outputPath Optional path for the output file. + * + * @return string The command output. + * + * @throws \RuntimeException If the compression process fails. + */ + public static function compress(string $inputPath, ?string $outputPath = null): string { $zstd = self::getZstdPath(); - // Escape the input and output paths - $inputPath = escapeshellarg($inputPath); - $outputPath = escapeshellarg($outputPath); - - // Compress the data - if (empty($outputPath)) { - return exec("$zstd --force -o $inputPath.zst $inputPath"); + if (!empty($outputPath)) { + $command = [$zstd, '--force', '-o', $outputPath, $inputPath]; } else { - return exec("$zstd --force -o $outputPath $inputPath"); + $command = [$zstd, '--force', '-o', $inputPath . '.zst', $inputPath]; } + + $process = new Process($command); + $process->mustRun(); // Automatically throws an exception if the process fails + + return $process->getOutput(); } - public static function decompress(string $inputPath, ?string $outputPath = null): false|string + /** + * Decompress a file using zstd. + * + * @param string $inputPath Path to the compressed file. + * @param string|null $outputPath Optional path for the decompressed file. + * + * @return string The command output. + * + * @throws \RuntimeException If the decompression process fails. + */ + public static function decompress(string $inputPath, ?string $outputPath = null): string { $zstd = self::getZstdPath(); - // Escape the input and output paths - $inputPath = escapeshellarg($inputPath); - $outputPath = escapeshellarg($outputPath); - - // Decompress the data - if (empty($outputPath)) { - return exec("$zstd --force -d $inputPath"); + if (!empty($outputPath)) { + $command = [$zstd, '--force', '-d', '-o', $outputPath, $inputPath]; } else { - return exec("$zstd --force -d -o $outputPath $inputPath"); + $command = [$zstd, '--force', '-d', $inputPath]; } + + $process = new Process($command); + $process->mustRun(); + + return $process->getOutput(); } - public static function compressDataFromStream(&$inputStream, $outputCallback): void + /** + * Compress data from a stream, providing output in chunks. + * + * @param mixed $inputStream The input stream or data to compress. + * @param callable $outputCallback Callback function to handle each output chunk. + * @param float|null $timeout Optional timeout in seconds (null for default). + * + * @return void + * + * @throws \RuntimeException If the stream compression fails. + */ + public static function compressDataFromStream($inputStream, callable $outputCallback, ?float $timeout = null): void { $zstd = self::getZstdPath(); - - $process = new Process( - [ - $zstd, - '--force', - ], - null, - null, - null, - 0, - ); - - $process->setInput($inputStream); - $process->start(); - - // Get output incrementally - foreach ($process as $type => $data) { - if ($type === Process::OUT) { - $outputCallback($data); - } - } - - $process->wait(); + self::runStreamProcess([$zstd, '--force'], $inputStream, $outputCallback, 'Stream compression failed', $timeout); } - public static function decompressDataFromStream(&$inputStream, $outputCallback): void + /** + * Decompress data from a stream, providing output in chunks. + * + * @param mixed $inputStream The input stream or data to decompress. + * @param callable $outputCallback Callback function to handle each output chunk. + * @param float|null $timeout Optional timeout in seconds (null for default). + * + * @return void + * + * @throws \RuntimeException If the stream decompression fails. + */ + public static function decompressDataFromStream($inputStream, callable $outputCallback, ?float $timeout = null): void { $zstd = self::getZstdPath(); - $process = new Process( - [ - $zstd, - '--force', - '-d', - ], - null, - null, - null, - 0, - ); + self::runStreamProcess([$zstd, '--force', '-d'], $inputStream, $outputCallback, 'Stream decompression failed', $timeout); + } + /** + * Run a stream process and handle output. + * + * @param array $command Command to execute. + * @param mixed $inputStream The input stream or data. + * @param callable $outputCallback Callback function to handle each output chunk. + * @param string $errorMessage Error message prefix if the process fails. + * @param float|null $timeout Optional timeout in seconds. + * + * @return void + * + * @throws \RuntimeException If the process fails. + */ + private static function runStreamProcess(array $command, $inputStream, callable $outputCallback, string $errorMessage, ?float $timeout = null): void + { + $process = new Process($command); + if ($timeout !== null) { + $process->setTimeout($timeout); + } $process->setInput($inputStream); $process->start(); - // Get output incrementally and execute the callback for each chunk foreach ($process as $type => $data) { if ($type === Process::OUT) { $outputCallback($data); @@ -92,22 +123,28 @@ public static function decompressDataFromStream(&$inputStream, $outputCallback): } $process->wait(); + + if (!$process->isSuccessful()) { + throw new \RuntimeException($errorMessage . ': ' . $process->getErrorOutput()); + } } + /** + * Retrieve the path to the zstd executable. + * + * @return string The path to the zstd executable. + * + * @throws \Exception If zstd is not installed. + */ private static function getZstdPath(): string { - // Find the local zstd library - // Use either which zstd or where zstd depending on the OS - $zstd = exec('which zstd'); - if (empty($zstd)) { - $zstd = exec('where zstd'); - } + $finder = new ExecutableFinder(); + $zstd = $finder->find('zstd'); - // If not installed, throw an exception - if (empty($zstd)) { - throw new \Exception('zstd not installed'); + if (!$zstd) { + throw new \Exception('zstd is not installed'); } return $zstd; } -} +} \ No newline at end of file