diff --git a/.github/workflows/dependency-canary.yml b/.github/workflows/dependency-canary.yml index 385ad96..8a26656 100644 --- a/.github/workflows/dependency-canary.yml +++ b/.github/workflows/dependency-canary.yml @@ -13,6 +13,6 @@ jobs: permissions: contents: read issues: write - uses: sympress/workflows/.github/workflows/dependency-canary.yml@177fa0d727b278d2103052ec77c102b4a4c492a0 + uses: sympress/workflows/.github/workflows/dependency-canary.yml@78d083a9a0176cf0252318b0c69fc64938a727d6 with: php_version: '8.5' diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index f43b763..6c2a686 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -12,10 +12,10 @@ permissions: jobs: workflows: - uses: sympress/workflows/.github/workflows/lint-workflows.yml@177fa0d727b278d2103052ec77c102b4a4c492a0 + uses: sympress/workflows/.github/workflows/lint-workflows.yml@78d083a9a0176cf0252318b0c69fc64938a727d6 qa: - uses: sympress/workflows/.github/workflows/sympress-qa.yml@177fa0d727b278d2103052ec77c102b4a4c492a0 + uses: sympress/workflows/.github/workflows/sympress-qa.yml@78d083a9a0176cf0252318b0c69fc64938a727d6 with: php_version: '8.5' secrets: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bb879f..6ab6af6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,16 +6,22 @@ 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. -## 1.1.2 — 2026-10-02 +## Unreleased -- Validate consumed configuration content once per mutable warm request while preserving same-timestamp edits, imported resources and the explicit immutable build-ID policy. -- Boot existing installations with group-writable compiled-cache directories through a fresh private generation without executing or chmod-adopting prior PHP cache artifacts. +- 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. +- Admit the stable SYMPRESS_PROJECT_DIR cache identity as a safe environment parameter without changing active release discovery. ## 1.1.3 — 2026-10-02 - Default mutable cache validation to mtime/size metadata; keep content hashing as an explicit `SYMPRESS_KERNEL_CONTENT_HASHES=1` mode. Warm container and discovery reads retain OPcache entries. - Verify upgrade from group-writable caches without executing or modifying prior metadata, discovery or container artifacts. +## 1.1.2 — 2026-10-02 + +- Validate consumed configuration content once per mutable warm request while preserving same-timestamp edits, imported resources and the explicit immutable build-ID policy. +- Boot existing installations with group-writable compiled-cache directories through a fresh private generation without executing or chmod-adopting prior PHP cache artifacts. + ### Added - Bridge `App::enableDebug()` and `App::disableDebug()` to an optional profiler service. diff --git a/docs/dependency-injection.md b/docs/dependency-injection.md index 23602e2..b79c15c 100644 --- a/docs/dependency-injection.md +++ b/docs/dependency-injection.md @@ -1000,19 +1000,25 @@ owned generations plus a one-hour grace period and never deletes unowned files. `APP_CACHE_DIR` is honored by both discovery and compilation. Server values take precedence over dotenv ENV values, then getenv. Relative values resolve under the -project. The default is `var/cache//kernel`; an unwritable cold -project or a web-exposed default uses a private per-user/project temporary cache. -An explicitly configured web-exposed cache is rejected. Existing read-only warm -caches can still be read. Existing group/world-writable cache directories use the private -fallback so older group-writable installations can boot without trusting or modifying their -existing PHP dumps. Symlinked directories are rejected; private fallback directories remain -owner-checked and never admit group/world writes. Keep configured cache parents trusted and outside -DOCUMENT_ROOT/WP_CONTENT_DIR. +project. The default is `var/cache//kernel`. Existing read-only warm +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 +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 and do not invalidate OPcache entries; publication invalidates -only files that were written. Missing recorded config signatures force a rebuild. +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. 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 diff --git a/src/DependencyInjection/EnvironmentParameterLoader.php b/src/DependencyInjection/EnvironmentParameterLoader.php index eb0df02..5e3e1a4 100644 --- a/src/DependencyInjection/EnvironmentParameterLoader.php +++ b/src/DependencyInjection/EnvironmentParameterLoader.php @@ -20,6 +20,7 @@ final class EnvironmentParameterLoader 'SYMPRESS_KERNEL_BUILD_ID', 'SYMPRESS_KERNEL_IMMUTABLE_CACHE', 'SYMPRESS_KERNEL_VALIDATE_SOURCE_RESOURCES', + 'SYMPRESS_PROJECT_DIR', 'WP_ENV', 'WP_ENVIRONMENT_TYPE', 'WORDPRESS_ENV', diff --git a/src/Discovery/KernelPackageManifestCache.php b/src/Discovery/KernelPackageManifestCache.php index 5bc0ebf..cab4dd9 100644 --- a/src/Discovery/KernelPackageManifestCache.php +++ b/src/Discovery/KernelPackageManifestCache.php @@ -43,6 +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) { return null; diff --git a/src/Kernel/CachePath.php b/src/Kernel/CachePath.php index 30f70e7..cc054cf 100644 --- a/src/Kernel/CachePath.php +++ b/src/Kernel/CachePath.php @@ -29,6 +29,9 @@ public static function resolve(string $project, string $environment, ?string $co // Existing group-writable PHP dumps may already have been replaced. Never // adopt them by chmod; start a fresh generation in the private fallback. if (!is_link($path) && is_dir($path) && (fileperms($path) & 0022) !== 0) { + if ($configured !== null) { + throw new \RuntimeException('APP_CACHE_DIR contains a group/world-writable kernel directory. Configure a durable private directory, create it as the PHP-FPM user with mode 0700, and run cache warmup as that same user.'); + } return self::privateFallback($project, $environment); } if ($configured === null && !is_file($path . '/meta.php') && !self::writableAncestor($path)) { @@ -71,12 +74,25 @@ 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()); - $root = sys_get_temp_dir() . '/sympress-kernel-' . $user; + $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.'); + } + } + $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.'); + } self::ensureDirectory($root); if (function_exists('posix_geteuid') && fileowner($root) !== posix_geteuid()) { - throw new \RuntimeException('The private temporary cache root belongs to another user.'); + throw new \RuntimeException('The private kernel cache root belongs to another user; configure APP_CACHE_DIR for the PHP-FPM identity.'); + } + if ($created) { + error_log('SymPress migrated its implicit kernel cache to ' . $root . '. Configure APP_CACHE_DIR for a shared deployment cache and warm it as the PHP-FPM user.'); } - return $root . '/' . substr(hash('sha256', $project), 0, 24) . '/' . $environment . '/kernel'; + return $root . '/' . $environment . '/kernel'; } private static function writableAncestor(string $path): bool diff --git a/src/Kernel/ContainerCacheManager.php b/src/Kernel/ContainerCacheManager.php index 355a5df..727c0cd 100644 --- a/src/Kernel/ContainerCacheManager.php +++ b/src/Kernel/ContainerCacheManager.php @@ -212,6 +212,9 @@ private function readMetadata(string $file): ?array return null; } try { + if (!$this->fingerprints->immutable() && function_exists('opcache_invalidate')) { + opcache_invalidate($file, true); + } $metadata = require $file; return is_array($metadata) ? $metadata : null; } catch (\ParseError) { diff --git a/tests/Kernel/CacheRequestCompatibilityTest.php b/tests/Kernel/CacheRequestCompatibilityTest.php index 8cce326..9ff1ff0 100644 --- a/tests/Kernel/CacheRequestCompatibilityTest.php +++ b/tests/Kernel/CacheRequestCompatibilityTest.php @@ -24,7 +24,14 @@ public function testExistingGroupWritableCacheUsesFreshPrivateStorage(): void $old = hash_file('sha256', $directory . '/meta.php'); $cache = CachePath::resolve($project, 'test'); self::assertNotSame($directory, $cache); - self::assertSame($cache, CachePath::resolve($project, 'test', $project . '/var/cache')); + self::assertStringStartsWith($project . '/var/cache-private-', $cache); + try { + CachePath::resolve($project, 'test', $project . '/var/cache'); + self::fail('Explicit unsafe cache directory was accepted.'); + } catch (\RuntimeException $exception) { + self::assertStringContainsString('APP_CACHE_DIR', $exception->getMessage()); + self::assertStringContainsString('PHP-FPM', $exception->getMessage()); + } $discovery = new KernelPackageManifestCache($project, 'test', ['sympress/']); self::assertNull($discovery->read()); $discovery->write(['sympress/kernel']); @@ -41,7 +48,7 @@ public function testExistingGroupWritableCacheUsesFreshPrivateStorage(): void (new Filesystem())->remove($cache); } - public function testSuccessiveRequestsUseMetadataWithoutHashesOrOpcacheInvalidation(): void + public function testSuccessiveRequestsUseMetadataWithoutHashesAndRefreshMutableOpcacheMetadata(): void { $project = $this->tmpPath('request-hashes'); mkdir($project . '/config', 0700, true); @@ -58,7 +65,7 @@ public function testSuccessiveRequestsUseMetadataWithoutHashesOrOpcacheInvalidat self::assertTrue($request['hit']); self::assertSame([], $request['hashes']); self::assertSame(0, $request['bytes']); - self::assertSame(0, $request['invalidations']); + self::assertSame(1, $request['invalidations']); } $stamp = filemtime($file); $this->writeConfig($file . '.new', 'other'); @@ -112,6 +119,29 @@ public function testMissingRecordedConfigResourceForcesRebuild(): void self::assertFalse($kernel->tryUseRuntimeContainer($kernel->createContainer(), new BundleRegistry())); } + public function testMutableMetadataReplacementIsVisibleWithOpcacheTimestampChecksDisabled(): void + { + $project = $this->tmpPath('mutable-opcache'); + mkdir($project, 0700, true); + $file = $project . '/meta.php'; + file_put_contents($file, ' "old"];'); + $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); +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->mustRun(); + self::assertSame([true, ['generation' => 'old'], ['generation' => 'old'], ['generation' => 'new']], json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR)); + } + private function writeConfig(string $file, string $value): void { file_put_contents($file, 'parameters()->set("imported.value", ' . var_export($value, true) . '); };'); diff --git a/tests/Kernel/RuntimeCacheCompatibilityTest.php b/tests/Kernel/RuntimeCacheCompatibilityTest.php index 8de546d..acd2e02 100644 --- a/tests/Kernel/RuntimeCacheCompatibilityTest.php +++ b/tests/Kernel/RuntimeCacheCompatibilityTest.php @@ -255,14 +255,14 @@ public function testCacheArtifactsArePrivateAndWarmCacheWorksReadOnly(): void } } - public function testPublicDefaultFallsBackAndExplicitPublicCacheIsRejected(): void + public function testPublicDefaultRequiresAnExplicitPrivateCache(): void { $project = $this->tmpPath('public-cache'); $_SERVER['DOCUMENT_ROOT'] = $project; try { - self::assertStringStartsWith(sys_get_temp_dir() . '/sympress-kernel-', CachePath::resolve($project, 'test')); $this->expectException(\RuntimeException::class); - CachePath::resolve($project, 'test', $project . '/var/cache'); + $this->expectExceptionMessage('APP_CACHE_DIR'); + CachePath::resolve($project, 'test'); } finally { unset($_SERVER['DOCUMENT_ROOT']); }