diff --git a/.github/workflows/php83.yaml b/.github/workflows/php83.yaml index 8ecb808..56bbbd1 100644 --- a/.github/workflows/php83.yaml +++ b/.github/workflows/php83.yaml @@ -32,8 +32,6 @@ jobs: name: Code Quality needs: test uses: WebFiori/workflows/.github/workflows/quality-sonarcloud.yaml@main - with: - coverage-file: 'php-8.3-coverage.xml' secrets: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c8e7451 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +## [2.0.1](https://github.com/WebFiori/cli/compare/v2.0.0...v2.0.1) (2025-10-06) + + +### Bug Fixes + +* Default Value for `select` ([6acd1fa](https://github.com/WebFiori/cli/commit/6acd1fac5f3b9e89b41b4d39a654c23321de5720)) + +## [2.0.0](https://github.com/WebFiori/cli/compare/v1.3.1...v2.0.0) (2025-09-27) + + +### Features + +* Aliasing of Commands ([660a179](https://github.com/WebFiori/cli/commit/660a1790ead3a7e0fc9d052422d376e038583f6e)) +* Auto-Discovery of Commands ([72c7fff](https://github.com/WebFiori/cli/commit/72c7fff4f37f42452534be8642cfc390e1e31214)) +* Help Command for All ([9d8772a](https://github.com/WebFiori/cli/commit/9d8772ac797f38d8790706667392e88428ef672c)) +* Table Display ([857ed5a](https://github.com/WebFiori/cli/commit/857ed5a38f78972934f301b58fc1a6ea3a4e616f)) +* Tables Display ([1cfbb48](https://github.com/WebFiori/cli/commit/1cfbb486ed6ee95994c60530e92dc4d05f1cae80)) + + +### Bug Fixes + +* App Path ([bdbbc6a](https://github.com/WebFiori/cli/commit/bdbbc6a7d68c3ccad98ba5bb129dcd3d763fcc6a)) +* Help Command ([e97ac83](https://github.com/WebFiori/cli/commit/e97ac83f1e2a0b39024d5c62861a6f19b168424d)) +* Namespaces Correction ([a07c08e](https://github.com/WebFiori/cli/commit/a07c08ea6bfa16879f88d1f2f004288f625f85bc)) +* Use of Self ([4bff72b](https://github.com/WebFiori/cli/commit/4bff72b218154f6d36957d8c67acdd09c31b2d7e)) + + +### Miscellaneous Chores + +* Added More Code Samples ([af30558](https://github.com/WebFiori/cli/commit/af30558522ba780a63fb3eb23c3cd20206178f8e)) +* Release 2.0.0 ([cb763c5](https://github.com/WebFiori/cli/commit/cb763c556bdbbd8538935eacf6936b233ff271d1)) +* Release 2.0.0 ([2a29b9d](https://github.com/WebFiori/cli/commit/2a29b9d53b6887ea8fb3157529b51d1fb05c00e4)) +* Update README.md ([5c940a1](https://github.com/WebFiori/cli/commit/5c940a1a287ea8633d9aab9e0634b8a2fc40a406)) +* Update README.md ([b4f1dcf](https://github.com/WebFiori/cli/commit/b4f1dcfa277fc0adc097e9244007ef3528a6b466)) +* Updated Config ([1df09ae](https://github.com/WebFiori/cli/commit/1df09ae140497270a65335db2b6b35c1d78d8cfc)) +* Updated README ([53c7471](https://github.com/WebFiori/cli/commit/53c7471629be117e61bb8b8c85e1a5d2cb0ccc83)) diff --git a/WebFiori/Cli/Command.php b/WebFiori/Cli/Command.php index 276f767..faafdd0 100644 --- a/WebFiori/Cli/Command.php +++ b/WebFiori/Cli/Command.php @@ -579,6 +579,56 @@ public function getInput(string $prompt, ?string $default = null, ?InputValidato return null; } /** + * Reads user input with characters masked by a specified character. + * + * This method is similar to getInput() but masks the input characters as the user types, + * making it suitable for sensitive information like passwords, tokens, or secrets. + * The actual input value is captured but only mask characters are displayed in the terminal. + * + * @param string $prompt The prompt message to display to the user. Must be non-empty. + * + * @param string $mask The character to display instead of the actual input characters. + * Default is '*'. Can be any single character or string. + * + * @param string|null $default An optional default value to use if the user provides + * empty input. If provided, it will be shown in the prompt. + * + * @param InputValidator|null $validator An optional validator to validate the input. + * If validation fails, the user will be prompted again. + * + * @return string|null Returns the actual input value (not masked) if valid input is provided, + * or null if the prompt is empty. + * + * @since 1.1.0 + */ + public function getMaskedInput(string $prompt, string $mask = '*', ?string $default = null, ?InputValidator $validator = null): ?string { + $trimmed = trim($prompt); + + if (strlen($trimmed) > 0) { + do { + $this->prints($trimmed, [ + 'color' => 'gray', + 'bold' => true + ]); + + if ($default !== null) { + $this->prints(" Enter = '".$default."'", [ + 'color' => 'light-blue' + ]); + } + $this->println(); + $input = trim($this->readMaskedLine($mask)); + + $check = $this->getInputHelper($input, $validator, $default); + + if ($check['valid']) { + return $check['value']; + } + } while (true); + } + + return null; + } /** * Returns the stream at which the command is sing to read inputs. * * @return null|InputStream If the stream is set, it will be returned as @@ -964,7 +1014,63 @@ public function readInteger(string $prompt, ?int $default = null) : int { public function readln() : string { return $this->getInputStream()->readLine(); } - + /** + * Reads a line from input stream with character masking. + * + * This method reads input character by character and displays mask characters + * instead of the actual input. It handles backspace for character deletion + * and ignores special keys like ESC and arrow keys. + * + * @param string $mask The character to display instead of actual input characters. + * + * @return string The actual input string (unmasked). + * + * @since 1.1.0 + */ + private function readMaskedLine(string $mask = '*'): string { + $input = ''; + + // For testing with ArrayInputStream, read the whole line at once + if ($this->getInputStream() instanceof \WebFiori\Cli\Streams\ArrayInputStream) { + $input = $this->getInputStream()->readLine(); + // Simulate masking output for testing + $this->prints(str_repeat($mask, strlen($input))); + $this->println(); + return $input; + } + + // Set terminal to raw mode with echo disabled for real-time character reading + $sttyMode = null; + if (function_exists('shell_exec') && PHP_OS_FAMILY !== 'Windows') { + $sttyMode = shell_exec('stty -g 2>/dev/null'); + shell_exec('stty -echo -icanon 2>/dev/null'); + } + + try { + // For real terminal input, read character by character + while (true) { + $char = KeysMap::readAndTranslate($this->getInputStream()); + + if ($char === 'LF' || $char === 'CR' || $char === '') { + break; + } elseif ($char === 'BACKSPACE' && strlen($input) > 0) { + $input = substr($input, 0, -1); + $this->prints("\x08 \x08"); // Backspace, space, backspace + } elseif ($char !== 'BACKSPACE' && $char !== 'ESC' && $char !== 'DOWN' && $char !== 'UP' && $char !== 'LEFT' && $char !== 'RIGHT') { + $input .= $char === 'SPACE' ? ' ' : $char; + $this->prints($mask); + } + } + } finally { + // Restore terminal settings + if ($sttyMode !== null) { + shell_exec('stty ' . $sttyMode . ' 2>/dev/null'); + } + } + + $this->println(); + return $input; + } /** * Reads a string that represents class namespace. * diff --git a/composer.json b/composer.json index c0e434b..c3a159d 100644 --- a/composer.json +++ b/composer.json @@ -7,8 +7,8 @@ "email": "ibrahim@webfiori.com" } ], - "license":"MIT", - "keywords":[ + "license": "MIT", + "keywords": [ "cli", "command line", "php", @@ -16,26 +16,26 @@ ], "require": { "php": "^8.1", - "webfiori/file":"2.0.*" + "webfiori/file": "2.0.*" }, "require-dev": { "phpunit/phpunit": "^10.0", "friendsofphp/php-cs-fixer": "^3.86" }, - "autoload" :{ - "psr-4":{ - "WebFiori\\Cli\\":"WebFiori/Cli" + "autoload": { + "psr-4": { + "WebFiori\\Cli\\": "WebFiori/Cli" } }, - "autoload-dev" :{ - "psr-4":{ - "WebFiori\\Tests\\":"tests/WebFiori/Tests" + "autoload-dev": { + "psr-4": { + "WebFiori\\Tests\\": "tests/WebFiori/Tests" } }, - "scripts" : { + "scripts": { "test": "vendor/bin/phpunit -c tests/phpunit.xml", "test10": "vendor/bin/phpunit -c tests/phpunit10.xml", - "wfcli":"bin/wfc", + "wfcli": "bin/wfc", "check-cs": "bin/ecs check --ansi", "fix-cs": "vendor/bin/php-cs-fixer fix --config=php_cs.php.dist", "phpstan": "vendor/bin/phpstan analyse --ansi --error-format symplify" diff --git a/examples/11-masked-input/README.md b/examples/11-masked-input/README.md new file mode 100644 index 0000000..8bb8c7e --- /dev/null +++ b/examples/11-masked-input/README.md @@ -0,0 +1,165 @@ +# Masked Input Example + +This example demonstrates the **masked input functionality** in WebFiori CLI, which allows secure entry of sensitive data like passwords, PINs, and tokens. + +## Features Demonstrated + +- **Basic Password Input**: Default asterisk (*) masking with validation +- **Custom Mask Characters**: Use different characters (•, #, X, -) for masking +- **Input Validation**: Enforce security requirements and format validation +- **Default Values**: Optional default values for sensitive fields +- **Confirmation Prompts**: Verify critical inputs by asking twice + +## Running the Example + +### Basic Usage +```bash +php main.php secure-input +``` + +### Run Specific Demos +```bash +# Password demo only +php main.php secure-input --demo=password + +# PIN demo with custom mask +php main.php secure-input --demo=pin + +# Token demo with default value +php main.php secure-input --demo=token + +# All demos (default) +php main.php secure-input --demo=all +``` + +## Code Examples + +### Basic Masked Input +```php +// Simple password input with default * masking +$password = $this->getMaskedInput('Enter password: '); +``` + +### Custom Mask Character +```php +// Use # characters for PIN masking +$pin = $this->getMaskedInput('Enter PIN: ', null, null, '#'); +``` + +### With Validation +```php +$validator = new InputValidator(function($password) { + return strlen($password) >= 8 && + preg_match('/[A-Z]/', $password) && + preg_match('/[0-9]/', $password); +}, 'Password must be 8+ chars with uppercase and number!'); + +$password = $this->getMaskedInput('Password: ', null, $validator); +``` + +### With Default Value +```php +// Provide a default token value +$token = $this->getMaskedInput('API Token: ', 'default-token', null, '•'); +``` + +## Method Signature + +```php +public function getMaskedInput( + string $prompt, // The prompt to display + ?string $default = null, // Optional default value + ?InputValidator $validator = null, // Optional input validator + string $mask = '*' // Mask character (default: *) +): ?string +``` + +## Security Features + +### Input Masking +- Characters are masked as you type +- Only mask characters are displayed in terminal +- Actual input is captured securely +- Supports backspace for corrections + +### Validation Support +- Enforce minimum length requirements +- Validate character patterns (uppercase, numbers, symbols) +- Custom validation logic +- Automatic retry on validation failure + +### Safe Handling +- Input is trimmed automatically +- Empty prompts return null safely +- Works with existing stream abstraction +- Compatible with testing framework + +## Use Cases + +### 1. User Authentication +```php +$password = $this->getMaskedInput('Login Password: '); +$confirmPassword = $this->getMaskedInput('Confirm Password: '); + +if ($password !== $confirmPassword) { + $this->error('Passwords do not match!'); + return 1; +} +``` + +### 2. API Configuration +```php +$apiKey = $this->getMaskedInput('API Key: ', null, null, '•'); +$secret = $this->getMaskedInput('API Secret: ', null, null, '-'); +``` + +### 3. Database Setup +```php +$dbPassword = $this->getMaskedInput('Database Password: '); + +$validator = new InputValidator(function($host) { + return filter_var($host, FILTER_VALIDATE_IP) || + filter_var($host, FILTER_VALIDATE_DOMAIN); +}, 'Invalid host format!'); + +$dbHost = $this->getInput('Database Host: ', 'localhost', $validator); +``` + +### 4. Secure Token Entry +```php +$jwtSecret = $this->getMaskedInput('JWT Secret: ', null, + new InputValidator(function($secret) { + return strlen($secret) >= 32; + }, 'JWT secret must be at least 32 characters!') +); +``` + +## Interactive Demo Features + +The example includes several interactive demonstrations: + +1. **Password Demo**: Shows validation with security requirements +2. **PIN Demo**: Demonstrates custom mask characters (#) +3. **Token Demo**: Shows default values with bullet (•) masking +4. **Advanced Demo**: Multiple scenarios including confirmation prompts + +## Testing + +The masked input functionality is fully testable using the existing `CommandTestCase` framework: + +```php +$output = $this->executeSingleCommand($command, [], ['secret123']); +$this->assertContains('Password received: secret123', $output); +``` + +## Best Practices + +1. **Always validate sensitive input** for security requirements +2. **Use appropriate mask characters** for different data types +3. **Implement confirmation prompts** for critical operations +4. **Never log or display** the actual sensitive values +5. **Provide clear error messages** for validation failures + +--- + +**Ready to secure your CLI applications?** Try the different demo modes to see masked input in action! diff --git a/examples/11-masked-input/SecureInputCommand.php b/examples/11-masked-input/SecureInputCommand.php new file mode 100644 index 0000000..b6b6fdb --- /dev/null +++ b/examples/11-masked-input/SecureInputCommand.php @@ -0,0 +1,143 @@ + [ + ArgumentOption::DESCRIPTION => 'Type of demo to run', + ArgumentOption::OPTIONAL => true, + ArgumentOption::VALUES => ['password', 'pin', 'token', 'all'], + ArgumentOption::DEFAULT => 'all' + ] + ], 'Demonstrates secure masked input functionality'); + } + + public function exec(): int { + $demo = $this->getArgValue('--demo') ?? 'all'; + + $this->println('🔒 WebFiori CLI - Masked Input Demo'); + $this->println('==================================='); + $this->println(); + + switch ($demo) { + case 'password': + $this->passwordDemo(); + break; + case 'pin': + $this->pinDemo(); + break; + case 'token': + $this->tokenDemo(); + break; + case 'all': + default: + $this->passwordDemo(); + $this->println(); + $this->pinDemo(); + $this->println(); + $this->tokenDemo(); + $this->println(); + $this->advancedDemo(); + break; + } + + $this->println(); + $this->success('✅ Demo completed successfully!'); + + return 0; + } + + /** + * Demonstrates basic password input with validation. + */ + private function passwordDemo(): void { + $this->info('📝 Password Demo - Basic masked input with validation'); + $this->println('Enter a password (minimum 8 characters):'); + + $validator = new InputValidator(function($password) { + if (strlen($password) < 8) { + return false; + } + if (!preg_match('/[A-Z]/', $password)) { + return false; + } + if (!preg_match('/[0-9]/', $password)) { + return false; + } + return true; + }, 'Password must be at least 8 characters with uppercase letter and number!'); + + $password = $this->getMaskedInput('Password: ', '*', null, $validator); + + $this->success("✅ Password accepted! Length: " . strlen($password)); + $this->println(" Captured value: $password"); + } + + /** + * Demonstrates PIN input with custom mask character. + */ + private function pinDemo(): void { + $this->info('🔢 PIN Demo - Custom mask character'); + $this->println('Enter a 4-digit PIN (will be masked with # characters):'); + + $validator = new InputValidator(function($pin) { + return strlen($pin) === 4 && ctype_digit($pin); + }, 'PIN must be exactly 4 digits!'); + + $pin = $this->getMaskedInput('PIN: ', '#', null, $validator); + + $this->success("✅ PIN accepted!"); + $this->println(" Captured value: $pin"); + } + + /** + * Demonstrates token input with default value. + */ + private function tokenDemo(): void { + $this->info('🎫 Token Demo - With default value'); + $this->println('Enter API token (or press Enter for demo token):'); + + $token = $this->getMaskedInput('API Token: ', '•', 'demo-token-12345'); + + $this->success("✅ Token set!"); + $this->println(" Captured value: $token"); + } + + /** + * Demonstrates advanced scenarios. + */ + private function advancedDemo(): void { + $this->info('🚀 Advanced Demo - Multiple scenarios'); + + // Database password with confirmation + $this->println('Setting up database connection:'); + + $dbPassword = $this->getMaskedInput('Database Password: '); + $confirmPassword = $this->getMaskedInput('Confirm Password: '); + + if ($dbPassword !== $confirmPassword) { + $this->error('❌ Passwords do not match!'); + $this->println(" First: $dbPassword"); + $this->println(" Second: $confirmPassword"); + return; + } + + $this->success('✅ Database password confirmed'); + $this->println(" Captured value: $dbPassword"); + } +} diff --git a/examples/11-masked-input/main.php b/examples/11-masked-input/main.php new file mode 100644 index 0000000..969d23c --- /dev/null +++ b/examples/11-masked-input/main.php @@ -0,0 +1,22 @@ +register(new SecureInputCommand()); + +// Start the application +exit($runner->start()); diff --git a/examples/README.md b/examples/README.md index f71ef77..62526f6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -17,7 +17,7 @@ Building more sophisticated CLI applications. - **[05-interactive-commands](05-interactive-commands/)** - Creating interactive command experiences - **[07-progress-bars](07-progress-bars/)** - Visual progress indicators for long operations - +- **[11-masked-input](11-masked-input/)** - Secure input with character masking ### 🔴 **Advanced Examples** Complex scenarios and advanced features. @@ -89,7 +89,7 @@ Explore examples 10-13 for real-world applications: |---------|----------|-------------| | **Command Creation** | 01, 02, 10 | Basic to advanced command structures | | **Arguments & Options** | 02, 13 | Parameter handling and validation | -| **User Input** | 03, 05 | Interactive input and validation | +| **User Input** | 03, 05, 11 | Interactive input, validation, and secure entry | | **Output Formatting** | 04, 07 | Colors, styles, and progress bars | | **Interactive Workflows** | 05, 10 | Menu systems and wizards | | **Progress Indicators** | 07, 10, 13 | Visual feedback for operations | diff --git a/tests/WebFiori/Tests/Cli/MaskedInputTest.php b/tests/WebFiori/Tests/Cli/MaskedInputTest.php new file mode 100644 index 0000000..6061bf4 --- /dev/null +++ b/tests/WebFiori/Tests/Cli/MaskedInputTest.php @@ -0,0 +1,190 @@ +getMaskedInput('Enter password: '); + $this->println("Password received: $input"); + return 0; + } + }; + + $output = $this->executeSingleCommand($command, [], ['secret123']); + + $this->assertContains("Enter password:\n", $output); + $this->assertContains("Password received: secret123\n", $output); + $this->assertEquals(0, $this->getExitCode()); + } + + /** + * Test masked input with default value. + * + * @test + */ + public function testMaskedInputWithDefault() { + $command = new class extends \WebFiori\Cli\Command { + public function __construct() { + parent::__construct('test-masked-default'); + } + + public function exec(): int { + $input = $this->getMaskedInput('Enter token: ', '*', 'default-token'); + $this->println("Token: $input"); + return 0; + } + }; + + // Test with empty input (should use default) + $output = $this->executeSingleCommand($command, [], ['']); + + $this->assertContains("Enter token: Enter = 'default-token'\n", $output); + $this->assertContains("Token: default-token\n", $output); + } + + /** + * Test masked input with custom mask character. + * + * @test + */ + public function testMaskedInputWithCustomMask() { + $command = new class extends \WebFiori\Cli\Command { + public function __construct() { + parent::__construct('test-custom-mask'); + } + + public function exec(): int { + $input = $this->getMaskedInput('Enter PIN: ', '#'); + $this->println("PIN: $input"); + return 0; + } + }; + + $output = $this->executeSingleCommand($command, [], ['1234']); + + $this->assertContains("Enter PIN:\n", $output); + $this->assertContains("PIN: 1234\n", $output); + } + + /** + * Test masked input with validation. + * + * @test + */ + public function testMaskedInputWithValidation() { + $command = new class extends \WebFiori\Cli\Command { + public function __construct() { + parent::__construct('test-masked-validation'); + } + + public function exec(): int { + $validator = new InputValidator(function($input) { + return strlen($input) >= 8; + }, 'Password must be at least 8 characters long!'); + + $input = $this->getMaskedInput('Enter password: ', '*', null, $validator); + $this->println("Valid password received"); + return 0; + } + }; + + // Test with invalid input first, then valid + $output = $this->executeSingleCommand($command, [], ['short', 'validpassword']); + + $this->assertContains("Error: Password must be at least 8 characters long!\n", $output); + $this->assertContains("Valid password received\n", $output); + } + + /** + * Test masked input with empty prompt. + * + * @test + */ + public function testMaskedInputWithEmptyPrompt() { + $command = new class extends \WebFiori\Cli\Command { + public function __construct() { + parent::__construct('test-empty-prompt'); + } + + public function exec(): int { + $input = $this->getMaskedInput(''); + $result = $input === null ? 'null' : $input; + $this->println("Result: $result"); + return 0; + } + }; + + $output = $this->executeSingleCommand($command); + + $this->assertContains("Result: null\n", $output); + } + + /** + * Test masked input with whitespace handling. + * + * @test + */ + public function testMaskedInputWhitespaceHandling() { + $command = new class extends \WebFiori\Cli\Command { + public function __construct() { + parent::__construct('test-whitespace'); + } + + public function exec(): int { + $input = $this->getMaskedInput('Enter value: '); + $this->println("Value: '$input'"); + return 0; + } + }; + + // Test with leading/trailing spaces + $output = $this->executeSingleCommand($command, [], [' spaced ']); + + $this->assertContains("Value: 'spaced'\n", $output); // Should be trimmed + } + + /** + * Test masked input with special characters. + * + * @test + */ + public function testMaskedInputWithSpecialCharacters() { + $command = new class extends \WebFiori\Cli\Command { + public function __construct() { + parent::__construct('test-special-chars'); + } + + public function exec(): int { + $input = $this->getMaskedInput('Enter complex password: '); + $this->println("Password length: " . strlen($input)); + return 0; + } + }; + + $complexPassword = 'P@ssw0rd!#$%'; + $output = $this->executeSingleCommand($command, [], [$complexPassword]); + + $this->assertContains("Password length: " . strlen($complexPassword) . "\n", $output); + } +}