Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ All notable changes to `php-vips` will be documented in this file.

## master

- report the real reason when FFI startup fails, and support
`ffi.enable=preload` with a new `FFI::preload()` [wadakatu]
- better ffi startup diagnostics [ping-localhost]
- add setBlock() and setBlockUntrusted() to control operation blocking [jcupitt]

Expand Down
37 changes: 32 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,39 @@ to your `composer.json`:
}
```

php-vips does not yet support preloading, so you need to enable FFI globally.
This has some security implications, since anyone who can run php on your
server can use it to call any native library they have access to.
The simplest thing is `ffi.enable=true`, but that lets any PHP code on your
server call any native library it has access to. You can avoid that with
`ffi.enable=preload`, which is PHP's default: FFI is then only usable from code
that PHP loaded at startup via `opcache.preload`, so php-vips can use it but
the rest of your application cannot.

Of course if attackers are running their own PHP code on your webserver you
are probably already toast, unfortunately.
To run php-vips that way, write a preload script:

```php
<?php
// preload.php
require __DIR__ . '/vendor/autoload.php';
Jcupitt\Vips\FFI::preload();
```

And point `php.ini` at it:

```
ffi.enable = preload
opcache.preload = /path/to/your/app/preload.php
```

`preload()` compiles all of php-vips into the opcache. PHP checks the function
an FFI call is made from, not the library as a whole, so php-vips has to be
preloaded as a whole too. The autoloader has to come first, or PHP will refuse
to start.

php.ini has room for only one preload script, so if your application already
has one (Symfony generates `config/preload.php`, for example), add the
`preload()` call to that rather than making a second script.

Preloading is not supported on Windows, and you need to restart PHP after
changing the preload script.

Finally, on php 8.3 and later you need to disable stack overflow
tests. php-vips executes FFI callbacks off the main thread and this confuses
Expand Down
58 changes: 51 additions & 7 deletions src/FFI.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,15 @@ class FFI
"" // system library
];

/**
* The message from the last failed library load, if any. FFI itself can
* refuse to run (eg. ffi.enable=preload and php-vips is not preloaded),
* and then this is much more useful than any guess we could make.
*
* @internal
*/
private static string $libraryLoadError = "";

/**
* Look up these once.
*
Expand Down Expand Up @@ -211,6 +220,40 @@ public static function addLibraryPath(string $path): bool
return true;
}

/**
* Compile php-vips into the opcache during startup.
*
* With `ffi.enable=preload`, PHP will only let code that was compiled
* during `opcache.preload` use FFI, so php-vips has to be preloaded as a
* whole. Call this from the script named by `opcache.preload`, after
* requiring the composer autoloader.
*
* This compiles php-vips' own PHP sources. It is not related to
* \FFI::load() or to the `ffi.preload` php.ini setting.
*
* @return void
*/
public static function preload(): void
{
static $done = false;
if ($done) {
return;
}
$done = true;

// opcache links each class as it compiles it, so DebugLogger's
// psr/log dependencies have to be in memory first
interface_exists(\Psr\Log\LoggerInterface::class);
trait_exists(\Psr\Log\LoggerTrait::class);

// scandir(), since glob() finds nothing inside a phar
foreach (scandir(__DIR__) ?: [] as $file) {
if (substr($file, -4) === ".php" && $file !== basename(__FILE__)) {
opcache_compile_file(__DIR__ . "/" . $file);
}
}
}

/**
* Shut down libvips. Call this just before process exit.
*
Expand Down Expand Up @@ -254,13 +297,15 @@ private static function libraryLoad(
string $interface
): ?\FFI {
Utils::debugLog("trying to open", ["libraryName" => $libraryName]);
self::$libraryLoadError = "";
foreach (self::$libraryPaths as $path) {
Utils::debugLog("trying path", ["path" => $path]);
try {
$library = \FFI::cdef($interface, $path . $libraryName);
Utils::debugLog("success", []);
return $library;
} catch (\FFI\Exception $e) {
self::$libraryLoadError = $e->getMessage();
Utils::debugLog("init", [
"msg" => "library load failed",
"exception" => $e->getMessage()
Expand All @@ -281,11 +326,6 @@ private static function init(): void
if (!extension_loaded("ffi")) {
throw new Exception("FFI extension not loaded");
}
$enable = ini_get("ffi.enable");
if ($enable != "true" &&
$enable != "1") {
throw new Exception("ffi.enable set to '$enable', not 'true'");
}

$vips_libname = self::libraryName("libvips", 42);
$glib_libname = self::libraryName("libglib-2.0", 0);
Expand Down Expand Up @@ -315,8 +355,12 @@ private static function init(): void
if (!empty(self::$libraryPaths)) {
$msg .= " in any of ['" . implode("', '", self::$libraryPaths) . "']";
}
$msg .= ". Make sure that you've installed libvips and that '$vips_libname'";
$msg .= " is on your system's library search path.";
if (self::$libraryLoadError !== "") {
$msg .= ". The error was: " . self::$libraryLoadError;
}
$msg .= ". Check that libvips is installed, that '$vips_libname' is on";
$msg .= " your system's library search path, and that FFI is usable";
$msg .= " (see the ffi.enable notes in the php-vips README).";
throw new Exception($msg);
}

Expand Down
40 changes: 40 additions & 0 deletions tests/PreloadTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace Jcupitt\Vips\Test;

use PHPUnit\Framework\TestCase;

class PreloadTest extends TestCase
{
public function testPreload()
{
if (!function_exists('opcache_get_status')) {
$this->markTestSkipped('no opcache');
}

/* opcache.preload can only be set at startup, so we have to use a
* subprocess.
*/
$command = escapeshellarg(PHP_BINARY) . ' -d opcache.enable_cli=1';

/* Before php 8.3, preloading as root fails unless you name the user
* to drop to. php warns if you set it when you are not root.
*/
if (function_exists('posix_geteuid') && posix_geteuid() === 0) {
$command .= ' -d opcache.preload_user=root';
}

$output = shell_exec(
$command .
' -d opcache.preload=' . escapeshellarg(__DIR__ . '/preload.php') .
' -r ' . escapeshellarg(
'$status = opcache_get_status();' .
'echo in_array("Jcupitt\\Vips\\Image", ' .
'$status["preload_statistics"]["classes"]) ? "preloaded" : "missing";'
) . ' 2>&1'
);

$this->assertStringNotContainsString("Can't preload", $output);
$this->assertStringContainsString('preloaded', $output);
}
}
9 changes: 9 additions & 0 deletions tests/preload.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

// preload script used by PreloadTest

require dirname(__DIR__) . '/vendor/autoload.php';

// twice, to check that preloading is idempotent
Jcupitt\Vips\FFI::preload();
Jcupitt\Vips\FFI::preload();
Loading