Skip to content
Draft
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
121 changes: 121 additions & 0 deletions src/DependencyInjection/Configurator.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,36 @@
use Nette\DI\Config\Loader;
use Nette\DI\Container as OriginalNetteContainer;
use Nette\DI\ContainerLoader;
use Nette\DI\Definitions\Statement;
use Nette\Neon\Entity;
use Override;
use PHPStan\File\CouldNotReadFileException;
use PHPStan\File\CouldNotWriteFileException;
use PHPStan\File\FileReader;
use PHPStan\File\FileWriter;
use PHPStan\Turbo\TurboExtensionEnabler;
use Throwable;
use function array_fill_keys;
use function array_intersect_key;
use function array_keys;
use function count;
use function error_reporting;
use function explode;
use function file_get_contents;
use function hash_file;
use function implode;
use function in_array;
use function is_array;
use function is_dir;
use function is_file;
use function is_string;
use function ksort;
use function preg_match;
use function preg_match_all;
use function restore_error_handler;
use function set_error_handler;
use function sprintf;
use function str_contains;
use function str_ends_with;
use function substr;
use function time;
Expand All @@ -38,6 +49,13 @@
final class Configurator extends \Nette\Bootstrap\Configurator
{

/**
* Env vars PHPStan reads at build time in a CompilerExtension (FnsrExtension reads PHPSTAN_FNSR),
* so the container depends on them and they must stay in the cache key. ContainerCacheKeyEnvGuardTest
* fails if a CompilerExtension reads an env var not listed here or referenced via %env.*%.
*/
public const BUILD_TIME_ENV_VARIABLES = ['PHPSTAN_FNSR'];

/** @var string[] */
private array $allConfigFiles = [];

Expand Down Expand Up @@ -97,6 +115,19 @@ public function loadContainer(): string
// make sure invocations via blackfire use the same container
unset($staticParameters['env']['BLACKFIRE_AGENT_SOCKET']);

// Keep only the env vars the container actually depends on in the cache key, so unrelated env
// changes (CI/shell) don't force a full recompile - phpstan/phpstan#14072. The full env stays
// in the container parameters, so %env.*% resolution is unaffected; this only narrows the key.
if (isset($staticParameters['env'])) {
$relevantEnvVariableNames = $this->relevantEnvVariableNamesForCacheKey();
if ($relevantEnvVariableNames !== null) {
$staticParameters['env'] = array_intersect_key(
$staticParameters['env'],
array_fill_keys($relevantEnvVariableNames, true),
);
}
}

$containerKey = [
$staticParameters,
array_keys($this->dynamicParameters),
Expand Down Expand Up @@ -231,6 +262,96 @@ public function createContainer(bool $initialize = true): OriginalNetteContainer
return $container;
}

/**
* Env vars that can change the generated container, so they must stay in the cache key: every
* %env.NAME% referenced across the loaded configs, plus BUILD_TIME_ENV_VARIABLES. Returns null
* when a config references the whole %env% array (or its references can't be safely enumerated),
* in which case all of it must be kept.
*
* References are enumerated from the config as parsed by PHPStan's own NeonAdapter - the same
* parser the container compiler uses - rather than from raw config text: comments are ignored and
* service entities become Statements, so a %env.NAME% in a parameter value, a service argument or
* a factory is found the same way it is at compile time. The %env.NAME% grammar mirrors Nette's
* parameter-name grammar (%([\w.-]*)%), so dashed names like %env.MY-VAR% are handled too.
*
* @return list<string>|null
*/
private function relevantEnvVariableNamesForCacheKey(): ?array
{
$names = self::BUILD_TIME_ENV_VARIABLES;
$adapter = new NeonAdapter([]);

foreach ($this->allConfigFiles as $file) {
$contents = @file_get_contents($file);
if ($contents === false || !str_contains($contents, '%env')) {
continue;
}

try {
$data = $adapter->load($file);
} catch (Throwable) {
// A config we can't parse as NEON here (e.g. a .php config) might reference any env
// var, so we can't prove which ones the container needs: keep the whole environment.
return null;
}

$referencesWholeEnv = false;
$referencedNames = $this->collectReferencedEnvVariableNames($data, $referencesWholeEnv);
if ($referencesWholeEnv) {
return null;
}

foreach ($referencedNames as $name) {
$names[] = $name;
}
}

return $names;
}

/**
* Recursively collect the env-variable names referenced by %env.NAME% in a parsed config node.
* Recurses into arrays and into the service definitions the NeonAdapter produces (Statements, and
* any remaining Neon entities) so references in service arguments and factories are not missed.
* Sets $referencesWholeEnv on a bare %env% (the whole environment), which forces keeping all of it.
*
* @param mixed $node
* @return list<string>
*/
private function collectReferencedEnvVariableNames($node, bool &$referencesWholeEnv): array
{
if (is_string($node)) {
if (preg_match('~%env%~', $node) === 1) {
$referencesWholeEnv = true;
}

if (preg_match_all('~%env\.([\w.-]+)%~', $node, $matches) > 0) {
return $matches[1];
}

return [];
}

if ($node instanceof Statement) {
$node = [$node->getEntity(), $node->arguments];
} elseif ($node instanceof Entity) {
$node = [$node->value, $node->attributes];
}

if (!is_array($node)) {
return [];
}

$names = [];
foreach ($node as $value) {
foreach ($this->collectReferencedEnvVariableNames($value, $referencesWholeEnv) as $name) {
$names[] = $name;
}
}

return $names;
}

/**
* @return string[]
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php declare(strict_types = 1);

namespace PHPStan\DependencyInjection;

use PHPUnit\Framework\TestCase;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
use function file_get_contents;
use function implode;
use function in_array;
use function preg_match_all;
use function sort;
use function str_contains;
use const PHP_EOL;

/**
* Guards the container-cache-key narrowing in Configurator (phpstan/phpstan#14072): when no config
* references %env.*%, env vars are dropped from the cache key, so any env var a CompilerExtension
* reads at *build time* (getenv()/$_ENV) must be declared in Configurator::BUILD_TIME_ENV_VARIABLES -
* otherwise changing it would silently reuse a stale container. A new build-time env read in any
* extension fails this test instead of re-opening that hole. Detection is intentionally simple, with
* documented limits (acceptable - PHPStan's build-time env access goes through these patterns):
* literal env-var names only (route dynamic ones via %env.*), getenv()/$_ENV reads (not $_SERVER),
* and classes that directly extend CompilerExtension (not env read in a helper they delegate to).
*/
final class ContainerCacheKeyEnvGuardTest extends TestCase
{

public function testBuildTimeEnvReadsInCompilerExtensionsAreDeclared(): void
{
$srcDir = __DIR__ . '/../../../src';
$offenders = [];

/** @var SplFileInfo $file */
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($srcDir, RecursiveDirectoryIterator::SKIP_DOTS)) as $file) {
if (!$file->isFile() || $file->getExtension() !== 'php') {
continue;
}

$contents = file_get_contents($file->getPathname());
if ($contents === false || !str_contains($contents, 'extends CompilerExtension')) {
continue;
}

if (preg_match_all('~(?:getenv\(|\$_ENV\[)\s*[\'"]([A-Za-z_][A-Za-z0-9_]*)[\'"]~', $contents, $matches) === 0) {
continue;
}

foreach ($matches[1] as $envName) {
if (in_array($envName, Configurator::BUILD_TIME_ENV_VARIABLES, true)) {
continue;
}

$offenders[] = $file->getFilename() . ': ' . $envName;
}
}

sort($offenders);

$this->assertSame(
[],
$offenders,
'A CompilerExtension reads an environment variable at build time that is not declared in '
. 'Configurator::BUILD_TIME_ENV_VARIABLES. Add it there (so it stays in the container cache '
. 'key and a change recompiles), or read it via %env.* in a config instead:' . PHP_EOL
. implode(PHP_EOL, $offenders),
);
}

}
Loading
Loading