From 9f966a361d697d6455160854278be1054be28a93 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sun, 10 May 2026 03:01:33 +0800 Subject: [PATCH 1/3] feat: add `AbstractCommand::callSilently()` --- system/CLI/AbstractCommand.php | 25 ++++++++ system/CLI/CLI.php | 12 ++-- system/CLI/NullInputOutput.php | 32 ++++++++++ .../Commands/Modern/AppAboutCommand.php | 5 ++ tests/system/CLI/AbstractCommandTest.php | 33 ++++++++++ tests/system/CLI/NullInputOutputTest.php | 63 +++++++++++++++++++ user_guide_src/source/changelogs/v4.8.0.rst | 3 + .../source/cli/cli_modern_commands.rst | 25 ++++++++ .../source/cli/cli_modern_commands/012.php | 12 ++++ 9 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 system/CLI/NullInputOutput.php create mode 100644 tests/system/CLI/NullInputOutputTest.php create mode 100644 user_guide_src/source/cli/cli_modern_commands/012.php diff --git a/system/CLI/AbstractCommand.php b/system/CLI/AbstractCommand.php index e92512e45060..f66063972edb 100644 --- a/system/CLI/AbstractCommand.php +++ b/system/CLI/AbstractCommand.php @@ -504,6 +504,31 @@ protected function call(string $command, array $arguments = [], array $options = return $this->commands->runCommand($command, $arguments, $this->resolveChildInteractiveState($options, $noInteractionOverride)); } + /** + * Like `call()`, but suppresses the sub-command's output. Defaults to non-interactive + * since a silenced sub-command cannot meaningfully prompt. + * + * @param list $arguments Parsed arguments from command line. + * @param array|string|null> $options Parsed options from command line. + * @param bool|null $noInteractionOverride See `call()` for the semantics. + */ + protected function callSilently(string $command, array $arguments = [], array $options = [], ?bool $noInteractionOverride = true): int + { + $priorIo = CLI::getInputOutput(); + + CLI::setInputOutput(new NullInputOutput()); + + try { + return $this->call($command, $arguments, $options, $noInteractionOverride); + } finally { + if ($priorIo instanceof InputOutput) { + CLI::setInputOutput($priorIo); + } else { + CLI::resetInputOutput(); + } + } + } + /** * Gets the unbound arguments that can be passed to other commands when called via the `call()` method. * diff --git a/system/CLI/CLI.php b/system/CLI/CLI.php index 75dc24f35f5f..78c21a4115a6 100644 --- a/system/CLI/CLI.php +++ b/system/CLI/CLI.php @@ -1166,8 +1166,14 @@ public static function resetLastWrite(): void } /** - * Testing purpose only - * + * @internal + */ + public static function getInputOutput(): ?InputOutput + { + return static::$io; + } + + /** * @internal */ public static function setInputOutput(InputOutput $io): void @@ -1176,8 +1182,6 @@ public static function setInputOutput(InputOutput $io): void } /** - * Testing purpose only - * * @internal */ public static function resetInputOutput(): void diff --git a/system/CLI/NullInputOutput.php b/system/CLI/NullInputOutput.php new file mode 100644 index 000000000000..a0fa897dbe61 --- /dev/null +++ b/system/CLI/NullInputOutput.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +/** + * An InputOutput sink that discards all output and never reads input. + * + * Backs `AbstractCommand::callSilently()` to silence a sub-command's + * output when the parent wants to emit its own consolidated message instead. + */ +final class NullInputOutput extends InputOutput +{ + public function fwrite($handle, string $string): void + { + } + + public function input(?string $prefix = null): string + { + return ''; + } +} diff --git a/tests/_support/Commands/Modern/AppAboutCommand.php b/tests/_support/Commands/Modern/AppAboutCommand.php index ca53fe9d35c4..87c52c1e1590 100644 --- a/tests/_support/Commands/Modern/AppAboutCommand.php +++ b/tests/_support/Commands/Modern/AppAboutCommand.php @@ -62,6 +62,11 @@ public function helpMe(): int return $this->call('help'); } + public function helpMeSilently(): int + { + return $this->callSilently('help'); + } + /** * @param array|string|null>|null $options */ diff --git a/tests/system/CLI/AbstractCommandTest.php b/tests/system/CLI/AbstractCommandTest.php index d3afb3558934..5a3fd8e0a150 100644 --- a/tests/system/CLI/AbstractCommandTest.php +++ b/tests/system/CLI/AbstractCommandTest.php @@ -34,6 +34,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use ReflectionClass; +use ReflectionProperty; use Tests\Support\Commands\Modern\AppAboutCommand; use Tests\Support\Commands\Modern\InteractFixtureCommand; use Tests\Support\Commands\Modern\InteractiveStateProbeCommand; @@ -257,6 +258,38 @@ public function testCommandCanCallAnotherCommand(): void $this->assertStringContainsString('help [options] [--] []', $this->getStreamFilterBuffer()); } + public function testCallSilentlySuppressesSubCommandOutputAndReturnsExitCode(): void + { + $command = new AppAboutCommand(new Commands()); + + $this->assertSame(EXIT_SUCCESS, $command->helpMeSilently()); + $this->assertSame('', $this->getStreamFilterBuffer()); + } + + public function testCallSilentlyRestoresPriorIo(): void + { + $custom = new InputOutput(); + CLI::setInputOutput($custom); + + $command = new AppAboutCommand(new Commands()); + $command->helpMeSilently(); + + $this->assertSame($custom, CLI::getInputOutput()); + } + + public function testCallSilentlyResetsToFreshInputOutputWhenPriorWasNull(): void + { + $property = new ReflectionProperty(CLI::class, 'io'); + $property->setValue(null, null); + + $command = new AppAboutCommand(new Commands()); + $command->helpMeSilently(); + + $current = CLI::getInputOutput(); + $this->assertInstanceOf(InputOutput::class, $current); + $this->assertNotInstanceOf(NullInputOutput::class, $current); + } + public function testRunCommand(): void { command('app:about a'); diff --git a/tests/system/CLI/NullInputOutputTest.php b/tests/system/CLI/NullInputOutputTest.php new file mode 100644 index 000000000000..4008bc7bf6ff --- /dev/null +++ b/tests/system/CLI/NullInputOutputTest.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\StreamFilterTrait; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class NullInputOutputTest extends CIUnitTestCase +{ + use StreamFilterTrait; + + public function testFwriteDiscardsOutput(): void + { + $io = new NullInputOutput(); + $io->fwrite(STDOUT, 'should not appear'); + $io->fwrite(STDERR, 'should not appear either'); + + $this->assertSame('', $this->getStreamFilterBuffer()); + } + + public function testInputReturnsEmptyStringWithoutEchoingPrefix(): void + { + $io = new NullInputOutput(); + + $this->assertSame('', $io->input()); + $this->assertSame('', $io->input('any prefix > ')); + $this->assertSame('', $this->getStreamFilterBuffer()); + } + + public function testCanBeSwappedIntoCliToSilenceWrites(): void + { + $prior = CLI::getInputOutput(); + CLI::setInputOutput(new NullInputOutput()); + + try { + CLI::write('this should be discarded'); + CLI::error('this too'); + $this->assertSame('', $this->getStreamFilterBuffer()); + } finally { + if ($prior instanceof InputOutput) { + CLI::setInputOutput($prior); + } else { + CLI::resetInputOutput(); + } + } + } +} diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 29e3aba1acfb..e967ba5a5c4f 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -193,6 +193,9 @@ Commands When used with ``CLI::getOption()``, an array option will return its last value (for example, in this case, ``value2``). To retrieve all values for an array option, use ``CLI::getRawOption()``. - Likewise, the ``command()`` function now also supports the above enhancements for command-line option parsing when using the function to run commands from code. - Added ``make:request`` generator command to scaffold :ref:`Form Request ` classes. +- Added ``AbstractCommand::callSilently()`` to invoke another command with its output discarded, restoring the prior IO afterwards. See :ref:`modern-commands-call-silently`. +- Added :php:class:`NullInputOutput `, an :php:class:`InputOutput ` sink that discards all writes and returns an empty string from ``input()``. +- Added ``CLI::getInputOutput()`` (``@internal``) as a getter symmetric to ``CLI::setInputOutput()`` and ``CLI::resetInputOutput()``. Testing ======= diff --git a/user_guide_src/source/cli/cli_modern_commands.rst b/user_guide_src/source/cli/cli_modern_commands.rst index 7df061cd9969..c9d805642149 100644 --- a/user_guide_src/source/cli/cli_modern_commands.rst +++ b/user_guide_src/source/cli/cli_modern_commands.rst @@ -273,6 +273,21 @@ To forward the caller's own input through to the target command, pass .. literalinclude:: cli_modern_commands/008.php +.. _modern-commands-call-silently: + +Calling Silently +================ + +When a command delegates a step to another command but wants to emit its own +consolidated message instead of letting the sub-command's output leak through, +use ``$this->callSilently()``: + +.. literalinclude:: cli_modern_commands/012.php + +The sub-command's output is suppressed and ``$noInteractionOverride`` defaults +to ``true``, since a silenced sub-command cannot meaningfully prompt. Pass an +explicit value to override. + ************** Usage Examples ************** @@ -546,6 +561,16 @@ covered in the sections above and are not listed here. Invokes another modern command. The arguments and options go through bind and validate on the target command, just like a user invocation. + .. php:method:: callSilently(string $command[, array $arguments = [], array $options = [], ?bool $noInteractionOverride = true]): int + + :param string $command: The name of the modern command to call. + :param array $arguments: Positional arguments to forward. + :param array $options: Options to forward, keyed by long name, shortcut, or negation. + :param bool|null $noInteractionOverride: See :php:meth:`call`. Defaults to ``true``. + :returns: The exit code returned by the called command. + + Like :php:meth:`call`, but suppresses the sub-command's output. + .. php:method:: getUnboundArguments(): array Returns the raw, parsed positional arguments as passed to the diff --git a/user_guide_src/source/cli/cli_modern_commands/012.php b/user_guide_src/source/cli/cli_modern_commands/012.php new file mode 100644 index 000000000000..0963ccccc08c --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/012.php @@ -0,0 +1,12 @@ +callSilently('cache:clear'); + +if ($exitCode === EXIT_SUCCESS) { + CLI::write('Cache cleared as part of deploy step.', 'green'); +} From 8ad03eb9f937d37cbd5b7be22f33e4a4f22c7fef Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sun, 10 May 2026 03:26:28 +0800 Subject: [PATCH 2/3] feat: add `key:rotate` command --- system/Commands/Encryption/RotateKey.php | 271 +++++++++ .../Commands/Encryption/RotateKeyTest.php | 540 ++++++++++++++++++ user_guide_src/source/changelogs/v4.8.0.rst | 1 + .../source/libraries/encryption.rst | 26 + 4 files changed, 838 insertions(+) create mode 100644 system/Commands/Encryption/RotateKey.php create mode 100644 tests/system/Commands/Encryption/RotateKeyTest.php diff --git a/system/Commands/Encryption/RotateKey.php b/system/Commands/Encryption/RotateKey.php new file mode 100644 index 000000000000..6dceb54d7cbc --- /dev/null +++ b/system/Commands/Encryption/RotateKey.php @@ -0,0 +1,271 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Encryption; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Option; +use Config\Paths; + +/** + * Rotates the encryption key, demoting the current key to `previousKeys`. + */ +#[Command( + name: 'key:rotate', + description: 'Rotates the encryption key, demoting the current key to `encryption.previousKeys` in the `.env` file.', + group: 'Encryption', +)] +class RotateKey extends AbstractCommand +{ + /** + * @var list + */ + private const VALID_PREFIXES = ['hex2bin', 'base64']; + + protected function configure(): void + { + $this + ->addOption(new Option( + name: 'force', + shortcut: 'f', + description: 'Skip the key rotation confirmation.', + )) + ->addOption(new Option( + name: 'length', + description: 'The length of the random string for the new key, in bytes.', + requiresValue: true, + default: '32', + )) + ->addOption(new Option( + name: 'prefix', + description: 'Prefix for the new key (either hex2bin or base64).', + requiresValue: true, + default: 'hex2bin', + )) + ->addOption(new Option( + name: 'keep', + description: 'Maximum number of previous keys to retain. Older keys are dropped. 0 means unlimited.', + requiresValue: true, + default: '0', + )); + } + + protected function interact(array &$arguments, array &$options): void + { + $prefix = $this->getUnboundOption('prefix', $options); + + 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'); + } + + if ($this->hasUnboundOption('force', $options)) { + return; + } + + if (env('encryption.key', '') === '') { + return; + } + + if (CLI::prompt('Rotate encryption key? The current key will be moved to `previousKeys`.', ['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($prefix, self::VALID_PREFIXES, true)) { + CLI::error(sprintf('Invalid prefix "%s". Use either "hex2bin" or "base64".', $prefix)); + + return EXIT_ERROR; + } + + $currentKey = env('encryption.key', ''); + + if ($currentKey === '') { + CLI::error('No existing `encryption.key` to rotate. Run `spark key:generate` first.'); + + return EXIT_ERROR; + } + + if ($options['force'] === false) { + if ($this->isInteractive()) { + CLI::error('Key rotation cancelled.'); + } else { + CLI::error('Key rotation aborted.'); + CLI::error('If you want, use the "--force" option to force the rotation.'); + } + + return EXIT_ERROR; + } + + $keep = $options['keep']; + + if (! is_numeric($keep) || (int) $keep < 0) { + CLI::error('The --keep option must be a non-negative integer.'); + + return EXIT_ERROR; + } + + $length = $options['length']; + + if (! is_numeric($length) || (int) $length < 1) { + CLI::error('The --length option must be a positive integer.'); + + return EXIT_ERROR; + } + + $previousKeys = $this->mergePreviousKeys($currentKey, $this->parsePreviousKeys(), (int) $keep); + + // Write previousKeys first. If the subsequent `key:generate` call fails, + // the worst case is a stale-but-still-decryptable `.env` (the rotated-out + // key is preserved on disk). + if (! $this->writePreviousKeys($previousKeys)) { + CLI::error('Error in writing `encryption.previousKeys` to `.env` file.'); + + return EXIT_ERROR; + } + + // Clear `encryption.previousKeys` from all env sources so the DotEnv + // reload triggered by `key:generate` picks up the new value (DotEnv's + // `setVariable()` skips vars that are already set). + putenv('encryption.previousKeys'); + unset($_ENV['encryption.previousKeys']); + service('superglobals')->unsetServer('encryption.previousKeys'); + + $exitCode = $this->callSilently('key:generate', options: [ + 'force' => null, + 'prefix' => $prefix, + 'length' => $length, + ]); + + if ($exitCode !== EXIT_SUCCESS) { + return $exitCode; // @codeCoverageIgnore + } + + $count = count($previousKeys); + + CLI::write(sprintf( + 'Encryption key rotated. %d %s retained for decryption fallback.', + $count, + $count === 1 ? 'previous key' : 'previous keys', + ), 'green'); + CLI::write('Re-encrypt existing data with the new key when ready.', 'yellow'); + + return EXIT_SUCCESS; + } + + /** + * Reads the existing `encryption.previousKeys` from the environment as a + * comma-separated list, ignoring blank entries. + * + * @return list + */ + private function parsePreviousKeys(): array + { + $raw = env('encryption.previousKeys', ''); + + if (! is_string($raw) || $raw === '') { + return []; + } + + return array_values(array_filter( + array_map(trim(...), explode(',', $raw)), + static fn (string $v): bool => $v !== '', + )); + } + + /** + * Prepends the rotated-out key, deduplicates while preserving newest-first order, + * and optionally caps the list length. + * + * @param list $existing + * + * @return list + */ + private function mergePreviousKeys(string $currentKey, array $existing, int $keep): array + { + $merged = [$currentKey, ...$existing]; + $seen = []; + $result = []; + + foreach ($merged as $key) { + if (isset($seen[$key])) { + continue; + } + + $seen[$key] = true; + $result[] = $key; + } + + if ($keep > 0) { + $result = array_slice($result, 0, $keep); + } + + return $result; + } + + /** + * Replaces or inserts the `encryption.previousKeys` line in the `.env` file. + * `key:generate` is responsible for the file's existence and the + * `encryption.key` line; this method only touches `encryption.previousKeys`. + * + * @param list $previousKeys + */ + private function writePreviousKeys(array $previousKeys): bool + { + $envFile = ((new Paths())->envDirectory ?? ROOTPATH) . '.env'; // @phpstan-ignore nullCoalesce.property + + if (! is_file($envFile)) { + return false; // @codeCoverageIgnore + } + + if (! is_writable($envFile)) { + return false; + } + + $contents = (string) file_get_contents($envFile); + $value = implode(',', $previousKeys); + + // Match an actual setting line, not a substring buried in a comment. The optional + // `export` prefix mirrors what DotEnv accepts. + $previousKeysPattern = '/^(\h*(?:export\h+)?encryption\.previousKeys\h*=\h*)[^\r\n]*$/m'; + + if (preg_match($previousKeysPattern, $contents) === 1) { + $contents = (string) preg_replace($previousKeysPattern, '$1' . $value, $contents, 1); + + return file_put_contents($envFile, $contents) !== false; + } + + // Insert right after the `encryption.key` line so the two stay grouped. + $injected = (string) preg_replace( + '/^(\h*(?:export\h+)?encryption\.key\h*=\h*[^\r\n]*)$/m', + "$1\nencryption.previousKeys = {$value}", + $contents, + 1, + ); + + if ($injected === $contents) { + // @codeCoverageIgnoreStart + // Fallback: append to the end. Shouldn't trigger because `key:generate` + // writes the `encryption.key` line just before this method runs. + $injected = $contents . "\nencryption.previousKeys = {$value}"; + // @codeCoverageIgnoreEnd + } + + return file_put_contents($envFile, $injected) !== false; + } +} diff --git a/tests/system/Commands/Encryption/RotateKeyTest.php b/tests/system/Commands/Encryption/RotateKeyTest.php new file mode 100644 index 000000000000..b24753c28727 --- /dev/null +++ b/tests/system/Commands/Encryption/RotateKeyTest.php @@ -0,0 +1,540 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Encryption; + +use CodeIgniter\CLI\CLI; +use CodeIgniter\Config\DotEnv; +use CodeIgniter\Config\Services; +use CodeIgniter\Superglobals; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockInputOutput; +use CodeIgniter\Test\StreamFilterTrait; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\WithoutErrorHandler; + +/** + * @internal + */ +#[Group('Others')] +final class RotateKeyTest extends CIUnitTestCase +{ + use StreamFilterTrait; + + private const SEED_KEY = 'hex2bin:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + + private string $envPath; + private string $backupEnvPath; + + #[WithoutErrorHandler] + protected function setUp(): void + { + parent::setUp(); + + CLI::resetLastWrite(); + Services::injectMock('superglobals', new Superglobals()); + + $this->envPath = ROOTPATH . '.env'; + $this->backupEnvPath = ROOTPATH . '.env.backup'; + + if (is_file($this->envPath)) { + rename($this->envPath, $this->backupEnvPath); + } + + $this->resetEnvironment(); + } + + protected function tearDown(): void + { + if (is_file($this->envPath)) { + unlink($this->envPath); + } + + if (is_file($this->backupEnvPath)) { + rename($this->backupEnvPath, $this->envPath); + } + + $this->resetEnvironment(); + $this->resetServices(); + + CLI::reset(); + } + + private function getUndecoratedBuffer(): string + { + return preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()) ?? ''; + } + + private static function getUndecoratedIoOutput(MockInputOutput $io): string + { + return preg_replace('/\e\[[^m]+m/', '', $io->getOutput()) ?? ''; + } + + private function resetEnvironment(): void + { + putenv('encryption.key'); + putenv('encryption.previousKeys'); + unset($_ENV['encryption.key'], $_ENV['encryption.previousKeys']); + + $superglobals = service('superglobals'); + $superglobals->unsetServer('encryption.key'); + $superglobals->unsetServer('encryption.previousKeys'); + } + + private function seedEnv(string $key, string $previousKeys = ''): void + { + $content = "encryption.key = {$key}\n"; + + if ($previousKeys !== '') { + $content .= "encryption.previousKeys = {$previousKeys}\n"; + } + + file_put_contents($this->envPath, $content); + + $this->resetEnvironment(); + (new DotEnv(ROOTPATH))->load(); + } + + public function testRotateMovesCurrentKeyToPreviousKeysAndGeneratesNew(): void + { + $this->seedEnv(self::SEED_KEY); + + command('key:rotate --force'); + + $this->assertSame( + <<<'EOT' + Encryption key rotated. 1 previous key retained for decryption fallback. + Re-encrypt existing data with the new key when ready. + + EOT, + $this->getUndecoratedBuffer(), + ); + + $contents = (string) file_get_contents($this->envPath); + $this->assertMatchesRegularExpression( + '/^encryption\.key = hex2bin:[a-f0-9]{64}\nencryption\.previousKeys = ' . preg_quote(self::SEED_KEY, '/') . '$/m', + $contents, + 'previousKeys should be inserted on the line directly after encryption.key.', + ); + $this->assertNotSame(self::SEED_KEY, env('encryption.key')); + $this->assertSame(self::SEED_KEY, env('encryption.previousKeys')); + } + + public function testRotatePrependsToExistingPreviousKeysList(): void + { + $older = 'hex2bin:' . str_repeat('a', 64); + $oldest = 'hex2bin:' . str_repeat('b', 64); + $this->seedEnv(self::SEED_KEY, "{$older},{$oldest}"); + + command('key:rotate --force'); + + $this->assertSame( + <<<'EOT' + Encryption key rotated. 3 previous keys retained for decryption fallback. + Re-encrypt existing data with the new key when ready. + + EOT, + $this->getUndecoratedBuffer(), + ); + + $this->assertSame( + self::SEED_KEY . ",{$older},{$oldest}", + env('encryption.previousKeys'), + ); + } + + public function testRotateDeduplicatesWhenCurrentKeyAlreadyInPreviousKeys(): void + { + $other = 'hex2bin:' . str_repeat('a', 64); + $this->seedEnv(self::SEED_KEY, self::SEED_KEY . ",{$other}"); + + command('key:rotate --force'); + + $contents = (string) file_get_contents($this->envPath); + $this->assertSame( + self::SEED_KEY . ",{$other}", + env('encryption.previousKeys'), + 'Current key should not appear twice in the rotated list.', + ); + $this->assertSame( + 1, + substr_count($contents, 'encryption.previousKeys = '), + 'Should rewrite the previousKeys line in place rather than appending a duplicate.', + ); + $this->assertStringNotContainsString( + "\n\nencryption.previousKeys", + $contents, + 'In-place replacement should not introduce a blank line before encryption.previousKeys.', + ); + } + + public function testRotateRespectsKeepLimit(): void + { + $a = 'hex2bin:' . str_repeat('a', 64); + $b = 'hex2bin:' . str_repeat('b', 64); + $c = 'hex2bin:' . str_repeat('c', 64); + $this->seedEnv(self::SEED_KEY, "{$a},{$b},{$c}"); + + command('key:rotate --force --keep=2'); + + $this->assertSame( + self::SEED_KEY . ",{$a}", + env('encryption.previousKeys'), + ); + $contents = (string) file_get_contents($this->envPath); + $this->assertStringNotContainsString($b, $contents); + $this->assertStringNotContainsString($c, $contents); + } + + public function testRotateRespectsKeepLimitOfOne(): void + { + $older = 'hex2bin:' . str_repeat('a', 64); + $oldest = 'hex2bin:' . str_repeat('b', 64); + $this->seedEnv(self::SEED_KEY, "{$older},{$oldest}"); + + command('key:rotate --force --keep=1'); + + $this->assertSame(self::SEED_KEY, env('encryption.previousKeys')); + $contents = (string) file_get_contents($this->envPath); + $this->assertStringNotContainsString($older, $contents); + $this->assertStringNotContainsString($oldest, $contents); + } + + public function testRotateErrorsWhenNoCurrentKey(): void + { + file_put_contents($this->envPath, "# encryption.key =\n"); + $this->resetEnvironment(); + (new DotEnv(ROOTPATH))->load(); + + command('key:rotate --force'); + + $this->assertSame( + <<<'EOT' + + No existing `encryption.key` to rotate. Run `spark key:generate` first. + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertStringNotContainsString('encryption.previousKeys', (string) file_get_contents($this->envPath)); + } + + public function testRotateAbortsWhenOverwritePromptIsDeclined(): void + { + $this->seedEnv(self::SEED_KEY); + + $io = new MockInputOutput(); + $io->setInputs(['n']); + CLI::setInputOutput($io); + + command('key:rotate'); + + $this->assertSame( + <<<'EOT' + Rotate encryption key? The current key will be moved to `previousKeys`. [n, y]: n + Key rotation cancelled. + + EOT, + self::getUndecoratedIoOutput($io), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + $this->assertStringContainsString(self::SEED_KEY, (string) file_get_contents($this->envPath)); + } + + public function testRotateOverwritesWhenOverwritePromptIsConfirmed(): void + { + $this->seedEnv(self::SEED_KEY); + + $io = new MockInputOutput(); + $io->setInputs(['y']); + CLI::setInputOutput($io); + + command('key:rotate --prefix base64'); + + $this->assertSame( + <<<'EOT' + Rotate encryption key? The current key will be moved to `previousKeys`. [n, y]: y + Encryption key rotated. 1 previous key retained for decryption fallback. + Re-encrypt existing data with the new key when ready. + + EOT, + self::getUndecoratedIoOutput($io), + ); + $this->assertNotSame(self::SEED_KEY, env('encryption.key')); + $this->assertSame(self::SEED_KEY, env('encryption.previousKeys')); + } + + public function testRotateAbortsNonInteractivelyAndHintsAboutForceFlag(): void + { + $this->seedEnv(self::SEED_KEY); + + command('key:rotate --no-interaction'); + + $this->assertSame( + <<<'EOT' + + Key rotation aborted. + If you want, use the "--force" option to force the rotation. + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + } + + public function testRotateWithBase64Prefix(): void + { + $this->seedEnv(self::SEED_KEY); + + command('key:rotate --prefix base64 --force'); + + $contents = (string) file_get_contents($this->envPath); + $this->assertMatchesRegularExpression('/^encryption\.key = base64:[A-Za-z0-9+\/]+={0,2}$/m', $contents); + $this->assertSame(self::SEED_KEY, env('encryption.previousKeys')); + } + + public function testRotateErrorsOnInvalidPrefixNonInteractively(): void + { + $this->seedEnv(self::SEED_KEY); + + command('key:rotate --prefix invalid --no-interaction'); + + $this->assertSame( + <<<'EOT' + + Invalid prefix "invalid". Use either "hex2bin" or "base64". + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + } + + public function testRotateInteractRePromptsForInvalidPrefix(): void + { + $this->seedEnv(self::SEED_KEY); + + $io = new MockInputOutput(); + // First input answers the invalid-prefix recovery prompt; second answers the rotate confirmation. + $io->setInputs(['base64', 'y']); + CLI::setInputOutput($io); + + command('key:rotate --prefix invalid'); + + $output = self::getUndecoratedIoOutput($io); + $this->assertStringContainsString('Please provide a valid prefix to use. [hex2bin, base64]: base64', $output); + $this->assertStringContainsString('Encryption key rotated. 1 previous key retained for decryption fallback.', $output); + + $contents = (string) file_get_contents($this->envPath); + $this->assertMatchesRegularExpression('/^encryption\.key = base64:[A-Za-z0-9+\/]+={0,2}$/m', $contents); + $this->assertSame(self::SEED_KEY, env('encryption.previousKeys')); + } + + public function testRotateInteractSkipsConfirmationWhenNoCurrentKey(): void + { + file_put_contents($this->envPath, "# encryption.key =\n"); + $this->resetEnvironment(); + (new DotEnv(ROOTPATH))->load(); + + // No MockInputOutput inputs are set; if interact() reached the rotate prompt it would + // throw `LogicException('No input data...')` from `MockInputOutput::input()`. + $io = new MockInputOutput(); + CLI::setInputOutput($io); + + command('key:rotate'); + + $this->assertSame( + <<<'EOT' + + No existing `encryption.key` to rotate. Run `spark key:generate` first. + + EOT, + self::getUndecoratedIoOutput($io), + ); + } + + public function testRotateRejectsNegativeKeepValue(): void + { + $this->seedEnv(self::SEED_KEY); + + command('key:rotate --force --keep=-1'); + + $this->assertSame( + <<<'EOT' + + The --keep option must be a non-negative integer. + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + } + + public function testRotateRejectsNonNumericKeepValue(): void + { + $this->seedEnv(self::SEED_KEY); + + command('key:rotate --force --keep=abc'); + + $this->assertSame( + <<<'EOT' + + The --keep option must be a non-negative integer. + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + } + + public function testRotateRejectsNegativeLengthValue(): void + { + $this->seedEnv(self::SEED_KEY); + $envContentsBefore = (string) file_get_contents($this->envPath); + + command('key:rotate --force --length=-1'); + + $this->assertSame( + <<<'EOT' + + The --length option must be a positive integer. + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + $this->assertSame( + $envContentsBefore, + (string) file_get_contents($this->envPath), + 'Validation must reject the run before any .env mutation.', + ); + } + + public function testRotateRejectsZeroLengthValue(): void + { + $this->seedEnv(self::SEED_KEY); + $envContentsBefore = (string) file_get_contents($this->envPath); + + command('key:rotate --force --length=0'); + + $this->assertSame( + <<<'EOT' + + The --length option must be a positive integer. + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + $this->assertSame($envContentsBefore, (string) file_get_contents($this->envPath)); + } + + public function testRotateRejectsNonNumericLengthValue(): void + { + $this->seedEnv(self::SEED_KEY); + $envContentsBefore = (string) file_get_contents($this->envPath); + + command('key:rotate --force --length=abc'); + + $this->assertSame( + <<<'EOT' + + The --length option must be a positive integer. + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + $this->assertSame($envContentsBefore, (string) file_get_contents($this->envPath)); + } + + public function testRotateIgnoresCommentMentioningPreviousKeysWhenInserting(): void + { + $envContents = "# encryption.previousKeys is for decryption fallback\nencryption.key = " . self::SEED_KEY . "\n"; + file_put_contents($this->envPath, $envContents); + $this->resetEnvironment(); + (new DotEnv(ROOTPATH))->load(); + + command('key:rotate --force'); + + $contents = (string) file_get_contents($this->envPath); + $this->assertMatchesRegularExpression( + '/^encryption\.previousKeys = ' . preg_quote(self::SEED_KEY, '/') . '$/m', + $contents, + 'A real `encryption.previousKeys` setting must be written even when a comment mentions the name.', + ); + $this->assertSame(self::SEED_KEY, env('encryption.previousKeys')); + } + + public function testRotateReplacesPreviousKeysLineWithExportPrefix(): void + { + $existing = 'hex2bin:' . str_repeat('a', 64); + $envContents = 'encryption.key = ' . self::SEED_KEY . "\nexport encryption.previousKeys = {$existing}\n"; + file_put_contents($this->envPath, $envContents); + $this->resetEnvironment(); + (new DotEnv(ROOTPATH))->load(); + + command('key:rotate --force'); + + $contents = (string) file_get_contents($this->envPath); + $this->assertMatchesRegularExpression( + '/^export encryption\.previousKeys = ' . preg_quote(self::SEED_KEY . ',' . $existing, '/') . '$/m', + $contents, + 'The existing `export` prefix should be preserved and the value rewritten.', + ); + $this->assertSame( + self::SEED_KEY . ',' . $existing, + env('encryption.previousKeys'), + ); + } + + public function testRotateInsertsAfterExportPrefixedEncryptionKey(): void + { + $envContents = 'export encryption.key = ' . self::SEED_KEY . "\n"; + file_put_contents($this->envPath, $envContents); + $this->resetEnvironment(); + (new DotEnv(ROOTPATH))->load(); + + command('key:rotate --force'); + + $contents = (string) file_get_contents($this->envPath); + $this->assertMatchesRegularExpression( + '/^export encryption\.key = hex2bin:[a-f0-9]{64}\nencryption\.previousKeys = ' . preg_quote(self::SEED_KEY, '/') . '$/m', + $contents, + '`encryption.previousKeys` should be inserted on the line directly after an `export`-prefixed `encryption.key`.', + ); + $this->assertSame(self::SEED_KEY, env('encryption.previousKeys')); + } + + public function testRotateErrorsWhenEnvFileIsNotWritable(): void + { + $this->seedEnv(self::SEED_KEY); + chmod($this->envPath, 0o444); + + try { + command('key:rotate --force'); + + $this->assertSame( + <<<'EOT' + + Error in writing `encryption.previousKeys` to `.env` file. + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + } 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 e967ba5a5c4f..8b3ad2cd370a 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -193,6 +193,7 @@ Commands When used with ``CLI::getOption()``, an array option will return its last value (for example, in this case, ``value2``). To retrieve all values for an array option, use ``CLI::getRawOption()``. - Likewise, the ``command()`` function now also supports the above enhancements for command-line option parsing when using the function to run commands from code. - Added ``make:request`` generator command to scaffold :ref:`Form Request ` classes. +- Added ``key:rotate`` command to demote the current ``encryption.key`` to ``encryption.previousKeys`` in **.env** and generate a new key. See :ref:`spark-key-rotate`. - Added ``AbstractCommand::callSilently()`` to invoke another command with its output discarded, restoring the prior IO afterwards. See :ref:`modern-commands-call-silently`. - Added :php:class:`NullInputOutput `, an :php:class:`InputOutput ` sink that discards all writes and returns an empty string from ``input()``. - Added ``CLI::getInputOutput()`` (``@internal``) as a getter symmetric to ``CLI::setInputOutput()`` and ``CLI::resetInputOutput()``. diff --git a/user_guide_src/source/libraries/encryption.rst b/user_guide_src/source/libraries/encryption.rst index bbdfa1e053c9..1ed293185c12 100644 --- a/user_guide_src/source/libraries/encryption.rst +++ b/user_guide_src/source/libraries/encryption.rst @@ -226,6 +226,32 @@ Key Rotation Workflow operations always use the current ``key``. If you pass an explicit key via the ``$params`` argument to ``encrypt()`` or ``decrypt()``, the previousKeys fallback will not be used. +.. _spark-key-rotate: + +Rotating with the ``key:rotate`` Command +---------------------------------------- + +.. versionadded:: 4.8.0 + +Step 2 above (demoting the current ``key`` and generating a new one) can be performed with the +``key:rotate`` spark command, which edits the **.env** file in place:: + + php spark key:rotate + +The command reads ``encryption.key`` from your environment, prepends it to +``encryption.previousKeys`` (newest first, deduplicated), and writes a fresh ``encryption.key``. +Useful options: + +- ``--prefix`` (``hex2bin`` or ``base64``, default ``hex2bin``) and ``--length`` (positive + integer, default ``32``) control how the new key is generated, mirroring ``key:generate``. +- ``--keep=N`` caps the retained ``previousKeys`` list to the ``N`` most recent entries. ``N`` must + be a non-negative integer; ``0`` (the default) keeps every previous key. +- ``--force`` / ``-f`` skips the interactive confirmation. Required when running with + ``--no-interaction``. + +All three options are validated up-front, so an invalid value cannot leave the **.env** file +half-rotated. + Padding ======= From 47fd5c2d746d0413f90f7cbe1b93d5eb2391b7fa Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sun, 10 May 2026 03:58:30 +0800 Subject: [PATCH 3/3] improve UX in `key:rotate` command --- system/Commands/Encryption/RotateKey.php | 23 +++++++++---------- .../Commands/Encryption/RotateKeyTest.php | 16 +++++++------ 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/system/Commands/Encryption/RotateKey.php b/system/Commands/Encryption/RotateKey.php index 6dceb54d7cbc..8acf83614b7a 100644 --- a/system/Commands/Encryption/RotateKey.php +++ b/system/Commands/Encryption/RotateKey.php @@ -103,12 +103,13 @@ protected function execute(array $arguments, array $options): int if ($options['force'] === false) { if ($this->isInteractive()) { - CLI::error('Key rotation cancelled.'); - } else { - CLI::error('Key rotation aborted.'); - CLI::error('If you want, use the "--force" option to force the rotation.'); + CLI::write('Key rotation cancelled.', 'yellow'); + + return EXIT_SUCCESS; } + CLI::error('Key rotation aborted: pass --force to rotate the encryption key in non-interactive mode.'); + return EXIT_ERROR; } @@ -133,8 +134,10 @@ protected function execute(array $arguments, array $options): int // Write previousKeys first. If the subsequent `key:generate` call fails, // the worst case is a stale-but-still-decryptable `.env` (the rotated-out // key is preserved on disk). - if (! $this->writePreviousKeys($previousKeys)) { - CLI::error('Error in writing `encryption.previousKeys` to `.env` file.'); + $envFile = ((new Paths())->envDirectory ?? ROOTPATH) . '.env'; // @phpstan-ignore nullCoalesce.property + + if (! $this->writePreviousKeys($previousKeys, $envFile)) { + CLI::error(sprintf('Failed to write `encryption.previousKeys` to %s.', clean_path($envFile))); return EXIT_ERROR; } @@ -225,10 +228,8 @@ private function mergePreviousKeys(string $currentKey, array $existing, int $kee * * @param list $previousKeys */ - private function writePreviousKeys(array $previousKeys): bool + private function writePreviousKeys(array $previousKeys, string $envFile): bool { - $envFile = ((new Paths())->envDirectory ?? ROOTPATH) . '.env'; // @phpstan-ignore nullCoalesce.property - if (! is_file($envFile)) { return false; // @codeCoverageIgnore } @@ -259,11 +260,9 @@ private function writePreviousKeys(array $previousKeys): bool ); if ($injected === $contents) { - // @codeCoverageIgnoreStart // Fallback: append to the end. Shouldn't trigger because `key:generate` // writes the `encryption.key` line just before this method runs. - $injected = $contents . "\nencryption.previousKeys = {$value}"; - // @codeCoverageIgnoreEnd + $injected = $contents . "\nencryption.previousKeys = {$value}"; // @codeCoverageIgnore } return file_put_contents($envFile, $injected) !== false; diff --git a/tests/system/Commands/Encryption/RotateKeyTest.php b/tests/system/Commands/Encryption/RotateKeyTest.php index b24753c28727..fb61b20ae378 100644 --- a/tests/system/Commands/Encryption/RotateKeyTest.php +++ b/tests/system/Commands/Encryption/RotateKeyTest.php @@ -229,7 +229,7 @@ public function testRotateErrorsWhenNoCurrentKey(): void $this->assertStringNotContainsString('encryption.previousKeys', (string) file_get_contents($this->envPath)); } - public function testRotateAbortsWhenOverwritePromptIsDeclined(): void + public function testRotateCancelsWhenOverwritePromptIsDeclined(): void { $this->seedEnv(self::SEED_KEY); @@ -274,7 +274,7 @@ public function testRotateOverwritesWhenOverwritePromptIsConfirmed(): void $this->assertSame(self::SEED_KEY, env('encryption.previousKeys')); } - public function testRotateAbortsNonInteractivelyAndHintsAboutForceFlag(): void + public function testRotateAbortsNonInteractively(): void { $this->seedEnv(self::SEED_KEY); @@ -283,8 +283,7 @@ public function testRotateAbortsNonInteractivelyAndHintsAboutForceFlag(): void $this->assertSame( <<<'EOT' - Key rotation aborted. - If you want, use the "--force" option to force the rotation. + Key rotation aborted: pass --force to rotate the encryption key in non-interactive mode. EOT, $this->getUndecoratedBuffer(), @@ -525,11 +524,14 @@ public function testRotateErrorsWhenEnvFileIsNotWritable(): void command('key:rotate --force'); $this->assertSame( - <<<'EOT' + sprintf( + <<<'EOT' - Error in writing `encryption.previousKeys` to `.env` file. + Failed to write `encryption.previousKeys` to %s. - EOT, + EOT, + clean_path($this->envPath), + ), $this->getUndecoratedBuffer(), ); $this->assertSame(self::SEED_KEY, env('encryption.key'));