From 989e4de60370a5c7938c5794b0660ba34cde5fa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20Sch=C3=A4ffner?= Date: Sat, 3 Oct 2026 18:33:25 +0200 Subject: [PATCH 1/2] Fix runtime inspection and maintenance of private kernel caches --- .github/workflows/dependency-canary.yml | 2 +- .github/workflows/qa.yml | 4 +- CHANGELOG.md | 9 +- composer.json | 3 +- .../0015-kernel-operations-and-diagnostics.md | 6 +- docs/kernel-integration.md | 23 +- docs/maintainers/parity.md | 2 +- docs/releases/1.2.2.md | 36 +++ src/Console/Doctor.php | 27 +- src/Kernel/CacheLocation.php | 145 +++++++++ src/Kernel/KernelPaths.php | 67 +++- src/Step/Builtin/KernelCacheStep.php | 5 +- tests/Contract/ArchitectureTest.php | 2 +- tests/Integration/KernelCacheLocationTest.php | 291 ++++++++++++++++++ 14 files changed, 593 insertions(+), 29 deletions(-) create mode 100644 docs/releases/1.2.2.md create mode 100644 src/Kernel/CacheLocation.php create mode 100644 tests/Integration/KernelCacheLocationTest.php diff --git a/.github/workflows/dependency-canary.yml b/.github/workflows/dependency-canary.yml index 324ea7e..982e4cd 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@78d083a9a0176cf0252318b0c69fc64938a727d6 + uses: sympress/workflows/.github/workflows/dependency-canary.yml@cd809f3986df3ea617d2d26bdf21ab69cae19c1e with: php_version: '8.5' diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 4773b60..f5dc89d 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -20,8 +20,8 @@ jobs: - name: Check documentation and public references run: python3 tools/check-docs.py workflows: - uses: sympress/workflows/.github/workflows/lint-workflows.yml@78d083a9a0176cf0252318b0c69fc64938a727d6 + uses: sympress/workflows/.github/workflows/lint-workflows.yml@cd809f3986df3ea617d2d26bdf21ab69cae19c1e qa: - uses: sympress/workflows/.github/workflows/sympress-qa.yml@78d083a9a0176cf0252318b0c69fc64938a727d6 + uses: sympress/workflows/.github/workflows/sympress-qa.yml@cd809f3986df3ea617d2d26bdf21ab69cae19c1e with: php_version: '8.5' diff --git a/CHANGELOG.md b/CHANGELOG.md index d55ab0a..b92f780 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ # Changelog -## Unreleased +## 1.2.2 — 2026-10-03 + +- Align read-only Doctor and explicit cache maintenance with Kernel 1.1.4's selected private fallback, configured cache/build roots and discovery metadata. +- Fail diagnostics for unsafe explicit cache directories; report implicit fallback selection without creating directories or executing cache metadata. +- Support explicitly configured external cache/build roots with mode 0700 and matching ownership; reject symlinks, parent traversal and unsafe permissions before any deletion. +- Require Kernel 1.1.4 or newer when the optional kernel is installed, and pin shared QA workflows to the released 1.2.2 source. + +## 1.2.1 — 2026-10-03 - Remove persisted process-value fingerprints; evaluate compiled interpolation templates on every warm request without reparsing Dotenv, including rotated, absent and newly supplied values. - Add side-effect-free `EnvironmentInspection` for requested raw values, sharing Doctor's configured cache/dump/file precedence and rejecting executable PHP or shell substitutions. diff --git a/composer.json b/composer.json index 96394bb..7b45983 100644 --- a/composer.json +++ b/composer.json @@ -55,10 +55,11 @@ }, "require-dev": { "phpunit/phpunit": "^13.0", - "sympress/kernel": "^1.1.1", + "sympress/kernel": "^1.1.4", "sympress/qa": "^0.1.2" }, "conflict": { + "sympress/kernel": "<1.1.4", "symfony/event-dispatcher-contracts": "<3.4.2" }, "autoload": { diff --git a/docs/adr/0015-kernel-operations-and-diagnostics.md b/docs/adr/0015-kernel-operations-and-diagnostics.md index 0ae113d..7c1d92d 100644 --- a/docs/adr/0015-kernel-operations-and-diagnostics.md +++ b/docs/adr/0015-kernel-operations-and-diagnostics.md @@ -6,7 +6,9 @@ Status: implementation decision, 2026-09-29. Implements ADR 0003 and native acce `kernel-cache` is an explicitly selected maintenance step. It resolves the canonical environment through the same WordPress environment mapping used by generated configuration. Default paths are `var/cache/{environment}/kernel`; APP_CACHE_DIR and APP_BUILD_DIR overrides resolve relative to the project or retain their absolute directory. The actual SiteKernel path API is compared in integration tests. If an active App kernel was explicitly constructed for a different environment, cache operations fail rather than selecting another environment silently. -All targets are checked before deletion. Cache/build directories are cleared without following child symlinks; an ancestor or target resolving outside the project fails before any deletion. A separate default `discovery-packages.php` is removed when cache overrides leave that metadata elsewhere. Other environments and sibling asset caches remain intact. This does not change the twelve upstream default steps or run a kernel inside Composer. +Runtime 1.2.2 uses a pure counterpart of Kernel 1.1.4's location policy, because calling Kernel's resolver can create and log a private fallback. Its implicit selection is `var/cache-private-{effective-user}/{environment}/kernel` when the normal location is public, group/world writable or unavailable. Tests compare selected paths and real compiled warmup with the installed Kernel. Doctor reports fallback selection without creating it and fails unsafe explicit roots. + +All targets are checked before deletion. Cache/build directories are cleared without following child symlinks. In-project targets retain the generic project boundary. Explicit external cache/build roots have a separate boundary: the root must already exist with mode 0700, its owner must match the current PHP identity, and root/ancestor symlinks, parent traversal, foreign descendant owners and group/world writable directories fail. A safe default `discovery-packages.php` is removed when overrides leave legacy metadata there; an unsafe implicit generation that caused fallback is never adopted or deleted. Other environments and sibling asset caches remain intact. This does not change the twelve upstream default steps or run a kernel inside Composer. Build IDs remain unchanged unless `kernel-build-id` is configured or `--generate-build-id` is used with explicit `kernel-cache` selection. IDs allow 1-128 letters, digits, dots, underscores and hyphens. They are stored atomically in a private `var/runtime/{environment}/kernel-build-id.json`. Generated configuration exposes the ID to the kernel's constant-based reader before boot. Precedence is an existing constant, environment value, configured fallback, persisted ID. Invalid persisted content fails without echoing its value. @@ -28,4 +30,4 @@ Fresh environment values, runtime caches and build dumps all reach `%env()%` thr doctor/check perform read-only checks of entry paths, content directories, symlink boundaries, required database environment names, database status, kernel cache/build paths, boot ownership and permissions. They do not execute setup autoload files, steps or PHP providers. Database status flags are labeled as provided when used. Configuration-disabled or unavailable checks remain unknown; missing kernel support is not-applicable. Exit codes are 0 for passing applicable checks, 1 for failures and 2 for unknown results without failures. JSON and text output contain names and fixed diagnostics, not credential values; configuration/inspection exceptions are redacted. -Readonly production permissions are accepted when the relevant artifact is readable and a valid environment dump is available. A readable directory check is not proof of a complete warm kernel; the separate compiled-kernel smoke provides that evidence. Fresh DDEV consumer/admin/WP-CLI acceptance and Windows execution remain final delivery gates. +Readonly production permissions are accepted when the relevant artifact is readable and a valid environment dump is available. External cache roots retain mode 0700. Doctor observes the calling UID and filesystem namespace; it cannot prove the PHP-FPM identity or a different service namespace. A readable directory check is not proof of a complete warm kernel; the separate compiled-kernel smoke provides that evidence. Fresh DDEV consumer/admin/WP-CLI acceptance and Windows execution remain final delivery gates. diff --git a/docs/kernel-integration.md b/docs/kernel-integration.md index 78248e5..32c1f1d 100644 --- a/docs/kernel-integration.md +++ b/docs/kernel-integration.md @@ -17,7 +17,28 @@ Composer exposes `sympress-runtime:doctor`, `sympress-runtime:check`, `sympress- Doctor exits with 0 when applicable checks pass, 1 on a failure, or 2 when a check remains unknown. `--json` includes each check's ID, status and redacted detail. Database-disabled status is unknown; it is never reported as a successful connection. A missing optional kernel is not-applicable. No setup autoload file or custom step executes during diagnosis. -`kernel-cache` preserves other environments and asset directories. It honors APP_CACHE_DIR and APP_BUILD_DIR and removes separately located discovery metadata. Unsafe or escaping symlink paths fail before any clearing. Align explicit SiteKernel environment overrides before running maintenance. +`kernel-cache` preserves other environments and asset directories. It honors APP_CACHE_DIR and APP_BUILD_DIR and clears discovery metadata in the selected cache, including Kernel 1.1.4's private fallback. A safe legacy default discovery file is also retired after an override. The unsafe old generation that triggered fallback stays untouched. Align explicit SiteKernel environment overrides before running maintenance. + +Explicit cache/build roots outside the project must already exist, belong to the +user running PHP and have mode 0700. Runtime rejects root or ancestor symlinks, +parent traversal, foreign directory owners and group/world writable directories +before deleting any selected target. External ancestors must belong to that user +or the system root user; shared writable ancestors require the sticky bit, as on +the system temporary directory. It removes only the selected environment's +`kernel` directories, preserving sibling caches and other environments. These +rules apply only to kernel maintenance; generated project artifacts retain their +project boundary. + +Doctor computes the same cache location without booting Kernel, creating a +fallback or changing permissions. A missing selected directory remains unknown; +an unsafe explicit root fails. `kernel.cache-migration` warns when Kernel would +select its private per-user fallback. Warmup must run as the PHP-FPM identity. +The guard also treats the known WordPress core/content paths and conventional +`public` directory as public without booting WordPress. Supply `doctor --webroot` +for a different server document root; diagnostics cannot discover web server +configuration from PHP files. +Doctor observes the calling identity; a CLI check cannot prove the web process's +UID or its filesystem namespace. See the [1.2.2 upgrade notes](releases/1.2.2.md). This is an explicit command-only step: Composer update does not automatically run it. A deployment requiring a deliberate clear or new build ID must invoke it and warm the selected environment before traffic switches. diff --git a/docs/maintainers/parity.md b/docs/maintainers/parity.md index 1648243..03b39f3 100644 --- a/docs/maintainers/parity.md +++ b/docs/maintainers/parity.md @@ -266,7 +266,7 @@ Native default order is the twelve-step sequence in the work order. Actual upstr | --- | --- | --- | --- | --- | --- | --- | | doctor/check | W; starter/bin/console | Check paths/env completeness/write permissions/symlinks/DB/kernel cache; JSON or text results with secrets redacted; explicit unknown/unavailable vs pass. | Console/DoctorCommand | `PAR-SYM-001` | verified | Parity | | validate | W; kernel schema style | Draft 2020-12 root/extra/extension schemas, validation every run, standalone command; no writes or execution of PHP providers during static validation. | Console/ValidateCommand | `PAR-SYM-002` | verified | D01 | -| Kernel cache | W; kernel KernelConfigurationResolver | Canonical env default var/cache/env/kernel, honor APP_CACHE_DIR/build overrides and discovery file; do not follow external symlinks; explicit ID generation. | Steps/KernelCacheStep | `PAR-SYM-003` | verified | ADR 0003 | +| Kernel cache | W; kernel KernelConfigurationResolver | Canonical env and Kernel 1.1.4 private fallback; honor private APP_CACHE_DIR/build overrides including explicit external roots; discovery follows selected cache, no symlinks/foreign owners/traversal; explicit ID generation. | Steps/KernelCacheStep; KernelCacheLocationTest | `PAR-SYM-003` | verified | ADR 0003; ADR 0015 | | Boot ownership | W; consumers app-starter.php; kernel App | Installed boot-providing package suppresses generated boot; opt-in boot when absent; detect known existing entry and metadata; ambiguous ownership diagnosed; exactly one boot in copy and symlink fixtures. | Steps/KernelBootStep | `PAR-SYM-004` | verified | Parity | | Kernel console integration | W; kernel Console and bundle discovery | When installed, expose doctor/validate/dump-env via wp console using optional bundle and native command services; no hard kernel dependency for plain WordPress. | Bridge/Kernel/RuntimeBundle | `PAR-SYM-005` | verified | Parity | | Env container visibility | W; kernel EnvConfig/EnvironmentParameterLoader | Fresh files/cache/dump all hydrate safe ENV/SERVER before kernel boot; %env()% works; build ID also bridged to constant/getenv consumer. | Bridge/Kernel/Environment | `PAR-SYM-006` | verified | D22 | diff --git a/docs/releases/1.2.2.md b/docs/releases/1.2.2.md new file mode 100644 index 0000000..5d8500c --- /dev/null +++ b/docs/releases/1.2.2.md @@ -0,0 +1,36 @@ +# Runtime 1.2.2 upgrade + +Runtime's Doctor and `kernel-cache` now select the same generation as Kernel +1.1.4, including its durable private per-user fallback. When the optional Kernel +is installed, Composer requires 1.1.4 or newer. Plain WordPress installations +remain supported without Kernel. + +An unsafe explicit `APP_CACHE_DIR` fails instead of producing a passing diagnostic +or silently selecting another directory. Implicit fallback selection appears as +`kernel.cache-migration`; Doctor does not create or migrate anything. A missing +selected cache remains unknown until it is warmed. Previously unsafe implicit +generations remain untouched and must not be reused. + +For an external deployment cache, provision an absolute `APP_CACHE_DIR` and +optional `APP_BUILD_DIR` outside the webroot. Each root must already exist with +mode 0700 and belong to the PHP-FPM user. Run diagnostics, explicit maintenance +and warmup as that same identity. Symlinks in roots or ancestors, parent traversal, +foreign directory owners and group/world writable directories are rejected before +any deletion. External ancestors must be protected, belong to the calling user or +the system root user, and have the sticky bit if shared writable. Only the selected +environment's `kernel` directory is cleared; +other environments and sibling asset caches are preserved. + +Standalone checks also reject known WordPress core/content and conventional +`public` cache locations without loading WordPress. Declare a custom server root +with `doctor --webroot`; Runtime does not inspect the web server configuration. + +Doctor checks the calling identity and filesystem namespace. Its readable cache +check does not prove complete warmup or access from a different PHP-FPM UID or +service namespace. Validate the deployed web process separately before traffic +switches. Generic Runtime artifact writes remain confined to the release project. + +Validation includes a real Kernel 1.1.4 compiled container, safe external cache and +build roots, fallback warning and read-only diagnostics, unsafe-root failure before +deletion, and preservation of other environments and assets. Hosted release and +consumer acceptance are separate checks. diff --git a/src/Console/Doctor.php b/src/Console/Doctor.php index a7e379e..0282ff0 100644 --- a/src/Console/Doctor.php +++ b/src/Console/Doctor.php @@ -64,7 +64,7 @@ public function inspect(bool $production = false, bool $databaseHealth = false, throw new RuntimeException('Invalid compatibility profile.'); } [$env] = EnvironmentInspection::loadReader($this->config, $this->paths); - $kernel = new KernelPaths($env, $this->paths); + $kernel = new KernelPaths($env, $this->paths, $webroot); $canonical = $kernel->environment(); if ($production || in_array($canonical, ['staging', 'production'], true)) { $home = $env->rawValue('WP_HOME'); @@ -107,14 +107,23 @@ public function inspect(bool $production = false, bool $databaseHealth = false, if (!$hasKernel) { $record('kernel.cache', 'not-applicable', 'sympress/kernel is not installed.'); } - if ($hasKernel) { - foreach (['cache' => $kernel->cache(), 'build' => $kernel->build()] as $id => $path) { - try { - $boundary->assertWritablePath($path); - $record('kernel.' . $id, is_dir($path) && is_readable($path) ? 'pass' : 'unknown', is_dir($path) ? 'Kernel directory is readable.' : 'Kernel directory has not been created.'); - } catch (Throwable) { - $record('kernel.' . $id, 'fail', 'Kernel directory is unsafe for runtime cache operations.'); + $kernelDirectories = []; + foreach (['cache', 'build'] as $id) { + try { + $path = $id === 'cache' ? $kernel->cache() : $kernel->build(); + $kernel->assertSafe($path, $id); + $kernelDirectories['kernel-' . $id] = $path; + if ($hasKernel) { + $record('kernel.' . $id, is_dir($path) && is_readable($path) ? 'pass' : 'unknown', is_dir($path) ? 'Selected kernel directory is readable.' : 'Selected kernel directory has not been created; run cache warmup as the PHP-FPM user.'); } + } catch (Throwable) { + $record('kernel.' . $id, 'fail', 'Unsafe kernel directory. Configure APP_CACHE_DIR/APP_BUILD_DIR outside the webroot without symlinks or group/world write access. External roots require mode 0700. Create and warm them as the PHP-FPM user.'); + $record('permissions.kernel-' . $id, 'fail', 'Unsafe kernel directory cannot be used for cache operations.'); + } + } + if ($hasKernel) { + if (isset($kernelDirectories['kernel-cache']) && $kernel->usesFallback()) { + $record('kernel.cache-migration', 'warning', 'Kernel selected its private per-user fallback because the implicit cache is unsafe, public or unavailable. Configure a durable APP_CACHE_DIR shared by CLI and PHP-FPM and warm it as the PHP-FPM user. Diagnostics do not create or migrate directories.'); } try { $owners = (new BootOwnership($packages, new MuPluginList($packages, $this->paths), $this->config, $this->paths))->providers(); @@ -124,7 +133,7 @@ public function inspect(bool $production = false, bool $databaseHealth = false, $record('kernel.boot', 'fail', 'Kernel boot ownership is ambiguous.'); } } - foreach (['configuration' => $this->paths->root('wp-config.php'), 'content' => $this->paths->wpContent(), 'kernel-cache' => $kernel->cache(), 'kernel-build' => $kernel->build()] as $id => $path) { + foreach (['configuration' => $this->paths->root('wp-config.php'), 'content' => $this->paths->wpContent(), ...$kernelDirectories] as $id => $path) { $ancestor = $path; while (!file_exists($ancestor) && dirname($ancestor) !== $ancestor) { $ancestor = dirname($ancestor); diff --git a/src/Kernel/CacheLocation.php b/src/Kernel/CacheLocation.php new file mode 100644 index 0000000..1c08663 --- /dev/null +++ b/src/Kernel/CacheLocation.php @@ -0,0 +1,145 @@ + $publicRoots */ + public static function resolve(string $project, string $environment, ?string $configured, array $publicRoots = []): string + { + $base = $configured === null ? $project . '/var/cache' : Path::makeAbsolute($configured, $project); + $path = rtrim($base, '/') . '/' . $environment . '/kernel'; + $public = self::isPublic($path, $publicRoots); + $unsafe = !is_link($path) && is_dir($path) && (fileperms($path) & 0022) !== 0; + if ($configured !== null) { + if ($public || $unsafe) { + throw new RuntimeException('APP_CACHE_DIR must be outside the webroot and not group/world writable. Create a private directory as the PHP-FPM user and warm it as that same user.'); + } + + return $path; + } + if (!$public && !$unsafe && (is_file($path . '/meta.php') || self::writableAncestor($path))) { + return $path; + } + $root = self::fallbackRoot($project); + if (self::isPublic($root, $publicRoots) || (!is_dir($root) && !self::writableAncestor($root))) { + throw new RuntimeException('No private kernel cache is available. Configure APP_CACHE_DIR outside the webroot, create it with mode 0700 as the PHP-FPM user, and run cache warmup as that user.'); + } + self::assertDirectory($root); + + return $root . '/' . $environment . '/kernel'; + } + + public static function fallbackRoot(string $project): string + { + $user = function_exists('posix_geteuid') ? (string) posix_geteuid() : hash('sha256', get_current_user()); + + return $project . '/var/cache-private-' . $user; + } + + /** + * Validate the configured root and existing descendants without creating them. + * + * @param list $publicRoots + */ + public static function assertTarget(string $path, string $root, bool $external, array $publicRoots = []): void + { + if (!Path::isBasePath($root, $path) || self::isPublic($path, $publicRoots)) { + throw new RuntimeException('Kernel cache target must remain inside its private cache root and outside the webroot.'); + } + $ancestor = $path; + while (true) { + self::assertDirectory($ancestor); + if ($ancestor === $root) { + break; + } + $ancestor = dirname($ancestor); + } + if (!$external) { + return; + } + if (!is_dir($root) || (fileperms($root) & 0777) !== 0700) { + throw new RuntimeException('An external APP_CACHE_DIR or APP_BUILD_DIR root must exist with mode 0700 and belong to the PHP-FPM user. Warm the cache as that same user.'); + } + // Even an ancestor above the explicitly trusted root must not redirect it. + for ($ancestor = dirname($root); dirname($ancestor) !== $ancestor; $ancestor = dirname($ancestor)) { + if (is_link($ancestor)) { + throw new RuntimeException('Refusing a symlink ancestor of an external kernel cache root.'); + } + $mode = fileperms($ancestor); + if ($mode === false || (($mode & 0022) !== 0 && ($mode & 01000) === 0)) { + throw new RuntimeException('External kernel cache roots require protected ancestors; shared writable ancestors must have the sticky bit.'); + } + if (function_exists('posix_geteuid') && !in_array(fileowner($ancestor), [0, posix_geteuid()], true)) { + throw new RuntimeException('An external kernel cache ancestor belongs to another user. Configure a protected root for the PHP-FPM identity.'); + } + } + } + + private static function assertDirectory(string $directory): void + { + if (is_link($directory)) { + throw new RuntimeException('Refusing a symlinked kernel cache directory.'); + } + if (!file_exists($directory)) { + return; + } + if (!is_dir($directory) || (fileperms($directory) & 0022) !== 0) { + throw new RuntimeException('Kernel cache directories must be private and not group/world writable; create them as the PHP-FPM user and warm the cache as that user.'); + } + if (function_exists('posix_geteuid') && fileowner($directory) !== posix_geteuid()) { + throw new RuntimeException('The kernel cache directory belongs to another user. Configure APP_CACHE_DIR for the PHP-FPM identity and warm it as that user.'); + } + } + + /** @param list $publicRoots */ + private static function isPublic(string $path, array $publicRoots = []): bool + { + foreach ([$_SERVER['DOCUMENT_ROOT'] ?? null, defined('WP_CONTENT_DIR') ? constant('WP_CONTENT_DIR') : null, ...$publicRoots] as $root) { + if (is_string($root) && $root !== '' && Path::isBasePath(self::canonical($root), self::canonical($path))) { + return true; + } + } + + return false; + } + + private static function canonical(string $path): string + { + $ancestor = Path::canonicalize($path); + $suffix = ''; + $real = realpath($ancestor); + while ($real === false) { + $parent = dirname($ancestor); + if ($parent === $ancestor) { + return $path; + } + $suffix = '/' . basename($ancestor) . $suffix; + $ancestor = $parent; + $real = realpath($ancestor); + } + + return rtrim($real, '/') . $suffix; + } + + private static function writableAncestor(string $path): bool + { + while (!file_exists($path)) { + $parent = dirname($path); + if ($parent === $path) { + return false; + } + $path = $parent; + } + + return is_dir($path) && is_writable($path); + } +} diff --git a/src/Kernel/KernelPaths.php b/src/Kernel/KernelPaths.php index 599afe0..bf0d822 100644 --- a/src/Kernel/KernelPaths.php +++ b/src/Kernel/KernelPaths.php @@ -9,12 +9,13 @@ use SymPress\Runtime\Env\EnvReader; use SymPress\Runtime\Env\EnvironmentName; use SymPress\Runtime\Filesystem\Paths; +use SymPress\Runtime\Filesystem\ProjectBoundary; use Symfony\Component\Filesystem\Path; /** @internal */ final readonly class KernelPaths { - public function __construct(private EnvReader $env, private Paths $paths) + public function __construct(private EnvReader $env, private Paths $paths, private ?string $webroot = null) { } @@ -30,7 +31,7 @@ public function environment(): string public function cache(): string { - return $this->directory('APP_CACHE_DIR') ?? $this->paths->root('var/cache/' . $this->environment() . '/kernel'); + return CacheLocation::resolve($this->paths->root(), $this->environment(), $this->configured('APP_CACHE_DIR'), $this->publicRoots()); } public function build(): string @@ -40,7 +41,26 @@ public function build(): string public function discovery(): string { - return $this->paths->root('var/cache/' . $this->environment() . '/kernel/discovery-packages.php'); + return $this->cache() . '/discovery-packages.php'; + } + + public function usesFallback(): bool + { + return $this->cache() === CacheLocation::fallbackRoot($this->paths->root()) . '/' . $this->environment() . '/kernel'; + } + + public function assertSafe(string $path, string $kind): void + { + $configured = $this->configured($kind === 'build' ? 'APP_BUILD_DIR' : 'APP_CACHE_DIR'); + if ($kind === 'build' && $configured === null) { + $configured = $this->configured('APP_CACHE_DIR'); + } + $root = $configured === null ? ($this->usesFallback() ? CacheLocation::fallbackRoot($this->paths->root()) : $this->paths->root('var/cache')) : Path::makeAbsolute($configured, $this->paths->root()); + $external = !Path::isBasePath($this->paths->root(), $root); + if (!$external) { + (new ProjectBoundary($this->paths))->assertWritablePath($path); + } + CacheLocation::assertTarget($path, $root, $external, $this->publicRoots()); } public function buildIdFile(): string @@ -52,8 +72,13 @@ public function buildIdFile(): string public function clearTargets(): array { $targets = array_values(array_unique([$this->cache(), $this->build()])); - if (!array_any($targets, fn (string $target): bool => Path::isBasePath($target, $this->discovery()))) { - $targets[] = $this->discovery(); + // Retire only the known legacy discovery artifact; never adopt or delete + // the unsafe implicit generation that caused Kernel's private fallback. + $legacy = $this->paths->root('var/cache/' . $this->environment() . '/kernel/discovery-packages.php'); + if (!$this->usesFallback() && is_file($legacy) && !array_any($targets, static fn (string $target): bool => Path::isBasePath($target, $legacy))) { + (new ProjectBoundary($this->paths))->assertWritablePath($legacy); + CacheLocation::assertTarget(dirname($legacy), $this->paths->root('var/cache'), false, $this->publicRoots()); + $targets[] = $legacy; } return $targets; @@ -61,8 +86,36 @@ public function clearTargets(): array private function directory(string $name): ?string { - $value = trim($this->env->rawValue($name) ?? ''); + $value = $this->configured($name); + + return $value === null ? null : Path::makeAbsolute($value, $this->paths->root()) . '/' . $this->environment() . '/kernel'; + } + + /** @return list */ + private function publicRoots(): array + { + $roots = [$this->paths->wpContent(), $this->paths->wp(), $this->paths->root('public')]; + if ($this->paths->wpParent() !== $this->paths->root()) { + $roots[] = $this->paths->wpParent(); + } + if ($this->webroot !== null) { + $roots[] = Path::makeAbsolute($this->webroot, $this->paths->root()); + } + + return $roots; + } + + private function configured(string $name): ?string + { + // Match KernelConfigurationResolver for already populated process data. + // phpcs:disable SlevomatCodingStandard.Variables.DisallowSuperGlobalVariable -- Keep the actual Kernel's configured-root precedence. + $value = $_SERVER[$name] ?? $_ENV[$name] ?? $this->env->rawValue($name); + // phpcs:enable SlevomatCodingStandard.Variables.DisallowSuperGlobalVariable + $value = is_scalar($value) || $value instanceof \Stringable ? trim((string) $value) : ''; + if (preg_match('~(?:^|[/\\\\])\\.\\.(?:[/\\\\]|$)~', $value) === 1) { + throw new RuntimeException('Kernel cache roots must not contain parent traversal segments.'); + } - return $value === '' ? null : Path::makeAbsolute($value, $this->paths->root()) . '/' . $this->environment() . '/kernel'; + return $value === '' ? null : $value; } } diff --git a/src/Step/Builtin/KernelCacheStep.php b/src/Step/Builtin/KernelCacheStep.php index f063580..75c4caa 100644 --- a/src/Step/Builtin/KernelCacheStep.php +++ b/src/Step/Builtin/KernelCacheStep.php @@ -34,9 +34,8 @@ public function allowed(Config $config, Paths $paths): bool public function run(Config $config, Paths $paths): int { $targets = $this->kernel->clearTargets(); - foreach ($targets as $target) { - $this->boundary->assertWritablePath($target); - } + $this->kernel->assertSafe($this->kernel->cache(), 'cache'); + $this->kernel->assertSafe($this->kernel->build(), 'build'); $id = $config['kernel-build-id']->unwrap(); if (is_string($id)) { $this->boundary->assertWritablePath($this->kernel->buildIdFile()); diff --git a/tests/Contract/ArchitectureTest.php b/tests/Contract/ArchitectureTest.php index dd395de..a56e9f5 100644 --- a/tests/Contract/ArchitectureTest.php +++ b/tests/Contract/ArchitectureTest.php @@ -29,7 +29,7 @@ public function testInstalledGraphExcludesWpStarterAndUsesTheRequiredStack(): vo self::assertSame('^8.1', $constraint); } self::assertArrayHasKey('sympress/qa', $manifest['require-dev']); - self::assertStringContainsString('sympress/workflows/.github/workflows/sympress-qa.yml@78d083a9a0176cf0252318b0c69fc64938a727d6', (string) file_get_contents($root . '/.github/workflows/qa.yml')); + self::assertStringContainsString('sympress/workflows/.github/workflows/sympress-qa.yml@cd809f3986df3ea617d2d26bdf21ab69cae19c1e', (string) file_get_contents($root . '/.github/workflows/qa.yml')); $workflow = (string) file_get_contents($root . '/.github/workflows/differential.yml'); self::assertStringContainsString('--fail-on-skipped', $workflow); self::assertStringContainsString('tools/parity-evidence.py', $workflow); diff --git a/tests/Integration/KernelCacheLocationTest.php b/tests/Integration/KernelCacheLocationTest.php new file mode 100644 index 0000000..c008ee5 --- /dev/null +++ b/tests/Integration/KernelCacheLocationTest.php @@ -0,0 +1,291 @@ +external !== null) { + (new Filesystem())->remove($this->external); + } + parent::tearDown(); + } + + private function fixture(string $extra = ''): KernelPaths + { + $this->write('vendor/autoload.php', 'write('vendor/composer/installed.json', '{"packages":[{"name":"sympress/kernel","version":"1.1.4","install-path":"../sympress/kernel"}]}'); + $this->write('composer.json', '{"extra":{"sympress-runtime":{"db-check":false,"require-wp":false}}}'); + $this->write('wordpress/wp-load.php', 'write('wp-content/mu-plugins/sympress-runtime-kernel.php', 'write('.env', "WP_ENVIRONMENT_TYPE=production\nWP_HOME=https://fixture.test\nDB_NAME=fixture\nDB_USER=fixture\nDB_PASSWORD=private-fixture\nDB_HOST=localhost\n" . $extra); + $reader = new EnvReader(); + $reader->loadFile($this->root . '/.env'); + + return new KernelPaths($reader, new Paths($this->root)); + } + + /** @return array{checks: list, exit: int} */ + private function diagnose(?string $webroot = null): array + { + $command = [PHP_BINARY, dirname(__DIR__, 2) . '/bin/runtime', '-n', 'doctor', '--json']; + if ($webroot !== null) { + array_push($command, '--webroot', $webroot); + } + $process = new Process($command, $this->root, ['COMPOSER' => false, 'COMPOSER_VENDOR_DIR' => false, 'APP_CACHE_DIR' => false, 'APP_BUILD_DIR' => false]); + $process->run(); + self::assertJson($process->getOutput(), $process->getErrorOutput()); + + return json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + } + + private function clear(KernelPaths $kernel): void + { + $paths = new Paths($this->root); + $boundary = new ProjectBoundary($paths); + $step = new KernelCacheStep($kernel, $boundary, new ArtifactWriter($boundary), new Io(new ArrayInput([]), new BufferedOutput())); + self::assertSame(StepInterface::SUCCESS, $step->run(new Config([], new Validator($paths)), $paths)); + } + + private function warm(KernelPaths $runtime): SiteKernel + { + ini_set('error_log', $this->root . '/kernel-warnings.log'); + $kernel = new SiteKernel($this->root, 'production'); + self::assertSame($kernel->getCacheDir(), $runtime->cache()); + self::assertSame($kernel->getBuildDir(), $runtime->build()); + $container = $kernel->createContainer(); + $registry = new BundleRegistry(); + $files = $kernel->configureContainer($container->builder(), $container, $registry); + $container->builder()->register('fixture.warmed', \ArrayObject::class)->setPublic(true); + $kernel->createRuntimeContainer($container, $registry, $files); + self::assertInstanceOf(\ArrayObject::class, $container->get('fixture.warmed')); + self::assertFileExists($kernel->getCacheDir() . '/meta.php'); + + return $kernel; + } + + public function testImplicitMigrationIsReadOnlyInDoctorAndClearsTheActuallyWarmedKernel(): void + { + $runtime = $this->fixture(); + $this->write('var/cache/production/kernel/meta.php', 'root . '/var/cache/production/kernel', 0770); + $selected = CacheLocation::fallbackRoot($this->root) . '/production/kernel'; + self::assertSame($selected, $runtime->cache()); + self::assertSame($selected . '/discovery-packages.php', $runtime->discovery()); + $report = $this->diagnose(); + $checks = array_column($report['checks'], 'status', 'id'); + self::assertSame('unknown', $checks['kernel.cache']); + self::assertSame('warning', $checks['kernel.cache-migration']); + self::assertDirectoryDoesNotExist(CacheLocation::fallbackRoot($this->root)); + self::assertFileDoesNotExist($this->root . '/var/cache/production/kernel/executed'); + $kernel = $this->warm($runtime); + self::assertSame(1, substr_count((string) file_get_contents($this->root . '/kernel-warnings.log'), 'SymPress migrated')); + $this->write('var/cache/production/assets/keep.css', 'asset'); + $this->write('var/cache/staging/kernel/keep.php', 'other environment'); + self::assertSame('pass', array_column($this->diagnose()['checks'], 'status', 'id')['kernel.cache']); + $this->clear($runtime); + self::assertDirectoryDoesNotExist($kernel->getCacheDir()); + self::assertFileExists($this->root . '/var/cache/production/kernel/meta.php'); + self::assertFileExists($this->root . '/var/cache/production/assets/keep.css'); + self::assertFileExists($this->root . '/var/cache/staging/kernel/keep.php'); + } + + public function testExplicitUnsafeCacheFailsDoctorAndClearBeforeAnyDeletion(): void + { + $runtime = $this->fixture("APP_CACHE_DIR=custom/cache\nAPP_BUILD_DIR=custom/build\n"); + $this->write('custom/cache/production/kernel/meta.php', 'unsafe'); + $this->write('custom/build/production/kernel/keep.php', 'keep'); + chmod($this->root . '/custom/cache/production/kernel', 0770); + $report = $this->diagnose(); + self::assertSame(1, $report['exit']); + $checks = array_column($report['checks'], 'status', 'id'); + self::assertSame('fail', $checks['kernel.cache']); + self::assertSame('fail', $checks['permissions.kernel-cache']); + self::assertStringContainsString('PHP-FPM', json_encode($report, JSON_THROW_ON_ERROR)); + try { + (new SiteKernel($this->root, 'production'))->getCacheDir(); + self::fail('Actual Kernel must reject the explicit unsafe cache.'); + } catch (RuntimeException) { + self::assertFileExists($this->root . '/custom/cache/production/kernel/meta.php'); + } + try { + $this->clear($runtime); + self::fail('Maintenance must reject the same unsafe cache.'); + } catch (RuntimeException) { + self::assertFileExists($this->root . '/custom/build/production/kernel/keep.php'); + } + } + + public function testPrivateAbsoluteCacheAndBuildOutsideTheReleaseAreWarmedDiagnosedAndCleared(): void + { + $this->external = $this->root . '-cache'; + mkdir($this->external, 0700); + mkdir($this->external . '/build', 0700); + $runtime = $this->fixture('APP_CACHE_DIR=' . $this->external . "\nAPP_BUILD_DIR=" . $this->external . "/build\n"); + $kernel = $this->warm($runtime); + (new Filesystem())->dumpFile($kernel->getBuildDir() . '/fixture.php', 'dumpFile($this->external . '/production/assets/keep.css', 'asset'); + (new Filesystem())->dumpFile($this->external . '/staging/kernel/keep.php', 'other environment'); + $report = $this->diagnose(); + $checks = array_column($report['checks'], 'status', 'id'); + self::assertSame('pass', $checks['kernel.cache']); + self::assertSame('pass', $checks['kernel.build']); + self::assertArrayNotHasKey('kernel.cache-migration', $checks); + self::assertSame($kernel->getCacheDir() . '/discovery-packages.php', $runtime->discovery()); + $this->clear($runtime); + self::assertDirectoryDoesNotExist($kernel->getCacheDir()); + self::assertDirectoryDoesNotExist($kernel->getBuildDir()); + self::assertFileExists($this->external . '/production/assets/keep.css'); + self::assertFileExists($this->external . '/staging/kernel/keep.php'); + $this->expectExceptionMessage('inside the project root'); + (new ProjectBoundary(new Paths($this->root)))->assertWritablePath($this->external . '/generic-artifact.php'); + } + + public function testPublicImplicitCacheSelectsTheSamePrivateLocationAsKernelWithoutDoctorCreatingIt(): void + { + $runtime = $this->fixture(); + $_SERVER['DOCUMENT_ROOT'] = $this->root . '/var/cache'; + $selected = $runtime->cache(); + self::assertDirectoryDoesNotExist(CacheLocation::fallbackRoot($this->root)); + $checks = array_column($this->diagnose('var/cache')['checks'], 'status', 'id'); + self::assertSame('unknown', $checks['kernel.cache']); + self::assertSame('warning', $checks['kernel.cache-migration']); + self::assertDirectoryDoesNotExist(CacheLocation::fallbackRoot($this->root)); + ini_set('error_log', $this->root . '/kernel-warnings.log'); + self::assertSame((new SiteKernel($this->root, 'production'))->getCacheDir(), $selected); + self::assertSame(CacheLocation::fallbackRoot($this->root) . '/production/kernel', $selected); + } + + /** @return iterable */ + public static function unsafeExternalRoots(): iterable + { + yield 'shared root' => ['mode']; + yield 'symlink root' => ['symlink']; + yield 'parent traversal' => ['traversal']; + yield 'symlink environment' => ['environment']; + yield 'shared ancestor' => ['ancestor']; + } + + #[DataProvider('unsafeExternalRoots')] + public function testUnsafeExternalRootsDoNotDeleteAValidSeparateCache(string $case): void + { + $this->external = $this->root . '-cache'; + mkdir($this->external, 0700); + $build = $this->external; + if ($case === 'mode') { + chmod($this->external, 0770); + } + if ($case === 'symlink') { + symlink($this->external, $this->root . '/build-link'); + $build = $this->root . '/build-link'; + } + if ($case === 'traversal') { + $build = $this->root . '/../' . basename($this->external); + } + if ($case === 'environment') { + symlink($this->root, $this->external . '/production'); + } + if ($case === 'ancestor') { + mkdir($this->external . '/private', 0700); + $build = $this->external . '/private'; + chmod($this->external, 0770); + } + $runtime = $this->fixture("APP_CACHE_DIR=custom/cache\nAPP_BUILD_DIR=" . $build . "\n"); + $this->write('custom/cache/production/kernel/keep.php', 'keep'); + self::assertSame('fail', array_column($this->diagnose()['checks'], 'status', 'id')['kernel.build']); + try { + $this->clear($runtime); + self::fail('Unsafe build roots must fail before deleting a valid cache.'); + } catch (RuntimeException) { + self::assertFileExists($this->root . '/custom/cache/production/kernel/keep.php'); + } + } + + public function testKernelServerPrecedenceWinsOverAConflictingProcessValue(): void + { + $runtime = $this->fixture("APP_CACHE_DIR=custom/cache\n"); + putenv('APP_CACHE_DIR=process/cache'); + $_SERVER['APP_CACHE_DIR'] = 'server/cache'; + self::assertSame((new SiteKernel($this->root, 'production'))->getCacheDir(), $runtime->cache()); + self::assertSame($this->root . '/server/cache/production/kernel', $runtime->cache()); + } + + /** @return iterable */ + public static function publicCacheRoots(): iterable + { + yield 'public cache' => ['public/cache', 'cache']; + yield 'public build' => ['public/build', 'build']; + yield 'content cache' => ['wp-content/cache', 'cache']; + yield 'core cache' => ['wordpress/cache', 'cache']; + } + + #[DataProvider('publicCacheRoots')] + public function testStandaloneDoctorAndMaintenanceRejectKnownPublicRootsWithoutBootingWordPress(string $base, string $kind): void + { + $name = $kind === 'cache' ? 'APP_CACHE_DIR' : 'APP_BUILD_DIR'; + $runtime = $this->fixture($name . '=' . $base . "\n"); + $this->write($base . '/production/kernel/keep.php', 'private modes do not make a public location safe'); + chmod($this->root . '/' . $base . '/production/kernel', 0700); + $report = $this->diagnose(); + self::assertSame(1, $report['exit']); + $checks = array_column($report['checks'], 'status', 'id'); + self::assertSame('fail', $checks['kernel.' . $kind]); + self::assertSame('fail', $checks['permissions.kernel-' . $kind]); + try { + $this->clear($runtime); + self::fail('Known public roots must fail maintenance.'); + } catch (RuntimeException) { + self::assertFileExists($this->root . '/' . $base . '/production/kernel/keep.php'); + } + if ($kind !== 'cache') { + return; + } + $_SERVER['DOCUMENT_ROOT'] = $this->root . '/' . dirname($base); + $this->expectExceptionMessage('outside publicly served directories'); + (new SiteKernel($this->root, 'production'))->getCacheDir(); + } + + public function testDoctorUsesAnExplicitCustomWebrootForItsCacheBoundary(): void + { + $this->fixture("APP_CACHE_DIR=served/cache\n"); + $this->write('served/cache/production/kernel/keep.php', 'keep'); + $report = $this->diagnose('served'); + self::assertSame(1, $report['exit']); + self::assertSame('fail', array_column($report['checks'], 'status', 'id')['kernel.cache']); + self::assertFileExists($this->root . '/served/cache/production/kernel/keep.php'); + } +} From d9f30db67bcacb926c4112c4f5b141fbb8607a49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20Sch=C3=A4ffner?= Date: Sat, 3 Oct 2026 18:45:30 +0200 Subject: [PATCH 2/2] Skip unused kernel cache diagnostics in plain WordPress projects --- CHANGELOG.md | 1 + docs/releases/1.2.2.md | 3 +++ src/Console/Doctor.php | 12 +++++------ tests/Integration/DoctorTest.php | 35 ++++++++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b92f780..68dd3a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Align read-only Doctor and explicit cache maintenance with Kernel 1.1.4's selected private fallback, configured cache/build roots and discovery metadata. - Fail diagnostics for unsafe explicit cache directories; report implicit fallback selection without creating directories or executing cache metadata. +- Mark unused cache/build diagnostics and permissions as not-applicable when the optional Kernel is absent, including WordPress core installed in the project root. - Support explicitly configured external cache/build roots with mode 0700 and matching ownership; reject symlinks, parent traversal and unsafe permissions before any deletion. - Require Kernel 1.1.4 or newer when the optional kernel is installed, and pin shared QA workflows to the released 1.2.2 source. diff --git a/docs/releases/1.2.2.md b/docs/releases/1.2.2.md index 5d8500c..54be8be 100644 --- a/docs/releases/1.2.2.md +++ b/docs/releases/1.2.2.md @@ -4,6 +4,9 @@ Runtime's Doctor and `kernel-cache` now select the same generation as Kernel 1.1.4, including its durable private per-user fallback. When the optional Kernel is installed, Composer requires 1.1.4 or newer. Plain WordPress installations remain supported without Kernel. +Doctor marks unused kernel cache/build directories and their permissions as +not-applicable when Kernel is absent, including plain WordPress installed directly +in the project root. Unused cache overrides do not cause diagnostic failures. An unsafe explicit `APP_CACHE_DIR` fails instead of producing a passing diagnostic or silently selecting another directory. Implicit fallback selection appears as diff --git a/src/Console/Doctor.php b/src/Console/Doctor.php index 0282ff0..eadc11d 100644 --- a/src/Console/Doctor.php +++ b/src/Console/Doctor.php @@ -104,18 +104,18 @@ public function inspect(bool $production = false, bool $databaseHealth = false, } $packages = new PackageFinder($this->context); $hasKernel = $packages->findByName('sympress/kernel') !== null; - if (!$hasKernel) { - $record('kernel.cache', 'not-applicable', 'sympress/kernel is not installed.'); - } $kernelDirectories = []; foreach (['cache', 'build'] as $id) { + if (!$hasKernel) { + $record('kernel.' . $id, 'not-applicable', 'sympress/kernel is not installed.'); + $record('permissions.kernel-' . $id, 'not-applicable', 'sympress/kernel is not installed; its directories are unused.'); + continue; + } try { $path = $id === 'cache' ? $kernel->cache() : $kernel->build(); $kernel->assertSafe($path, $id); $kernelDirectories['kernel-' . $id] = $path; - if ($hasKernel) { - $record('kernel.' . $id, is_dir($path) && is_readable($path) ? 'pass' : 'unknown', is_dir($path) ? 'Selected kernel directory is readable.' : 'Selected kernel directory has not been created; run cache warmup as the PHP-FPM user.'); - } + $record('kernel.' . $id, is_dir($path) && is_readable($path) ? 'pass' : 'unknown', is_dir($path) ? 'Selected kernel directory is readable.' : 'Selected kernel directory has not been created; run cache warmup as the PHP-FPM user.'); } catch (Throwable) { $record('kernel.' . $id, 'fail', 'Unsafe kernel directory. Configure APP_CACHE_DIR/APP_BUILD_DIR outside the webroot without symlinks or group/world write access. External roots require mode 0700. Create and warm them as the PHP-FPM user.'); $record('permissions.kernel-' . $id, 'fail', 'Unsafe kernel directory cannot be used for cache operations.'); diff --git a/tests/Integration/DoctorTest.php b/tests/Integration/DoctorTest.php index 99ee450..97a2156 100644 --- a/tests/Integration/DoctorTest.php +++ b/tests/Integration/DoctorTest.php @@ -4,6 +4,7 @@ namespace SymPress\Runtime\Tests\Integration; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use SymPress\Runtime\Tests\Support\TemporaryProject; use Symfony\Component\Process\Process; @@ -85,4 +86,38 @@ public function testInvalidRuntimeOptionsStillProduceStructuredRedactedDiagnosti $report = json_decode($failed->getOutput(), true, flags: JSON_THROW_ON_ERROR); self::assertSame('fail', array_column($report['checks'], 'status', 'id')['configuration']); } + + /** @return iterable */ + public static function kernelFreeLayouts(): iterable + { + yield 'ordinary layout' => [false, false]; + yield 'core in root' => [true, false]; + yield 'unused unsafe cache' => [false, true]; + yield 'core in root with unused unsafe cache' => [true, true]; + } + + #[DataProvider('kernelFreeLayouts')] + #[Group('PAR-SYM-001')] + public function testKernelFreeWordPressDoesNotInspectUnusedKernelDirectories(bool $coreInRoot, bool $unsafeCache): void + { + $this->fixture(); + if ($coreInRoot) { + $this->write('composer.json', '{"extra":{"wordpress-install-dir":".","sympress-runtime":{}}}'); + $this->write('wp-load.php', 'write('unused-cache/production/kernel/meta.php', 'unused'); + chmod($this->root . '/unused-cache/production/kernel', 0770); + $this->write('.env', (string) file_get_contents($this->root . '/.env') . "APP_CACHE_DIR=unused-cache\n"); + } + $process = $this->diagnose(); + self::assertSame(0, $process->getExitCode(), $process->getOutput() . $process->getErrorOutput()); + $report = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + $checks = array_column($report['checks'], 'status', 'id'); + foreach (['kernel.cache', 'kernel.build', 'permissions.kernel-cache', 'permissions.kernel-build'] as $id) { + self::assertSame('not-applicable', $checks[$id]); + } + self::assertFileDoesNotExist($this->root . '/.env.cached.php'); + self::assertDirectoryDoesNotExist($this->root . '/var'); + } }