Added password prompt with asterisks displayed while typing

This commit is contained in:
2026-06-26 07:40:07 +02:00
parent d6118894a0
commit 3837258d47
3 changed files with 212 additions and 2 deletions

View File

@ -17,6 +17,7 @@
- `1` runtime/SFTP error
- `2` argument/usage error
- Missing `--host`, `--user`, `--password` should still support interactive prompt mode.
- Interactive password prompts should avoid echoing the password, mask typed characters when possible, and restore terminal settings after input errors.
- `--skip` and `--skip-delete` matching semantics should remain stable.
- Rules without wildcard characters (`*`, `?`) use legacy exact matching.
- Exact rules without slash match any path segment; exact rules with slash match a relative subpath.

View File

@ -25,7 +25,7 @@ From repository root:
php src/SFTPsync.php --host example.com --user myuser --password mypass --sync ./local /var/www/app
```
If `--host`, `--user`, or `--password` is missing, the script asks for it interactively (TTY only).
If `--host`, `--user`, or `--password` is missing, the script asks for it interactively (TTY only). Interactive password input is hidden or masked when the terminal supports it.
## CLI Usage

View File

@ -1213,7 +1213,7 @@ function askForMissingRequiredOptions(array $config): array
$config['user'] = promptValue('user');
}
if ($config['password'] === '') {
$config['password'] = promptValue('password');
$config['password'] = readPassword('Password: ');
}
return $config;
@ -1241,6 +1241,215 @@ function promptValue(string $name): string
}
}
function readPassword(string $prompt = 'Password: '): string
{
if (!isInteractiveStdin()) {
throw new CliUsageException('Missing required option --password and no interactive input available.');
}
while (true) {
$password = trim(readPasswordOnce($prompt));
if ($password !== '') {
return $password;
}
logError('Value for --password cannot be empty.');
}
}
function readPasswordOnce(string $prompt): string
{
$password = readPasswordFromRawTerminal($prompt);
if ($password !== null) {
return $password;
}
$password = readPasswordFromWindowsConsole($prompt);
if ($password !== null) {
return $password;
}
$password = readPasswordWithEchoDisabled($prompt);
if ($password !== null) {
return $password;
}
// Last-resort fallback for platforms where PHP cannot safely switch the terminal
// to no-echo or character-by-character input. This may echo the password.
fwrite(STDOUT, $prompt);
$line = fgets(STDIN);
if ($line === false) {
throw new CliUsageException('Could not read --password from interactive input.');
}
return $line;
}
function readPasswordFromRawTerminal(string $prompt): ?string
{
if (PHP_OS_FAMILY === 'Windows' || !function_exists('shell_exec')) {
return null;
}
$originalStty = @shell_exec('stty -g 2>/dev/null');
if ($originalStty === null || trim($originalStty) === '') {
return null;
}
$originalStty = trim($originalStty);
fwrite(STDOUT, $prompt);
@shell_exec('stty -icanon -echo min 1 time 0 2>/dev/null');
$password = '';
try {
while (true) {
$char = fgetc(STDIN);
if ($char === false) {
throw new CliUsageException('Could not read --password from interactive input.');
}
if ($char === "\n" || $char === "\r") {
fwrite(STDOUT, PHP_EOL);
return $password;
}
if ($char === "\x03") {
throw new CliUsageException('Password input interrupted.');
}
if ($char === "\x04") {
throw new CliUsageException('Could not read --password from interactive input.');
}
if ($char === "\x08" || $char === "\x7f") {
if ($password !== '') {
$password = substr($password, 0, -1);
fwrite(STDOUT, "\x08 \x08");
}
continue;
}
if ($char === "\x1b") {
readPasswordHandleEscapeSequence($password);
continue;
}
if (ord($char) < 32) {
continue;
}
$password .= $char;
fwrite(STDOUT, '*');
}
} finally {
@shell_exec('stty ' . escapeshellarg($originalStty) . ' 2>/dev/null');
}
}
function readPasswordHandleEscapeSequence(string &$password): void
{
$second = fgetc(STDIN);
$third = fgetc(STDIN);
if ($second !== '[' || $third !== '3') {
return;
}
$fourth = fgetc(STDIN);
if ($fourth === '~' && $password !== '') {
$password = substr($password, 0, -1);
fwrite(STDOUT, "\x08 \x08");
}
}
function readPasswordFromWindowsConsole(string $prompt): ?string
{
if (PHP_OS_FAMILY !== 'Windows' || !function_exists('proc_open')) {
return null;
}
$promptLiteral = "'" . str_replace("'", "''", $prompt) . "'";
$script = '$PromptText = ' . $promptLiteral . PHP_EOL . <<<'POWERSHELL'
[Console]::Error.Write($PromptText)
$password = New-Object System.Text.StringBuilder
while ($true) {
$key = [Console]::ReadKey($true)
if ($key.Key -eq [ConsoleKey]::Enter) {
[Console]::Error.WriteLine()
break
}
if ($key.Key -eq [ConsoleKey]::Backspace -or $key.Key -eq [ConsoleKey]::Delete) {
if ($password.Length -gt 0) {
[void]$password.Remove($password.Length - 1, 1)
[Console]::Error.Write("`b `b")
}
continue
}
if ($key.KeyChar -ne [char]0) {
[void]$password.Append($key.KeyChar)
[Console]::Error.Write("*")
}
}
[Console]::Out.Write($password.ToString())
POWERSHELL;
$process = @proc_open(
['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', $script],
[
0 => STDIN,
1 => ['pipe', 'w'],
2 => STDERR,
],
$pipes
);
if (!is_resource($process)) {
return null;
}
try {
$password = stream_get_contents($pipes[1]);
fclose($pipes[1]);
$exitCode = proc_close($process);
} catch (Throwable) {
proc_terminate($process);
return null;
}
if ($exitCode !== 0 || $password === false) {
return null;
}
return $password;
}
function readPasswordWithEchoDisabled(string $prompt): ?string
{
if (PHP_OS_FAMILY === 'Windows' || !function_exists('shell_exec')) {
return null;
}
$originalStty = @shell_exec('stty -g 2>/dev/null');
if ($originalStty === null || trim($originalStty) === '') {
return null;
}
$originalStty = trim($originalStty);
fwrite(STDOUT, $prompt);
@shell_exec('stty -echo 2>/dev/null');
try {
$line = fgets(STDIN);
if ($line === false) {
throw new CliUsageException('Could not read --password from interactive input.');
}
fwrite(STDOUT, PHP_EOL);
return $line;
} finally {
@shell_exec('stty ' . escapeshellarg($originalStty) . ' 2>/dev/null');
}
}
function isInteractiveStdin(): bool
{
if (!defined('STDIN')) {