diff --git a/system/Commands/Encryption/GenerateKey.php b/system/Commands/Encryption/GenerateKey.php index 1e56365ec3aa..838c294826d1 100644 --- a/system/Commands/Encryption/GenerateKey.php +++ b/system/Commands/Encryption/GenerateKey.php @@ -104,17 +104,29 @@ protected function execute(array $arguments, array $options): int $currentKey = env('encryption.key', ''); if ($currentKey !== '' && $options['force'] === false) { - CLI::error('Setting new encryption key aborted.'); + if ($this->isInteractive()) { + CLI::write('Setting new encryption key cancelled.', 'yellow'); - if (! $this->isInteractive()) { - CLI::error('If you want, use the "--force" option to force overwrite the existing key.'); + return EXIT_SUCCESS; } + CLI::error('Setting new encryption key aborted: pass --force to overwrite the existing key in non-interactive mode.'); + + return EXIT_ERROR; + } + + $envFile = ((new Paths())->envDirectory ?? ROOTPATH) . '.env'; // @phpstan-ignore nullCoalesce.property + $baseEnv = ROOTPATH . 'env'; + + if (! is_file($envFile) && ! is_file($baseEnv)) { + CLI::write('Both default shipped `env` file and custom `.env` are missing.', 'yellow'); + CLI::write(sprintf('Here\'s your new key instead: %s', CLI::color($encodedKey, 'yellow'))); + return EXIT_ERROR; } - if (! $this->writeNewEncryptionKeyToFile($currentKey, $encodedKey)) { - CLI::write('Error in setting new encryption key to .env file.'); + if (! $this->writeNewEncryptionKeyToFile($currentKey, $encodedKey, $envFile, $baseEnv)) { + CLI::error(sprintf('Failed to write new encryption key to %s.', clean_path($envFile))); return EXIT_ERROR; } @@ -125,7 +137,7 @@ protected function execute(array $arguments, array $options): int $dotenv = new DotEnv((new Paths())->envDirectory ?? ROOTPATH); // @phpstan-ignore nullCoalesce.property $dotenv->load(); - CLI::write('Application\'s new encryption key was successfully set.', 'green'); + CLI::write(sprintf('New encryption key written to %s.', clean_path($envFile)), 'green'); CLI::newLine(); return EXIT_SUCCESS; @@ -146,24 +158,19 @@ private function generateRandomKey(string $prefix, int $length): string } /** - * Writes the new encryption key to .env file. + * Writes the new encryption key to .env file. The caller is responsible + * for ensuring at least one of `$envFile` or `$baseEnv` exists. */ - private function writeNewEncryptionKeyToFile(string $oldKey, string $newKey): bool + private function writeNewEncryptionKeyToFile(string $oldKey, string $newKey, string $envFile, string $baseEnv): bool { - $baseEnv = ROOTPATH . 'env'; - $envFile = ((new Paths())->envDirectory ?? ROOTPATH) . '.env'; // @phpstan-ignore nullCoalesce.property - if (! is_file($envFile)) { - if (! is_file($baseEnv)) { - CLI::write('Both default shipped `env` file and custom `.env` are missing.', 'yellow'); - CLI::write('Here\'s your new key instead: ' . CLI::color($newKey, 'yellow')); - - return false; - } - copy($baseEnv, $envFile); } + if (! is_writable($envFile)) { + return false; + } + $oldFileContents = (string) file_get_contents($envFile); $replacementKey = "\nencryption.key = {$newKey}"; diff --git a/tests/system/Commands/Encryption/GenerateKeyTest.php b/tests/system/Commands/Encryption/GenerateKeyTest.php index bea6d21b4074..c5190371a817 100644 --- a/tests/system/Commands/Encryption/GenerateKeyTest.php +++ b/tests/system/Commands/Encryption/GenerateKeyTest.php @@ -17,7 +17,6 @@ use CodeIgniter\Config\Services; use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; -use CodeIgniter\Test\Filters\CITestStreamFilter; use CodeIgniter\Test\Mock\MockInputOutput; use CodeIgniter\Test\StreamFilterTrait; use PHPUnit\Framework\Attributes\Group; @@ -70,14 +69,6 @@ protected function tearDown(): void CLI::reset(); } - /** - * Gets buffer contents then releases it. - */ - protected function getBuffer(): string - { - return $this->getStreamFilterBuffer(); - } - protected function resetEnvironment(): void { putenv('encryption.key'); @@ -88,13 +79,15 @@ protected function resetEnvironment(): void public function testGenerateKeyShowsEncodedKey(): void { command('key:generate --show'); - $this->assertStringContainsString('hex2bin:', $this->getBuffer()); + $this->assertStringContainsString('hex2bin:', $this->getStreamFilterBuffer()); + $this->resetStreamFilterBuffer(); command('key:generate --prefix base64 --show'); - $this->assertStringContainsString('base64:', $this->getBuffer()); + $this->assertStringContainsString('base64:', $this->getStreamFilterBuffer()); + $this->resetStreamFilterBuffer(); command('key:generate --prefix hex2bin --show'); - $this->assertStringContainsString('hex2bin:', $this->getBuffer()); + $this->assertStringContainsString('hex2bin:', $this->getStreamFilterBuffer()); } #[PreserveGlobalState(false)] @@ -102,17 +95,19 @@ public function testGenerateKeyShowsEncodedKey(): void public function testGenerateKeyCreatesNewKey(): void { command('key:generate'); - $this->assertStringContainsString('successfully set.', $this->getBuffer()); + $this->assertStringContainsString(sprintf('New encryption key written to ROOTPATH%s.env.', DIRECTORY_SEPARATOR), $this->getStreamFilterBuffer()); $this->assertStringContainsString(env('encryption.key'), (string) file_get_contents($this->envPath)); $this->assertStringContainsString('hex2bin:', (string) file_get_contents($this->envPath)); + $this->resetStreamFilterBuffer(); command('key:generate --prefix base64 --force'); - $this->assertStringContainsString('successfully set.', $this->getBuffer()); + $this->assertStringContainsString(sprintf('New encryption key written to ROOTPATH%s.env.', DIRECTORY_SEPARATOR), $this->getStreamFilterBuffer()); $this->assertStringContainsString(env('encryption.key'), (string) file_get_contents($this->envPath)); $this->assertStringContainsString('base64:', (string) file_get_contents($this->envPath)); + $this->resetStreamFilterBuffer(); command('key:generate --prefix hex2bin --force'); - $this->assertStringContainsString('successfully set.', $this->getBuffer()); + $this->assertStringContainsString(sprintf('New encryption key written to ROOTPATH%s.env.', DIRECTORY_SEPARATOR), $this->getStreamFilterBuffer()); $this->assertStringContainsString(env('encryption.key'), (string) file_get_contents($this->envPath)); $this->assertStringContainsString('hex2bin:', (string) file_get_contents($this->envPath)); } @@ -123,8 +118,9 @@ public function testDefaultShippedEnvIsMissing(): void command('key:generate'); rename(ROOTPATH . 'lostenv', ROOTPATH . 'env'); - $this->assertStringContainsString('Both default shipped', $this->getBuffer()); - $this->assertStringContainsString('Error in setting', $this->getBuffer()); + $this->assertStringContainsString('Both default shipped', $this->getStreamFilterBuffer()); + $this->assertStringContainsString('Here\'s your new key instead:', $this->getStreamFilterBuffer()); + $this->assertStringNotContainsString('Failed to write', $this->getStreamFilterBuffer()); } /** @@ -136,7 +132,7 @@ public function testKeyGenerateWhenKeyIsMissingInDotEnvFile(): void command('key:generate'); - $this->assertStringContainsString('Application\'s new encryption key was successfully set.', $this->getBuffer()); + $this->assertStringContainsString(sprintf('New encryption key written to ROOTPATH%s.env.', DIRECTORY_SEPARATOR), $this->getStreamFilterBuffer()); $this->assertSame("\nencryption.key = " . env('encryption.key'), file_get_contents($this->envPath)); } @@ -152,9 +148,9 @@ public function testKeyGenerateWhenNewHexKeyIsSubsequentlyCommentedOut(): void )); $this->assertSame(1, $count, 'Failed commenting out the previously set application key.'); - CITestStreamFilter::$buffer = ''; + $this->resetStreamFilterBuffer(); command('key:generate --force'); - $this->assertStringContainsString('was successfully set.', $this->getBuffer()); + $this->assertStringContainsString(sprintf('New encryption key written to ROOTPATH%s.env.', DIRECTORY_SEPARATOR), $this->getStreamFilterBuffer()); $this->assertNotSame($key, env('encryption.key', $key), 'Failed replacing the commented out key.'); } @@ -170,9 +166,9 @@ public function testKeyGenerateWhenNewBase64KeyIsSubsequentlyCommentedOut(): voi )); $this->assertSame(1, $count, 'Failed commenting out the previously set application key.'); - CITestStreamFilter::$buffer = ''; + $this->resetStreamFilterBuffer(); command('key:generate --force'); - $this->assertStringContainsString('was successfully set.', $this->getBuffer()); + $this->assertStringContainsString(sprintf('New encryption key written to ROOTPATH%s.env.', DIRECTORY_SEPARATOR), $this->getStreamFilterBuffer()); $this->assertNotSame($key, env('encryption.key', $key), 'Failed replacing the commented out key.'); } @@ -190,15 +186,15 @@ public function testKeyGenerateReplacesUnloadedKeyInDotEnvFile(): void $this->assertSame('', env('encryption.key', '')); command('key:generate --force'); + $this->assertStringContainsString(sprintf('New encryption key written to ROOTPATH%s.env.', DIRECTORY_SEPARATOR), $this->getStreamFilterBuffer()); - $this->assertStringContainsString('was successfully set.', $this->getBuffer()); - - $contents = (string) file_get_contents($this->envPath); + $contents = @file_get_contents($this->envPath); + $this->assertIsString($contents, 'Failed to read .env file contents.'); $this->assertStringNotContainsString($existingKey, $contents); $this->assertStringContainsString('encryption.key = ' . env('encryption.key'), $contents); } - public function testKeyGenerateAbortsWhenOverwritePromptIsDeclined(): void + public function testKeyGenerateCancelsWhenOverwritePromptIsDeclined(): void { command('key:generate'); $key = env('encryption.key', ''); @@ -208,12 +204,13 @@ public function testKeyGenerateAbortsWhenOverwritePromptIsDeclined(): void $io->setInputs(['n']); CLI::setInputOutput($io); + $this->resetStreamFilterBuffer(); command('key:generate'); $this->assertSame($key, env('encryption.key', ''), 'Existing key should not change.'); $this->assertStringContainsString($key, (string) file_get_contents($this->envPath)); $this->assertStringContainsString('Overwrite existing key?', $io->getOutput()); - $this->assertStringContainsString('Setting new encryption key aborted.', $io->getOutput()); + $this->assertStringContainsString('Setting new encryption key cancelled.', $io->getOutput()); } public function testKeyGenerateOverwritesWhenOverwritePromptIsConfirmed(): void @@ -226,12 +223,13 @@ public function testKeyGenerateOverwritesWhenOverwritePromptIsConfirmed(): void $io->setInputs(['y']); CLI::setInputOutput($io); + $this->resetStreamFilterBuffer(); command('key:generate --prefix base64'); $this->assertNotSame($oldKey, env('encryption.key', $oldKey)); $this->assertStringContainsString('base64:', (string) file_get_contents($this->envPath)); $this->assertStringContainsString('Overwrite existing key?', $io->getOutput()); - $this->assertStringContainsString('successfully set.', $io->getOutput()); + $this->assertStringContainsString(sprintf('New encryption key written to ROOTPATH%s.env.', DIRECTORY_SEPARATOR), $io->getOutput()); } #[PreserveGlobalState(false)] @@ -243,19 +241,20 @@ public function testKeyGenerateAbortsNonInteractivelyWithExistingKey(): void $this->assertNotSame('', $key); $this->resetStreamFilterBuffer(); - command('key:generate --no-interaction'); $this->assertSame($key, env('encryption.key', ''), 'Existing key should not change.'); - $this->assertStringContainsString('Setting new encryption key aborted.', $this->getBuffer()); - $this->assertStringContainsString('--force', $this->getBuffer()); + $this->assertStringContainsString( + 'Setting new encryption key aborted: pass --force to overwrite the existing key in non-interactive mode.', + $this->getStreamFilterBuffer(), + ); } public function testKeyGenerateErrorsOnInvalidPrefixNonInteractively(): void { command('key:generate --prefix invalid --show --no-interaction'); - $this->assertStringContainsString('Invalid prefix "invalid"', $this->getBuffer()); + $this->assertStringContainsString('Invalid prefix "invalid"', $this->getStreamFilterBuffer()); } public function testKeyGeneratePromptsForInvalidPrefix(): void @@ -269,4 +268,19 @@ public function testKeyGeneratePromptsForInvalidPrefix(): void $this->assertStringContainsString('Please provide a valid prefix to use.', $io->getOutput()); $this->assertStringContainsString('hex2bin:', $io->getOutput()); } + + public function testKeyGenerateErrorsWhenEnvFileIsNotWritable(): void + { + command('key:generate'); + chmod($this->envPath, 0o444); + + try { + $this->resetStreamFilterBuffer(); + command('key:generate --force'); + + $this->assertStringContainsString(sprintf('Failed to write new encryption key to ROOTPATH%s.env.', DIRECTORY_SEPARATOR), $this->getStreamFilterBuffer()); + } finally { + chmod($this->envPath, 0o644); + } + } } diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 9efb8fd61ea8..515fcd3854a2 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -30,6 +30,7 @@ Behavior Changes - **Commands:** The ``filter:check`` command now requires the HTTP method argument to be uppercase (e.g., ``spark filter:check GET /`` instead of ``spark filter:check get /``). - **Commands:** Several built-in commands have been migrated from ``BaseCommand`` to the modern ``AbstractCommand`` style. Applications that extend a built-in command to override behaviour may need to re-implement against the modern API (``configure()`` + ``execute()`` and the ``#[Command]`` attribute) once the class it extends is migrated, or, preferably, compose instead of extending. Invocations on the command line are unaffected. +- **Commands:** Declining the ``key:generate`` overwrite prompt interactively now returns ``EXIT_SUCCESS`` instead of ``EXIT_ERROR``. Output messages were also reworded; CI/automation that branches on the exit code or greps the previous wording will need updating. - **Database:** The Postgre driver's ``$db->error()['code']`` previously always returned ``''``. It now returns the 5-character SQLSTATE string for query and transaction failures (e.g., ``'42P01'``), or ``'08006'`` for connection-level failures. Code that relied on ``$db->error()['code'] === ''`` will need updating. - **Filters:** HTTP method matching for method-based filters is now case-sensitive. The keys in ``Config\Filters::$methods`` must exactly match the request method (e.g., ``GET``, ``POST``). Lowercase method names (e.g., ``post``) will no longer match.