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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ 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

- 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.

## Unreleased

### Added
Expand Down
13 changes: 9 additions & 4 deletions docs/dependency-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -1003,12 +1003,17 @@ precedence over dotenv ENV values, then getenv. Relative values resolve under th
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. Symlinked cache directories and writable-by-other-user
cache directories are rejected. Keep configured cache parents trusted and outside
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.

Normal validation hashes root/package Composer metadata and config contents,
including imported files, so equal size/timestamp edits invalidate correctly.
Normal validation hashes root/package Composer metadata and consumed config contents,
including imported files, so equal size/timestamp edits invalidate correctly. Top-level
configuration is no longer hashed again in the deployment identity. Missing recorded
config digests force a rebuild. This reduces work on each real warm request while
retaining content checks across requests; a stat-only digest cache could miss same-timestamp edits.
Recorded config directory entries detect newly added/deleted config files. Debug
also hashes source contents. Production source changes require a new
`SYMPRESS_KERNEL_BUILD_ID`, a cache clear, or optional
Expand Down
5 changes: 5 additions & 0 deletions src/Kernel/CachePath.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ public static function resolve(string $project, string $environment, ?string $co
return self::privateFallback($project, $environment);
}
}
// 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) {
return self::privateFallback($project, $environment);
}
if ($configured === null && !is_file($path . '/meta.php') && !self::writableAncestor($path)) {
return self::privateFallback($project, $environment);
}
Expand Down
12 changes: 11 additions & 1 deletion src/Kernel/ContainerCacheManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,17 @@ private function useCachedRuntimeContainer(
return false;
}

if (!$this->fingerprints->configResourcesAreFresh($metadata['config_resources'] ?? null)) {
$configResources = $metadata['config_resources'] ?? null;
$configFiles = $metadata['runtime_config_files'] ?? null;
if (!is_array($configFiles) || !is_array($configResources)) {
return false;
}
foreach ($configFiles as $file) {
if (!is_string($file) || !is_string($configResources[$file] ?? null)) {
return false;
}
}
if (!$this->fingerprints->configResourcesAreFresh($configResources)) {
return false;
}

Expand Down
15 changes: 4 additions & 11 deletions src/Kernel/ContainerResourceFingerprinter.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,17 +42,10 @@ public function fingerprint(BundleRegistry $bundles, array $configFiles): string
$parts[] = $this->fileFingerprint($bundle->composerFile());
}

foreach ($configFiles as $file) {
if ($this->immutable()) {
$parts[] = $file;
continue;
}
$parts[] = sprintf(
'%s:%s',
$file,
is_file($file) ? $this->fileFingerprint($file) : 'missing',
);
}
// Consumed config contents (including imports) are validated separately by
// configResourcesAreFresh and participate in the compiled generation key.
// Keep only discovery identity here to avoid reading each config twice on hits.
array_push($parts, ...$configFiles);

return hash('sha256', implode('|', $parts));
}
Expand Down
15 changes: 15 additions & 0 deletions tests/Fixtures/cache-hash-probe.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace SymPress\Kernel\Kernel;

use function hash_file as native_hash_file;

function hash_file(string $algorithm, string $filename, bool $binary = false): string|false
{
$GLOBALS['cache_request_hashes'][$filename] = ($GLOBALS['cache_request_hashes'][$filename] ?? 0) + 1;
$GLOBALS['cache_request_bytes'] = ($GLOBALS['cache_request_bytes'] ?? 0) + filesize($filename);

return native_hash_file($algorithm, $filename, $binary);
}
21 changes: 21 additions & 0 deletions tests/Fixtures/cache-request.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace SymPress\Kernel\Kernel;

use SymPress\Kernel\Bundle\BundleRegistry;
use SymPress\Kernel\WpContext;

require __DIR__ . '/cache-hash-probe.php';
require dirname(__DIR__, 2) . '/vendor/autoload.php';
$kernel = new SiteKernel($argv[1], 'test', false, null, WpContext::new()->force(WpContext::CORE));
$container = $kernel->createContainer();
$bundles = new BundleRegistry();
$start = hrtime(true);
$hit = $kernel->tryUseRuntimeContainer($container, $bundles);
if (!$hit) {
$files = $kernel->configureContainer($container->builder(), $container, $bundles);
$kernel->createRuntimeContainer($container, $bundles, $files);
}
echo json_encode(['hit' => $hit, 'hashes' => $GLOBALS['cache_request_hashes'] ?? [], 'bytes' => $GLOBALS['cache_request_bytes'] ?? 0, 'milliseconds' => (hrtime(true) - $start) / 1e6, 'value' => $container->getParameter('imported.value')], JSON_THROW_ON_ERROR);
81 changes: 81 additions & 0 deletions tests/Kernel/CacheRequestCompatibilityTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

declare(strict_types=1);

namespace SymPress\Kernel\Tests\Kernel;

use SymPress\Kernel\Bundle\BundleRegistry;
use SymPress\Kernel\Kernel\CachePath;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Process\Process;

final class CacheRequestCompatibilityTest extends KernelTestCase
{
public function testExistingGroupWritableCacheUsesFreshPrivateStorage(): void
{
$project = $this->tmpPath('legacy-group-cache');
$directory = $project . '/var/cache/test/kernel';
mkdir($directory, 0775, true);
chmod($directory, 0775);
file_put_contents($directory . '/meta.php', '<?php throw new \\RuntimeException("Untrusted old cache was executed");');
$cache = CachePath::resolve($project, 'test');
self::assertNotSame($directory, $cache);
self::assertSame($cache, CachePath::resolve($project, 'test', $project . '/var/cache'));
$kernel = $this->kernel($project);
$container = $kernel->createContainer();
$files = $kernel->configureContainer($container->builder(), $container, new BundleRegistry());
$kernel->createRuntimeContainer($container, new BundleRegistry(), $files);
self::assertTrue($kernel->tryUseRuntimeContainer($kernel->createContainer(), new BundleRegistry()));
self::assertSame(0700, fileperms($cache) & 0777);
self::assertSame(0775, fileperms($directory) & 0777);
(new Filesystem())->remove($cache);
}

public function testSuccessiveRequestsHashConfigOnceAndDetectSameTimestampReplacement(): void
{
$project = $this->tmpPath('request-hashes');
mkdir($project . '/config', 0700, true);
$file = $project . '/config/services.php';
$this->writeConfig($file, 'first');
$run = static function () use ($project): array {
$process = new Process([PHP_BINARY, dirname(__DIR__) . '/Fixtures/cache-request.php', $project]);
$process->mustRun();
return json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR);
};
self::assertFalse($run()['hit']);
foreach (range(1, 2) as $unused) {
$request = $run();
self::assertTrue($request['hit']);
self::assertSame(1, $request['hashes'][$file]);
}
$stamp = filemtime($file);
$this->writeConfig($file . '.new', 'other');
touch($file . '.new', $stamp);
rename($file . '.new', $file);
$request = $run();
self::assertFalse($request['hit']);
self::assertSame('other', $request['value']);
}

public function testMissingRecordedConfigResourceForcesRebuild(): void
{
$project = $this->tmpPath('missing-config-digest');
mkdir($project . '/config', 0700, true);
$file = $project . '/config/services.php';
$this->writeConfig($file, 'first');
$kernel = $this->kernel($project);
$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;
unset($metadata['config_resources'][$file]);
file_put_contents($metaFile, '<?php return ' . var_export($metadata, true) . ';');
self::assertFalse($kernel->tryUseRuntimeContainer($kernel->createContainer(), new BundleRegistry()));
}

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) . '); };');
}
}
Loading