Skip to content
Merged
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: 1 addition & 1 deletion .github/workflows/dependency-canary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
4 changes: 2 additions & 2 deletions .github/workflows/qa.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 9 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 16 additions & 10 deletions docs/dependency-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<environment>/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/<environment>/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-<uid>/<environment>/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
Expand Down
1 change: 1 addition & 0 deletions src/DependencyInjection/EnvironmentParameterLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions src/Discovery/KernelPackageManifestCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
22 changes: 19 additions & 3 deletions src/Kernel/CachePath.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/Kernel/ContainerCacheManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
36 changes: 33 additions & 3 deletions tests/Kernel/CacheRequestCompatibilityTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand All @@ -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);
Expand All @@ -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');
Expand Down Expand Up @@ -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, '<?php return ["generation" => "old"];');
$code = <<<'PHP'
require $argv[1];
$file = $argv[2] . '/meta.php';
$primed = opcache_compile_file($file);
$old = require $file;
file_put_contents($file . '.new', '<?php return ["generation" => "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, '<?php return static function (\\Symfony\\Component\\DependencyInjection\\Loader\\Configurator\\ContainerConfigurator $c): void { $c->parameters()->set("imported.value", ' . var_export($value, true) . '); };');
Expand Down
6 changes: 3 additions & 3 deletions tests/Kernel/RuntimeCacheCompatibilityTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
}
Expand Down
Loading