Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 26 additions & 15 deletions terminal.c
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ static zend_class_entry *terminal_key_ce;
static zend_class_entry *terminal_mode_token_ce;
static zend_object_handlers terminal_mode_token_handlers;

static bool terminal_mode_token_stream_is_valid(const terminal_mode_token_object *mode);
static bool terminal_restore_stream_mode(const terminal_saved_mode *saved);

static inline terminal_mode_token_object *terminal_mode_token_from_obj(zend_object *obj)
{
return (terminal_mode_token_object *) ((char *) obj - offsetof(terminal_mode_token_object, std));
Expand All @@ -93,6 +96,12 @@ static void terminal_mode_token_free_obj(zend_object *object)
{
terminal_mode_token_object *intern = terminal_mode_token_from_obj(object);

if (intern->valid && memcmp(intern->saved.magic, TERMINAL_MODE_TOKEN_MAGIC, TERMINAL_MODE_TOKEN_MAGIC_LEN) == 0) {
if (terminal_mode_token_stream_is_valid(intern)) {
terminal_restore_stream_mode(&intern->saved);
}
}

if (!Z_ISUNDEF(intern->stream_resource)) {
zval_ptr_dtor(&intern->stream_resource);
ZVAL_UNDEF(&intern->stream_resource);
Expand Down Expand Up @@ -296,24 +305,16 @@ static bool terminal_size_from_environment(zend_long *columns, zend_long *rows)
}

#ifdef PHP_WIN32
static bool terminal_env_is_non_empty(const char *name, size_t name_len)
static bool terminal_no_color_is_set(void)
{
zend_string *value = php_getenv(name, name_len);
bool result;
zend_string *value = php_getenv("NO_COLOR", sizeof("NO_COLOR") - 1);

if (value == NULL) {
return false;
}

result = ZSTR_LEN(value) > 0;
zend_string_release(value);

return result;
}

static bool terminal_no_color_is_set(void)
{
return terminal_env_is_non_empty("NO_COLOR", sizeof("NO_COLOR") - 1);
return true;
}

/* Process-global: Windows has one console per process, so this is
Expand Down Expand Up @@ -1322,7 +1323,14 @@ static zend_string *terminal_key_from_csi_sequence(const unsigned char *sequence
unsigned int number = 0;

while (i < sequence_len - 1 && sequence[i] >= '0' && sequence[i] <= '9') {
number = (number * 10) + (unsigned int) (sequence[i] - '0');
unsigned int digit = (unsigned int) (sequence[i] - '0');

if (number > (UINT_MAX - digit) / 10) {
number = 0;
break;
}

number = (number * 10) + digit;
i++;
}

Expand Down Expand Up @@ -1404,7 +1412,7 @@ static zend_string *terminal_key_from_ss3_sequence(unsigned char key)

static bool terminal_is_csi_final_byte(unsigned char key)
{
return (key >= 'A' && key <= 'Z') || key == '~';
return key >= 0x40 && key <= 0x7e;
}

static zend_string *terminal_key_from_escape_sequence(int fd, int sequence_timeout_ms)
Expand Down Expand Up @@ -1689,6 +1697,9 @@ static zend_string *terminal_read_stdin_secret(void)
zend_string_release(escape_key);

if (is_escape) {
zend_long nl_written;

terminal_stream_write(terminal_native_stream_from_id(TERMINAL_STREAM_STDOUT), "\n", 1, &nl_written);
goto restore;
}

Expand Down Expand Up @@ -2061,12 +2072,12 @@ ZEND_METHOD(Terminal_Terminal, readKey)
Z_PARAM_DOUBLE_OR_NULL(sequence_timeout, sequence_timeout_is_null)
ZEND_PARSE_PARAMETERS_END();

if (!timeout_is_null && timeout < 0) {
if (!timeout_is_null && (zend_isnan(timeout) || timeout < 0)) {
zend_argument_value_error(1, "must be greater than or equal to 0");
RETURN_THROWS();
}

if (!sequence_timeout_is_null && sequence_timeout < 0) {
if (!sequence_timeout_is_null && (zend_isnan(sequence_timeout) || sequence_timeout < 0)) {
zend_argument_value_error(2, "must be greater than or equal to 0");
RETURN_THROWS();
}
Expand Down
99 changes: 99 additions & 0 deletions tests/027_csi_lowercase_final_byte.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
--TEST--
Terminal\Terminal::readKey handles CSI sequences with ECMA-48 lowercase final bytes
--EXTENSIONS--
terminal
--SKIPIF--
<?php
if (PHP_OS_FAMILY === 'Windows') {
die("skip pseudo terminal test is POSIX only\n");
}

if (!function_exists('proc_open')) {
die("skip proc_open is unavailable\n");
}

$descriptors = [
0 => ['pty'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];

$process = @proc_open(escapeshellarg(PHP_BINARY) . ' -r ' . escapeshellarg('exit(0);'), $descriptors, $pipes);
if (!is_resource($process)) {
die("skip pseudo terminal is unavailable\n");
}

foreach ($pipes as $pipe) {
fclose($pipe);
}

proc_close($process);
?>
--FILE--
<?php
function read_key_from_child(string $input): string
{
$extension = dirname(__DIR__) . '/modules/terminal.' . PHP_SHLIB_SUFFIX;
$code = <<<'PHP'
echo "ready\n";
$start = microtime(true);
$key = Terminal\Terminal::readKey(1.0, 0.1);
$elapsed = microtime(true) - $start;
if ($key instanceof Terminal\Key) {
echo $key->name . ':' . $key->value . "\n";
} else {
var_dump($key);
}
// Sequence should be processed fast (well under 0.3s)
echo ($elapsed < 0.3 ? "fast" : "slow") . "\n";
PHP;
$command = escapeshellarg(PHP_BINARY) . ' -n -d extension=' . escapeshellarg($extension) . ' -r ' . escapeshellarg($code);
$descriptors = [
0 => ['pty'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];

$process = proc_open($command, $descriptors, $pipes);
if (!is_resource($process)) {
return 'proc_open failed';
}

stream_set_blocking($pipes[1], false);
$output = '';
$start = microtime(true);

while (microtime(true) - $start < 2 && !str_contains($output, "ready\n")) {
$output .= stream_get_contents($pipes[1]);
usleep(10000);
}

fwrite($pipes[0], $input);

stream_set_blocking($pipes[1], true);
$output .= stream_get_contents($pipes[1]);
$error = stream_get_contents($pipes[2]);

foreach ($pipes as $pipe) {
fclose($pipe);
}

$status = proc_close($process);
if ($status !== 0 || $error !== '') {
return $output . $error;
}

return $output;
}

// \033[0m ends with lowercase 'm' (SGR)
$output1 = read_key_from_child("\033[0m");
echo str_contains($output1, "Escape:escape\nfast") ? "sgr-handled-fast\n" : $output1;

// \033[?1;2c ends with lowercase 'c' (Device Attributes report)
$output2 = read_key_from_child("\033[?1;2c");
echo str_contains($output2, "Escape:escape\nfast") ? "da-handled-fast\n" : $output2;
?>
--EXPECT--
sgr-handled-fast
da-handled-fast
94 changes: 94 additions & 0 deletions tests/028_read_secret_escape_abort.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
--TEST--
Terminal\Terminal::readSecret prints a newline and throws on Escape abort
--EXTENSIONS--
terminal
--SKIPIF--
<?php
if (PHP_OS_FAMILY === 'Windows') {
die("skip pseudo terminal test is POSIX only\n");
}

if (!function_exists('proc_open')) {
die("skip proc_open is unavailable\n");
}

$descriptors = [
0 => ['pty'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];

$process = @proc_open(escapeshellarg(PHP_BINARY) . ' -r ' . escapeshellarg('exit(0);'), $descriptors, $pipes);
if (!is_resource($process)) {
die("skip pseudo terminal is unavailable\n");
}

foreach ($pipes as $pipe) {
fclose($pipe);
}

proc_close($process);
?>
--FILE--
<?php
/**
* Verifies that readSecret() prints a newline to stdout and throws an
* error when the user presses Escape (0x1b) to abort.
*/
function read_secret_abort(string $input): string
{
$extension = dirname(__DIR__) . '/modules/terminal.' . PHP_SHLIB_SUFFIX;
$code = <<<'PHP'
echo "ready\n";
try {
$secret = Terminal\Terminal::readSecret('pw: ');
echo "SECRET:" . $secret . "\n";
} catch (\Error $e) {
echo "ERROR:" . $e->getMessage() . "\n";
}
PHP;
$command = escapeshellarg(PHP_BINARY) . ' -n -d extension=' . escapeshellarg($extension) . ' -r ' . escapeshellarg($code);
$descriptors = [
0 => ['pty'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];

$process = proc_open($command, $descriptors, $pipes);
if (!is_resource($process)) {
return 'proc_open failed';
}

stream_set_blocking($pipes[1], false);
$output = '';
$start = microtime(true);

while (microtime(true) - $start < 2 && !str_contains($output, "ready\n")) {
$output .= stream_get_contents($pipes[1]);
usleep(10000);
}

fwrite($pipes[0], $input);

stream_set_blocking($pipes[1], true);
$output .= stream_get_contents($pipes[1]);
$error = stream_get_contents($pipes[2]);

foreach ($pipes as $pipe) {
fclose($pipe);
}

proc_close($process);

return $output . $error;
}

// Send Escape (0x1b) to abort the secret input.
$output = read_secret_abort("\x1b");

echo str_contains($output, 'ERROR:Unable to read secret from terminal') ? "abort-throws\n" : $output;
echo str_contains($output, "\nERROR:") ? "newline-before-error\n" : "no-newline\n";
?>
--EXPECT--
abort-throws
newline-before-error
73 changes: 73 additions & 0 deletions tests/029_mode_token_auto_restore.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
--TEST--
Terminal\Terminal::enableRawMode token auto-restores terminal mode on destruction
--EXTENSIONS--
terminal
--SKIPIF--
<?php
if (PHP_OS_FAMILY === 'Windows') {
die("skip pseudo terminal test is POSIX only\n");
}

if (!function_exists('proc_open')) {
die("skip proc_open is unavailable\n");
}

$descriptors = [
0 => ['pty'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];

$process = @proc_open(escapeshellarg(PHP_BINARY) . ' -r ' . escapeshellarg('exit(0);'), $descriptors, $pipes);
if (!is_resource($process)) {
die("skip pseudo terminal is unavailable\n");
}

foreach ($pipes as $pipe) {
fclose($pipe);
}

proc_close($process);
?>
--FILE--
<?php
$extension = dirname(__DIR__) . '/modules/terminal.' . PHP_SHLIB_SUFFIX;
$code = <<<'PHP'
$mode = Terminal\Terminal::enableRawMode();
if (!$mode instanceof Terminal\ModeToken) {
echo "raw-mode-failed\n";
exit;
}

// Unset the token - should trigger RAII auto-restore in destructor
unset($mode);

// Verify mode was restored by reading a line with fgets (which requires canonical mode)
echo "auto-restored\n";
PHP;

$command = escapeshellarg(PHP_BINARY) . ' -n -d extension=' . escapeshellarg($extension) . ' -r ' . escapeshellarg($code);
$descriptors = [
0 => ['pty'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];

$process = proc_open($command, $descriptors, $pipes);
if (!is_resource($process)) {
echo "proc_open failed\n";
exit;
}

$output = stream_get_contents($pipes[1]);
$error = stream_get_contents($pipes[2]);

foreach ($pipes as $pipe) {
fclose($pipe);
}

$status = proc_close($process);
echo $status === 0 && $error === '' ? $output : $output . $error;
?>
--EXPECT--
auto-restored
21 changes: 21 additions & 0 deletions tests/030_read_key_nan_validation.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
--TEST--
Terminal\Terminal::readKey rejects NAN for timeout and sequenceTimeout
--EXTENSIONS--
terminal
--FILE--
<?php
try {
Terminal\Terminal::readKey(NAN);
} catch (ValueError $e) {
echo $e->getMessage(), "\n";
}

try {
Terminal\Terminal::readKey(null, NAN);
} catch (ValueError $e) {
echo $e->getMessage(), "\n";
}
?>
--EXPECT--
Terminal\Terminal::readKey(): Argument #1 ($timeout) must be greater than or equal to 0
Terminal\Terminal::readKey(): Argument #2 ($sequenceTimeout) must be greater than or equal to 0