diff --git a/src/CLI.php b/src/CLI.php index a4618ab..0821454 100644 --- a/src/CLI.php +++ b/src/CLI.php @@ -573,6 +573,61 @@ public static function secret(string $question) : string return \trim((string) \fgets(\STDIN)); } + /** + * Prompt a question with a masked answer. + * + * Instead of disabling the terminal echo like secret(), each typed + * character is echoed as the given mask character. On Windows or when + * the required POSIX functions are unavailable, input is still read + * normally and simply not echoed. + * + * @param string $question The question to prompt + * @param string $mask The character echoed for each typed character + * + * @return string The masked answer + */ + public static function masked(string $question, string $mask = '*') : string + { + $question .= ': '; + \fwrite(\STDOUT, $question); + if (static::isWindows() + || !\function_exists('shell_exec') + || !\function_exists('stream_isatty') + || !\stream_isatty(\STDIN) + ) { + // Without an interactive TTY the echo cannot be controlled, + // so fall back to a plain line read. + return \trim((string) \fgets(\STDIN)); + } + @\shell_exec('stty -echo 2>/dev/null'); + $answer = ''; + try { + while (true) { + $char = \fgetc(\STDIN); + $code = $char === false ? 0 : \ord($char); + if ($char === false || $code === 10) { + break; + } + if ($code === 13) { + // consume the \n that follows on Windows-style line endings + \fgetc(\STDIN); + break; + } + if ($code === 127 || $code === 8) { + $answer = \substr($answer, 0, -1); + \fwrite(\STDOUT, \chr(8) . ' ' . \chr(8)); + continue; + } + $answer .= $char; + \fwrite(\STDOUT, $mask); + } + \fwrite(\STDOUT, \PHP_EOL); + return $answer; + } finally { + @\shell_exec('stty echo 2>/dev/null'); + } + } + /** * Creates a well formatted table. * diff --git a/src/Command.php b/src/Command.php index 7e051dd..9a14fb2 100644 --- a/src/Command.php +++ b/src/Command.php @@ -333,11 +333,10 @@ public function setOptionDefinitions(array $definitions) : static */ public function validate(array $arguments, array $options) : array { - $errors = \array_merge( + return \array_merge( $this->validateDefinitions($this->argumentDefinitions, $arguments, 'argument'), $this->validateDefinitions($this->optionDefinitions, $options, 'option') ); - return $errors; } /** diff --git a/tests/MaskedTest.php b/tests/MaskedTest.php new file mode 100644 index 0000000..10221d9 --- /dev/null +++ b/tests/MaskedTest.php @@ -0,0 +1,102 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Tests\CLI; + +use Framework\CLI\CLI; +use PHPUnit\Framework\TestCase; + +/** + * Class MaskedTest. + */ +final class MaskedTest extends TestCase +{ + /** + * Runs masked() in a real POSIX subprocess on a pty: each typed character + * must be echoed as the mask character and the real answer returned. + */ + public function testMaskedEchoesMaskCharactersOnPosix() : void + { + if (CLI::isWindows()) { + self::markTestSkipped('Requires a POSIX terminal for stty based masking.'); + } + \exec('command -v script 2>/dev/null', $check, $found); + if ($found !== 0 || $check === []) { + self::markTestSkipped('The script command is required to emulate a TTY.'); + } + $script = <<<'PHP' + use Framework\CLI\CLI; + + $answer = CLI::masked('Token', '#'); + \fwrite(\STDOUT, 'answer=' . $answer); + PHP; + [$exitCode, $output] = $this->runScript($script, 's3cret', 'pty'); + $output = \str_replace("\r", '', $output); + self::assertSame(0, $exitCode); + self::assertStringContainsString('Token: ######', $output); + self::assertStringContainsString('answer=s3cret', $output); + // The answer must not appear as an echo after the prompt. + self::assertStringNotContainsString('Token: s3cret', $output); + } + + /** + * Without a TTY, masked() reads the line normally: the answer is + * returned but no mask characters are echoed. + */ + public function testMaskedWithoutTtyFallsBackToPlainRead() : void + { + if (CLI::isWindows()) { + self::markTestSkipped('Requires a POSIX pipe for the no TTY fallback.'); + } + $script = <<<'PHP' + use Framework\CLI\CLI; + + $answer = CLI::masked('Token', '#'); + \fwrite(\STDOUT, 'answer=' . $answer); + PHP; + [$exitCode, $output] = $this->runScript($script, 's3cret', 'pipe'); + $output = \str_replace("\r", '', $output); + self::assertSame(0, $exitCode); + self::assertStringNotContainsString('######', $output); + self::assertStringContainsString('answer=s3cret', $output); + } + + /** + * Run a script as a real PHP process, either with STDIN as a plain pipe + * or attached to a pseudo terminal (via the script utility) so stty + * based masking can be exercised. + * + * @param string $stdin The text fed to the script on STDIN + * @param string $mode 'pipe' or 'pty' + * + * @return array{0:int,1:string} + */ + private function runScript(string $code, string $stdin, string $mode = 'pipe') : array + { + $autoloader = \dirname(__DIR__) . '/vendor/autoload.php'; + $file = \sys_get_temp_dir() . '/webisters-cli-masked-' . \uniqid() . '.php'; + \file_put_contents( + $file, + '&1'; + } else { + $command = 'echo ' . \escapeshellarg($stdin) . ' | ' . $php . ' 2>&1'; + } + \exec($command, $output, $exitCode); + + \unlink($file); + + return [$exitCode, \implode("\n", $output)]; + } +}