diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9c9c42..db5b4e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,9 @@ on: jobs: test: name: PHP ${{ matrix.php }} - runs-on: ubuntu-latest + # Pinned rather than ubuntu-latest: the servers this package targets run Ubuntu 24.04, so the + # bundled binary is tested on that release. Move it up when the servers move. + runs-on: ubuntu-24.04 strategy: fail-fast: false @@ -27,41 +29,45 @@ jobs: tools: composer:v2 coverage: none - - name: Install wkhtmltopdf (upstream jammy deb, as the README's native install) - # Why not the BUNDLED x64/linux/wkhtmltopdf: it links OpenSSL 1.1 (libssl.so.1.1, - # libcrypto.so.1.1; read from its ELF DT_NEEDED entries on 2026-10-12), which Ubuntu - # 22.04 and later do not ship. On this runner it is executable but cannot start, and - # since bootstrap.php only checks is_executable() the wrapper would still choose it and - # every render test would fail. bootstrap.php prefers /usr/local/bin/wkhtmltopdf on Linux, - # which is where this deb installs, so the suite runs the same upstream 0.12.6.1-3 - # patched-Qt build the Docker image is made from. + - name: Install the bundled Linux binary's runtime libraries (README apt line) + # Since 1.1.0 the suite tests the BUNDLED x64/linux/wkhtmltopdf, not an upstream .deb + # install (issue #6). Up to 1.0.3 the bundled binary linked OpenSSL 1.1, which Ubuntu 22.04 + # and later do not ship, so CI had to install the jammy deb instead and the bundled binary + # went untested. It is now the binary from that same jammy deb (OpenSSL 3), so it can run + # here, and this step installs only what the README tells server admins to install. That + # keeps the README's library list honest: if it misses a library, the probe test fails. + # Most of these are already on the runner image; apt skips what is installed. # Why not the Docker fallback: the image runs as root, so the output PDF it writes into # the host's /tmp is root-owned, and /tmp is sticky, so the runner user most likely # cannot delete it and the temp-file tests would fail on that. Inferred from the code # and the Dockerfile (no USER), not run. - # Dependency list: README, "Native install on Ubuntu servers" (the xfonts packages are - # the ones a bare dpkg -i usually misses). run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends xfonts-75dpi xfonts-base libfontconfig1 libfreetype6 libjpeg-turbo8 libx11-6 libxext6 libxrender1 - wget -q https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.jammy_amd64.deb - sudo apt-get install -y ./wkhtmltox_0.12.6.1-3.jammy_amd64.deb - /usr/local/bin/wkhtmltopdf -V + sudo apt-get install -y --no-install-recommends libfontconfig1 libfreetype6 libjpeg-turbo8 libpng16-16t64 libssl3t64 libx11-6 libxrender1 zlib1g fonts-dejavu-core - - name: Show whether the bundled Linux binary can start here (informational) - # Never fails the job: it only records, in the log, which shared libraries the bundled - # binary is missing on the current runner image. + - name: Check the bundled binary resolves all its shared libraries + # Fails the job (grep finds a "not found" line) instead of only logging it, as before. run: | - ldd x64/linux/wkhtmltopdf | grep 'not found' || echo 'bundled binary: all libraries found' + ldd x64/linux/wkhtmltopdf + if ldd x64/linux/wkhtmltopdf | grep 'not found'; then exit 1; fi + x64/linux/wkhtmltopdf --version - name: Install dependencies # No composer.lock is committed (it is a library), so this resolves fresh per PHP version. run: composer install --no-interaction --no-progress + - name: Check the wrapper resolves the bundled binary + # bootstrap.php prefers /usr/local/bin/wkhtmltopdf; this runner has none, so the render + # tests below must be running the bundled binary. Fails loudly if that ever changes. + run: | + resolved=$(php -r 'require "vendor/autoload.php"; echo Restruct\WkhtmltoPdf\WkhtmltoPdfStatic::getPath();') + echo "resolved: $resolved" + test "$resolved" = "$PWD/x64/linux/wkhtmltopdf" + - name: Run tests # --fail-on-skipped: the render tests skip when no wkhtmltopdf can be resolved, which is # right on a developer machine without one but would let CI pass green with nothing - # rendered. Here one was installed above, so a skip is a failure. + # rendered. Here the bundled binary is checked above, so a skip is a failure. # --fail-on-empty-test-suite: a run that discovers no tests exits non-zero instead of 0. run: vendor/bin/phpunit --fail-on-skipped --fail-on-empty-test-suite diff --git a/CHANGELOG.md b/CHANGELOG.md index b824700..5083d60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 1.1.0 (2026-10-12) + +### Changed + +- The bundled Linux binary (`x64/linux/wkhtmltopdf`) is replaced with the upstream wkhtmltopdf + 0.12.6.1 (patched Qt) binary from the official Ubuntu 22.04 package + (https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.jammy_amd64.deb, + `.deb` sha256 `4f723b2691ad8638a9df960e0421d346d7315083e3583a334f33362280ddba15`, binary sha256 + `750c92708349321a502b92e5e6c16b25499318ec24d1175cc8a81b6aefa321b2`). The previous binary linked + OpenSSL 1.1 and could not start on Ubuntu 22.04 or later; the new one links OpenSSL 3 and runs on + 22.04 and 24.04. **It no longer runs on Ubuntu 20.04 or older** (#6). The README lists the runtime + libraries and the apt line for 24.04 (on a stock 24.04 server only `libxrender1` may be missing). +- CI now tests the bundled Linux binary on Ubuntu 24.04, installing only the README's runtime + libraries, instead of installing the upstream `.deb`. + +### Fixed + +- A binary that is executable but cannot start (for example a missing shared library) is no longer + chosen. Each candidate is run once with `--version` and must exit 0 (cached per PHP process); if + it fails, the next candidate is tried and then the Docker strategy. Before, only the exec bit was + checked, so such a binary was chosen and every render failed (#6). + +### Added + +- `WkhtmltoPdfStatic::probeBinary($path)` and `WkhtmltoPdfStatic::setProbeTimeout($seconds)` + (default 10 s). + ## 1.0.3 (2026-10-11) ### Fixed diff --git a/README.md b/README.md index b2295da..129a4e3 100644 --- a/README.md +++ b/README.md @@ -105,17 +105,79 @@ WKHTMLTOPDF_DOCKER_IMAGE=ghcr.io/restruct/wkhtmltopdf:0.12.6 If both resolve, the native binary wins. +A binary only counts as resolved if it can actually start (from 1.1.0). Each candidate path is run +once with `--version` (10 s timeout) and must exit 0; the result is cached for the rest of the PHP +process, so this costs one extra process start per path, not one per render. A binary that is +executable but cannot start, for example because a shared library is missing, is skipped: the +next candidate in the table above is tried, and if none starts the Docker strategy is used. Up to +1.0.3 only the exec bit was checked, so such a binary was chosen anyway and every render failed. +`WkhtmltoPdfStatic::probeBinary($path)` runs the same check by hand. + ## Platform support | Platform | Native binary | Docker image | |---|---|---| | macOS x64 | ✅ bundled (`x64/mac`) | — | | macOS ARM (Apple Silicon) | ✅ bundled x64 binary via Rosetta 2 | ✅ native `linux/arm64` (in OrbStack/Docker Desktop) | -| Linux x64 | ✅ bundled (`x64/linux`, Ubuntu 20.04+) or system deb | ✅ `linux/amd64` | +| Linux x64 | ✅ bundled (`x64/linux`, Ubuntu 22.04+, see [below](#the-bundled-linux-binary)) or system deb | ✅ `linux/amd64` | | Linux ARM64 | ❌ upstream never shipped one | ✅ `linux/arm64` | The Docker image is the answer for every platform without a workable native binary — and the long-term insurance for when OS upgrades break the native ones. +## The bundled Linux binary + +`x64/linux/wkhtmltopdf` is the upstream wkhtmltopdf 0.12.6.1 (patched Qt) binary, taken unchanged +from the official Ubuntu 22.04 (jammy) amd64 package (from 1.1.0). It links OpenSSL 3, so it runs +on Ubuntu 22.04 and 24.04. It does **not** run on Ubuntu 20.04 or older, which ship OpenSSL 1.1 +only. Up to 1.0.3 the bundled binary was an older build linked against OpenSSL 1.1, which in turn +could not start on 22.04 and later (#6). + +| | | +|---|---| +| Source | https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.jammy_amd64.deb | +| `.deb` sha256 | `4f723b2691ad8638a9df960e0421d346d7315083e3583a334f33362280ddba15` | +| Binary sha256 | `750c92708349321a502b92e5e6c16b25499318ec24d1175cc8a81b6aefa321b2` (`usr/local/bin/wkhtmltopdf` in the `.deb`) | +| Version | `wkhtmltopdf 0.12.6.1 (with patched qt)` | + +Upstream publishes no checksums for its release assets (neither on the release page nor on +wkhtmltopdf.org), so the `.deb` sha256 above is the one we computed on download. It matches the +value several unrelated public projects pin for the same file, and the binary matches the MD5 in the +`.deb`'s own `md5sums` list. To check the bundled binary yourself: + +```bash +wget https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.jammy_amd64.deb +sha256sum wkhtmltox_0.12.6.1-3.jammy_amd64.deb +dpkg-deb -x wkhtmltox_0.12.6.1-3.jammy_amd64.deb deb && sha256sum deb/usr/local/bin/wkhtmltopdf +``` + +### Runtime libraries (Ubuntu 24.04) + +The binary links these shared libraries (its ELF `DT_NEEDED` list): `libjpeg.so.8`, +`libpng16.so.16`, `libXrender.so.1`, `libfontconfig.so.1`, `libfreetype.so.6`, `libX11.so.6`, +`libssl.so.3`, `libcrypto.so.3`, `libz.so.1`, plus the C/C++ runtime (`libc`, `libm`, `libstdc++`, +`libgcc_s`). Fontconfig also needs at least one font installed to render text. + +On the official Ubuntu 24.04 server cloud image (package manifest of 2026-09-26) all of these are +already installed **except `libxrender1`**. A server provisioned with more packages (Laravel Forge, +for example) usually has that one too. To be sure on any 24.04 server, run this; apt skips what is +already installed: + +```bash +sudo apt-get install -y --no-install-recommends libfontconfig1 libfreetype6 libjpeg-turbo8 libpng16-16t64 libssl3t64 libx11-6 libxrender1 zlib1g fonts-dejavu-core +``` + +On 22.04 two of the package names differ: use `libpng16-16` and `libssl3` instead of +`libpng16-16t64` and `libssl3t64`. Then check that nothing is missing: + +```bash +ldd vendor/restruct/wkhtmltopdf-static/x64/linux/wkhtmltopdf | grep 'not found' # prints nothing +vendor/restruct/wkhtmltopdf-static/x64/linux/wkhtmltopdf --version +``` + +The upstream `.deb` also depends on `xfonts-75dpi` and `xfonts-base` (X bitmap fonts). The binary +starts and renders without them; install them only if your HTML relies on those fonts. CI runs the +test suite against the bundled binary on Ubuntu 24.04 with exactly the apt line above. + ## The Docker image `ghcr.io/restruct/wkhtmltopdf:0.12.6` — public, multi-arch (`linux/amd64` + `linux/arm64`). Ubuntu 22.04 with the official final wkhtmltopdf deb and all runtime libraries frozen in. Built from [`build/Dockerfile`](build/Dockerfile): diff --git a/bootstrap.php b/bootstrap.php index c951540..183416a 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -16,6 +16,10 @@ * 3. macOS: bundled x64/mac/wkhtmltopdf * 4. Linux: system-installed /usr/local/bin/wkhtmltopdf → bundled x64/linux/wkhtmltopdf * + * Each candidate path (env var included) must pass a probe: it is run once with --version and + * must exit 0 (WkhtmltoPdfStatic::probeBinary(), cached per process). A binary that is executable + * but cannot start, e.g. because a shared library is missing, falls through to the next candidate. + * * Priority for WKHTMLTOPDF_DOCKER_IMAGE: * 1. Already defined constant * 2. WKHTMLTOPDF_DOCKER_IMAGE environment variable @@ -25,9 +29,17 @@ if (!defined('WKHTMLTOPDF_PATH')) { $wkPath = null; + # Probe that the candidate can actually start, not just that it has the exec bit (issue #6). + # Falls back to is_executable() only if this file is included without the package autoloader. + $wkUsable = static function (string $candidate): bool { + return class_exists(\Restruct\WkhtmltoPdf\WkhtmltoPdfStatic::class) + ? \Restruct\WkhtmltoPdf\WkhtmltoPdfStatic::probeBinary($candidate) + : is_executable($candidate); + }; + # Check environment variable first $envPath = getenv('WKHTMLTOPDF_PATH'); - if ($envPath !== false && $envPath !== '' && is_executable($envPath)) { + if ($envPath !== false && $envPath !== '' && $wkUsable($envPath)) { $wkPath = $envPath; } @@ -36,7 +48,7 @@ case 'Darwin': # macOS: use bundled binary (Intel, runs on ARM via Rosetta 2) $candidate = __DIR__ . '/x64/mac/wkhtmltopdf'; - if (is_executable($candidate)) { + if ($wkUsable($candidate)) { $wkPath = $candidate; } break; @@ -45,10 +57,10 @@ # Linux: prefer system-installed, fall back to bundled $candidates = [ '/usr/local/bin/wkhtmltopdf', # System install (Debian/Ubuntu package) - __DIR__ . '/x64/linux/wkhtmltopdf', # Bundled Ubuntu 20.04+ binary + __DIR__ . '/x64/linux/wkhtmltopdf', # Bundled Ubuntu 22.04+ binary (jammy build, OpenSSL 3; up to 1.0.3 it was a 20.04+ build needing OpenSSL 1.1) ]; foreach ($candidates as $candidate) { - if (is_executable($candidate)) { + if ($wkUsable($candidate)) { $wkPath = $candidate; break; } diff --git a/src/WkhtmltoPdfStatic.php b/src/WkhtmltoPdfStatic.php index e8b279f..81fcb58 100644 --- a/src/WkhtmltoPdfStatic.php +++ b/src/WkhtmltoPdfStatic.php @@ -21,6 +21,16 @@ class WkhtmltoPdfStatic /** Cached Docker availability check */ private static ?bool $dockerAvailable = null; + /** + * Per-process cache of binary probe results, keyed by path. + * + * @var array + */ + private static array $probeCache = []; + + /** Timeout in seconds for one binary probe (`--version`) */ + private static int $probeTimeout = 10; + /** * Include the bootstrap file (once). */ @@ -71,9 +81,12 @@ public static function isAvailable(): bool */ public static function getStrategy(): string { - # Prefer direct binary (faster, no container overhead) + # Prefer direct binary (faster, no container overhead). It must actually start: a binary + # that is executable but cannot load its shared libraries (e.g. one linked against + # OpenSSL 1.1 on Ubuntu 22.04+, issue #6) would otherwise be chosen and fail every render, + # where falling through to Docker could still work. $path = self::getPath(); - if ($path !== '' && is_executable($path)) { + if ($path !== '' && self::probeBinary($path)) { return 'binary'; } @@ -86,6 +99,51 @@ public static function getStrategy(): string return ''; } + /** + * Check that a wkhtmltopdf binary can actually start, by running it with `--version`. + * + * is_executable() only checks the file mode; it says nothing about whether the dynamic loader + * can resolve the binary's shared libraries. A missing library makes the loader exit with + * 127 before wkhtmltopdf runs at all, so the exit code of a cheap `--version` call is the + * signal. The result is cached per path for the rest of the process, so the probe costs one + * extra process start per path, not one per render. + * + * @param string $path Full path to a candidate binary + * @return bool True if it ran and exited 0 within the probe timeout + */ + public static function probeBinary(string $path): bool + { + if (array_key_exists($path, self::$probeCache)) { + return self::$probeCache[$path]; + } + + # No point starting a process for a path that is not an executable file + if ($path === '' || !is_file($path) || !is_executable($path)) { + return self::$probeCache[$path] = false; + } + + try { + $process = new Process([$path, '--version']); + $process->setTimeout(self::$probeTimeout); + $process->run(); + $usable = $process->getExitCode() === 0; + } catch (\Exception $e) { + # Timed out (ProcessTimedOutException) or could not be started at all + # (Symfony's RuntimeException): either way not a binary to render with + $usable = false; + } + + return self::$probeCache[$path] = $usable; + } + + /** + * Set the timeout for one binary probe. A probe that times out counts as unusable. + */ + public static function setProbeTimeout(int $seconds): void + { + self::$probeTimeout = $seconds; + } + /** * Get the wkhtmltopdf version string. */ diff --git a/tests/BinaryProbeTest.php b/tests/BinaryProbeTest.php new file mode 100644 index 0000000..a461961 --- /dev/null +++ b/tests/BinaryProbeTest.php @@ -0,0 +1,158 @@ +workDir = sys_get_temp_dir() . '/wkhtmltopdf-probe-test-' . bin2hex(random_bytes(6)); + mkdir($this->workDir); + } + + protected function tearDown(): void + { + # Restore the default, the timeout test lowers it and the setting is static + WkhtmltoPdfStatic::setProbeTimeout(10); + + foreach (glob($this->workDir . '/*') ?: [] as $file) { + unlink($file); + } + rmdir($this->workDir); + } + + public function testProbeAcceptsBinaryThatStarts(): void + { + $fake = $this->fakeExecutable('starts', "echo 'wkhtmltopdf 0.12.6.1 (with patched qt)'\nexit 0"); + + $this->assertTrue(WkhtmltoPdfStatic::probeBinary($fake)); + } + + public function testProbeRejectsBinaryWithMissingLibrary(): void + { + # What the dynamic loader does when a DT_NEEDED library is missing: message on stderr, exit 127 + $fake = $this->fakeExecutable( + 'missing-lib', + "echo 'error while loading shared libraries: libssl.so.1.1: cannot open shared object file' >&2\nexit 127" + ); + + $this->assertTrue(is_executable($fake), 'Control: the fake passes the old is_executable() check'); + $this->assertFalse(WkhtmltoPdfStatic::probeBinary($fake)); + } + + public function testProbeRejectsFileTheKernelCannotExecute(): void + { + # Exec bit set, but neither a valid binary nor a script: execve() fails with ENOEXEC + $fake = $this->workDir . '/garbage'; + file_put_contents($fake, "\x7fELF this is not a real ELF file\n"); + chmod($fake, 0755); + + $this->assertTrue(is_executable($fake), 'Control: the fake passes the old is_executable() check'); + $this->assertFalse(WkhtmltoPdfStatic::probeBinary($fake)); + } + + public function testProbeRejectsMissingAndNonExecutablePaths(): void + { + $plain = $this->workDir . '/not-executable'; + file_put_contents($plain, "#!/bin/sh\nexit 0\n"); + chmod($plain, 0644); + + $this->assertFalse(WkhtmltoPdfStatic::probeBinary($this->workDir . '/does-not-exist')); + $this->assertFalse(WkhtmltoPdfStatic::probeBinary($plain)); + $this->assertFalse(WkhtmltoPdfStatic::probeBinary('')); + # A directory has the exec bit too, but is not a binary + $this->assertFalse(WkhtmltoPdfStatic::probeBinary($this->workDir)); + } + + public function testProbeTimesOutOnBinaryThatHangs(): void + { + # exec, so the timeout kill hits sleep itself and does not leave an orphan running + $fake = $this->fakeExecutable('hangs', 'exec sleep 30'); + WkhtmltoPdfStatic::setProbeTimeout(1); + + $start = microtime(true); + $usable = WkhtmltoPdfStatic::probeBinary($fake); + + $this->assertFalse($usable); + $this->assertLessThan(10, microtime(true) - $start, 'The probe must give up at its timeout'); + } + + public function testProbeResultIsCachedPerProcess(): void + { + # Each run appends a line, so the line count is the number of times the probe started it + $counter = $this->workDir . '/runs.log'; + $fake = $this->fakeExecutable('counted', "echo run >> '{$counter}'\nexit 0"); + + $this->assertTrue(WkhtmltoPdfStatic::probeBinary($fake)); + $this->assertTrue(WkhtmltoPdfStatic::probeBinary($fake)); + + $this->assertSame(1, count(file($counter))); + } + + /** + * The resolved binary cannot start and no Docker image is configured: before the probe, + * getStrategy() returned 'binary' here and every render failed. + */ + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testStrategyRejectsResolvedBinaryThatCannotStart(): void + { + $fake = $this->fakeExecutable('missing-lib', 'exit 127'); + # Fresh process, so these constants are not defined yet; bootstrap.php leaves them alone + define('WKHTMLTOPDF_PATH', $fake); + define('WKHTMLTOPDF_DOCKER_IMAGE', ''); + + $this->assertSame('', WkhtmltoPdfStatic::getStrategy()); + $this->assertFalse(WkhtmltoPdfStatic::isAvailable()); + } + + /** + * An env var pointing at a binary that cannot start must not become WKHTMLTOPDF_PATH; + * bootstrap.php falls through to its OS candidates instead. + */ + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testBootstrapSkipsEnvBinaryThatCannotStart(): void + { + $fake = $this->fakeExecutable('missing-lib', 'exit 127'); + putenv('WKHTMLTOPDF_PATH=' . $fake); + + $this->assertNotSame($fake, WkhtmltoPdfStatic::getPath()); + } + + /** + * The bundled Linux binary must start on the host it is tested on. In CI that is a plain + * Ubuntu runner with only the runtime libraries the README lists. + */ + public function testBundledLinuxBinaryStarts(): void + { + if (PHP_OS_FAMILY !== 'Linux' || php_uname('m') !== 'x86_64') { + $this->markTestSkipped('The bundled Linux binary is x86_64 Linux only'); + } + + $this->assertTrue(WkhtmltoPdfStatic::probeBinary(dirname(__DIR__) . '/x64/linux/wkhtmltopdf')); + } + + private function fakeExecutable(string $name, string $body): string + { + $path = $this->workDir . '/' . $name; + file_put_contents($path, "#!/bin/sh\n{$body}\n"); + chmod($path, 0755); + + return $path; + } +} diff --git a/x64/linux/wkhtmltopdf b/x64/linux/wkhtmltopdf index 2c70183..3e88de4 100755 Binary files a/x64/linux/wkhtmltopdf and b/x64/linux/wkhtmltopdf differ