From f818914168083e5a7988de36114818ec50f6e1f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20Sch=C3=A4ffner?= Date: Mon, 5 Oct 2026 12:42:06 +0200 Subject: [PATCH] fix: publish mutable cache metadata without reader invalidation --- CHANGELOG.md | 7 +- docs/cache-request-cost.md | 30 +-- docs/dependency-injection.md | 35 ++-- src/Discovery/KernelPackageManifestCache.php | 33 ++-- src/Kernel/CachePath.php | 60 +++++- src/Kernel/ContainerCacheManager.php | 24 +-- .../KernelPackageManifestCacheTest.php | 27 +++ tests/Fixtures/cache-fpm-request.php | 26 +++ tests/Fixtures/discovery-opcache-probe.php | 14 ++ .../Kernel/CacheRequestCompatibilityTest.php | 181 +++++++++++++++--- .../Kernel/RuntimeCacheCompatibilityTest.php | 21 +- 11 files changed, 353 insertions(+), 105 deletions(-) create mode 100644 tests/Fixtures/cache-fpm-request.php create mode 100644 tests/Fixtures/discovery-opcache-probe.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ab6af6..7c5eac5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) where applicable. -## Unreleased +## 1.1.5 — 2026-10-05 + +- Store container metadata and discovery descriptors as atomic private JSON, so unchanged readers retain OPcache entries while CLI publications remain visible to existing FPM workers with timestamp validation disabled. Ignore and rebuild legacy PHP metadata without executing it. +- Restore automatic private-cache fallback outside served directories for classic WordPress webroots and cold read-only projects; reject pre-created symlinks, foreign owners and writable shared cache roots. + +## 1.1.4 — 2026-10-03 - Fail unsafe explicit APP_CACHE_DIR settings with deployment guidance and migrate implicit legacy caches once into visible durable project storage shared by CLI/PHP-FPM. - Refresh mutable metadata/discovery OPcache reads after atomic replacement while retaining immutable build-ID cache behavior. diff --git a/docs/cache-request-cost.md b/docs/cache-request-cost.md index a0ea216..d21c191 100644 --- a/docs/cache-request-cost.md +++ b/docs/cache-request-cost.md @@ -12,19 +12,19 @@ configuration file and runs the real container cache hit path. The probe counts content-hash bytes and OPcache invalidations. The server and fixture are removed afterward; no customer application or database is touched. -The before/after run on PHP 8.5.9, 2026-10-02, produced: +The 2026-10-05 PHP 8.5.9 regression checks record zero content-hash bytes and zero +OPcache invalidations on unchanged mutable container reads. Discovery readers +also perform zero invalidations. Metadata is now JSON; compiled containers retain +generation-specific PHP filenames. A native fixture keeps the same FPM worker +alive with `opcache.validate_timestamps=0`, updates container and discovery data +from separate CLI writers, and verifies that subsequent requests select the new +generation without forcing metadata invalidation. -| Measurement | Previous content default | Metadata default | -| --- | ---: | ---: | -| Warm hits | 40/40 | 40/40 | -| Hashed bytes per request | 1,288,952 | 0 | -| OPcache invalidations per request | 1 | 0 | -| Cache selection p50 / p95, ms | 1.364 / 1.648 | 0.942 / 1.006 | -| HTTP p50 / p95, ms | 1.876 / 2.518 | 1.379 / 1.439 | - -These are local fixture timings, not WordPress response-time guarantees. Input -size, filesystems, PHP configuration and concurrent load affect latency. -`CacheRequestCompatibilityTest` verifies zero warm hashing/invalidation, timestamp -invalidation, explicit content-mode detection and safe migration of group-writable -cache artifacts. The metadata mode intentionally cannot detect an edit preserving -both size and mtime; clear the cache for such edits. +No timing benchmark was rerun for 1.1.5. The earlier 2026-10-02 candidate timings +did not describe the reader invalidation introduced in 1.1.4 and are not evidence +of current response latency. Filesystems, PHP configuration and concurrent load +affect latency; the fixture is not a WordPress response-time guarantee. +`CacheRequestCompatibilityTest` covers timestamp invalidation, explicit +content-mode detection, legacy PHP metadata migration, classic/read-only default +paths and safe migration of group-writable cache artifacts. Metadata mode cannot +detect an edit preserving both size and mtime; clear the cache for such edits. diff --git a/docs/dependency-injection.md b/docs/dependency-injection.md index b79c15c..c45857f 100644 --- a/docs/dependency-injection.md +++ b/docs/dependency-injection.md @@ -990,7 +990,7 @@ wp console container:dump --format=yaml ## Runtime Cache Both debug and production use compiled-container cache hits. A hit loads recorded -config paths from `meta.php`, validates consumed input signatures, and takes a shared +config paths from `meta.json`, validates consumed input signatures, and takes a shared reader lock; it does not execute config loaders or rebuild discovery globs. Only misses take the exclusive writer lock and recheck after waiting. Writer metadata and container publication are atomic, private files use mode 0600, and newly @@ -1004,21 +1004,29 @@ project. The default is `var/cache//kernel`. Existing read-only war caches can still be read. An implicit legacy group/world-writable cache migrates to durable project storage at `var/cache-private-//kernel` without executing or modifying old PHP dumps; creation logs the selected path once. This -path is shared by CLI and PHP-FPM even with systemd PrivateTmp. An explicit unsafe -`APP_CACHE_DIR`, a web-exposed default or an unwritable cold project fails with an -actionable diagnostic instead of silently selecting another cache. Symlinked -directories are rejected; private migration directories are owner-checked and -never admit group/world writes. Provision an explicit durable private cache with +path is shared by CLI and PHP-FPM even with systemd PrivateTmp. If the project is +the webroot or a cold project cannot be written, the implicit cache uses a +project-specific, owner-checked directory under the system temporary directory, +outside DOCUMENT_ROOT/WP_CONTENT_DIR. Pre-created symlinks, other owners and +group/world-writable directories are rejected. This fallback restores automatic +boot for classic and read-only WordPress projects; it is not a shared deployment +cache when CLI and FPM have different users or PrivateTmp namespaces. An explicit +unsafe `APP_CACHE_DIR` still fails with an actionable diagnostic. Provision an +explicit durable private cache with the same CLI/PHP-FPM identity and mode 0700, run production cache warmup and `lint:container` under that identity, then verify a request before switching traffic. Keep configured cache parents trusted and outside DOCUMENT_ROOT/WP_CONTENT_DIR. Normal validation compares modification time and size for root/package Composer metadata and consumed configuration, including imports. Warm requests do not read -these file contents. Mutable metadata/discovery reads invalidate their PHP OPcache -entries before loading, so atomic replacement remains visible with timestamp -validation disabled; immutable build-ID policy retains those entries. Publication -also invalidates files that were written. Missing recorded config signatures force a rebuild. +these file contents. Container metadata and discovery descriptors are atomic JSON +files (`meta.json` and `discovery-packages.json`), so their replacement by a CLI +writer is visible to existing FPM workers even with OPcache timestamp validation +disabled. Unchanged mutable and immutable readers do not invalidate OPcache. +Compiled containers remain PHP files with generation-specific names; writers +invalidate only the compiled PHP file they publish. Legacy `meta.php` and +`discovery-packages.php` are ignored and rebuilt once without executing them. +Missing recorded config signatures force a rebuild. Edits preserving both size and modification time require a cache clear or explicit `SYMPRESS_KERNEL_CONTENT_HASHES=1`. That opt-in mode hashes consumed inputs and debug source files, detects those edits, and has a distinct cache identity. Recorded @@ -1044,8 +1052,9 @@ its boot state and rethrows, without serving a stale container. The kernel error hook remains available and the built-in diagnostic logs class/location and a correlation identifier, excluding raw exception messages. The WordPress/PHP boundary determines the failure response (normally HTTP 500); operators can map -it to a maintenance 503/retry policy. A readonly cache miss requires a writable -private cache or explicit deployment warmup. Configure production PHP to hide +it to a maintenance 503/retry policy. An explicit read-only cache miss requires +deployment warmup or a writable private cache; implicit cold paths may use the +private fallback described above. Configure production PHP to hide exception text and monitor failed readiness before switching traffic. ## Environment Parameters @@ -1138,7 +1147,7 @@ Useful debug points: - `kernel.container_configured`, to inspect loaded configuration files. - `kernel.container_ready`, to inspect the runtime container. -- `var/cache//kernel/meta.php`, to inspect fingerprint, class, and cache +- `var/cache//kernel/meta.json`, to inspect fingerprint, class, and cache file metadata. - The showcase plugin screen for attributes, locators, tags, and lazy services. diff --git a/src/Discovery/KernelPackageManifestCache.php b/src/Discovery/KernelPackageManifestCache.php index cab4dd9..cb089d6 100644 --- a/src/Discovery/KernelPackageManifestCache.php +++ b/src/Discovery/KernelPackageManifestCache.php @@ -43,11 +43,9 @@ public function read(): ?array return null; } try { - if (!$this->immutable() && function_exists('opcache_invalidate')) { - opcache_invalidate($file, true); - } - $metadata = require $file; - } catch (\ParseError) { + $contents = file_get_contents($file); + $metadata = $contents === false ? null : json_decode($contents, true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException) { return null; } @@ -120,17 +118,14 @@ public function write(array $packages, array $metadata = [], array $inputs = []) } sort($packages); - $payload = sprintf( - " $this->fingerprint(), - 'packages' => array_values(array_unique($packages)), - 'metadata' => $metadata, - 'inputs' => $inputs, - ], - true, - ), + $payload = json_encode( + [ + 'fingerprint' => $this->fingerprint(), + 'packages' => array_values(array_unique($packages)), + 'metadata' => $metadata, + 'inputs' => $inputs, + ], + JSON_THROW_ON_ERROR, ); $temporaryFile = sprintf('%s.%s.tmp', $file, bin2hex(random_bytes(6))); @@ -140,9 +135,7 @@ public function write(array $packages, array $metadata = [], array $inputs = []) return; } chmod($temporaryFile, 0600); - if (@rename($temporaryFile, $file) && function_exists('opcache_invalidate')) { - opcache_invalidate($file, true); - } + @rename($temporaryFile, $file); } finally { umask($previousMask); if (is_file($temporaryFile)) { @@ -164,7 +157,7 @@ private function cacheFile(): ?string } $configured = $_SERVER['APP_CACHE_DIR'] ?? $_ENV['APP_CACHE_DIR'] ?? getenv('APP_CACHE_DIR'); - return CachePath::resolve($this->projectDir, $environment, is_string($configured) && $configured !== '' ? $configured : null) . '/discovery-packages.php'; + return CachePath::resolve($this->projectDir, $environment, is_string($configured) && $configured !== '' ? $configured : null) . '/discovery-packages.json'; } private function fingerprint(): string diff --git a/src/Kernel/CachePath.php b/src/Kernel/CachePath.php index cc054cf..4fffd53 100644 --- a/src/Kernel/CachePath.php +++ b/src/Kernel/CachePath.php @@ -34,7 +34,7 @@ public static function resolve(string $project, string $environment, ?string $co } return self::privateFallback($project, $environment); } - if ($configured === null && !is_file($path . '/meta.php') && !self::writableAncestor($path)) { + if ($configured === null && !is_file($path . '/meta.json') && !self::writableAncestor($path)) { return self::privateFallback($project, $environment); } return $path; @@ -73,20 +73,27 @@ public static function canonical(string $path): string private static function privateFallback(string $project, string $environment): string { - $user = function_exists('posix_geteuid') ? (string) posix_geteuid() : hash('sha256', get_current_user()); + $owner = self::currentOwner(); + $user = (string) $owner; $root = $project . '/var/cache-private-' . $user; - $resolved = self::canonical($root); - foreach ([$_SERVER['DOCUMENT_ROOT'] ?? null, defined('WP_CONTENT_DIR') ? constant('WP_CONTENT_DIR') : null] as $public) { - if (is_string($public) && $public !== '' && ($resolved === self::canonical($public) || str_starts_with($resolved, rtrim(self::canonical($public), '/') . '/'))) { - throw new \RuntimeException('No private kernel cache is available outside the webroot. Set APP_CACHE_DIR to a durable private directory and warm it as the PHP-FPM user.'); + if (self::isPublic($root) || !self::writableAncestor($root)) { + // A cold read-only project or classic WordPress docroot still boots. + // A shared temporary parent is safe only with a private, owned root: + // never follow or adopt a pre-created symlink or another user's tree. + $temporary = self::canonical(sys_get_temp_dir()); + $permissions = fileperms($temporary); + if ($permissions === false || (($permissions & 0022) !== 0 && ($permissions & 01000) === 0)) { + throw new \RuntimeException('A private kernel cache requires a trusted temporary parent or APP_CACHE_DIR; shared temporary directories must have the sticky bit.'); } + $root = rtrim($temporary, '/') . '/sympress-kernel-' . $user . '-' + . substr(hash('sha256', self::canonical($project)), 0, 24); } - $created = !is_dir($root); - if ($created && !self::writableAncestor($root)) { - throw new \RuntimeException('The private kernel cache cannot be created. Set APP_CACHE_DIR to a durable directory shared by CLI and PHP-FPM, create it with mode 0700, and warm it as the PHP-FPM user.'); + if (self::isPublic($root)) { + throw new \RuntimeException('No private kernel cache is available outside the webroot. Set APP_CACHE_DIR to a durable private directory and warm it as the PHP-FPM user.'); } + $created = !is_dir($root); self::ensureDirectory($root); - if (function_exists('posix_geteuid') && fileowner($root) !== posix_geteuid()) { + if (fileowner($root) !== $owner) { throw new \RuntimeException('The private kernel cache root belongs to another user; configure APP_CACHE_DIR for the PHP-FPM identity.'); } if ($created) { @@ -95,6 +102,39 @@ private static function privateFallback(string $project, string $environment): s return $root . '/' . $environment . '/kernel'; } + private static function isPublic(string $path): bool + { + $resolved = self::canonical($path); + foreach ([$_SERVER['DOCUMENT_ROOT'] ?? null, defined('WP_CONTENT_DIR') ? constant('WP_CONTENT_DIR') : null] as $public) { + if (is_string($public) && $public !== '' && ($resolved === self::canonical($public) || str_starts_with($resolved, rtrim(self::canonical($public), '/') . '/'))) { + return true; + } + } + return false; + } + + private static function currentOwner(): int + { + if (function_exists('posix_geteuid')) { + return posix_geteuid(); + } + // get_current_user() identifies the script owner, not the process user. + // The owner of a file we create remains reliable without ext-posix. + $probe = tmpfile(); + if (!is_resource($probe)) { + throw new \RuntimeException('Unable to verify private cache ownership; configure APP_CACHE_DIR.'); + } + try { + $status = fstat($probe); + if ($status === false) { + throw new \RuntimeException('Unable to verify private cache ownership; configure APP_CACHE_DIR.'); + } + return $status['uid']; + } finally { + fclose($probe); + } + } + private static function writableAncestor(string $path): bool { while (!file_exists($path)) { diff --git a/src/Kernel/ContainerCacheManager.php b/src/Kernel/ContainerCacheManager.php index 727c0cd..254520b 100644 --- a/src/Kernel/ContainerCacheManager.php +++ b/src/Kernel/ContainerCacheManager.php @@ -32,7 +32,7 @@ public function tryUseRuntimeContainer( array $runtimeConfigFiles, ): bool { - $metaFile = sprintf('%s/meta.php', $this->cacheDir); + $metaFile = sprintf('%s/meta.json', $this->cacheDir); $lockFile = sprintf('%s/container.lock', $this->cacheDir); if ( !is_file($metaFile) || !is_file($lockFile) || is_link($metaFile) || is_link($lockFile) @@ -80,7 +80,7 @@ public function createRuntimeContainer( $filesystem = new Filesystem(); CachePath::ensureDirectory($this->cacheDir); - $metaFile = sprintf('%s/meta.php', $this->cacheDir); + $metaFile = sprintf('%s/meta.json', $this->cacheDir); $lockFile = sprintf('%s/container.lock', $this->cacheDir); $fingerprint = $this->fingerprints->fingerprint($bundles, $configFiles); if (is_link($lockFile)) { @@ -166,10 +166,8 @@ public function createRuntimeContainer( chmod($containerFile, 0600); $filesystem->dumpFile( $metaFile, - sprintf( - " $fingerprint, 'runtime_config_files' => $configFiles, 'config_discovery' => $this->fingerprints->discoveryResourceManifest($configDirectories), @@ -178,15 +176,13 @@ public function createRuntimeContainer( 'source_resources' => $sourceResources, 'class' => $class, 'file' => basename($containerFile), - ], - true, - ), + ], + JSON_THROW_ON_ERROR, ), ); chmod($metaFile, 0600); if (function_exists('opcache_invalidate')) { - opcache_invalidate($metaFile, true); opcache_invalidate($containerFile, true); } } finally { @@ -212,12 +208,10 @@ private function readMetadata(string $file): ?array return null; } try { - if (!$this->fingerprints->immutable() && function_exists('opcache_invalidate')) { - opcache_invalidate($file, true); - } - $metadata = require $file; + $contents = file_get_contents($file); + $metadata = $contents === false ? null : json_decode($contents, true, flags: JSON_THROW_ON_ERROR); return is_array($metadata) ? $metadata : null; - } catch (\ParseError) { + } catch (\JsonException) { return null; } } diff --git a/tests/Discovery/KernelPackageManifestCacheTest.php b/tests/Discovery/KernelPackageManifestCacheTest.php index db35064..8d9cf57 100644 --- a/tests/Discovery/KernelPackageManifestCacheTest.php +++ b/tests/Discovery/KernelPackageManifestCacheTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase; use SymPress\Kernel\Discovery\KernelPackageManifestCache; use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Process\Process; final class KernelPackageManifestCacheTest extends TestCase { @@ -41,6 +42,32 @@ public function testManifestRoundTripsUntilComposerMetadataChanges(): void self::assertNull($cache->read()); } + public function testUnchangedManifestReadersDoNotInvalidateOpcache(): void + { + $project = $this->tmpPath('discovery-reader-cost'); + $code = <<<'PHP' +namespace SymPress\Kernel\Discovery { + function opcache_invalidate(string $filename, bool $force = false): bool { + $GLOBALS['reader_invalidations']++; + return \opcache_invalidate($filename, $force); + } +} +namespace { + require $argv[1]; + $cache = new SymPress\Kernel\Discovery\KernelPackageManifestCache($argv[2], 'production', ['sympress/']); + $GLOBALS['reader_invalidations'] = 0; + $cache->write(['sympress/kernel']); + $GLOBALS['reader_invalidations'] = 0; + $first = $cache->read(); + $second = $cache->read(); + echo json_encode([$first, $second, $GLOBALS['reader_invalidations']], JSON_THROW_ON_ERROR); +} +PHP; + $process = new Process([PHP_BINARY, '-r', $code, dirname(__DIR__, 2) . '/vendor/autoload.php', $project]); + $process->mustRun(); + self::assertSame([['sympress/kernel'], ['sympress/kernel'], 0], json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR)); + } + private function tmpPath(string $prefix): string { $path = sprintf('%s/%s-%s', sys_get_temp_dir(), $prefix, uniqid('', true)); diff --git a/tests/Fixtures/cache-fpm-request.php b/tests/Fixtures/cache-fpm-request.php new file mode 100644 index 0000000..eba511d --- /dev/null +++ b/tests/Fixtures/cache-fpm-request.php @@ -0,0 +1,26 @@ +force(WpContext::CORE)); +$container = $kernel->createContainer(); +$hit = $kernel->tryUseRuntimeContainer($container, new BundleRegistry()); +echo json_encode([ + 'hit' => $hit, + 'value' => $hit ? $container->getParameter('imported.value') : null, + 'packages' => (new KernelPackageManifestCache($project, 'test', ['sympress/']))->read(), + 'invalidations' => $GLOBALS['cache_request_invalidations'] ?? 0, + 'worker' => getmypid(), + 'validate_timestamps' => opcache_get_configuration()['directives']['opcache.validate_timestamps'], +], JSON_THROW_ON_ERROR); diff --git a/tests/Fixtures/discovery-opcache-probe.php b/tests/Fixtures/discovery-opcache-probe.php new file mode 100644 index 0000000..66a2ecf --- /dev/null +++ b/tests/Fixtures/discovery-opcache-probe.php @@ -0,0 +1,14 @@ +remove($cache); } - public function testSuccessiveRequestsUseMetadataWithoutHashesAndRefreshMutableOpcacheMetadata(): void + public function testSuccessiveRequestsUseMetadataWithoutHashesOrOpcacheInvalidation(): void { $project = $this->tmpPath('request-hashes'); mkdir($project . '/config', 0700, true); @@ -65,7 +66,7 @@ public function testSuccessiveRequestsUseMetadataWithoutHashesAndRefreshMutableO self::assertTrue($request['hit']); self::assertSame([], $request['hashes']); self::assertSame(0, $request['bytes']); - self::assertSame(1, $request['invalidations']); + self::assertSame(0, $request['invalidations']); } $stamp = filemtime($file); $this->writeConfig($file . '.new', 'other'); @@ -112,34 +113,170 @@ public function testMissingRecordedConfigResourceForcesRebuild(): void $container = $kernel->createContainer(); $files = $kernel->configureContainer($container->builder(), $container, new BundleRegistry()); $kernel->createRuntimeContainer($container, new BundleRegistry(), $files); - $metaFile = $project . '/var/cache/test/kernel/meta.php'; - $metadata = require $metaFile; + $metaFile = $project . '/var/cache/test/kernel/meta.json'; + $metadata = json_decode(file_get_contents($metaFile), true, flags: JSON_THROW_ON_ERROR); unset($metadata['config_resources'][$file]); - file_put_contents($metaFile, 'tryUseRuntimeContainer($kernel->createContainer(), new BundleRegistry())); } - public function testMutableMetadataReplacementIsVisibleWithOpcacheTimestampChecksDisabled(): void + public function testLegacyPhpMetadataIsNotExecutedDuringMigration(): void { - $project = $this->tmpPath('mutable-opcache'); - mkdir($project, 0700, true); - $file = $project . '/meta.php'; - file_put_contents($file, ' "old"];'); + $project = $this->tmpPath('legacy-php-metadata'); + $directory = $project . '/var/cache/test/kernel'; + mkdir($directory, 0700, true); + foreach (['meta.php', 'discovery-packages.php'] as $filename) { + file_put_contents($directory . '/' . $filename, 'read()); + $discovery->write(['sympress/kernel']); + self::assertSame(['sympress/kernel'], $discovery->read()); + $kernel = $this->kernel($project); + $container = $kernel->createContainer(); + $bundles = new BundleRegistry(); + self::assertFalse($kernel->tryUseRuntimeContainer($container, $bundles)); + $files = $kernel->configureContainer($container->builder(), $container, $bundles); + $kernel->createRuntimeContainer($container, $bundles, $files); + self::assertTrue($kernel->tryUseRuntimeContainer($kernel->createContainer(), $bundles)); + self::assertFileExists($directory . '/meta.json'); + } + + public function testReadOnlyColdProjectBootsWithPrivateFallback(): void + { + $project = $this->tmpPath('readonly-cold-project'); + mkdir($project, 0500); + $cache = CachePath::resolve($project, 'test'); + try { + self::assertFalse(str_starts_with(CachePath::canonical($cache), CachePath::canonical($project) . '/')); + $kernel = $this->kernel($project); + $container = $kernel->createContainer(); + $bundles = new BundleRegistry(); + $files = $kernel->configureContainer($container->builder(), $container, $bundles); + $kernel->createRuntimeContainer($container, $bundles, $files); + self::assertTrue($kernel->tryUseRuntimeContainer($kernel->createContainer(), $bundles)); + $discovery = new KernelPackageManifestCache($project, 'test', ['sympress/']); + $discovery->write(['sympress/kernel']); + self::assertSame(['sympress/kernel'], $discovery->read()); + self::assertSame(0700, fileperms(dirname($cache, 2)) & 0777); + self::assertSame(0600, fileperms($cache . '/meta.json') & 0777); + } finally { + chmod($project, 0700); + (new Filesystem())->remove(dirname($cache, 2)); + } + } + + public function testTemporaryFallbackRejectsPrecreatedSymlink(): void + { + $project = $this->tmpPath('public-symlink-cache'); + $outside = $this->tmpPath('untrusted-fallback'); + mkdir($outside, 0700); + $_SERVER['DOCUMENT_ROOT'] = $project; + $root = null; + try { + $root = dirname(CachePath::resolve($project, 'test'), 2); + rmdir($root); + symlink($outside, $root); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('symlinked'); + CachePath::resolve($project, 'test'); + } finally { + unset($_SERVER['DOCUMENT_ROOT']); + if ($root !== null && is_link($root)) { + unlink($root); + } + } + } + + public function testPrivateFallbackVerifiesProcessOwnerWithoutPosixExtension(): void + { + $project = $this->tmpPath('fallback-no-posix'); $code = <<<'PHP' require $argv[1]; -$file = $argv[2] . '/meta.php'; -$primed = opcache_compile_file($file); -$old = require $file; -file_put_contents($file . '.new', ' "new"];'); -rename($file . '.new', $file); -$stale = require $file; -$manager = new SymPress\Kernel\Kernel\ContainerCacheManager($argv[2], false, new SymPress\Kernel\Kernel\ContainerResourceFingerprinter($argv[2], 'test', false)); -$fresh = (new ReflectionMethod($manager, 'readMetadata'))->invoke($manager, $file); -echo json_encode([$primed, $old, $stale, $fresh], JSON_THROW_ON_ERROR); +$_SERVER['DOCUMENT_ROOT'] = $argv[2]; +$path = SymPress\Kernel\Kernel\CachePath::resolve($argv[2], 'test'); +$root = dirname($path, 2); +$probe = tmpfile(); +$owner = fstat($probe)['uid']; +fclose($probe); +echo json_encode([$root, fileowner($root), $owner, fileperms($root) & 0777], JSON_THROW_ON_ERROR); PHP; - $process = new Process([PHP_BINARY, '-d', 'opcache.enable_cli=1', '-d', 'opcache.validate_timestamps=0', '-d', 'opcache.file_update_protection=0', '-r', $code, dirname(__DIR__, 2) . '/vendor/autoload.php', $project]); + $process = new Process([PHP_BINARY, '-d', 'disable_functions=posix_geteuid', '-r', $code, dirname(__DIR__, 2) . '/vendor/autoload.php', $project]); $process->mustRun(); - self::assertSame([true, ['generation' => 'old'], ['generation' => 'old'], ['generation' => 'new']], json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR)); + [$root, $actual, $expected, $permissions] = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + try { + self::assertSame($expected, $actual); + self::assertSame(0700, $permissions); + } finally { + (new Filesystem())->remove($root); + } + } + + public function testCliWriterRefreshesPersistentFpmReadersWithoutMetadataInvalidation(): void + { + $finder = new ExecutableFinder(); + $fpm = $finder->find('php-fpm8.5') ?? $finder->find('php-fpm'); + $fcgi = $finder->find('cgi-fcgi'); + if ($fpm === null || $fcgi === null || !function_exists('posix_geteuid')) { + self::markTestSkipped('Native CLI/FPM fixture requires PHP-FPM, cgi-fcgi and POSIX identity.'); + } + $project = $this->tmpPath('cli-fpm-metadata'); + mkdir($project . '/config', 0700, true); + $file = $project . '/config/services.php'; + $this->writeConfig($file, 'first'); + $write = static function (string $package) use ($project): void { + (new Process([PHP_BINARY, dirname(__DIR__) . '/Fixtures/cache-request.php', $project]))->mustRun(); + $code = 'require $argv[1]; (new SymPress\\Kernel\\Discovery\\KernelPackageManifestCache($argv[2], "test", ["sympress/"]))->write([$argv[3]]);'; + (new Process([PHP_BINARY, '-r', $code, dirname(__DIR__, 2) . '/vendor/autoload.php', $project, $package]))->mustRun(); + }; + $write('sympress/first'); + $socket = $project . '/fpm.sock'; + $config = $project . '/fpm.conf'; + $user = posix_getpwuid(posix_geteuid())['name']; + $group = posix_getgrgid(posix_getegid())['name']; + file_put_contents($config, "[global]\nerror_log={$project}/fpm.log\ndaemonize=no\n[fixture]\nuser={$user}\ngroup={$group}\nlisten={$socket}\nlisten.mode=0600\npm=static\npm.max_children=1\nclear_env=yes\nphp_admin_flag[opcache.enable]=on\nphp_admin_value[opcache.validate_timestamps]=0\nphp_admin_value[opcache.file_update_protection]=0\n"); + $pool = new Process([$fpm, '-F', '-y', $config]); + $pool->start(); + try { + $deadline = microtime(true) + 10; + while (!is_file($socket) && !file_exists($socket)) { + self::assertTrue($pool->isRunning(), $pool->getErrorOutput()); + self::assertLessThan($deadline, microtime(true), 'Fixture FPM socket was not created.'); + usleep(50000); + } + $request = static function () use ($fcgi, $socket, $project): array { + $process = new Process([$fcgi, '-bind', '-connect', $socket], env: [ + 'SCRIPT_FILENAME' => dirname(__DIR__) . '/Fixtures/cache-fpm-request.php', + 'SCRIPT_NAME' => '/cache-fpm-request.php', + 'REQUEST_METHOD' => 'GET', + 'SERVER_PROTOCOL' => 'HTTP/1.1', + 'REDIRECT_STATUS' => '200', + 'SYMPRESS_TEST_PROJECT' => $project, + ]); + $process->mustRun(); + $parts = preg_split('/\r?\n\r?\n/', $process->getOutput(), 2); + return json_decode($parts[1] ?? '', true, flags: JSON_THROW_ON_ERROR); + }; + $first = $request(); + self::assertTrue($first['hit']); + self::assertSame('first', $first['value']); + self::assertSame(['sympress/first'], $first['packages']); + self::assertFalse($first['validate_timestamps']); + self::assertSame(0, $first['invalidations']); + self::assertSame($first, $request()); + $this->writeConfig($file, 'other'); + touch($file, time() + 2); + $write('sympress/other'); + $next = $request(); + self::assertSame($first['worker'], $next['worker']); + self::assertTrue($next['hit']); + self::assertSame('other', $next['value']); + self::assertSame(['sympress/other'], $next['packages']); + self::assertSame(0, $next['invalidations']); + self::assertSame($next, $request()); + } finally { + $pool->stop(); + } } private function writeConfig(string $file, string $value): void diff --git a/tests/Kernel/RuntimeCacheCompatibilityTest.php b/tests/Kernel/RuntimeCacheCompatibilityTest.php index acd2e02..c8450df 100644 --- a/tests/Kernel/RuntimeCacheCompatibilityTest.php +++ b/tests/Kernel/RuntimeCacheCompatibilityTest.php @@ -17,6 +17,7 @@ use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\Process\Process; +use Symfony\Component\Filesystem\Filesystem; use SymPress\Kernel\Kernel\ContainerCacheManager; use Symfony\Component\Config\Resource\DirectoryResource; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -255,14 +256,16 @@ public function testCacheArtifactsArePrivateAndWarmCacheWorksReadOnly(): void } } - public function testPublicDefaultRequiresAnExplicitPrivateCache(): void + public function testPublicDefaultAutomaticallyUsesPrivateCacheOutsideWebroot(): void { $project = $this->tmpPath('public-cache'); $_SERVER['DOCUMENT_ROOT'] = $project; try { - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('APP_CACHE_DIR'); - CachePath::resolve($project, 'test'); + $path = CachePath::resolve($project, 'test'); + self::assertFalse(str_starts_with(CachePath::canonical($path), CachePath::canonical($project) . '/')); + CachePath::ensureDirectory($path); + self::assertSame(0700, fileperms($path) & 0777); + (new Filesystem())->remove(dirname($path, 2)); } finally { unset($_SERVER['DOCUMENT_ROOT']); } @@ -326,7 +329,7 @@ public function testCorruptMetadataAndFailedRecompileCannotServeTheOldContainer( $container = $kernel->createContainer(); $files = $kernel->configureContainer($container->builder(), $container, new BundleRegistry()); $kernel->createRuntimeContainer($container, new BundleRegistry(), $files); - file_put_contents($project . '/var/cache/test/kernel/meta.php', 'tryUseRuntimeContainer($kernel->createContainer(), new BundleRegistry())); file_put_contents($file, 'createContainer(); $files = $kernel->configureContainer($container->builder(), $container, $registry); $kernel->createRuntimeContainer($container, $registry, $files); - $path = $project . '/var/cache/test/kernel/meta.php'; + $path = $project . '/var/cache/test/kernel/meta.json'; foreach ([[0 => 'malformed'], ['invalid' => ['nested']]] as $resources) { - $metadata = require $path; + $metadata = json_decode(file_get_contents($path), true, flags: JSON_THROW_ON_ERROR); $metadata['config_resources'] = $resources; - file_put_contents($path, 'createContainer(); self::assertFalse($kernel->tryUseRuntimeContainer($rebuilt, $registry)); $files = $kernel->configureContainer($rebuilt->builder(), $rebuilt, $registry); @@ -470,7 +473,7 @@ public function testPackageDescriptorsReuseAcrossRequestsAndInvalidateConsumedCo $nextRequest = new KernelPackageManifestCache($project, 'test', ['sympress/']); self::assertSame(['sympress/one'], $nextRequest->read()); self::assertSame($descriptors, $nextRequest->metadata()); - self::assertSame(0600, fileperms($project . '/var/cache/test/kernel/discovery-packages.php') & 0777); + self::assertSame(0600, fileperms($project . '/var/cache/test/kernel/discovery-packages.json') & 0777); file_put_contents($input, '{"name":"sympress/two"}'); touch($input, $stamp); self::assertNull((new KernelPackageManifestCache($project, 'test', ['sympress/']))->read());