belisoful / prado-compression
Additional Prado ICompressor codecs, including an xz/LZMA codec with a native-extension-or-CLI fallback.
Requires
- php: >=8.1
- pradosoft/prado: ^4.4 || dev-master
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- phpunit/phpunit: ^10.0
Suggests
- ext-brotli: Native brotli codec; TBrotliFallbackCompressor uses the brotli command without it.
- ext-bz2: Native bzip2 codec; TBzip2FallbackCompressor uses the bzip2 command without it.
- ext-zlib: Native gzip codec; TGzipFallbackCompressor uses the gzip command without it.
- ext-zstd: Native zstd codec; TZstdFallbackCompressor uses the zstd command without it.
README
Additional Prado ICompressor codecs, each backed by a
system command when its PHP extension is absent.
Prado core ships the native-extension compression codecs (gzip, zlib, deflate, bzip2, zstd,
brotli) and the negotiation façade, but a core codec is unavailable when its extension is not
installed, and xz/LZMA has no PHP extension at all. This package adds a fallback layer: each
codec here prefers the native extension and drops to the standalone command through Prado's
TCliCompressorTrait when the extension is missing, so the format keeps working on a machine
where the extension was never built. The order is configurable per codec through a
backend mode, and the command backend stages data through temporary
files, which a shared system must be set up to keep private.
Codecs
| Class | Format | Native backend | Command backend |
|---|---|---|---|
TXzFallbackCompressor |
xz/LZMA (.xz) |
none published yet | xz |
TGzipFallbackCompressor |
gzip (.gz) |
zlib extension |
gzip |
TBzip2FallbackCompressor |
bzip2 (.bz2) |
bz2 extension |
bzip2 |
TZstdFallbackCompressor |
zstd (.zst) |
zstd extension |
zstd |
TBrotliFallbackCompressor |
brotli (.br) |
brotli extension |
brotli |
Each implements Prado\IO\Compression\ICompressor, so it drops into any code that accepts a
codec class:
use Prado\IO\Compression\TXzFallbackCompressor; if (TXzFallbackCompressor::isAvailable()) { $packed = TXzFallbackCompressor::compress($data); $data = TXzFallbackCompressor::decompress($packed); }
isAvailable() is true when a backend the codec's mode permits is
present; under the default mode that is either backend. The compressed bytes are the standard
stream of the format either way, so data compressed on a machine with the extension decompresses
on a machine with only the command, and the reverse.
Prado core keeps an inert Prado\IO\Compression\TXzCompressor stub that throws
TNotSupportedException; installing this package provides the working codec.
Backend selection
Each codec selects between its two backends through a static BackendMode property that holds
a TFallbackCompressorMode value:
| Mode | Selection |
|---|---|
PhpFirst |
The extension when it is loaded, otherwise the command. The default. |
CliFirst |
The command when it is present, otherwise the extension. |
PhpOnly |
The extension only; the command never runs. |
CliOnly |
The command only; the extension is never used. |
use Prado\IO\Compression\TFallbackCompressorMode; use Prado\IO\Compression\TGzipFallbackCompressor; TGzipFallbackCompressor::setBackendMode(TFallbackCompressorMode::CliFirst); TGzipFallbackCompressor::getBackendMode(); // 'CliFirst' TGzipFallbackCompressor::setBackendMode(null); // restores the default, PhpFirst
The property is per codec class: setting the gzip mode leaves the zstd mode untouched. The
setter matches its string case-insensitively and throws TInvalidDataValueException for a
value that is not a mode.
The mode routes by availability, and it changes what isAvailable() reports. An "only" mode
counts only its named backend, so a codec in PhpOnly mode without its extension is
unavailable even when the command is installed, and a call routed to a missing backend throws
that backend's TIOException. A "first" mode uses its preferred backend when that backend is
present and the other one otherwise.
The default PhpFirst is the selection the codecs made before the property existed, so code
written against 1.0 behaves identically.
Securing the command backend
The command backend stages the command's standard input and output through temporary files —
by default in PHP's temporary directory (sys_get_temp_dir()), or in the per-codec directory
set with setTempDirectory() — so every byte a codec compresses or decompresses through a
command is written to disk for the life of the command run. On a single-tenant system with a
private staging directory the data stays contained; on a shared or misconfigured system it
can leak to other local users:
tempnam()creates the staging files readable only by their owner (0600), but every process running as the same system user can read them, whichever directory they are in. On a shared host where many applications run as one user (such aswww-data), another tenant's code can read the plaintext while the command runs.- The staging directory may be backed up, snapshotted, or on a disk that retains remnants,
so the bytes can outlive the
unlink()that removes the files. - The command is resolved by scanning
PATH. APATHdirectory writable by another user lets that user substitute the binary and read everything piped through it.
Set the command backend up safely:
-
Point the codec at a private staging directory. The static
setTempDirectory()property redirects a codec's staging files to a directory owned by the application user, mode0700, excluded from backups — nophp.inichange needed:TXzFallbackCompressor::setTempDirectory('/var/lib/myapp/tmp'); TXzFallbackCompressor::getTempDirectory(); // the canonical path TXzFallbackCompressor::setTempDirectory(null); // restores the system directory
The property is per codec class, and the setting is fail-closed: the directory must exist and be writable when set (
TInvalidDataValueExceptionotherwise) and is validated again at every use, so a directory that later disappears or loses its permissions raises aTIOExceptionat the call instead of silently falling back to the shared system directory — the fallbacktempnam()itself performs is detected, the escaped file removed, and the call rejected. The two error keys this raises,fallbackcompressor_tempdirectory_invalidandfallbackcompressor_tempdirectory_escaped, are defined inconfig/errorMessages.txt. -
Give PHP a private temporary directory. For codecs left on the default, point
sys_temp_dirinphp.ini(or theTMPDIRenvironment variable) at a directory owned by the application user, mode0700, excluded from backups:sys_temp_dir = /var/lib/myapp/tmpUnder systemd,
PrivateTmp=trueon thephp-fpmservice isolates/tmpwithout aphp.inichange. -
Run each application as its own system user. Owner-only file modes protect nothing between applications that share a user.
-
Keep
PATHminimal and root-owned. Every directory on the PHP process'sPATHshould be writable only by root (for examplePATH=/usr/bin:/bin). To remove the lookup entirely, subclass the codec and return absolute paths fromcommands():class TSystemXzCompressor extends TXzFallbackCompressor { protected static function commands(): array { return ['/usr/bin/xz']; } }
-
Keep sensitive data off the command path. For data that must never touch disk, install the extension and set the codec to
PhpOnly; the mode guarantees the command, and with it the temporary files, is never used:TZstdFallbackCompressor::setBackendMode(TFallbackCompressorMode::PhpOnly);
The native extension backend compresses in memory and writes no files.
Level fidelity across backends
The two backends always produce a valid, interoperable stream, but they do not always agree on how hard to compress, because a command does not expose every level its library does:
TGzipFallbackCompressorat level0. Thezlibextension stores the data uncompressed inside a gzip wrapper; thegzipcommand has no-0, so the command backend falls through to gzip's default level 6 and compresses. Do not use level0to mean "do not compress" unless the extension is known to be present.TZstdFallbackCompressorabove level 19. Thezstdextension accepts 1..22; the command needs--ultrapastMAX_CLI_LEVEL(19), so the command backend omits the flag and uses zstd's default level 3 instead.
Pass an explicit in-range level when the exact ratio matters.
TBzip2FallbackCompressor is not on that list: it passes the block size to the command
explicitly, resolving a level exactly as TBzip2Compressor does, so both backends select the
same block size for the same request. bzip2 is deterministic, so the two in fact produce
byte-identical output.
Prado integration
The config/ directory holds the package's declarative configuration, mirroring what the
framework keeps in framework/classes.php and framework/Exceptions/messages/messages.txt:
| File | Purpose |
|---|---|
config/classMap.json |
Prado3-style short name to fully qualified name, for Prado::registerClassMap() |
config/errorMessages.txt |
Package error messages, for TException::addMessageFile() |
Neither is auto-discovered: the framework loads its own classes.php and messages.txt, and
scans no package paths. An application (or a plugin module) wires them once at start-up:
use Prado\Exceptions\TException; use Prado\Prado; $config = __DIR__ . '/vendor/belisoful/prado-compression/config'; Prado::registerClassMap(json_decode(file_get_contents($config . '/classMap.json'), true)); TException::addMessageFile($config . '/errorMessages.txt');
registerClassMap() (Prado 4.4+) merges the map into the autoloader, so the codecs resolve by
short name the way core classes do — in a configuration file, a template, or
Prado::createComponent('TXzFallbackCompressor'). Existing entries win, so a package cannot
shadow a core class. Composer's PSR-4 autoloading already covers use by fully qualified name,
so the map is only needed for short-name resolution.
config/errorMessages.txt defines the two keys the package raises itself,
fallbackcompressor_tempdirectory_invalid and fallbackcompressor_tempdirectory_escaped,
from the staging-directory validation. Registering the file
makes those exceptions render as readable text; unregistered, an exception still carries its
key as the message, so behavior is unaffected. Every other key the codecs surface comes from
the core base class and trait they build on and is already in the framework's message file —
a key restated here would be dead, because the framework's file is consulted last and wins.
Requirements
- PHP 8.1+
pradosoft/prado4.4 (thePrado\IO\Compressioncodecs landed in 4.4)
Install
composer require belisoful/prado-compression
Test
composer install
composer test
The suite covers both backends of every codec: the native path through compress()/decompress()
and the command path directly, so the fallback is tested even on a machine whose extension would
otherwise shadow it. The backend modes are tested through fixtures whose command backend returns
a marker, so the routing is observable without a command installed. A test that needs a backend
that is not installed skips.
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text
Line coverage of src/ is 100%. Branch coverage
(php -d memory_limit=1G vendor/bin/phpunit --path-coverage --coverage-text) is complete for
every branch a machine can reach: the native arm of a codec runs only where its extension is
loaded (and the xz native arm not until a PHP xz extension exists), and two artifacts of the
tooling remain even then — Xdebug enumerates short-circuit paths that cannot occur, and
php-code-coverage merges the per-class copies of a trait method under one record, which can
under-report an arm the tests demonstrably execute.