diff --git a/CHANGELOG.md b/CHANGELOG.md index 808adfb..ae9847d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/dependency-injection.md b/docs/dependency-injection.md index e8d2a42..52bbf58 100644 --- a/docs/dependency-injection.md +++ b/docs/dependency-injection.md @@ -1003,12 +1003,17 @@ precedence over dotenv ENV values, then getenv. Relative values resolve under th 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. 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 diff --git a/src/Kernel/CachePath.php b/src/Kernel/CachePath.php index 201f4ee..30f70e7 100644 --- a/src/Kernel/CachePath.php +++ b/src/Kernel/CachePath.php @@ -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); } diff --git a/src/Kernel/ContainerCacheManager.php b/src/Kernel/ContainerCacheManager.php index 3e43c06..df5b738 100644 --- a/src/Kernel/ContainerCacheManager.php +++ b/src/Kernel/ContainerCacheManager.php @@ -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; } diff --git a/src/Kernel/ContainerResourceFingerprinter.php b/src/Kernel/ContainerResourceFingerprinter.php index e4eb694..f62ae05 100644 --- a/src/Kernel/ContainerResourceFingerprinter.php +++ b/src/Kernel/ContainerResourceFingerprinter.php @@ -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)); } diff --git a/tests/Fixtures/cache-hash-probe.php b/tests/Fixtures/cache-hash-probe.php new file mode 100644 index 0000000..5fcec19 --- /dev/null +++ b/tests/Fixtures/cache-hash-probe.php @@ -0,0 +1,15 @@ +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); diff --git a/tests/Kernel/CacheRequestCompatibilityTest.php b/tests/Kernel/CacheRequestCompatibilityTest.php new file mode 100644 index 0000000..bcfe779 --- /dev/null +++ b/tests/Kernel/CacheRequestCompatibilityTest.php @@ -0,0 +1,81 @@ +tmpPath('legacy-group-cache'); + $directory = $project . '/var/cache/test/kernel'; + mkdir($directory, 0775, true); + chmod($directory, 0775); + file_put_contents($directory . '/meta.php', '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, 'tryUseRuntimeContainer($kernel->createContainer(), new BundleRegistry())); + } + + private function writeConfig(string $file, string $value): void + { + file_put_contents($file, 'parameters()->set("imported.value", ' . var_export($value, true) . '); };'); + } +}