Skip to content
Open
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
295 changes: 248 additions & 47 deletions system/Config/BaseConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use Config\Modules;
use ReflectionClass;
use ReflectionException;
use ReflectionMethod;

/**
* Class BaseConfig
Expand Down Expand Up @@ -69,6 +70,36 @@ class BaseConfig
*/
protected static $moduleConfig;

/**
* Environment variables grouped by the text before their first "." or "_",
* then by source ("env", "server", "getenv").
*
* @var array<string, array<string, array<array-key, mixed>>>|null
*/
private static ?array $envIndex = null;

/**
* The [$_ENV, $_SERVER] arrays the index was built from.
*
* @var array{array<array-key, mixed>, array<array-key, mixed>}|null
*/
private static ?array $envSnapshot = null;

/**
* Env vars found with live getenv() calls on each class's first build, keyed
* by class name. The getenv() array omits names that contain ".", " " or "[".
*
* @var array<class-string, array<string, string>>
*/
private static array $unlistedEnvVars = [];

/**
* Whether a class overrides getEnvValue() or initEnvValue(), keyed by class name.
*
* @var array<class-string, bool>
*/
private static array $overridesEnvHooks = [];

/**
* @param array<string, mixed> $array
*/
Expand Down Expand Up @@ -106,6 +137,9 @@ public static function reset(): void
static::$override = true;
static::$didDiscovery = false;
static::$moduleConfig = null;

self::$envIndex = self::$envSnapshot = null;
self::$unlistedEnvVars = [];
}

/**
Expand All @@ -124,13 +158,31 @@ public function __construct()

$this->registerProperties();

$properties = array_keys(get_object_vars($this));
$values = get_object_vars($this);
$properties = array_keys($values);
$prefix = static::class;
$slashAt = strrpos($prefix, '\\');
$shortPrefix = strtolower(substr($prefix, $slashAt === false ? 0 : $slashAt + 1));

// Classes with custom env hooks keep the full walk with live lookups.
$sources = (self::$overridesEnvHooks[static::class] ??= self::overridesEnvHooks(static::class))
? null
: self::scopedEnvSources($prefix, $shortPrefix);

if ($sources !== null) {
$sources['getenv'] = (self::$unlistedEnvVars[static::class] ??= self::findUnlistedEnvVars($values, $prefix, $shortPrefix))
+ $sources['getenv'];

// Every env var that can override this class, for a fast miss check per leaf.
$candidates = $sources['env'] + $sources['server'] + $sources['getenv'];
}

foreach ($properties as $property) {
$this->initEnvValue($this->{$property}, $property, $prefix, $shortPrefix);
if ($sources === null) {
$this->initEnvValue($this->{$property}, $property, $prefix, $shortPrefix);
} elseif ($candidates !== []) {
$this->initScopedEnvValue($sources, $candidates, $this->{$property}, $property, $prefix, $shortPrefix);
}

if ($this instanceof Encryption) {
if ($property === 'key') {
Expand Down Expand Up @@ -179,75 +231,224 @@ protected function initEnvValue(&$property, string $name, string $prefix, string
$this->initEnvValue($property[$key], "{$name}.{$key}", $prefix, $shortPrefix);
}
} elseif (($value = $this->getEnvValue($name, $prefix, $shortPrefix)) !== false && $value !== null) {
if ($value === 'false') {
$value = false;
} elseif ($value === 'true') {
$value = true;
}
if (is_bool($value)) {
$property = $value;
// If the default value of the property is `null` and the type is not
// `string`, TypeError will happen.
// So cannot set `declare(strict_types=1)` in this file.
$property = self::castEnvValue($value, $property);
}
}

return;
/**
* Same as initEnvValue(), but looks values up in the pre-filtered sources
* from scopedEnvSources() instead of the live environment.
*
* @param array{env: array<array-key, mixed>, server: array<array-key, mixed>, getenv: array<array-key, mixed>} $sources
* @param array<array-key, mixed> $candidates All keys of $sources
* @param array<array-key, mixed>|bool|float|int|string|null $property
*/
private function initScopedEnvValue(array $sources, array $candidates, &$property, string $name, string $prefix, string $shortPrefix): void
{
if (is_array($property)) {
foreach (array_keys($property) as $key) {
$this->initScopedEnvValue($sources, $candidates, $property[$key], "{$name}.{$key}", $prefix, $shortPrefix);
}

$value = trim($value, '\'"');
return;
}

if (is_int($property)) {
$value = (int) $value;
} elseif (is_float($property)) {
$value = (float) $value;
}
$keys = self::envKeys($name, $prefix, $shortPrefix);

// If the default value of the property is `null` and the type is not
// `string`, TypeError will happen.
// So cannot set `declare(strict_types=1)` in this file.
$property = $value;
if (! array_key_exists($keys[0], $candidates) && ! array_key_exists($keys[1], $candidates)
&& ! array_key_exists($keys[2], $candidates) && ! array_key_exists($keys[3], $candidates)) {
return;
}

if (($value = self::lookupEnvValue($sources['env'], $sources['server'], $sources['getenv'], $keys)) !== false && $value !== null) {
$property = self::castEnvValue($value, $property);
}
}

/**
* Retrieve an environment-specific configuration setting
* Converts a raw env value to the type of the property's default value.
*
* @return string|null
* @param bool|float|int|string|null $default
*
* @return bool|float|int|string
*/
protected function getEnvValue(string $property, string $prefix, string $shortPrefix)
private static function castEnvValue(mixed $value, $default)
{
if ($value === 'false') {
return false;
}

if ($value === 'true') {
return true;
}

if (is_bool($value)) {
return $value;
}

$value = trim($value, '\'"');

if (is_int($default)) {
return (int) $value;
}

if (is_float($default)) {
return (float) $value;
}

return $value;
}

/**
* The env var names for a property, in lookup order: short prefix dot and
* underscore forms, then full prefix dot and underscore forms.
*
* @return array{string, string, string, string}
*/
private static function envKeys(string $property, string $prefix, string $shortPrefix): array
{
$shortPrefix = ltrim($shortPrefix, '\\');
$underscoreProperty = str_replace('.', '_', $property);

switch (true) {
case array_key_exists("{$shortPrefix}.{$property}", $_ENV):
return $_ENV["{$shortPrefix}.{$property}"];
return [
"{$shortPrefix}.{$property}",
"{$shortPrefix}_{$underscoreProperty}",
"{$prefix}.{$property}",
"{$prefix}_{$underscoreProperty}",
];
}

/**
* Finds the first of the env var names from envKeys(). For each prefix,
* $_ENV wins over $_SERVER. getenv() is checked last, and is read live
* when $getenv is null.
*
* @param array<array-key, mixed> $env
* @param array<array-key, mixed> $server
* @param array<array-key, mixed>|null $getenv
* @param array{string, string, string, string} $keys
*/
private static function lookupEnvValue(array $env, array $server, ?array $getenv, array $keys): mixed
{
foreach ([[$keys[0], $keys[1]], [$keys[2], $keys[3]]] as $prefixKeys) {
foreach ([$env, $server] as $source) {
foreach ($prefixKeys as $key) {
if (array_key_exists($key, $source)) {
return $source[$key];
}
}
}
}

foreach ($keys as $key) {
if (($value = $getenv === null ? getenv($key) : ($getenv[$key] ?? false)) !== false) {
return $value;
}
}

return null;
}

case array_key_exists("{$shortPrefix}_{$underscoreProperty}", $_ENV):
return $_ENV["{$shortPrefix}_{$underscoreProperty}"];
/**
* Returns the env vars that can override a class with these prefixes, by source.
*
* @return array{env: array<array-key, mixed>, server: array<array-key, mixed>, getenv: array<array-key, mixed>}
*/
private static function scopedEnvSources(string $prefix, string $shortPrefix): array
{
// Unchanged superglobals share memory with the snapshot, so this check is cheap.
if ([$_ENV, $_SERVER] !== self::$envSnapshot) {
self::$envIndex = [];

case array_key_exists("{$shortPrefix}.{$property}", $_SERVER):
return $_SERVER["{$shortPrefix}.{$property}"]; // @phpstan-ignore codeigniter.superglobalsOffsetAccess (reads live $_SERVER, not the snapshot service)
foreach (['env' => $_ENV, 'server' => $_SERVER, 'getenv' => getenv()] as $source => $vars) {
foreach ($vars as $key => $value) {
self::$envIndex[self::envGroup((string) $key)][$source][$key] = $value;
}
}
}

// Re-share memory even when only equal contents were written.
self::$envSnapshot = [$_ENV, $_SERVER];

$sources = ['env' => [], 'server' => [], 'getenv' => []];

foreach ([$shortPrefix, $prefix] as $p) {
// The prefix itself may contain "_", so match within its group.
foreach (self::$envIndex[self::envGroup($p)] ?? [] as $source => $vars) {
foreach ($vars as $key => $value) {
if (str_starts_with((string) $key, "{$p}.") || str_starts_with((string) $key, "{$p}_")) {
$sources[$source][$key] = $value;
}
}
}
}

case array_key_exists("{$shortPrefix}_{$underscoreProperty}", $_SERVER):
return $_SERVER["{$shortPrefix}_{$underscoreProperty}"]; // @phpstan-ignore codeigniter.superglobalsOffsetAccess (reads live $_SERVER, not the snapshot service)
return $sources;
}

case array_key_exists("{$prefix}.{$property}", $_ENV):
return $_ENV["{$prefix}.{$property}"];
/**
* Looks up, with getenv(), every leaf's env var names that the getenv()
* array omits.
*
* @param array<array-key, mixed> $values
*
* @return array<string, string>
*/
private static function findUnlistedEnvVars(array $values, string $prefix, string $shortPrefix, string $parent = ''): array
{
$found = [];

case array_key_exists("{$prefix}_{$underscoreProperty}", $_ENV):
return $_ENV["{$prefix}_{$underscoreProperty}"];
foreach ($values as $key => $value) {
$name = $parent === '' ? (string) $key : "{$parent}.{$key}";

case array_key_exists("{$prefix}.{$property}", $_SERVER):
return $_SERVER["{$prefix}.{$property}"]; // @phpstan-ignore codeigniter.superglobalsOffsetAccess (reads live $_SERVER, not the snapshot service)
if (is_array($value)) {
$found += self::findUnlistedEnvVars($value, $prefix, $shortPrefix, $name);

case array_key_exists("{$prefix}_{$underscoreProperty}", $_SERVER):
return $_SERVER["{$prefix}_{$underscoreProperty}"]; // @phpstan-ignore codeigniter.superglobalsOffsetAccess (reads live $_SERVER, not the snapshot service)
continue;
}

default:
$value = getenv("{$shortPrefix}.{$property}");
$value = $value === false ? getenv("{$shortPrefix}_{$underscoreProperty}") : $value;
$value = $value === false ? getenv("{$prefix}.{$property}") : $value;
$value = $value === false ? getenv("{$prefix}_{$underscoreProperty}") : $value;
// Dot forms always contain "."; underscore forms only need a live
// lookup when the name itself contains " " or "[".
$envKeys = strpbrk($name, ' [') === false
? ["{$shortPrefix}.{$name}", "{$prefix}.{$name}"]
: self::envKeys($name, $prefix, $shortPrefix);

return $value === false ? null : $value;
foreach ($envKeys as $envKey) {
if (($envValue = getenv($envKey)) !== false) {
$found[$envKey] = $envValue;
}
}
}

return $found;
}

private static function envGroup(string $key): string
{
return substr($key, 0, strcspn($key, '._'));
}

/**
* @param class-string $class
*/
private static function overridesEnvHooks(string $class): bool
{
return (new ReflectionMethod($class, 'getEnvValue'))->class !== self::class
|| (new ReflectionMethod($class, 'initEnvValue'))->class !== self::class;
}

/**
* Retrieve an environment-specific configuration setting
*
* @return string|null
*/
protected function getEnvValue(string $property, string $prefix, string $shortPrefix)
{
$keys = self::envKeys($property, $prefix, ltrim($shortPrefix, '\\'));

return self::lookupEnvValue($_ENV, $_SERVER, null, $keys);
}

/**
Expand Down
Loading
Loading