diff --git a/AGENTS.md b/AGENTS.md index 028d5eb..f897bcb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index 7f17a90..ce3cff6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/SFTPsync.php b/src/SFTPsync.php index 2a230c5..fe66e24 100644 --- a/src/SFTPsync.php +++ b/src/SFTPsync.php @@ -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')) {