diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index eeaf721cee3b1..35e2bcf78d1bc 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -219,6 +219,11 @@ 'OCP\\Comments\\MessageTooLongException' => $baseDir . '/lib/public/Comments/MessageTooLongException.php', 'OCP\\Comments\\NotFoundException' => $baseDir . '/lib/public/Comments/NotFoundException.php', 'OCP\\Common\\Exception\\NotFoundException' => $baseDir . '/lib/public/Common/Exception/NotFoundException.php', + 'OCP\\ConfigLexicon\\ConfigLexiconEntry' => $baseDir . '/lib/public/ConfigLexicon/ConfigLexiconEntry.php', + 'OCP\\ConfigLexicon\\ConfigLexiconStrictness' => $baseDir . '/lib/public/ConfigLexicon/ConfigLexiconStrictness.php', + 'OCP\\ConfigLexicon\\IConfigLexicon' => $baseDir . '/lib/public/ConfigLexicon/IConfigLexicon.php', + 'OCP\\ConfigLexicon\\IConfigLexiconEntry' => $baseDir . '/lib/public/ConfigLexicon/IConfigLexiconEntry.php', + 'OCP\\ConfigLexicon\\ValueType' => $baseDir . '/lib/public/ConfigLexicon/ValueType.php', 'OCP\\Config\\BeforePreferenceDeletedEvent' => $baseDir . '/lib/public/Config/BeforePreferenceDeletedEvent.php', 'OCP\\Config\\BeforePreferenceSetEvent' => $baseDir . '/lib/public/Config/BeforePreferenceSetEvent.php', 'OCP\\Console\\ConsoleEvent' => $baseDir . '/lib/public/Console/ConsoleEvent.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 0bf63ce8267ce..b1533c0d7fdab 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -252,6 +252,11 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Comments\\MessageTooLongException' => __DIR__ . '/../../..' . '/lib/public/Comments/MessageTooLongException.php', 'OCP\\Comments\\NotFoundException' => __DIR__ . '/../../..' . '/lib/public/Comments/NotFoundException.php', 'OCP\\Common\\Exception\\NotFoundException' => __DIR__ . '/../../..' . '/lib/public/Common/Exception/NotFoundException.php', + 'OCP\\ConfigLexicon\\ConfigLexiconEntry' => __DIR__ . '/../../..' . '/lib/public/ConfigLexicon/ConfigLexiconEntry.php', + 'OCP\\ConfigLexicon\\ConfigLexiconStrictness' => __DIR__ . '/../../..' . '/lib/public/ConfigLexicon/ConfigLexiconStrictness.php', + 'OCP\\ConfigLexicon\\IConfigLexicon' => __DIR__ . '/../../..' . '/lib/public/ConfigLexicon/IConfigLexicon.php', + 'OCP\\ConfigLexicon\\IConfigLexiconEntry' => __DIR__ . '/../../..' . '/lib/public/ConfigLexicon/IConfigLexiconEntry.php', + 'OCP\\ConfigLexicon\\ValueType' => __DIR__ . '/../../..' . '/lib/public/ConfigLexicon/ValueType.php', 'OCP\\Config\\BeforePreferenceDeletedEvent' => __DIR__ . '/../../..' . '/lib/public/Config/BeforePreferenceDeletedEvent.php', 'OCP\\Config\\BeforePreferenceSetEvent' => __DIR__ . '/../../..' . '/lib/public/Config/BeforePreferenceSetEvent.php', 'OCP\\Console\\ConsoleEvent' => __DIR__ . '/../../..' . '/lib/public/Console/ConsoleEvent.php', diff --git a/lib/private/AppConfig.php b/lib/private/AppConfig.php index bd574d4335ca9..f4a03490ebf99 100644 --- a/lib/private/AppConfig.php +++ b/lib/private/AppConfig.php @@ -11,6 +11,11 @@ use InvalidArgumentException; use JsonException; +use OC\AppFramework\Bootstrap\Coordinator; +use OCP\ConfigLexicon\ConfigLexiconEntry; +use OCP\ConfigLexicon\ConfigLexiconStrictness; +use OCP\ConfigLexicon\IConfigLexiconEntry; +use OCP\ConfigLexicon\ValueType; use OCP\DB\Exception as DBException; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\Exceptions\AppConfigIncorrectTypeException; @@ -55,6 +60,8 @@ class AppConfig implements IAppConfig { private array $valueTypes = []; // type for all config values private bool $fastLoaded = false; private bool $lazyLoaded = false; + /** @var array, strict: bool}> ['app_id' => ['strict' => bool, 'entries' => ['config_key' => IConfigLexiconEntry[]]] */ + private array $configLexiconDetails = []; /** * $migrationCompleted is only needed to manage the previous structure @@ -430,6 +437,10 @@ private function getTypedValue( int $type, ): string { $this->assertParams($app, $key, valueType: $type); + if (!$this->compareRegisteredConfigValues($app, $key, $lazy, $type, $default)) { + return $default; // returns default if strictness of lexicon is set to WARNING (block and report) + } + $this->loadConfig($lazy); /** @@ -721,6 +732,9 @@ private function setTypedValue( int $type, ): bool { $this->assertParams($app, $key); + if (!$this->compareRegisteredConfigValues($app, $key, $lazy, $type)) { + return false; // returns false as database is not updated + } $this->loadConfig($lazy); $sensitive = $this->isTyped(self::VALUE_SENSITIVE, $type); @@ -1538,4 +1552,109 @@ private function getSensitiveKeys(string $app): array { public function clearCachedConfig(): void { $this->clearCache(); } + + /** + * verify and compare current use of config values with defined lexicon + * + * @throws AppConfigUnknownKeyException + * @throws AppConfigTypeConflictException + */ + private function compareRegisteredConfigValues( + string $app, + string $key, + bool &$lazy, + int &$type, + string &$default = '', + ): bool { + if (in_array($key, + [ + 'enabled', + 'installed_version', + 'types', + ])) { + return false; + } + $configDetails = $this->getConfigDetailsFromLexicon($app); + if (!array_key_exists($key, $configDetails['entries'])) { + return $this->applyLexiconStrictness($app, $key, $configDetails['strictness']); + } + + /** @var ConfigLexiconEntry $configValue */ + $configValue = $configDetails['entries'][$key]; + $type &= ~self::VALUE_SENSITIVE; + + if ($type === self::VALUE_MIXED) { + $type = $configValue->getValueType()->value; // we overwrite if value was requested as mixed + } else if ($configValue->getValueType()->value !== $type) { + throw new AppConfigTypeConflictException('The key ' . $app . '/' . $key . ' is typed incorrectly in relation to the config lexicon'); + } + + $lazy = $configValue->isLazy(); + $default = $configValue->getDefault() ?? $default; // default from Lexicon got priority + if ($configValue->isSensitive()) { + $type |= self::VALUE_SENSITIVE; + } + if ($configValue->isDeprecated()) { + $this->logger->notice('config value ' . $app . '/' . $key . ' is set as deprecated.'); + } + + return true; + } + + /** + * @param string $app + * @param string $key + * @param ConfigLexiconStrictness $strictness + * + * @return bool TRUE if conflict can be fully ignored + * @throws AppConfigUnknownKeyException + */ + private function applyLexiconStrictness( + string $app, + string $key, + ?ConfigLexiconStrictness $strictness + ): bool { + if ($strictness === null) { + return true; + } + + $line = 'The key ' . $app . '/' . $key . ' is not defined in the config lexicon'; + switch($strictness) { + case ConfigLexiconStrictness::IGNORE: + return true; + case ConfigLexiconStrictness::NOTICE: + $this->logger->notice($line); + return true; + case ConfigLexiconStrictness::WARNING: + $this->logger->warning($line); + return false; + } + + throw new AppConfigUnknownKeyException($line); + } + + /** + * extract details from registered $appId's config lexicon + * + * @param string $appId + * + * @return array{entries: array, strict: bool} + */ + private function getConfigDetailsFromLexicon(string $appId): array { + if (!array_key_exists($appId, $this->configLexiconDetails)) { + $entries = []; + $bootstrapCoordinator = \OCP\Server::get(Coordinator::class); + $configLexicon = $bootstrapCoordinator->getRegistrationContext()?->getConfigLexicon($appId); + foreach ($configLexicon?->getAppConfigs() ?? [] as $configEntry) { + $entries[$configEntry->getKey()] = $configEntry; + } + + $this->configLexiconDetails[$appId] = [ + 'entries' => $entries, + 'strictness' => $configLexicon?->getStrictness() ?? ConfigLexiconStrictness::IGNORE + ]; + } + + return $this->configLexiconDetails[$appId]; + } } diff --git a/lib/private/AppFramework/Bootstrap/RegistrationContext.php b/lib/private/AppFramework/Bootstrap/RegistrationContext.php index d7a380f9e1de8..334ec493e1139 100644 --- a/lib/private/AppFramework/Bootstrap/RegistrationContext.php +++ b/lib/private/AppFramework/Bootstrap/RegistrationContext.php @@ -21,6 +21,7 @@ use OCP\Calendar\Room\IBackend as IRoomBackend; use OCP\Capabilities\ICapability; use OCP\Collaboration\Reference\IReferenceProvider; +use OCP\ConfigLexicon\IConfigLexicon; use OCP\Dashboard\IManager; use OCP\Dashboard\IWidget; use OCP\EventDispatcher\IEventDispatcher; @@ -141,6 +142,9 @@ class RegistrationContext { /** @var ServiceRegistration[] */ private array $declarativeSettings = []; + /** @var array */ + private array $configLexiconClasses = []; + /** @var ServiceRegistration[] */ private array $teamResourceProviders = []; @@ -422,6 +426,13 @@ public function registerMailProvider(string $class): void { $class ); } + + public function registerConfigLexicon(string $configLexiconClass): void { + $this->context->registerConfigLexicon( + $this->appId, + $configLexiconClass + ); + } }; } @@ -621,6 +632,13 @@ public function registerMailProvider(string $appId, string $class): void { $this->mailProviders[] = new ServiceRegistration($appId, $class); } + /** + * @psalm-param class-string $configLexiconClass + */ + public function registerConfigLexicon(string $appId, string $configLexiconClass): void { + $this->configLexiconClasses[$appId] = $configLexiconClass; + } + /** * @param App[] $apps */ @@ -972,4 +990,20 @@ public function getTaskProcessingTaskTypes(): array { public function getMailProviders(): array { return $this->mailProviders; } + + /** + * returns IConfigLexicon registered by the app. + * null if none registered. + * + * @param string $appId + * + * @return IConfigLexicon|null + */ + public function getConfigLexicon(string $appId): ?IConfigLexicon { + if (!array_key_exists($appId, $this->configLexiconClasses)) { + return null; + } + + return \OCP\Server::get($this->configLexiconClasses[$appId]); + } } diff --git a/lib/public/AppFramework/Bootstrap/IRegistrationContext.php b/lib/public/AppFramework/Bootstrap/IRegistrationContext.php index 57e76f268d986..effb9998b4307 100644 --- a/lib/public/AppFramework/Bootstrap/IRegistrationContext.php +++ b/lib/public/AppFramework/Bootstrap/IRegistrationContext.php @@ -423,4 +423,14 @@ public function registerTaskProcessingTaskType(string $taskProcessingTaskTypeCla */ public function registerMailProvider(string $class): void; + + /** + * Register an implementation of \OCP\ConfigLexicon\IConfigLexicon that + * will handle the implementation of config lexicon + * + * @param string $configLexiconClass + * @psalm-param class-string<\OCP\ConfigLexicon\IConfigLexicon> $configLexiconClass + * @since 31.0.0 + */ + public function registerConfigLexicon(string $configLexiconClass): void; } diff --git a/lib/public/ConfigLexicon/ConfigLexiconEntry.php b/lib/public/ConfigLexicon/ConfigLexiconEntry.php new file mode 100644 index 0000000000000..12b89c9b90e65 --- /dev/null +++ b/lib/public/ConfigLexicon/ConfigLexiconEntry.php @@ -0,0 +1,179 @@ +default = match ($type) { + ValueType::STRING => $this->convertFromString($default), + ValueType::INT => $this->convertFromInt($default), + ValueType::FLOAT => $this->convertFromFloat($default), + ValueType::BOOL => $this->convertFromBool($default), + ValueType::ARRAY => $this->convertFromArray($default) + }; + } catch (\TypeError) { + // the idea is that the $default value should be typed as set by the $type argument + $this->default = (string) $default; + } + } + + /** @psalm-suppress UndefinedClass */ + if (\OC::$CLI) { // only store definition if ran from CLI + $this->definition = $definition; + } + } + + /** + * @inheritDoc + * + * @return string config key + * @since 31.0.0 + */ + public function getKey(): string { + return $this->key; + } + + /** + * @inheritDoc + * + * @return ValueType + * @see self::TYPE_STRING and others + * @since 31.0.0 + */ + public function getValueType(): ValueType { + return $this->type; + } + + /** + * @param string $default + * @return string + * @since 31.0.0 + */ + private function convertFromString(string $default): string { + return $default; + } + + /** + * @param int $default + * @return string + * @since 31.0.0 + */ + private function convertFromInt(int $default): string { + return (string) $default; + } + + /** + * @param float $default + * @return string + * @since 31.0.0 + */ + private function convertFromFloat(float $default): string { + return (string) $default; + } + + /** + * @param bool $default + * @return string + * @since 31.0.0 + */ + private function convertFromBool(bool $default): string { + return ($default) ? '1' : '0'; + } + + /** + * @param array $default + * @return string + * @since 31.0.0 + */ + private function convertFromArray(array $default): string { + return json_encode($default); + } + + /** + * @inheritDoc + * + * @return string|null NULL if no default is set + * @since 31.0.0 + */ + public function getDefault(): ?string { + return $this->default; + } + + /** + * @inheritDoc + * + * @return string + * @since 31.0.0 + */ + public function getDefinition(): string { + return $this->definition; + } + + /** + * @inheritDoc + * + * @see IAppConfig for details on lazy config values + * @return bool TRUE if config value is lazy + * @since 31.0.0 + */ + public function isLazy(): bool { + return $this->lazy; + } + + /** + * @inheritDoc + * + * @see IAppConfig for details on sensitive config values + * @return bool TRUE if config value is sensitive + * @since 31.0.0 + */ + public function isSensitive(): bool { + return $this->sensitive; + } + + /** + * @inheritDoc + * + * @return bool TRUE if config si deprecated + * @since 31.0.0 + */ + public function isDeprecated(): bool { + return $this->deprecated; + } +} diff --git a/lib/public/ConfigLexicon/ConfigLexiconStrictness.php b/lib/public/ConfigLexicon/ConfigLexiconStrictness.php new file mode 100644 index 0000000000000..08d5b8eb6bf6b --- /dev/null +++ b/lib/public/ConfigLexicon/ConfigLexiconStrictness.php @@ -0,0 +1,25 @@ + + * + * @author Maxence Lange + * + * @license AGPL-3.0 or later + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCP\ConfigLexicon; + +/** + * Listing of available value type for config lexicon + * + * @see IConfigLexicon + * @since 31.0.0 + */ +enum ValueType: int { + /** @since 31.0.0 */ + case STRING = 4; + /** @since 31.0.0 */ + case INT = 8; + /** @since 31.0.0 */ + case FLOAT = 16; + /** @since 31.0.0 */ + case BOOL = 32; + /** @since 31.0.0 */ + case ARRAY = 64; +} diff --git a/tests/lib/AppConfigTest.php b/tests/lib/AppConfigTest.php index e8c10fe654b17..775c9027dd671 100644 --- a/tests/lib/AppConfigTest.php +++ b/tests/lib/AppConfigTest.php @@ -9,6 +9,7 @@ use InvalidArgumentException; use OC\AppConfig; +use OC\AppFramework\Bootstrap\Coordinator; use OCP\Exceptions\AppConfigTypeConflictException; use OCP\Exceptions\AppConfigUnknownKeyException; use OCP\IAppConfig; @@ -28,6 +29,8 @@ class AppConfigTest extends TestCase { protected IDBConnection $connection; private LoggerInterface $logger; private ICrypto $crypto; + private Coordinator $coordinator; + private array $originalConfig; /** @@ -88,6 +91,7 @@ protected function setUp(): void { $this->connection = \OCP\Server::get(IDBConnection::class); $this->logger = \OCP\Server::get(LoggerInterface::class); $this->crypto = \OCP\Server::get(ICrypto::class); + $this->coordinator = \OCP\Server::get(Coordinator::class); // storing current config and emptying the data table $sql = $this->connection->getQueryBuilder(); @@ -178,6 +182,7 @@ private function generateAppConfig(bool $preLoading = true): IAppConfig { $this->connection, $this->logger, $this->crypto, + $this->coordinator ); $msg = ' generateAppConfig() failed to confirm cache status';