From 9da82eb8d763f658219d5ab44cb3782cc3f75784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20Sch=C3=A4ffner?= <53712359+brianvarskonst@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:29:03 +0200 Subject: [PATCH] Release 1.2.4: preserve warm OPcache and validate immutable FPM caches --- CHANGELOG.md | 7 +++ docs/releases/1.2.4.md | 20 +++++++ src/Console/Doctor.php | 2 +- src/Env/EnvCacheFormat.php | 3 -- src/Env/EnvReader.php | 2 +- src/Env/SecureFileWriter.php | 9 +++- src/Kernel/CacheLocation.php | 8 +-- src/Kernel/KernelPaths.php | 8 ++- src/Package/PackageFinder.php | 12 +++++ src/Step/Builtin/KernelBootStep.php | 2 +- src/Step/Builtin/MuLoaderStep.php | 4 +- templates/sympress-runtime-mu-loader.php | 1 + tests/Integration/EnvCacheOpcacheTest.php | 63 +++++++++++++++++++++++ tests/Integration/KernelBootTest.php | 3 +- 14 files changed, 128 insertions(+), 16 deletions(-) create mode 100644 docs/releases/1.2.4.md create mode 100644 tests/Integration/EnvCacheOpcacheTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index f9460fe..769c318 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 1.2.4 — 2026-10-05 + +- Keep compiled environment caches and immutable dumps in OPcache on reads. Invalidate only a mutable cache actually rewritten by this process; unchanged writes do not waste shared memory. +- Include the installed Runtime version in generated MU loader and kernel plugin headers for security inventories. +- Production doctor accepts sealed, readable project-owned immutable caches when run as PHP-FPM. Mutable maintenance, external roots, symlinks and group/world write protection retain their ownership boundaries. +- Verify 8,000 warm OPcache reads without a reset and complete two SSH/MariaDB/FPM deployments with the actual PHP identity. + ## 1.2.3 — 2026-10-05 - Preserve existing outbound WordPress HTTP integrations in 1.x: production and staging no longer enable `WP_HTTP_BLOCK_EXTERNAL` automatically. Explicit environment/constant opt-in retains operator host overrides and the Composer-managed core-host fallback. diff --git a/docs/releases/1.2.4.md b/docs/releases/1.2.4.md new file mode 100644 index 0000000..3ea0c9b --- /dev/null +++ b/docs/releases/1.2.4.md @@ -0,0 +1,20 @@ +# Runtime 1.2.4 + +Update with `composer update sympress/runtime --with-all-dependencies`, commit +the lockfile and regenerate release artifacts with `vendor/bin/runtime` and +`vendor/bin/runtime dump-env production`. + +Environment cache reads no longer invalidate OPcache. Only a successful changed +mutable cache write invalidates that file; immutable dumps and identical writes +remain compiled. The regression fixture performs 8,000 warm reads with a 16 MB +OPcache and timestamp validation disabled, then verifies changed-cache refresh. + +Generated MU loader and kernel headers contain the installed Runtime version. +Regeneration resolves the missing-version inventory finding. This does not add +the private Security package or require its vulnerability gate in any project. + +Doctor can run as PHP-FPM against a warmed immutable release owned by the deploy +user. The cache must be inside the project, owned by its release owner, readable +and unwritable by PHP-FPM, with an explicit immutable flag and build ID. External +cache roots and mutable cache maintenance still require the calling owner. +Group/world-writable directories and symlinks remain failures. diff --git a/src/Console/Doctor.php b/src/Console/Doctor.php index eadc11d..3efcfa9 100644 --- a/src/Console/Doctor.php +++ b/src/Console/Doctor.php @@ -113,7 +113,7 @@ public function inspect(bool $production = false, bool $databaseHealth = false, } try { $path = $id === 'cache' ? $kernel->cache() : $kernel->build(); - $kernel->assertSafe($path, $id); + $kernel->assertSafe($path, $id, readOnly: true); $kernelDirectories['kernel-' . $id] = $path; $record('kernel.' . $id, is_dir($path) && is_readable($path) ? 'pass' : 'unknown', is_dir($path) ? 'Selected kernel directory is readable.' : 'Selected kernel directory has not been created; run cache warmup as the PHP-FPM user.'); } catch (Throwable) { diff --git a/src/Env/EnvCacheFormat.php b/src/Env/EnvCacheFormat.php index 1306757..5b40a18 100644 --- a/src/Env/EnvCacheFormat.php +++ b/src/Env/EnvCacheFormat.php @@ -27,9 +27,6 @@ public static function read(string $file, bool $dataOnly = false): array } $data = (new EnvCacheLiteral())->decode($source); } - if (!$dataOnly && function_exists('opcache_invalidate')) { - opcache_invalidate($file, true); - } $data = $dataOnly ? $data : (static fn (string $path): mixed => require $path)($file); } catch (Throwable) { throw new RuntimeException('Cannot read environment cache.'); diff --git a/src/Env/EnvReader.php b/src/Env/EnvReader.php index 0da1502..1c0d779 100644 --- a/src/Env/EnvReader.php +++ b/src/Env/EnvReader.php @@ -425,7 +425,7 @@ public function dumpCached(string $file, bool $immutable = false): bool return false; } - return SecureFileWriter::write($file, EnvCacheFormat::encode($payload), $this->fileMode); + return SecureFileWriter::write($file, EnvCacheFormat::encode($payload), $this->fileMode, invalidateOpcache: !$immutable); } /** @internal */ diff --git a/src/Env/SecureFileWriter.php b/src/Env/SecureFileWriter.php index 7a9e43d..4bf69f1 100644 --- a/src/Env/SecureFileWriter.php +++ b/src/Env/SecureFileWriter.php @@ -31,7 +31,7 @@ public static function canWrite(string $file): bool return !is_link($file) && !is_dir($file); } - public static function write(string $file, string $content, int $mode = 0600): bool + public static function write(string $file, string $content, int $mode = 0600, bool $invalidateOpcache = false): bool { if (!in_array($mode, [0600, 0640], true)) { throw new InvalidArgumentException('Generated file mode must be 0600 or 0640.'); @@ -60,9 +60,14 @@ public static function write(string $file, string $content, int $mode = 0600): b return false; } - return @chmod($temporary, $mode) + $written = @chmod($temporary, $mode) && @file_put_contents($temporary, $content) === strlen($content) && @rename($temporary, $file); + if ($written && $invalidateOpcache && function_exists('opcache_invalidate')) { + opcache_invalidate($file, true); + } + + return $written; } finally { if (is_file($temporary)) { unlink($temporary); diff --git a/src/Kernel/CacheLocation.php b/src/Kernel/CacheLocation.php index 591f530..85aceb9 100644 --- a/src/Kernel/CacheLocation.php +++ b/src/Kernel/CacheLocation.php @@ -60,14 +60,14 @@ public static function fallbackRoot(string $project, array $publicRoots = []): s * * @param list $publicRoots */ - public static function assertTarget(string $path, string $root, bool $external, array $publicRoots = [], bool $implicit = false): void + public static function assertTarget(string $path, string $root, bool $external, array $publicRoots = [], bool $implicit = false, ?int $readOnlyOwner = null): void { if (!Path::isBasePath($root, $path) || self::isPublic($path, $publicRoots)) { throw new RuntimeException('Kernel cache target must remain inside its private cache root and outside the webroot.'); } $ancestor = $path; while (true) { - self::assertDirectory($ancestor); + self::assertDirectory($ancestor, $external ? null : $readOnlyOwner); if ($ancestor === $root) { break; } @@ -94,7 +94,7 @@ public static function assertTarget(string $path, string $root, bool $external, } } - private static function assertDirectory(string $directory): void + private static function assertDirectory(string $directory, ?int $readOnlyOwner = null): void { if (is_link($directory)) { throw new RuntimeException('Refusing a symlinked kernel cache directory.'); @@ -105,7 +105,7 @@ private static function assertDirectory(string $directory): void if (!is_dir($directory) || (fileperms($directory) & 0022) !== 0) { throw new RuntimeException('Kernel cache directories must be private and not group/world writable; create them as the PHP-FPM user and warm the cache as that user.'); } - if (fileowner($directory) !== self::currentOwner()) { + if (fileowner($directory) !== self::currentOwner() && !(fileowner($directory) === $readOnlyOwner && is_readable($directory) && !is_writable($directory))) { throw new RuntimeException('The kernel cache directory belongs to another user. Configure APP_CACHE_DIR for the PHP-FPM identity and warm it as that user.'); } } diff --git a/src/Kernel/KernelPaths.php b/src/Kernel/KernelPaths.php index 94f8b90..4311bc7 100644 --- a/src/Kernel/KernelPaths.php +++ b/src/Kernel/KernelPaths.php @@ -49,7 +49,7 @@ public function usesFallback(): bool return $this->configured('APP_CACHE_DIR') === null && $this->cache() !== $this->paths->root('var/cache/' . $this->environment() . '/kernel'); } - public function assertSafe(string $path, string $kind): void + public function assertSafe(string $path, string $kind, bool $readOnly = false): void { $configured = $this->configured($kind === 'build' ? 'APP_BUILD_DIR' : 'APP_CACHE_DIR'); if ($kind === 'build' && $configured === null) { @@ -61,7 +61,11 @@ public function assertSafe(string $path, string $kind): void if (!$external) { (new ProjectBoundary($this->paths))->assertWritablePath($path); } - CacheLocation::assertTarget($path, $root, $external, $this->publicRoots(), $implicit); + $immutable = filter_var($this->env->rawValue('SYMPRESS_KERNEL_IMMUTABLE_CACHE'), FILTER_VALIDATE_BOOLEAN); + $buildId = $this->env->rawValue('SYMPRESS_KERNEL_BUILD_ID'); + $owner = $readOnly && !$external && !$implicit && $immutable && is_string($buildId) && preg_match('/\A[A-Za-z0-9._-]{1,128}\z/', $buildId) === 1 + ? fileowner($this->paths->root()) : false; + CacheLocation::assertTarget($path, $root, $external, $this->publicRoots(), $implicit, $owner === false ? null : $owner); } public function buildIdFile(): string diff --git a/src/Package/PackageFinder.php b/src/Package/PackageFinder.php index a0083f2..fecd456 100644 --- a/src/Package/PackageFinder.php +++ b/src/Package/PackageFinder.php @@ -4,6 +4,7 @@ namespace SymPress\Runtime\Package; +use Composer\InstalledVersions; use RuntimeException; use SymPress\Runtime\Application\RunContext; use SymPress\Runtime\Config\ConfigLoader; @@ -49,6 +50,17 @@ public function findPathOf(Package $package): string return $package->getInstallPath(); } + public function runtimeVersion(): string + { + $version = $this->findByName('sympress/runtime')?->getPrettyVersion(); + if ($version === null && InstalledVersions::isInstalled('sympress/runtime')) { + $version = InstalledVersions::getPrettyVersion('sympress/runtime'); + } + + return is_string($version) && preg_match('/\A[vV]?[0-9]+\.[0-9]+\.[0-9]+(?:[.+-][a-zA-Z0-9.+-]+)?\z/', $version) === 1 + ? ltrim($version, 'vV') : 'dev'; + } + /** @return list */ public function search(string $name): array { diff --git a/src/Step/Builtin/KernelBootStep.php b/src/Step/Builtin/KernelBootStep.php index 0650961..e9800eb 100644 --- a/src/Step/Builtin/KernelBootStep.php +++ b/src/Step/Builtin/KernelBootStep.php @@ -55,7 +55,7 @@ public function run(Config $config, Paths $paths): int } $root = var_export(Path::makeRelative($paths->root(), dirname($file)), true); $autoload = var_export(Path::makeRelative($paths->vendor('autoload.php'), dirname($file)), true); - $source = "packages->runtimeVersion() . "\n */\n"; $source .= "if (!defined('ABSPATH')) { return; }\nrequire_once __DIR__ . '/' . " . $autoload . ";\n"; $source .= "if (\\SymPress\\Kernel\\App::kernel() === null) {\n \\SymPress\\Kernel\\App::bootKernel(new \\SymPress\\Kernel\\Kernel\\SiteKernel(__DIR__ . '/' . " . $root . "));\n}\n"; diff --git a/src/Step/Builtin/MuLoaderStep.php b/src/Step/Builtin/MuLoaderStep.php index 0571c34..d572e13 100644 --- a/src/Step/Builtin/MuLoaderStep.php +++ b/src/Step/Builtin/MuLoaderStep.php @@ -11,6 +11,7 @@ use SymPress\Runtime\Filesystem\ProjectBoundary; use SymPress\Runtime\Generation\ArtifactWriter; use SymPress\Runtime\Package\MuPluginList; +use SymPress\Runtime\Package\PackageFinder; use SymPress\Runtime\Step\ConditionalStepInterface; use SymPress\Runtime\Step\FileCreationStepInterface; use Symfony\Component\Filesystem\Path; @@ -21,7 +22,7 @@ final class MuLoaderStep implements FileCreationStepInterface, ConditionalStepIn /** @var array */ private array $plugins = []; - public function __construct(private readonly MuPluginList $list, private readonly FileContentBuilder $builder, private readonly ArtifactWriter $writer) + public function __construct(private readonly MuPluginList $list, private readonly FileContentBuilder $builder, private readonly ArtifactWriter $writer, private readonly PackageFinder $packages) { } @@ -52,6 +53,7 @@ public function run(Config $config, Paths $paths): int $template = 'wpstarter-mu-loader.php'; } $content = $this->builder->build($paths, $template, [ + 'RUNTIME_VERSION' => $this->packages->runtimeVersion(), 'MU_PLUGINS_ARRAY' => var_export($relative, true), 'MU_NATIVE' => var_export($config['compatibility-profile']->is('native'), true), 'MU_PLUGINS_LIST' => str_replace(['\\', "'"], ['\\\\', "\\'"], implode(', ', $relative)), diff --git a/templates/sympress-runtime-mu-loader.php b/templates/sympress-runtime-mu-loader.php index b357fb9..4ee7942 100644 --- a/templates/sympress-runtime-mu-loader.php +++ b/templates/sympress-runtime-mu-loader.php @@ -2,6 +2,7 @@ // @generated by sympress/runtime mu-loader:v1 /** * Plugin Name: SymPress Runtime MU loader + * Version: {{{RUNTIME_VERSION}}} * Description: Loads the discovered Composer and local MU plugin entry points. */ diff --git a/tests/Integration/EnvCacheOpcacheTest.php b/tests/Integration/EnvCacheOpcacheTest.php new file mode 100644 index 0000000..c4ab551 --- /dev/null +++ b/tests/Integration/EnvCacheOpcacheTest.php @@ -0,0 +1,63 @@ +write('probe.php', <<<'PHP' + 1, 'value' => 'first']); +foreach (['cache.php', 'dump.php'] as $file) { + SecureFileWriter::write($file, $first); + if (!opcache_compile_file($file)) { + throw new RuntimeException('OPcache fixture must be enabled.'); + } +} +$before = opcache_get_status(false); +for ($i = 0; $i < 4000; ++$i) { + foreach (['cache.php', 'dump.php'] as $file) { + if (EnvCacheFormat::read($file)['value'] !== 'first') { + throw new RuntimeException('Warm environment value changed.'); + } + } +} +$after = opcache_get_status(false); +$second = EnvCacheFormat::encode(['format' => 1, 'value' => 'second']); +SecureFileWriter::write('cache.php', $second, invalidateOpcache: true); +$refreshed = EnvCacheFormat::read('cache.php')['value']; +$written = opcache_get_status(false); +for ($i = 0; $i < 100; ++$i) { + SecureFileWriter::write('cache.php', $second, invalidateOpcache: true); + EnvCacheFormat::read('cache.php'); +} +$unchanged = opcache_get_status(false); +echo json_encode([$before, $after, $refreshed, $written, $unchanged]); +PHP); + $process = new Process([ + PHP_BINARY, '-d', 'opcache.enable_cli=1', '-d', 'opcache.validate_timestamps=0', + '-d', 'opcache.file_update_protection=0', '-d', 'opcache.memory_consumption=16', + '-d', 'opcache.interned_strings_buffer=4', + '-d', 'opcache.max_accelerated_files=10000', '-d', 'opcache.jit_buffer_size=0', + $this->root . '/probe.php', dirname(__DIR__, 2) . '/vendor/autoload.php', + ], $this->root); + $process->mustRun(); + [$before, $after, $refreshed, $written, $unchanged] = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertTrue($before['opcache_enabled']); + self::assertSame($before['memory_usage']['wasted_memory'], $after['memory_usage']['wasted_memory']); + self::assertSame($before['opcache_statistics']['oom_restarts'], $after['opcache_statistics']['oom_restarts']); + self::assertSame($before['opcache_statistics']['manual_restarts'], $after['opcache_statistics']['manual_restarts']); + self::assertGreaterThanOrEqual(8000, $after['opcache_statistics']['hits'] - $before['opcache_statistics']['hits']); + self::assertSame('second', $refreshed); + self::assertSame($written['memory_usage']['wasted_memory'], $unchanged['memory_usage']['wasted_memory']); + } +} diff --git a/tests/Integration/KernelBootTest.php b/tests/Integration/KernelBootTest.php index ceaa1d0..8ddbcbd 100644 --- a/tests/Integration/KernelBootTest.php +++ b/tests/Integration/KernelBootTest.php @@ -16,7 +16,7 @@ private function fixture(array $packages = [], bool $enabled = true): void { $this->write('composer.json', json_encode(['extra' => ['sympress-runtime' => ['require-wp' => false, 'db-check' => false, 'kernel-boot' => $enabled]]], JSON_THROW_ON_ERROR)); $this->write('vendor/autoload.php', 'write('vendor/composer/installed.json', json_encode(['packages' => [['name' => 'sympress/kernel', 'version' => '1.0.0', 'type' => 'library', 'install-path' => '../sympress/kernel'], ...$packages]], JSON_THROW_ON_ERROR)); + $this->write('vendor/composer/installed.json', json_encode(['packages' => [['name' => 'sympress/kernel', 'version' => '1.0.0', 'type' => 'library', 'install-path' => '../sympress/kernel'], ['name' => 'sympress/runtime', 'version' => 'v1.2.4', 'type' => 'composer-plugin', 'install-path' => '../sympress/runtime'], ...$packages]], JSON_THROW_ON_ERROR)); $this->write('vendor/kernel-stub.php', <<<'PHP' getExitCode(), $run->getErrorOutput()); $file = $this->root . '/wp-content/mu-plugins/sympress-runtime-kernel.php'; self::assertFileExists($file); + self::assertStringContainsString(' * Version: 1.2.4', (string) file_get_contents($file)); $boot = new Process([PHP_BINARY, '-r', 'define("ABSPATH", __DIR__); require "wp-content/mu-plugins/sympress-runtime-kernel.php"; require "wp-content/mu-plugins/sympress-runtime-kernel.php"; echo json_encode([SymPress\\Kernel\\App::$boots, realpath(SymPress\\Kernel\\App::kernel()->root)]);'], $this->root); $boot->mustRun(); self::assertSame([1, $this->root], json_decode($boot->getOutput(), true, flags: JSON_THROW_ON_ERROR));