diff --git a/.github/workflows/prado.yml b/.github/workflows/prado.yml index ae690576a..5fde78dd4 100644 --- a/.github/workflows/prado.yml +++ b/.github/workflows/prado.yml @@ -38,7 +38,7 @@ jobs: uses: shivammathur/setup-php@v2 #https://github.com/shivammathur/setup-php with: php-version: ${{ matrix.php-versions }} - extensions: ctype, dom, intl, json, mbstring, memcached, pdo_mysql, pdo_pgsql, pdo_sqlite, openssl, pcre, spl, zlib + extensions: ctype, dom, intl, json, mbstring, memcached, pdo_mysql, pdo_pgsql, pdo_sqlite, openssl, pcre, spl, zlib, bz2, zstd, brotli tools: php-cs-fixer, phpstan, cs2pr - name: Validate composer.json and composer.lock @@ -174,7 +174,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ env.PHP_VERSION }} - extensions: ctype, dom, intl, json, mbstring, memcached, pdo_mysql, pdo_pgsql, pdo_sqlite, openssl, pcre, spl, zlib + extensions: ctype, dom, intl, json, mbstring, memcached, pdo_mysql, pdo_pgsql, pdo_sqlite, openssl, pcre, spl, zlib, bz2, zstd, brotli tools: php-cs-fixer, phpstan, cs2pr - name: Validate composer.json and composer.lock @@ -243,7 +243,7 @@ jobs: with: php-version: ${{ env.PHP_VERSION }} # memcached extension omitted: libmemcached is not available on Windows - extensions: ctype, dom, intl, json, mbstring, pdo_mysql, pdo_pgsql, pdo_sqlite, openssl, pcre, spl, bz2, xsl, zip, zlib + extensions: ctype, dom, intl, json, mbstring, pdo_mysql, pdo_pgsql, pdo_sqlite, openssl, pcre, spl, bz2, xsl, zip, zlib, zstd, brotli tools: php-cs-fixer, phpstan, cs2pr - name: Validate composer.json and composer.lock diff --git a/framework/Exceptions/messages/messages.txt b/framework/Exceptions/messages/messages.txt index 53f2ee93a..3fb0879a8 100644 --- a/framework/Exceptions/messages/messages.txt +++ b/framework/Exceptions/messages/messages.txt @@ -582,6 +582,15 @@ streamwrapper_already_registered = Stream wrapper protocol '{0}' is already reg streamwrapper_registration_failed = Unable to register stream wrapper protocol '{0}' for class '{1}'. reservedspace_invalid = A reserved space requires a non-negative offset and a positive length; offset '{0}' and length '{1}' were given. reservedspace_access_denied = The operation overlaps the reserved space at offset '{0}'. +builtincompressor_extension_required = The '{0}' compressor requires the '{1}' PHP extension. +builtincompressor_compress_failed = The '{0}' compressor failed to compress the data. +builtincompressor_decompress_failed = The '{0}' compressor failed to decompress the data; it may be corrupt or in another format. +compression_method_unknown = The compression method '{0}' is unknown or its extension is not available. +compression_none_available = No compression method is available in this PHP installation. +compression_xz_unsupported = xz/LZMA has no PHP extension; install belisoful/prado-compression for a native-or-CLI xz codec. +clicompressor_command_missing = The '{0}' compressor requires one of these system commands on the PATH: '{1}'. +clicompressor_process_failed = The '{0}' compressor could not start the '{1}' command. +clicompressor_command_failed = The '{0}' compressor command '{1}' exited with code {2}: {3} socketservermodule_endpoint_required = TSocketServerModule requires an Endpoint, or a positive Port to compose one. socketservermodule_serverclass_invalid = TSocketServerModule ServerClass '{0}' is invalid. It must be TSocketServer or a subclass. diff --git a/framework/IO/Compression/ICompressor.php b/framework/IO/Compression/ICompressor.php new file mode 100644 index 000000000..aa2c21442 --- /dev/null +++ b/framework/IO/Compression/ICompressor.php @@ -0,0 +1,44 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\IO\Compression; + +/** + * ICompressor interface. + * + * A whole-string compression codec: {@see compress()} encodes a byte string and + * {@see decompress()} restores it. Implementing it lets a caller select a compression + * codec without binding to one algorithm, whether hand-written (LZW, run-length) or an + * extension-gated wrapper (zstd, brotli). An implementation may accept additional + * optional parameters (a compression level, a format-specific setting) after the data. + * + * A codec that transforms incrementally, without holding the whole string, extends + * {@see \Prado\IO\Filter\TStreamCodecFilter} instead; the two are companion forms of the + * same algorithm, one whole-string and one streaming. + * + * @author Brad Anderson + * @since 4.4.0 + */ +interface ICompressor +{ + /** + * Compresses a byte string. + * @param string $data The raw bytes. + * @return string The encoded bytes. + */ + public static function compress(string $data): string; + + /** + * Decompresses a byte string produced by {@see compress()}. + * @param string $data The encoded bytes. + * @return string The decoded bytes. + */ + public static function decompress(string $data): string; +} diff --git a/framework/IO/Compression/TBrotliCompressor.php b/framework/IO/Compression/TBrotliCompressor.php new file mode 100644 index 000000000..e27c6c106 --- /dev/null +++ b/framework/IO/Compression/TBrotliCompressor.php @@ -0,0 +1,66 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\IO\Compression; + +/** + * TBrotliCompressor class. + * + * Wraps the Brotli functions for the `br` format: a codec that compresses text more tightly + * than gzip, which is why browsers advertise it for web content. It is the HTTP + * `Content-Encoding: br` coding. The functions live in the optional `brotli` extension, so + * {@see isAvailable()} reports whether the codec can run before it is used; the framework + * never bundles a fallback implementation. + * + * The quality runs 0..11 (higher is smaller and slower); -1 selects {@see DEFAULT_QUALITY}, + * the maximum, which is Brotli's own default. + * + * @author Brad Anderson + * @since 4.4.0 + */ +class TBrotliCompressor extends TBuiltinCompressor +{ + /** The wire-format name (the HTTP content-coding token). */ + public const NAME = 'br'; + + /** The backing PHP extension. */ + protected const EXTENSION = 'brotli'; + + /** The lowest Brotli quality. */ + public const MIN_QUALITY = 0; + + /** The highest Brotli quality, which is also its default. */ + public const MAX_QUALITY = 11; + + /** The Brotli quality used when no explicit level is given. */ + public const DEFAULT_QUALITY = 11; + + /** + * Compresses with brotli_compress. + * @param string $data The raw bytes. + * @param int $level The quality 0..11, or -1 for {@see DEFAULT_QUALITY}. + * @return false|string The brotli bytes, or false on failure. + */ + protected static function encode(string $data, int $level): string|false + { + $quality = ($level < self::MIN_QUALITY || $level > self::MAX_QUALITY) ? self::DEFAULT_QUALITY : $level; + return @brotli_compress($data, $quality); + } + + /** + * Decompresses with brotli_uncompress. + * @param string $data The brotli bytes. + * @return false|string The raw bytes, or false on failure. + */ + protected static function decode(string $data): string|false + { + return @brotli_uncompress($data); + } +} diff --git a/framework/IO/Compression/TBuiltinCompressor.php b/framework/IO/Compression/TBuiltinCompressor.php new file mode 100644 index 000000000..d459ebc14 --- /dev/null +++ b/framework/IO/Compression/TBuiltinCompressor.php @@ -0,0 +1,107 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\IO\Compression; + +use Prado\Exceptions\TIOException; + +/** + * TBuiltinCompressor class. + * + * The shared base for the {@see ICompressor} codecs that wrap PHP's native compression + * functions, so the framework exposes the standard formats without reimplementing them. + * A subclass names its required extension through {@see EXTENSION} and provides the two + * native calls through {@see encode()} and {@see decode()}; this base adds the availability + * guard, the compression-level parameter, and uniform {@see TIOException} failure reporting. + * + * A native call reports failure by returning a non-string (`false` for the zlib functions, + * an integer error code for the bzip2 functions), so both surface as a thrown exception + * rather than a silent wrong value. The concrete codecs are {@see TGzipCompressor}, + * {@see TZlibCompressor}, {@see TDeflateCompressor}, and {@see TBzip2Compressor}. + * + * @author Brad Anderson + * @since 4.4.0 + */ +abstract class TBuiltinCompressor implements ICompressor +{ + /** The wire-format name of the codec, for diagnostics and content negotiation. */ + public const NAME = ''; + + /** The PHP extension the codec requires, or '' when it is part of the PHP core. */ + protected const EXTENSION = ''; + + /** + * Returns whether the codec's backing extension is loaded in this PHP installation. + * @return bool Whether the codec can run. + */ + public static function isAvailable(): bool + { + return static::EXTENSION === '' || extension_loaded(static::EXTENSION); + } + + /** + * Compresses a byte string with the native codec. + * @param string $data The raw bytes. + * @param int $level The compression level, or -1 for the codec's default. + * @throws TIOException When the backing extension is absent or the native call fails. + * @return string The compressed bytes. + */ + public static function compress(string $data, int $level = -1): string + { + static::assertAvailable(); + $result = static::encode($data, $level); + if (!is_string($result)) { + throw new TIOException('builtincompressor_compress_failed', static::NAME); + } + return $result; + } + + /** + * Decompresses a byte string produced by {@see compress()}. + * @param string $data The compressed bytes. + * @throws TIOException When the backing extension is absent or the data is corrupt. + * @return string The decompressed bytes. + */ + public static function decompress(string $data): string + { + static::assertAvailable(); + $result = static::decode($data); + if (!is_string($result)) { + throw new TIOException('builtincompressor_decompress_failed', static::NAME); + } + return $result; + } + + /** + * Asserts the backing extension is loaded before a native call. + * @throws TIOException When the extension is absent. + */ + protected static function assertAvailable(): void + { + if (!static::isAvailable()) { + throw new TIOException('builtincompressor_extension_required', static::NAME, static::EXTENSION); + } + } + + /** + * Runs the native compression call. + * @param string $data The raw bytes. + * @param int $level The compression level, or -1 for the codec's default. + * @return false|int|string The compressed bytes, or a non-string on failure. + */ + abstract protected static function encode(string $data, int $level): string|int|false; + + /** + * Runs the native decompression call. + * @param string $data The compressed bytes. + * @return false|int|string The decompressed bytes, or a non-string on failure. + */ + abstract protected static function decode(string $data): string|int|false; +} diff --git a/framework/IO/Compression/TBzip2Compressor.php b/framework/IO/Compression/TBzip2Compressor.php new file mode 100644 index 000000000..4304a1f5c --- /dev/null +++ b/framework/IO/Compression/TBzip2Compressor.php @@ -0,0 +1,60 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\IO\Compression; + +/** + * TBzip2Compressor class. + * + * Wraps PHP's {@see https://www.php.net/bzcompress bzcompress}/{@see https://www.php.net/bzdecompress + * bzdecompress} for the bzip2 format: a Burrows-Wheeler codec that compresses more tightly + * than DEFLATE on many inputs at a higher CPU cost. This is the format of a `.bz2` file. + * The bzip2 functions live in the optional `bz2` extension, so {@see isAvailable()} reports + * whether the codec can run before it is used. + * + * The compression level is a bzip2 block size of 1..9 (each step is 100 KB of working + * memory); -1 selects the library default. + * + * @author Brad Anderson + * @since 4.4.0 + */ +class TBzip2Compressor extends TBuiltinCompressor +{ + /** The wire-format name. */ + public const NAME = 'bzip2'; + + /** The backing PHP extension. */ + protected const EXTENSION = 'bz2'; + + /** The bzip2 block size used when no explicit level is given. */ + public const DEFAULT_BLOCK_SIZE = 4; + + /** + * Compresses with bzcompress. + * @param string $data The raw bytes. + * @param int $level The bzip2 block size 1..9, or -1 for {@see DEFAULT_BLOCK_SIZE}. + * @return int|string The bzip2 bytes, or a bzip2 error number on failure. + */ + protected static function encode(string $data, int $level): string|int + { + $blockSize = ($level < 1 || $level > 9) ? self::DEFAULT_BLOCK_SIZE : $level; + return bzcompress($data, $blockSize); + } + + /** + * Decompresses with bzdecompress. + * @param string $data The bzip2 bytes. + * @return int|string The raw bytes, or a bzip2 error number on failure. + */ + protected static function decode(string $data): string|int + { + return bzdecompress($data); + } +} diff --git a/framework/IO/Compression/TCliCompressor.php b/framework/IO/Compression/TCliCompressor.php new file mode 100644 index 000000000..ebcfb2434 --- /dev/null +++ b/framework/IO/Compression/TCliCompressor.php @@ -0,0 +1,70 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\IO\Compression; + +/** + * TCliCompressor class. + * + * The base for an {@see ICompressor} codec that is backed only by a system command, for a + * format with no PHP extension at all. It wires the {@see TCliCompressorTrait} command + * backend to the {@see ICompressor} contract: {@see isAvailable()} reports whether the + * command is present, and {@see compress()}/{@see decompress()} run it. A subclass supplies + * the command and its arguments through {@see commands()}, {@see compressArgs()}, and + * {@see decompressArgs()}. + * + * A codec whose format also has a PHP extension does not extend this; it extends + * {@see TBuiltinCompressor} and mixes in {@see TCliCompressorTrait} directly to fall back to + * the command only when the extension is absent. A CLI codec spawns a process per call, so + * it costs far more than a native codec and suits an occasional whole-string transform, not a + * hot loop. + * + * @author Brad Anderson + * @since 4.4.0 + */ +abstract class TCliCompressor implements ICompressor +{ + use TCliCompressorTrait; + + /** The wire-format name of the codec. */ + public const NAME = ''; + + /** + * Returns whether the backing command is available on this system. + * @return bool Whether the codec can run. + */ + public static function isAvailable(): bool + { + return static::cliAvailable(); + } + + /** + * Compresses a byte string by running the command. + * @param string $data The raw bytes. + * @param int $level The compression level, or -1 for the command's default. + * @throws \Prado\Exceptions\TIOException When the command is missing or fails. + * @return string The compressed bytes. + */ + public static function compress(string $data, int $level = -1): string + { + return static::cliCompress($data, $level); + } + + /** + * Decompresses a byte string by running the command. + * @param string $data The compressed bytes. + * @throws \Prado\Exceptions\TIOException When the command is missing, the data is corrupt, or the command fails. + * @return string The decompressed bytes. + */ + public static function decompress(string $data): string + { + return static::cliDecompress($data); + } +} diff --git a/framework/IO/Compression/TCliCompressorTrait.php b/framework/IO/Compression/TCliCompressorTrait.php new file mode 100644 index 000000000..5ba0ff3eb --- /dev/null +++ b/framework/IO/Compression/TCliCompressorTrait.php @@ -0,0 +1,187 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\IO\Compression; + +use Prado\Exceptions\TIOException; + +/** + * TCliCompressorTrait trait. + * + * The command-line backend shared by codecs that transform data through a system command + * rather than a PHP extension, for a format PHP cannot handle in-process. A using class + * names the command through {@see commands()} and the arguments through {@see compressArgs()} + * and {@see decompressArgs()}; the trait locates the command on the `PATH`, runs it shell-free, + * pipes the data through its standard input and output, and reports a missing command or a + * non-zero exit as a {@see TIOException}. + * + * The trait serves both shapes of CLI codec: {@see TCliCompressor} mixes it in for a codec + * that is only ever a command, and a {@see TBuiltinCompressor} subclass mixes it in to fall + * back to the command when its PHP extension is absent. The `cli`-prefixed methods stay clear + * of the {@see TBuiltinCompressor} surface so the two compose without collision. + * + * The command runs through {@see https://www.php.net/proc_open proc_open} with an argument + * vector, so no shell parses the arguments and untrusted data cannot inject a command. The + * standard streams are staged through temporary files, so a large transfer never blocks on a + * full pipe buffer and the run does not depend on stream_select, which is unreliable for pipes + * on Windows. + * + * @author Brad Anderson + * @since 4.4.0 + */ +trait TCliCompressorTrait +{ + /** + * Returns the candidate command names, most preferred first; the first found on the + * `PATH` (or given as an absolute path) is used. + * @return string[] The candidate command names. + */ + abstract protected static function commands(): array; + + /** + * Returns the command arguments that compress standard input to standard output. + * @param int $level The compression level, or -1 for the command's default. + * @return string[] The argument vector after the command name. + */ + abstract protected static function compressArgs(int $level): array; + + /** + * Returns the command arguments that decompress standard input to standard output. + * @return string[] The argument vector after the command name. + */ + abstract protected static function decompressArgs(): array; + + /** + * Returns whether the backing command is available on this system. + * @return bool Whether a candidate command was found. + */ + protected static function cliAvailable(): bool + { + return static::cliResolveCommand() !== null; + } + + /** + * Compresses a byte string by running the command over a pipe. + * @param string $data The raw bytes. + * @param int $level The compression level, or -1 for the command's default. + * @throws TIOException When the command is missing or fails. + * @return string The compressed bytes. + */ + protected static function cliCompress(string $data, int $level): string + { + return static::cliRun(static::compressArgs($level), $data); + } + + /** + * Decompresses a byte string by running the command over a pipe. + * @param string $data The compressed bytes. + * @throws TIOException When the command is missing, the data is corrupt, or the command fails. + * @return string The decompressed bytes. + */ + protected static function cliDecompress(string $data): string + { + return static::cliRun(static::decompressArgs(), $data); + } + + /** + * Runs the backing command with the given arguments, feeding the input on its standard + * input and returning its standard output. The three standard streams are staged through + * temporary files rather than pipes, so a large transfer never blocks on a full pipe buffer + * and the run does not depend on {@see https://www.php.net/stream_select stream_select}, + * which is unreliable for pipes on Windows. + * @param string[] $args The argument vector after the command name. + * @param string $input The bytes to feed to the command. + * @throws TIOException When the command is missing, cannot start, or exits non-zero. + * @return string The command's standard output. + */ + protected static function cliRun(array $args, string $input): string + { + $command = static::cliResolveCommand(); + if ($command === null) { + throw new TIOException('clicompressor_command_missing', static::NAME, implode("', '", static::commands())); + } + $stdin = self::cliTempFile(); + $stdout = self::cliTempFile(); + $stderr = self::cliTempFile(); + try { + file_put_contents($stdin, $input); + $descriptors = [0 => ['file', $stdin, 'r'], 1 => ['file', $stdout, 'w'], 2 => ['file', $stderr, 'w']]; + $process = @proc_open([$command, ...$args], $descriptors, $pipes); + if (!is_resource($process)) { + throw new TIOException('clicompressor_process_failed', static::NAME, $command); + } + $exit = proc_close($process); // blocks until the command finishes; the file descriptors need no draining + if ($exit !== 0) { + throw new TIOException('clicompressor_command_failed', static::NAME, $command, $exit, trim((string) file_get_contents($stderr))); + } + return (string) file_get_contents($stdout); + } finally { + @unlink($stdin); + @unlink($stdout); + @unlink($stderr); + } + } + + /** + * Creates a temporary file for staging one of the command's standard streams. + * @throws TIOException When a temporary file cannot be created. + * @return string The temporary file path. + */ + private static function cliTempFile(): string + { + $path = tempnam(sys_get_temp_dir(), 'prado_cli_'); + if ($path === false) { + throw new TIOException('clicompressor_process_failed', static::NAME, 'tempnam'); + } + return $path; + } + + /** + * Resolves the first candidate command that exists on the system. + * @return ?string The command path, or null when none is found. + */ + protected static function cliResolveCommand(): ?string + { + foreach (static::commands() as $name) { + $path = static::cliLocate($name); + if ($path !== null) { + return $path; + } + } + return null; + } + + /** + * Locates an executable by name on the `PATH`, or accepts an absolute path. The lookup + * scans the `PATH` in PHP rather than spawning a locator process. + * @param string $name The command name or absolute path. + * @return ?string The executable path, or null when it is not found. + */ + protected static function cliLocate(string $name): ?string + { + $isWindows = DIRECTORY_SEPARATOR === '\\'; + $extensions = $isWindows ? explode(';', (string) (getenv('PATHEXT') ?: '.EXE;.CMD;.BAT')) : ['']; + if (str_contains($name, DIRECTORY_SEPARATOR)) { + return is_file($name) && is_executable($name) ? $name : null; + } + foreach (explode(PATH_SEPARATOR, (string) getenv('PATH')) as $dir) { + if ($dir === '') { + continue; + } + foreach ($extensions as $extension) { + $candidate = rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $name . $extension; + if (is_file($candidate) && is_executable($candidate)) { + return $candidate; + } + } + } + return null; + } +} diff --git a/framework/IO/Compression/TCompression.php b/framework/IO/Compression/TCompression.php new file mode 100644 index 000000000..e45ae2ada --- /dev/null +++ b/framework/IO/Compression/TCompression.php @@ -0,0 +1,210 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\IO\Compression; + +use Prado\Exceptions\TIOException; + +/** + * TCompression class. + * + * A static façade over the {@see ICompressor} codecs keyed by their HTTP content-coding + * token, so a caller compresses by name and negotiates against a client's `Accept-Encoding` + * without binding to one codec. The tokens, in descending preference, are: + * + * | Token | Codec | Availability | + * |-----------|------------------------|--------------| + * | `zstd` | {@see TZstdCompressor} | `zstd` extension | + * | `br` | {@see TBrotliCompressor} | `brotli` extension | + * | `gzip` | {@see TGzipCompressor} | `zlib` extension | + * | `deflate` | {@see TZlibCompressor} | `zlib` extension | + * + * The HTTP `deflate` coding is the zlib format (RFC 9110), so it maps to + * {@see TZlibCompressor}. Only codings a client and server both support are offered: + * {@see getAvailableMethods()} filters by the loaded extensions, {@see getBestMethod()} + * takes the most-preferred available coding, and {@see negotiate()} resolves an + * `Accept-Encoding` header, honoring q-values and the `*` wildcard. Codecs outside HTTP + * content negotiation (bzip2, raw DEFLATE) are used through their classes directly. + * + * The `xz`/LZMA format stays out of this negotiation: it is not a registered HTTP content + * coding, and having no PHP extension it is a command-backed {@see TXzCompressor} rather than + * a native in-process codec. It is used through that class directly. + * + * ```php + * $method = TCompression::negotiate($request->getHeader('Accept-Encoding')); + * if ($method !== null) { + * $body = TCompression::compress($body, $method); + * $response->setHeader('Content-Encoding', $method); + * } + * ``` + * + * @author Brad Anderson + * @since 4.4.0 + */ +class TCompression +{ + /** @var array> The content-coding token to codec map, in preference order. */ + private const CODECS = [ + 'zstd' => TZstdCompressor::class, + 'br' => TBrotliCompressor::class, + 'gzip' => TGzipCompressor::class, + 'deflate' => TZlibCompressor::class, + ]; + + /** + * Returns every known content-coding token, in descending preference. + * @return string[] The content-coding tokens. + */ + public static function getMethods(): array + { + return array_keys(self::CODECS); + } + + /** + * Returns the codec class for a content-coding token, or null when the token is unknown. + * @param string $method The content-coding token (case-insensitive). + * @return ?class-string The codec class, or null. + */ + public static function getCodec(string $method): ?string + { + return self::CODECS[strtolower($method)] ?? null; + } + + /** + * Returns whether a content-coding is known and its backing extension is loaded. + * @param string $method The content-coding token (case-insensitive). + * @return bool Whether the coding can be applied here. + */ + public static function isAvailable(string $method): bool + { + $codec = self::getCodec($method); + return $codec !== null && $codec::isAvailable(); + } + + /** + * Returns the content-coding tokens whose codecs can run here, in descending preference. + * @return string[] The available content-coding tokens. + */ + public static function getAvailableMethods(): array + { + return array_values(array_filter(self::getMethods(), self::isAvailable(...))); + } + + /** + * Returns the most-preferred content-coding available here, or null when none can run. + * @return ?string The best content-coding token, or null. + */ + public static function getBestMethod(): ?string + { + return self::getAvailableMethods()[0] ?? null; + } + + /** + * Compresses data with the named content-coding, or the best available when none is named. + * @param string $data The raw bytes. + * @param ?string $method The content-coding token, or null for {@see getBestMethod()}. + * @param int $level The compression level, or -1 for the codec's default. + * @throws TIOException When the coding is unknown or no codec is available. + * @return string The compressed bytes. + */ + public static function compress(string $data, ?string $method = null, int $level = -1): string + { + return self::resolve($method)::compress($data, $level); + } + + /** + * Decompresses data produced under the named content-coding. + * @param string $data The compressed bytes. + * @param string $method The content-coding token the data was compressed with. + * @throws TIOException When the coding is unknown, its codec is unavailable, or the data is corrupt. + * @return string The decompressed bytes. + */ + public static function decompress(string $data, string $method): string + { + return self::resolve($method)::decompress($data); + } + + /** + * Resolves a content-coding token to an available codec class. + * @param ?string $method The content-coding token, or null for the best available. + * @throws TIOException When the coding is unknown or no codec is available. + * @return class-string The codec class. + */ + private static function resolve(?string $method): string + { + $method ??= self::getBestMethod(); + if ($method === null) { + throw new TIOException('compression_none_available'); + } + $codec = self::getCodec($method); + if ($codec === null || !$codec::isAvailable()) { + throw new TIOException('compression_method_unknown', $method); + } + return $codec; + } + + /** + * Chooses the content-coding to serve for a client's `Accept-Encoding` header: the + * server's most preferred coding among those the client accepts, honoring q-values (a + * `q=0` rejects a coding) and the `*` wildcard. The server's preference order decides + * between two accepted codings, not the client's relative q-values, which RFC 9110 + * permits. Returns null when no shared coding is acceptable, so the caller sends the + * content uncompressed (identity). + * @param ?string $acceptEncoding The client's `Accept-Encoding` header, or null when absent. + * @param ?string[] $allowed The codings the server will offer, or null for {@see getAvailableMethods()}. + * @return ?string The chosen content-coding token, or null for identity. + */ + public static function negotiate(?string $acceptEncoding, ?array $allowed = null): ?string + { + $offer = $allowed ?? self::getAvailableMethods(); + if ($offer === []) { + return null; + } + if ($acceptEncoding === null || trim($acceptEncoding) === '') { + return null; // an absent header carries no preference; serve identity + } + $weights = self::parseAcceptEncoding($acceptEncoding); + $wildcard = $weights['*'] ?? null; + foreach ($offer as $method) { + $token = strtolower($method); + $q = $weights[$token] ?? $wildcard; + if ($q !== null && $q > 0.0) { + return $method; + } + } + return null; + } + + /** + * Parses an `Accept-Encoding` header into a token-to-qvalue map, lowercasing the tokens. + * @param string $header The `Accept-Encoding` header value. + * @return array The content-coding token to q-value map. + */ + private static function parseAcceptEncoding(string $header): array + { + $weights = []; + foreach (explode(',', $header) as $part) { + $segments = explode(';', trim($part)); + $token = strtolower(trim($segments[0])); + if ($token === '') { + continue; + } + $q = 1.0; + foreach (array_slice($segments, 1) as $parameter) { + [$name, $value] = array_pad(explode('=', $parameter, 2), 2, ''); + if (strtolower(trim($name)) === 'q') { + $q = (float) trim($value); + } + } + $weights[$token] = $q; + } + return $weights; + } +} diff --git a/framework/IO/Compression/TDeflateCompressor.php b/framework/IO/Compression/TDeflateCompressor.php new file mode 100644 index 000000000..d8d300372 --- /dev/null +++ b/framework/IO/Compression/TDeflateCompressor.php @@ -0,0 +1,58 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\IO\Compression; + +/** + * TDeflateCompressor class. + * + * Wraps PHP's {@see https://www.php.net/gzdeflate gzdeflate}/{@see https://www.php.net/gzinflate + * gzinflate} for raw DEFLATE (RFC 1951): the compressed body with no header, checksum, or + * length. Raw DEFLATE is the most compact of the three zlib forms and the payload other + * formats wrap, so it suits an embedded stream that carries its own framing (a PNG `IDAT` + * chunk, a ZIP entry, a WebSocket permessage-deflate frame). Its streaming counterpart is + * {@see \Prado\IO\Stream\TDeflateStream} with `ZLIB_ENCODING_RAW`. + * + * With no checksum, corrupt input is caught only when it cannot be inflated. The + * header-and-checksum variants are {@see TZlibCompressor} and {@see TGzipCompressor}. + * + * @author Brad Anderson + * @since 4.4.0 + */ +class TDeflateCompressor extends TBuiltinCompressor +{ + /** The wire-format name. */ + public const NAME = 'deflate'; + + /** The backing PHP extension. */ + protected const EXTENSION = 'zlib'; + + /** + * Compresses with gzdeflate. + * @param string $data The raw bytes. + * @param int $level The compression level 0..9, or -1 for the zlib default. + * @return false|string The raw DEFLATE bytes, or false on failure. + */ + protected static function encode(string $data, int $level): string|false + { + $level = ($level < -1 || $level > 9) ? -1 : $level; // an out-of-range level is the zlib default, never a ValueError + return @gzdeflate($data, $level); + } + + /** + * Decompresses with gzinflate. + * @param string $data The raw DEFLATE bytes. + * @return false|string The raw bytes, or false on failure. + */ + protected static function decode(string $data): string|false + { + return @gzinflate($data); + } +} diff --git a/framework/IO/Compression/TGzipCompressor.php b/framework/IO/Compression/TGzipCompressor.php new file mode 100644 index 000000000..5d4e65e50 --- /dev/null +++ b/framework/IO/Compression/TGzipCompressor.php @@ -0,0 +1,59 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\IO\Compression; + +/** + * TGzipCompressor class. + * + * Wraps PHP's {@see https://www.php.net/gzencode gzencode}/{@see https://www.php.net/gzdecode + * gzdecode} for the gzip format (RFC 1952): a header, a raw DEFLATE body, and a trailing + * CRC-32 and length. This is the format of a `.gz` file and of an HTTP `Content-Encoding: + * gzip` body, so the compressed bytes interoperate with any gzip reader. Its streaming + * counterpart is {@see \Prado\IO\Stream\TDeflateStream} with `ZLIB_ENCODING_GZIP`. + * + * ```php + * $packed = TGzipCompressor::compress($data); // a .gz-compatible byte string + * $data = TGzipCompressor::decompress($packed); + * ``` + * + * @author Brad Anderson + * @since 4.4.0 + */ +class TGzipCompressor extends TBuiltinCompressor +{ + /** The wire-format name. */ + public const NAME = 'gzip'; + + /** The backing PHP extension. */ + protected const EXTENSION = 'zlib'; + + /** + * Compresses with gzencode. + * @param string $data The raw bytes. + * @param int $level The compression level 0..9, or -1 for the zlib default. + * @return false|string The gzip bytes, or false on failure. + */ + protected static function encode(string $data, int $level): string|false + { + $level = ($level < -1 || $level > 9) ? -1 : $level; // an out-of-range level is the zlib default, never a ValueError + return @gzencode($data, $level); + } + + /** + * Decompresses with gzdecode. + * @param string $data The gzip bytes. + * @return false|string The raw bytes, or false on failure. + */ + protected static function decode(string $data): string|false + { + return @gzdecode($data); + } +} diff --git a/framework/IO/Compression/TXzCompressor.php b/framework/IO/Compression/TXzCompressor.php new file mode 100644 index 000000000..73bccb6ee --- /dev/null +++ b/framework/IO/Compression/TXzCompressor.php @@ -0,0 +1,94 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\IO\Compression; + +use Prado\Exceptions\TNotSupportedException; + +/** + * TXzCompressor class. + * + * A built-in stub for the xz/LZMA format (`.xz`). PHP has no xz extension, so unlike the + * other {@see TBuiltinCompressor} codecs there is no native function to wrap: the codec is + * always {@see isAvailable() unavailable} and every call throws {@see TNotSupportedException}, + * so the format has a stable, discoverable class in core without pretending to work. + * + * A working xz codec lives in the `belisoful/prado-compression` package, which uses the xz + * command as a fallback (and a native xz extension first, if one is ever adopted) through + * {@see TCliCompressorTrait}. When a maintained PHP xz extension is adopted into core, this + * stub is replaced by a native codec with the same name and {@see ICompressor} contract. The + * `.tar.xz` archive path uses the xz command directly in {@see \Prado\IO\TTarFileExtractor}. + * + * @author Brad Anderson + * @since 4.4.0 + */ +class TXzCompressor extends TBuiltinCompressor +{ + /** The wire-format name. */ + public const NAME = 'xz'; + + /** The PHP extension that would back the codec; none is published, so the stub is inert. */ + protected const EXTENSION = 'xz'; + + /** + * Reports the codec unavailable: no PHP xz extension exists. + * @return bool Always false. + */ + public static function isAvailable(): bool + { + return false; + } + + /** + * Throws: the core stub cannot compress xz. Use `belisoful/prado-compression`. + * @param string $data The raw bytes. + * @param int $level The compression level (unused). + * @throws TNotSupportedException Always. + * @return string Never returns. + */ + public static function compress(string $data, int $level = -1): string + { + throw new TNotSupportedException('compression_xz_unsupported'); + } + + /** + * Throws: the core stub cannot decompress xz. Use `belisoful/prado-compression`. + * @param string $data The compressed bytes. + * @throws TNotSupportedException Always. + * @return string Never returns. + */ + public static function decompress(string $data): string + { + throw new TNotSupportedException('compression_xz_unsupported'); + } + + /** + * Unreachable native backend; the stub has no xz extension to call. + * @param string $data The raw bytes. + * @param int $level The compression level. + * @throws TNotSupportedException Always. + * @return false|int|string Never returns. + */ + protected static function encode(string $data, int $level): string|int|false + { + throw new TNotSupportedException('compression_xz_unsupported'); + } + + /** + * Unreachable native backend; the stub has no xz extension to call. + * @param string $data The compressed bytes. + * @throws TNotSupportedException Always. + * @return false|int|string Never returns. + */ + protected static function decode(string $data): string|int|false + { + throw new TNotSupportedException('compression_xz_unsupported'); + } +} diff --git a/framework/IO/Compression/TZlibCompressor.php b/framework/IO/Compression/TZlibCompressor.php new file mode 100644 index 000000000..3b4f477a0 --- /dev/null +++ b/framework/IO/Compression/TZlibCompressor.php @@ -0,0 +1,58 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\IO\Compression; + +/** + * TZlibCompressor class. + * + * Wraps PHP's {@see https://www.php.net/gzcompress gzcompress}/{@see https://www.php.net/gzuncompress + * gzuncompress} for the zlib format (RFC 1950): a two-byte header, a raw DEFLATE body, and + * a trailing Adler-32 checksum. The zlib format is more compact than gzip and is what the + * HTTP `Content-Encoding: deflate` token specifies (despite the token's name). Its + * streaming counterpart is {@see \Prado\IO\Stream\TDeflateStream} with the default + * `ZLIB_ENCODING_DEFLATE`. + * + * The headerless variant is {@see TDeflateCompressor}; the CRC-checked, widely interchanged + * variant is {@see TGzipCompressor}. + * + * @author Brad Anderson + * @since 4.4.0 + */ +class TZlibCompressor extends TBuiltinCompressor +{ + /** The wire-format name. */ + public const NAME = 'zlib'; + + /** The backing PHP extension. */ + protected const EXTENSION = 'zlib'; + + /** + * Compresses with gzcompress. + * @param string $data The raw bytes. + * @param int $level The compression level 0..9, or -1 for the zlib default. + * @return false|string The zlib bytes, or false on failure. + */ + protected static function encode(string $data, int $level): string|false + { + $level = ($level < -1 || $level > 9) ? -1 : $level; // an out-of-range level is the zlib default, never a ValueError + return @gzcompress($data, $level); + } + + /** + * Decompresses with gzuncompress. + * @param string $data The zlib bytes. + * @return false|string The raw bytes, or false on failure. + */ + protected static function decode(string $data): string|false + { + return @gzuncompress($data); + } +} diff --git a/framework/IO/Compression/TZstdCompressor.php b/framework/IO/Compression/TZstdCompressor.php new file mode 100644 index 000000000..a46f7571b --- /dev/null +++ b/framework/IO/Compression/TZstdCompressor.php @@ -0,0 +1,66 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\IO\Compression; + +/** + * TZstdCompressor class. + * + * Wraps the Zstandard (zstd) functions for the `zstd` format: a modern codec that reaches + * gzip-class ratios at much higher speed, and higher ratios at comparable speed. It is the + * HTTP `Content-Encoding: zstd` coding. The functions live in the optional `zstd` + * extension, so {@see isAvailable()} reports whether the codec can run before it is used; + * the framework never bundles a fallback implementation. + * + * The compression level runs 1..22 (higher is smaller and slower); -1 selects + * {@see DEFAULT_LEVEL}, the zstd default. + * + * @author Brad Anderson + * @since 4.4.0 + */ +class TZstdCompressor extends TBuiltinCompressor +{ + /** The wire-format name (the HTTP content-coding token). */ + public const NAME = 'zstd'; + + /** The backing PHP extension. */ + protected const EXTENSION = 'zstd'; + + /** The lowest zstd compression level. */ + public const MIN_LEVEL = 1; + + /** The highest zstd compression level. */ + public const MAX_LEVEL = 22; + + /** The zstd compression level used when no explicit level is given. */ + public const DEFAULT_LEVEL = 3; + + /** + * Compresses with zstd_compress. + * @param string $data The raw bytes. + * @param int $level The compression level 1..22, or -1 for {@see DEFAULT_LEVEL}. + * @return false|string The zstd bytes, or false on failure. + */ + protected static function encode(string $data, int $level): string|false + { + $level = ($level < self::MIN_LEVEL || $level > self::MAX_LEVEL) ? self::DEFAULT_LEVEL : $level; + return @zstd_compress($data, $level); + } + + /** + * Decompresses with zstd_uncompress. + * @param string $data The zstd bytes. + * @return false|string The raw bytes, or false on failure. + */ + protected static function decode(string $data): string|false + { + return @zstd_uncompress($data); + } +} diff --git a/framework/classes.php b/framework/classes.php index 5bff6452f..a3de5cf57 100644 --- a/framework/classes.php +++ b/framework/classes.php @@ -267,6 +267,18 @@ 'IModuleDependency' => 'Prado\IModuleDependency', 'TBinaryStreamBehavior' => 'Prado\IO\Behaviors\TBinaryStreamBehavior', 'TPhpStreamBehavior' => 'Prado\IO\Behaviors\TPhpStreamBehavior', +'ICompressor' => 'Prado\IO\Compression\ICompressor', +'TBrotliCompressor' => 'Prado\IO\Compression\TBrotliCompressor', +'TBuiltinCompressor' => 'Prado\IO\Compression\TBuiltinCompressor', +'TBzip2Compressor' => 'Prado\IO\Compression\TBzip2Compressor', +'TCliCompressor' => 'Prado\IO\Compression\TCliCompressor', +'TCliCompressorTrait' => 'Prado\IO\Compression\TCliCompressorTrait', +'TCompression' => 'Prado\IO\Compression\TCompression', +'TDeflateCompressor' => 'Prado\IO\Compression\TDeflateCompressor', +'TGzipCompressor' => 'Prado\IO\Compression\TGzipCompressor', +'TXzCompressor' => 'Prado\IO\Compression\TXzCompressor', +'TZlibCompressor' => 'Prado\IO\Compression\TZlibCompressor', +'TZstdCompressor' => 'Prado\IO\Compression\TZstdCompressor', 'TStreamFilter' => 'Prado\IO\Filter\TStreamFilter', 'TStreamFilterHandle' => 'Prado\IO\Filter\TStreamFilterHandle', 'TStreamFilterName' => 'Prado\IO\Filter\TStreamFilterName', diff --git a/tests/unit/IO/Compression/ICompressorTest.php b/tests/unit/IO/Compression/ICompressorTest.php new file mode 100644 index 000000000..298138508 --- /dev/null +++ b/tests/unit/IO/Compression/ICompressorTest.php @@ -0,0 +1,45 @@ +getMethod('compress')->isStatic()); + self::assertTrue($ref->getMethod('decompress')->isStatic()); + } +} diff --git a/tests/unit/IO/Compression/TBuiltinCompressorTest.php b/tests/unit/IO/Compression/TBuiltinCompressorTest.php new file mode 100644 index 000000000..243bb5a93 --- /dev/null +++ b/tests/unit/IO/Compression/TBuiltinCompressorTest.php @@ -0,0 +1,180 @@ +, 1: string, 2: ?int}> */ + public static function codecProvider(): array + { + return [ + // class NAME ZLIB_ENCODING for cross-check + 'gzip' => [TGzipCompressor::class, 'gzip', ZLIB_ENCODING_GZIP], + 'zlib' => [TZlibCompressor::class, 'zlib', ZLIB_ENCODING_DEFLATE], + 'deflate' => [TDeflateCompressor::class, 'deflate', ZLIB_ENCODING_RAW], + 'bzip2' => [TBzip2Compressor::class, 'bzip2', null], + 'zstd' => [TZstdCompressor::class, 'zstd', null], + 'brotli' => [TBrotliCompressor::class, 'br', null], + ]; + } + + /** + * @dataProvider codecProvider + * @param class-string $codec + */ + public function testImplementsTheCompressorContract(string $codec): void + { + self::assertContains(ICompressor::class, class_implements($codec)); + self::assertInstanceOf(TBuiltinCompressor::class, (new \ReflectionClass($codec))->newInstanceWithoutConstructor()); + } + + /** + * @dataProvider codecProvider + * @param class-string $codec + * @param string $name + */ + public function testNameIsExposed(string $codec, string $name): void + { + self::assertSame($name, $codec::NAME); + } + + /** + * @dataProvider codecProvider + * @param class-string $codec + * @param string $name + */ + public function testRoundTrip(string $codec, string $name): void + { + $this->skipIfUnavailable($codec); + foreach (['', 'A', 'PRADO framework', str_repeat('abcABC123 ', 5000), random_bytes(4096)] as $data) { + $packed = $codec::compress($data); + self::assertSame($data, $codec::decompress($packed), "{$name} round-trip of " . strlen($data) . ' bytes'); + } + } + + /** + * @dataProvider codecProvider + * @param class-string $codec + */ + public function testCompressesRepetitiveData(string $codec): void + { + $this->skipIfUnavailable($codec); + $data = str_repeat('PRADO', 4000); + self::assertLessThan(strlen($data), strlen($codec::compress($data)), 'A repetitive payload shrinks.'); + } + + /** + * @dataProvider codecProvider + * @param class-string $codec + */ + public function testLevelIsHonored(string $codec): void + { + $this->skipIfUnavailable($codec); + $data = str_repeat('the quick brown fox jumps ', 2000) . random_bytes(1000); + $low = $codec::compress($data, 1); + $high = $codec::compress($data, 9); + self::assertSame($data, $codec::decompress($low), 'Level 1 round-trips.'); + self::assertSame($data, $codec::decompress($high), 'Level 9 round-trips.'); + self::assertLessThanOrEqual(strlen($low), strlen($high), 'The maximum level is no larger than the minimum.'); + } + + /** + * An out-of-range level falls back to the codec's default rather than leaking a raw + * ValueError from the native call, so every codec upholds the same contract. + * @dataProvider codecProvider + * @param class-string $codec + */ + public function testOutOfRangeLevelFallsBackToTheDefault(string $codec): void + { + $this->skipIfUnavailable($codec); + $data = str_repeat('level guard ', 500); + foreach ([-99, 99] as $level) { + $packed = $codec::compress($data, $level); // must not throw a ValueError + self::assertSame($data, $codec::decompress($packed), "An out-of-range level ({$level}) still round-trips."); + } + } + + /** + * @dataProvider codecProvider + * @param class-string $codec + * @param string $name + */ + public function testCorruptDataThrows(string $codec, string $name): void + { + $this->skipIfUnavailable($codec); + $this->expectException(TIOException::class); + $codec::decompress('not ' . $name . ' compressed data at all'); + } + + /** + * The whole-string zlib codecs must produce bytes the streaming inflater decodes, since + * they are two forms of the same algorithm. bzip2 has no ZLIB encoding and is skipped. + * @dataProvider codecProvider + * @param class-string $codec + * @param string $name + * @param ?int $encoding + */ + public function testInteroperatesWithTheStreamingInflater(string $codec, string $name, ?int $encoding): void + { + if ($encoding === null) { + self::markTestSkipped('bzip2 has no zlib streaming counterpart.'); + } + $data = str_repeat('interop payload ', 1000); + $packed = $codec::compress($data); + $inflated = (new TInflateStream(TStream::fromString($packed), $encoding))->getContents(); + self::assertSame($data, $inflated, "{$name} output is readable by TInflateStream."); + } + + public function testAvailabilityReflectsTheExtension(): void + { + self::assertSame(extension_loaded('zlib'), TGzipCompressor::isAvailable()); + self::assertSame(extension_loaded('zlib'), TDeflateCompressor::isAvailable()); + self::assertSame(extension_loaded('zlib'), TZlibCompressor::isAvailable()); + self::assertSame(extension_loaded('bz2'), TBzip2Compressor::isAvailable()); + self::assertSame(extension_loaded('zstd'), TZstdCompressor::isAvailable()); + self::assertSame(extension_loaded('brotli'), TBrotliCompressor::isAvailable()); + } + + /** + * An absent extension makes the codec throw at use rather than fatal on an undefined + * function, so a deployment without zstd/brotli degrades cleanly. + * @dataProvider codecProvider + * @param class-string $codec + */ + public function testUnavailableCodecThrowsExtensionRequired(string $codec): void + { + if ($codec::isAvailable()) { + self::markTestSkipped($codec::NAME . ' is available; the unavailable path cannot be exercised here.'); + } + $this->expectException(TIOException::class); + $codec::compress('data'); + } + + public function testGzipOutputIsAStandardGzipStream(): void + { + $this->skipIfUnavailable(TGzipCompressor::class); + $packed = TGzipCompressor::compress('gzip magic check'); + self::assertSame("\x1f\x8b", substr($packed, 0, 2), 'A gzip stream starts with the 1f 8b magic.'); + self::assertSame('gzip magic check', gzdecode($packed), 'A stock gzdecode reads it.'); + } + + /** + * @param class-string $codec + */ + private function skipIfUnavailable(string $codec): void + { + if (!$codec::isAvailable()) { + self::markTestSkipped($codec::NAME . ' requires a PHP extension that is not loaded.'); + } + } +} diff --git a/tests/unit/IO/Compression/TCliCompressorTest.php b/tests/unit/IO/Compression/TCliCompressorTest.php new file mode 100644 index 000000000..aff9ebbcd --- /dev/null +++ b/tests/unit/IO/Compression/TCliCompressorTest.php @@ -0,0 +1,91 @@ +expectException(TIOException::class); + MissingCliCompressor::compress('data'); + } + + public function testPumpRoundTripsThroughAnIdentityCommand() + { + $this->skipWithoutCat(); + foreach (['', 'A', 'hello world', random_bytes(4096)] as $data) { + self::assertSame($data, CatCliCompressor::compress($data), 'cat passes bytes through unchanged.'); + self::assertSame($data, CatCliCompressor::decompress($data), 'The decompress path uses the same pump.'); + } + } + + public function testHandlesDataLargerThanAPipeBuffer() + { + $this->skipWithoutCat(); + // 4 MB dwarfs the ~64 KB OS pipe buffer; staging through temporary files transfers it + // without a deadlock and without stream_select, which is unreliable on Windows. + $data = random_bytes(4 * 1024 * 1024); + self::assertSame($data, CatCliCompressor::compress($data), 'A large transfer streams through the file-backed run.'); + } +} diff --git a/tests/unit/IO/Compression/TCompressionTest.php b/tests/unit/IO/Compression/TCompressionTest.php new file mode 100644 index 000000000..30b644eb0 --- /dev/null +++ b/tests/unit/IO/Compression/TCompressionTest.php @@ -0,0 +1,129 @@ +expectException(TIOException::class); + TCompression::compress('data', 'nonsense'); + } + + // ---- Accept-Encoding negotiation ------------------------------------------- + + public function testNegotiatePicksTheServerPreferredCoding() + { + // The client accepts both; the server prefers gzip over deflate, so gzip wins. + self::assertSame('gzip', TCompression::negotiate('deflate, gzip', ['gzip', 'deflate'])); + } + + public function testNegotiateHonorsAClientQValueRejection() + { + // q=0 rejects gzip, leaving deflate the only acceptable coding. + self::assertSame('deflate', TCompression::negotiate('gzip;q=0, deflate', ['gzip', 'deflate'])); + } + + public function testNegotiateHonorsTheWildcard() + { + self::assertSame('gzip', TCompression::negotiate('*', ['gzip', 'deflate'])); + self::assertNull(TCompression::negotiate('*;q=0', ['gzip', 'deflate']), 'A zero wildcard accepts nothing.'); + } + + public function testNegotiateReturnsNullWhenNothingIsShared() + { + self::assertNull(TCompression::negotiate('br', ['gzip', 'deflate']), 'The client accepts only a coding the server does not offer.'); + } + + public function testNegotiateOnAnAbsentHeaderServesIdentity() + { + self::assertNull(TCompression::negotiate(null, ['gzip'])); + self::assertNull(TCompression::negotiate('', ['gzip'])); + } + + public function testNegotiateDefaultsToAvailableMethods() + { + if (!extension_loaded('zlib')) { + self::markTestSkipped('zlib is required.'); + } + // With no explicit offer, the server offers what it can run; gzip is accepted here. + self::assertSame('gzip', TCompression::negotiate('gzip')); + } + + public function testNegotiatedCodingRoundTrips() + { + if (!extension_loaded('zlib')) { + self::markTestSkipped('zlib is required.'); + } + $method = TCompression::negotiate('br;q=0.9, gzip;q=0.8', ['gzip', 'deflate']); + self::assertSame('gzip', $method, 'br is unavailable/unoffered, so gzip is chosen.'); + $data = str_repeat('negotiated ', 300); + self::assertSame($data, TCompression::decompress(TCompression::compress($data, $method), $method)); + } +} diff --git a/tests/unit/IO/Compression/TXzCompressorTest.php b/tests/unit/IO/Compression/TXzCompressorTest.php new file mode 100644 index 000000000..2657d5a85 --- /dev/null +++ b/tests/unit/IO/Compression/TXzCompressorTest.php @@ -0,0 +1,33 @@ +newInstanceWithoutConstructor()); + self::assertSame('xz', TXzCompressor::NAME); + } + + public function testIsNeverAvailable() + { + self::assertFalse(TXzCompressor::isAvailable(), 'No PHP xz extension exists, so the core stub is inert.'); + } + + public function testCompressThrowsNotSupported() + { + $this->expectException(TNotSupportedException::class); + TXzCompressor::compress('data'); + } + + public function testDecompressThrowsNotSupported() + { + $this->expectException(TNotSupportedException::class); + TXzCompressor::decompress('data'); + } +}