diff --git a/system/Commands/Encryption/GenerateKey.php b/system/Commands/Encryption/GenerateKey.php index ec5007560ed0..1e56365ec3aa 100644 --- a/system/Commands/Encryption/GenerateKey.php +++ b/system/Commands/Encryption/GenerateKey.php @@ -13,88 +13,108 @@ namespace CodeIgniter\Commands\Encryption; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Option; use CodeIgniter\Config\DotEnv; use CodeIgniter\Encryption\Encryption; use Config\Paths; /** - * Generates a new encryption key. + * Generates a new encryption key and writes it in an `.env` file. */ -class GenerateKey extends BaseCommand +#[Command(name: 'key:generate', description: 'Generates a new encryption key and writes it in an `.env` file.', group: 'Encryption')] +class GenerateKey extends AbstractCommand { /** - * The Command's group. - * - * @var string + * @var list */ - protected $group = 'Encryption'; + private const VALID_PREFIXES = ['hex2bin', 'base64']; - /** - * The Command's name. - * - * @var string - */ - protected $name = 'key:generate'; + protected function configure(): void + { + $this + ->addOption(new Option( + name: 'force', + shortcut: 'f', + description: 'Force overwrite existing key in `.env` file.', + )) + ->addOption(new Option( + name: 'length', + description: 'The length of the random string that should be returned in bytes.', + requiresValue: true, + default: '32', + )) + ->addOption(new Option( + name: 'prefix', + description: 'Prefix to prepend to encoded key (either hex2bin or base64).', + requiresValue: true, + default: 'hex2bin', + )) + ->addOption(new Option( + name: 'show', + description: 'Shows the generated key in the terminal instead of storing in the `.env` file.', + )); + } - /** - * The Command's usage. - * - * @var string - */ - protected $usage = 'key:generate [options]'; + protected function interact(array &$arguments, array &$options): void + { + $prefix = $this->getUnboundOption('prefix', $options); - /** - * The Command's short description. - * - * @var string - */ - protected $description = 'Generates a new encryption key and writes it in an `.env` file.'; + if (is_string($prefix) && ! in_array($prefix, self::VALID_PREFIXES, true)) { + $options['prefix'] = CLI::prompt('Please provide a valid prefix to use.', self::VALID_PREFIXES, 'required'); + } - /** - * The command's options - * - * @var array - */ - protected $options = [ - '--force' => 'Force overwrite existing key in `.env` file.', - '--length' => 'The length of the random string that should be returned in bytes. Defaults to 32.', - '--prefix' => 'Prefix to prepend to encoded key (either hex2bin or base64). Defaults to hex2bin.', - '--show' => 'Shows the generated key in the terminal instead of storing in the `.env` file.', - ]; + if ($this->hasUnboundOption('show', $options)) { + return; + } - /** - * Actually execute the command. - */ - public function run(array $params) - { - $prefix = $params['prefix'] ?? CLI::getOption('prefix'); + if ($this->hasUnboundOption('force', $options)) { + return; + } - if (in_array($prefix, [null, true], true)) { - $prefix = 'hex2bin'; - } elseif (! in_array($prefix, ['hex2bin', 'base64'], true)) { - $prefix = CLI::prompt('Please provide a valid prefix to use.', ['hex2bin', 'base64'], 'required'); // @codeCoverageIgnore + if (env('encryption.key', '') === '') { + return; } - $length = $params['length'] ?? CLI::getOption('length'); + if (CLI::prompt('Overwrite existing key?', ['n', 'y']) === 'y') { + $options['force'] = null; // simulate the presence of the --force option + } + } + + protected function execute(array $arguments, array $options): int + { + $prefix = $options['prefix']; - if (in_array($length, [null, true], true)) { - $length = 32; + if (! in_array($prefix, self::VALID_PREFIXES, true)) { + CLI::error(sprintf('Invalid prefix "%s". Use either "hex2bin" or "base64".', $prefix)); + + return EXIT_ERROR; } - $encodedKey = $this->generateRandomKey($prefix, $length); + $encodedKey = $this->generateRandomKey($prefix, (int) $options['length']); - if (array_key_exists('show', $params) || (bool) CLI::getOption('show')) { + if ($options['show'] === true) { CLI::write($encodedKey, 'yellow'); - CLI::newLine(); return EXIT_SUCCESS; } - if (! $this->setNewEncryptionKey($encodedKey, $params)) { - CLI::write('Error in setting new encryption key to .env file.', 'light_gray', 'red'); - CLI::newLine(); + $currentKey = env('encryption.key', ''); + + if ($currentKey !== '' && $options['force'] === false) { + CLI::error('Setting new encryption key aborted.'); + + if (! $this->isInteractive()) { + CLI::error('If you want, use the "--force" option to force overwrite the existing key.'); + } + + return EXIT_ERROR; + } + + if (! $this->writeNewEncryptionKeyToFile($currentKey, $encodedKey)) { + CLI::write('Error in setting new encryption key to .env file.'); return EXIT_ERROR; } @@ -114,7 +134,7 @@ public function run(array $params) /** * Generates a key and encodes it. */ - protected function generateRandomKey(string $prefix, int $length): string + private function generateRandomKey(string $prefix, int $length): string { $key = Encryption::createKey($length); @@ -125,37 +145,10 @@ protected function generateRandomKey(string $prefix, int $length): string return 'base64:' . base64_encode($key); } - /** - * Sets the new encryption key in your .env file. - * - * @param array $params - */ - protected function setNewEncryptionKey(string $key, array $params): bool - { - $currentKey = env('encryption.key', ''); - - if ($currentKey !== '' && ! $this->confirmOverwrite($params)) { - // Not yet testable since it requires keyboard input - return false; // @codeCoverageIgnore - } - - return $this->writeNewEncryptionKeyToFile($currentKey, $key); - } - - /** - * Checks whether to overwrite existing encryption key. - * - * @param array $params - */ - protected function confirmOverwrite(array $params): bool - { - return (array_key_exists('force', $params) || CLI::getOption('force')) || CLI::prompt('Overwrite existing key?', ['n', 'y']) === 'y'; - } - /** * Writes the new encryption key to .env file. */ - protected function writeNewEncryptionKeyToFile(string $oldKey, string $newKey): bool + private function writeNewEncryptionKeyToFile(string $oldKey, string $newKey): bool { $baseEnv = ROOTPATH . 'env'; $envFile = ((new Paths())->envDirectory ?? ROOTPATH) . '.env'; // @phpstan-ignore nullCoalesce.property @@ -164,7 +157,6 @@ protected function writeNewEncryptionKeyToFile(string $oldKey, string $newKey): 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')); - CLI::newLine(); return false; } @@ -195,7 +187,7 @@ protected function writeNewEncryptionKeyToFile(string $oldKey, string $newKey): /** * Get the regex of the current encryption key. */ - protected function keyPattern(string $oldKey): string + private function keyPattern(string $oldKey): string { $escaped = preg_quote($oldKey, '/'); diff --git a/tests/system/Commands/Encryption/GenerateKeyTest.php b/tests/system/Commands/Encryption/GenerateKeyTest.php index a4fb452fd5a2..bea6d21b4074 100644 --- a/tests/system/Commands/Encryption/GenerateKeyTest.php +++ b/tests/system/Commands/Encryption/GenerateKeyTest.php @@ -13,10 +13,12 @@ namespace CodeIgniter\Commands\Encryption; +use CodeIgniter\CLI\CLI; 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; use PHPUnit\Framework\Attributes\PreserveGlobalState; @@ -39,6 +41,7 @@ protected function setUp(): void { parent::setUp(); + CLI::resetLastWrite(); Services::injectMock('superglobals', new Superglobals()); $this->envPath = ROOTPATH . '.env'; @@ -62,6 +65,9 @@ protected function tearDown(): void } $this->resetEnvironment(); + + CLI::resetLastWrite(); + CLI::reset(); } /** @@ -169,4 +175,98 @@ public function testKeyGenerateWhenNewBase64KeyIsSubsequentlyCommentedOut(): voi $this->assertStringContainsString('was successfully set.', $this->getBuffer()); $this->assertNotSame($key, env('encryption.key', $key), 'Failed replacing the commented out key.'); } + + /** + * Simulates a stale env cache: the `.env` file has a valid key, but + * `env('encryption.key')` resolves to '' because nothing has loaded it + * into the superglobals. The primary regex (built from `oldKey`) cannot + * locate the line, so the fallback regex must replace the existing entry. + */ + public function testKeyGenerateReplacesUnloadedKeyInDotEnvFile(): void + { + $existingKey = 'hex2bin:' . str_repeat('a', 64); + file_put_contents($this->envPath, "encryption.key = {$existingKey}\n"); + + $this->assertSame('', env('encryption.key', '')); + + command('key:generate --force'); + + $this->assertStringContainsString('was successfully set.', $this->getBuffer()); + + $contents = (string) file_get_contents($this->envPath); + $this->assertStringNotContainsString($existingKey, $contents); + $this->assertStringContainsString('encryption.key = ' . env('encryption.key'), $contents); + } + + public function testKeyGenerateAbortsWhenOverwritePromptIsDeclined(): void + { + command('key:generate'); + $key = env('encryption.key', ''); + $this->assertNotSame('', $key); + + $io = new MockInputOutput(); + $io->setInputs(['n']); + CLI::setInputOutput($io); + + 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()); + } + + public function testKeyGenerateOverwritesWhenOverwritePromptIsConfirmed(): void + { + command('key:generate'); + $oldKey = env('encryption.key', ''); + $this->assertNotSame('', $oldKey); + + $io = new MockInputOutput(); + $io->setInputs(['y']); + CLI::setInputOutput($io); + + 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()); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testKeyGenerateAbortsNonInteractivelyWithExistingKey(): void + { + command('key:generate'); + $key = env('encryption.key', ''); + $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()); + } + + public function testKeyGenerateErrorsOnInvalidPrefixNonInteractively(): void + { + command('key:generate --prefix invalid --show --no-interaction'); + + $this->assertStringContainsString('Invalid prefix "invalid"', $this->getBuffer()); + } + + public function testKeyGeneratePromptsForInvalidPrefix(): void + { + $io = new MockInputOutput(); + $io->setInputs(['hex2bin']); + CLI::setInputOutput($io); + + command('key:generate --prefix invalid --show'); + + $this->assertStringContainsString('Please provide a valid prefix to use.', $io->getOutput()); + $this->assertStringContainsString('hex2bin:', $io->getOutput()); + } }