diff --git a/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt b/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt index c497855f7347..0259162e7def 100644 --- a/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt +++ b/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt @@ -30,6 +30,13 @@ Io\Poll\InactiveWatcherException Io\Poll\InvalidHandleException Io\Poll\PollException Io\Poll\Watcher +Io\Terminal\Key +Io\Terminal\ModeToken +Io\Terminal\SystemModeToken +Io\Terminal\SystemTerminal +Io\Terminal\Terminal +Io\Terminal\TerminalException +Io\Terminal\TerminalSize RoundingMode SortDirection StreamBucket diff --git a/ext/standard/basic_functions.c b/ext/standard/basic_functions.c index 3699564169b5..4f15a9f64cb5 100644 --- a/ext/standard/basic_functions.c +++ b/ext/standard/basic_functions.c @@ -301,6 +301,7 @@ PHP_MINIT_FUNCTION(basic) /* {{{ */ BASIC_MINIT_SUBMODULE(standard_filters) BASIC_MINIT_SUBMODULE(user_filters) BASIC_MINIT_SUBMODULE(poll) + BASIC_MINIT_SUBMODULE(terminal) BASIC_MINIT_SUBMODULE(password) BASIC_MINIT_SUBMODULE(image) @@ -378,6 +379,7 @@ PHP_MSHUTDOWN_FUNCTION(basic) /* {{{ */ BASIC_MSHUTDOWN_SUBMODULE(crypt) BASIC_MSHUTDOWN_SUBMODULE(password) BASIC_MSHUTDOWN_SUBMODULE(image) + BASIC_MSHUTDOWN_SUBMODULE(terminal) return SUCCESS; } @@ -409,6 +411,7 @@ PHP_RINIT_FUNCTION(basic) /* {{{ */ PHP_RINIT(filestat)(INIT_FUNC_ARGS_PASSTHRU); BASIC_RINIT_SUBMODULE(dir) BASIC_RINIT_SUBMODULE(url_scanner_ex) + BASIC_RINIT_SUBMODULE(terminal) /* Initialize memory for last http headers */ ZVAL_UNDEF(&BG(last_http_headers)); @@ -476,6 +479,7 @@ PHP_RSHUTDOWN_FUNCTION(basic) /* {{{ */ BASIC_RSHUTDOWN_SUBMODULE(user_filters) BASIC_RSHUTDOWN_SUBMODULE(browscap) + BASIC_RSHUTDOWN_SUBMODULE(terminal) /* Free last http headers */ zval_ptr_dtor(&BG(last_http_headers)); diff --git a/ext/standard/basic_functions.h b/ext/standard/basic_functions.h index 4d5a4c02aec0..24642fbf150e 100644 --- a/ext/standard/basic_functions.h +++ b/ext/standard/basic_functions.h @@ -43,6 +43,10 @@ PHP_MINFO_FUNCTION(basic); ZEND_API void php_get_highlight_struct(zend_syntax_highlighter_ini *syntax_highlighter_ini); PHP_MINIT_FUNCTION(poll); +PHP_MINIT_FUNCTION(terminal); +PHP_MSHUTDOWN_FUNCTION(terminal); +PHP_RINIT_FUNCTION(terminal); +PHP_RSHUTDOWN_FUNCTION(terminal); PHP_MINIT_FUNCTION(user_filters); PHP_RSHUTDOWN_FUNCTION(user_filters); PHP_RSHUTDOWN_FUNCTION(browscap); diff --git a/ext/standard/config.m4 b/ext/standard/config.m4 index 7edfcf1da1f5..5dc23b172ae7 100644 --- a/ext/standard/config.m4 +++ b/ext/standard/config.m4 @@ -419,6 +419,7 @@ PHP_NEW_EXTENSION([standard], m4_normalize([ incomplete_class.c info.c io_poll.c + io_terminal.c iptc.c levenshtein.c libavifinfo/avifinfo.c diff --git a/ext/standard/config.w32 b/ext/standard/config.w32 index 56096097620d..bf7e5bcc50a9 100644 --- a/ext/standard/config.w32 +++ b/ext/standard/config.w32 @@ -28,7 +28,7 @@ EXTENSION("standard", "array.c base64.c basic_functions.c browscap.c \ crypt_sha512.c php_crypt_r.c " + (TARGET_ARCH != 'arm64'? " crc32_x86.c" : "") + " \ datetime.c dir.c dl.c dns.c dns_win32.c exec.c \ file.c filestat.c formatted_print.c fsock.c head.c html.c image.c \ - info.c io_poll.c iptc.c link.c mail.c math.c md5.c metaphone.c microtime.c \ + info.c io_poll.c io_terminal.c iptc.c link.c mail.c math.c md5.c metaphone.c microtime.c \ net.c pack.c pageinfo.c quot_print.c soundex.c \ string.c scanf.c syslog.c type.c uniqid.c url.c var.c \ versioning.c assert.c strnatcmp.c levenshtein.c incomplete_class.c \ diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c new file mode 100644 index 000000000000..34a8793c05ef --- /dev/null +++ b/ext/standard/io_terminal.c @@ -0,0 +1,3243 @@ +/* + +----------------------------------------------------------------------+ + | Copyright (c) The PHP Group | + +----------------------------------------------------------------------+ + | This source file is subject to the Modified BSD License that is | + | bundled with this package in the file LICENSE, and is available | + | through the World Wide Web at . | + | | + | SPDX-License-Identifier: BSD-3-Clause | + +----------------------------------------------------------------------+ + | Author: Pratik Bhujel | + +----------------------------------------------------------------------+ +*/ + +#include "php.h" +#include "zend_enum.h" +#include "zend_exceptions.h" +#include "zend_smart_str.h" +#include "php_network.h" +#include "php_poll.h" +#include "php_streams.h" +#include "ext/standard/basic_functions.h" +#include "ext/date/php_time.h" +#include "io_terminal.h" +#include "io_terminal_decl.h" +#include "io_terminal_arginfo.h" + +#include +#include +#include +#include +#include + +#ifdef PHP_WIN32 +# include +# include +#else +# include +# include +# include +# include +# include +# include +# include +#endif + +#define PHP_IO_TERMINAL_SEQUENCE_TIMEOUT_MS 25 + +static inline time_t php_io_terminal_clamp_duration_seconds(uint64_t seconds) +{ + if (sizeof(time_t) < 8) { + return seconds > (uint64_t) INT32_MAX ? (time_t) INT32_MAX : (time_t) seconds; + } + return seconds > (uint64_t) INT64_MAX ? (time_t) INT64_MAX : (time_t) seconds; +} + +static inline zend_hrtime_t php_io_terminal_saturating_add_deadline(zend_hrtime_t base_ns, zend_hrtime_t duration_ns) +{ + if (UINT64_MAX - base_ns <= duration_ns) { + return UINT64_MAX; + } + return base_ns + duration_ns; +} + +#ifdef PHP_WIN32 +typedef HANDLE php_io_terminal_native_stream; +# define PHP_IO_TERMINAL_INVALID_NATIVE_STREAM INVALID_HANDLE_VALUE +# define PHP_IO_TERMINAL_MAX_WAIT_MS (INFINITE - 1) +#else +typedef int php_io_terminal_native_stream; +# define PHP_IO_TERMINAL_INVALID_NATIVE_STREAM (-1) + +static inline zend_hrtime_t php_io_terminal_timespec_to_ns(const struct timespec *ts) +{ + if (ts == NULL) { + return 0; + } + if (ts->tv_sec <= 0 && ts->tv_nsec <= 0) { + return 0; + } + if ((uint64_t) ts->tv_sec >= (UINT64_MAX / 1000000000ULL)) { + return UINT64_MAX; + } + zend_hrtime_t sec_ns = (zend_hrtime_t) ts->tv_sec * 1000000000ULL; + if (UINT64_MAX - sec_ns < (zend_hrtime_t) ts->tv_nsec) { + return UINT64_MAX; + } + return sec_ns + (zend_hrtime_t) ts->tv_nsec; +} + +static inline void php_io_terminal_ns_to_timespec(zend_hrtime_t ns, struct timespec *ts) +{ + ts->tv_sec = (time_t) (ns / 1000000000ULL); + ts->tv_nsec = (long) (ns % 1000000000ULL); +} + +typedef struct php_io_terminal_utf8_pending { + unsigned char bytes[4]; + size_t length; + size_t expected; +} php_io_terminal_utf8_pending; +#endif + +#ifdef PHP_WIN32 +typedef struct php_io_terminal_identity { + php_io_terminal_native_stream stream; + bool is_console; +} php_io_terminal_identity; +#else +typedef struct php_io_terminal_identity { + php_io_terminal_native_stream stream; + dev_t dev; + bool has_dev; + pid_t sid; + bool has_sid; +} php_io_terminal_identity; +#endif + +typedef struct php_io_terminal_shared_mode { + php_io_terminal_identity identity; + php_io_terminal_native_stream restore_stream; +#ifdef PHP_WIN32 + DWORD saved_mode; +#else + struct termios saved_mode; +#endif + uint32_t lease_count; + struct php_io_terminal_shared_mode *prev; + struct php_io_terminal_shared_mode *next; +} php_io_terminal_shared_mode; + +typedef struct php_io_terminal_mode_token_object { + php_io_terminal_shared_mode *shared; + bool valid; + zend_object std; +} php_io_terminal_mode_token_object; + +typedef struct php_io_terminal_object { + zval input_stream_val; + zval output_stream_val; + php_io_terminal_identity identity; + bool has_identity; + zend_object *active_mode_token; + zend_long last_cols; + zend_long last_rows; + bool has_last_size; +#ifdef PHP_WIN32 + INPUT_RECORD pending_key; + WCHAR pending_key_high_surrogate; + WCHAR pending_high_surrogate; +#else + php_io_terminal_utf8_pending pending_utf8; +#endif + zend_object std; +} php_io_terminal_object; + +typedef struct php_io_terminal_stream_target { + php_io_terminal_native_stream native_stream; + php_stream *php_stream; + zval *stream_resource; + bool is_default; +} php_io_terminal_stream_target; + +static zend_class_entry *php_io_terminal_exception_ce; +static zend_class_entry *php_io_terminal_key_ce; +static zend_class_entry *php_io_terminal_terminal_size_ce; +static zend_class_entry *php_io_terminal_mode_token_ce; +static zend_class_entry *php_io_terminal_terminal_ce; +static zend_class_entry *php_io_terminal_system_mode_token_ce; +static zend_class_entry *php_io_terminal_system_terminal_ce; +static zend_object_handlers php_io_terminal_mode_token_handlers; +static zend_object_handlers php_io_terminal_object_handlers; +ZEND_TLS php_io_terminal_shared_mode *php_io_terminal_active_shared_modes; + +#if !defined(PHP_WIN32) +# if defined(HAVE_PTSNAME_R) || defined(_GNU_SOURCE) || defined(__APPLE__) || defined(__FreeBSD__) || defined(__NetBSD__) || defined(__DragonFly__) +# define PHP_IO_TERMINAL_HAVE_PTSNAME_R 1 +# endif +# if !defined(PHP_IO_TERMINAL_HAVE_PTSNAME_R) && defined(ZTS) +static MUTEX_T php_io_terminal_ptsname_mutex = NULL; +# define PHP_IO_TERMINAL_PTSNAME_LOCK() do { if (php_io_terminal_ptsname_mutex) tsrm_mutex_lock(php_io_terminal_ptsname_mutex); } while (0) +# define PHP_IO_TERMINAL_PTSNAME_UNLOCK() do { if (php_io_terminal_ptsname_mutex) tsrm_mutex_unlock(php_io_terminal_ptsname_mutex); } while (0) +# else +# define PHP_IO_TERMINAL_PTSNAME_LOCK() +# define PHP_IO_TERMINAL_PTSNAME_UNLOCK() +# endif + +#define PHP_IO_TERMINAL_READ_EOF (-2) +#define PHP_IO_TERMINAL_READ_ERROR (-1) +#define PHP_IO_TERMINAL_READ_TIMEOUT 0 +#define PHP_IO_TERMINAL_READ_SUCCESS 1 +#define PHP_IO_TERMINAL_READ_RESIZE 2 + +#ifdef SIGWINCH +static volatile sig_atomic_t php_io_terminal_resize_generation = 0; +static unsigned int php_io_terminal_resize_readers = 0; +static struct sigaction php_io_terminal_previous_resize_action; +# ifdef ZTS +static MUTEX_T php_io_terminal_resize_mutex = NULL; +# define PHP_IO_TERMINAL_RESIZE_LOCK() do { if (php_io_terminal_resize_mutex) tsrm_mutex_lock(php_io_terminal_resize_mutex); } while (0) +# define PHP_IO_TERMINAL_RESIZE_UNLOCK() do { if (php_io_terminal_resize_mutex) tsrm_mutex_unlock(php_io_terminal_resize_mutex); } while (0) +# else +# define PHP_IO_TERMINAL_RESIZE_LOCK() +# define PHP_IO_TERMINAL_RESIZE_UNLOCK() +# endif + +static zend_always_inline bool php_io_terminal_is_callable_handler(void (*handler)(int)) +{ + if (handler == NULL) { + return false; + } + if (handler == SIG_DFL) { + return false; + } + if (handler == SIG_IGN) { + return false; + } + return true; +} + +static void php_io_terminal_sigwinch_handler(int signo, siginfo_t *info, void *context) +{ + (void) signo; + + php_io_terminal_resize_generation = php_io_terminal_resize_generation == SIG_ATOMIC_MAX + ? 0 + : php_io_terminal_resize_generation + 1; + + /* Forward to existing handler if present and not default/ignored */ + if (php_io_terminal_is_callable_handler(php_io_terminal_previous_resize_action.sa_handler)) { + if (php_io_terminal_previous_resize_action.sa_flags & SA_SIGINFO) { + php_io_terminal_previous_resize_action.sa_sigaction(signo, info, context); + } else { + php_io_terminal_previous_resize_action.sa_handler(signo); + } + } +} + +static bool php_io_terminal_install_resize_handler(sig_atomic_t *generation) +{ + struct sigaction action; + bool installed = true; + sig_atomic_t current_generation; + + PHP_IO_TERMINAL_RESIZE_LOCK(); + current_generation = php_io_terminal_resize_generation; + + if (php_io_terminal_resize_readers == 0) { + memset(&action, 0, sizeof(action)); + action.sa_sigaction = php_io_terminal_sigwinch_handler; + action.sa_flags = SA_SIGINFO; + sigemptyset(&action.sa_mask); + + installed = sigaction(SIGWINCH, &action, &php_io_terminal_previous_resize_action) == 0; + } + + if (installed) { + php_io_terminal_resize_readers++; + *generation = current_generation; + } + + PHP_IO_TERMINAL_RESIZE_UNLOCK(); + + return installed; +} + +static void php_io_terminal_restore_resize_handler(void) +{ + PHP_IO_TERMINAL_RESIZE_LOCK(); + + if (php_io_terminal_resize_readers > 0 && --php_io_terminal_resize_readers == 0) { + sigaction(SIGWINCH, &php_io_terminal_previous_resize_action, NULL); + } + + PHP_IO_TERMINAL_RESIZE_UNLOCK(); +} +#endif /* SIGWINCH */ +#endif /* !PHP_WIN32 */ + +#define PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZOBJ(_obj) \ + ZEND_CONTAINER_OF(_obj, php_io_terminal_mode_token_object, std) +#define PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZV(_zv) \ + PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZOBJ(Z_OBJ_P(_zv)) + +#define PHP_IO_TERMINAL_OBJ_FROM_ZOBJ(_obj) \ + ZEND_CONTAINER_OF(_obj, php_io_terminal_object, std) +#define PHP_IO_TERMINAL_OBJ_FROM_ZV(_zv) \ + PHP_IO_TERMINAL_OBJ_FROM_ZOBJ(Z_OBJ_P(_zv)) + +static inline php_io_terminal_mode_token_object *php_io_terminal_get_active_mode_token(php_io_terminal_object *intern) +{ + if (intern->active_mode_token != NULL) { + php_io_terminal_mode_token_object *token = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZOBJ(intern->active_mode_token); + if (!token->valid || token->shared == NULL) { + OBJ_RELEASE(intern->active_mode_token); + intern->active_mode_token = NULL; + return NULL; + } + return token; + } + return NULL; +} + +static php_io_terminal_native_stream php_io_terminal_native_stream_from_php_stream(php_stream *stream); + +static bool php_io_terminal_native_stream_is_valid(php_io_terminal_native_stream stream) +{ +#ifdef PHP_WIN32 + return stream != INVALID_HANDLE_VALUE && stream != NULL; +#else + return stream >= 0; +#endif +} + +static bool php_io_terminal_native_stream_is_tty(php_io_terminal_native_stream stream) +{ + if (!php_io_terminal_native_stream_is_valid(stream)) { + return false; + } +#ifdef PHP_WIN32 + DWORD mode; + return GetConsoleMode(stream, &mode) != 0; +#else + return isatty(stream) == 1; +#endif +} + +#ifdef PHP_WIN32 +static bool php_io_terminal_get_identity(php_io_terminal_native_stream handle, php_io_terminal_identity *identity) +{ + identity->stream = handle; + identity->is_console = false; + if (!php_io_terminal_native_stream_is_valid(handle)) { + return false; + } + DWORD mode; + if (!GetConsoleMode(handle, &mode)) { + return false; + } + identity->is_console = true; + return true; +} + +static bool php_io_terminal_identities_match(const php_io_terminal_identity *first, const php_io_terminal_identity *second) +{ + return first->is_console && second->is_console; +} +#else +#ifndef PATH_MAX +# define PATH_MAX 1024 +#endif + +static bool php_io_terminal_get_pty_peer_dev(int fd, dev_t *peer_dev) +{ + char peer_path[PATH_MAX]; + bool got_peer_name = false; + +#if defined(PHP_IO_TERMINAL_HAVE_PTSNAME_R) + if (ptsname_r(fd, peer_path, sizeof(peer_path)) == 0) { + got_peer_name = true; + } +#else + PHP_IO_TERMINAL_PTSNAME_LOCK(); + const char *pts = ptsname(fd); + if (pts != NULL) { + size_t len = strlen(pts); + if (len < sizeof(peer_path)) { + memcpy(peer_path, pts, len + 1); + got_peer_name = true; + } + } + PHP_IO_TERMINAL_PTSNAME_UNLOCK(); +#endif + + if (got_peer_name && peer_path[0] != '\0') { + struct stat st; + if (stat(peer_path, &st) == 0 && S_ISCHR(st.st_mode) && st.st_rdev != 0) { + *peer_dev = st.st_rdev; + return true; + } + } + + return false; +} + +static bool php_io_terminal_get_identity(php_io_terminal_native_stream fd, php_io_terminal_identity *identity) +{ + memset(identity, 0, sizeof(*identity)); + identity->stream = fd; + identity->sid = -1; + + if (!php_io_terminal_native_stream_is_valid(fd) || isatty(fd) != 1) { + return false; + } + + pid_t sid = tcgetsid(fd); + if (sid > 0) { + identity->sid = sid; + identity->has_sid = true; + } + + dev_t peer_dev = 0; + if (php_io_terminal_get_pty_peer_dev(fd, &peer_dev)) { + identity->dev = peer_dev; + identity->has_dev = true; + return true; + } + +#if defined(TIOCGDEV) + unsigned int kdev = 0; + if (ioctl(fd, TIOCGDEV, &kdev) == 0 && kdev != 0) { + identity->dev = (dev_t) kdev; + identity->has_dev = true; + return true; + } +#endif + + struct stat st; + if (fstat(fd, &st) == 0 && S_ISCHR(st.st_mode) && st.st_rdev != 0) { + struct stat st_tty; + if (stat("/dev/tty", &st_tty) == 0 && S_ISCHR(st_tty.st_mode) && st.st_rdev == st_tty.st_rdev) { + /* /dev/tty is an indirect alias identified by its controlling terminal session */ + } else { + identity->dev = st.st_rdev; + identity->has_dev = true; + return true; + } + } + + return identity->has_dev || identity->has_sid; +} + +static bool php_io_terminal_identities_match(const php_io_terminal_identity *first, const php_io_terminal_identity *second) +{ + if (first->has_dev && second->has_dev) { + return first->dev == second->dev; + } + + if (first->has_sid && second->has_sid && first->sid == second->sid) { + return true; + } + + return false; +} +#endif + +#ifdef PHP_WIN32 +static php_io_terminal_native_stream php_io_terminal_dup_stream(php_io_terminal_native_stream handle) +{ + HANDLE current_process = GetCurrentProcess(); + HANDLE new_handle = INVALID_HANDLE_VALUE; + + if (DuplicateHandle(current_process, handle, current_process, &new_handle, 0, FALSE, DUPLICATE_SAME_ACCESS)) { + return new_handle; + } + + new_handle = CreateFileW( + L"CONIN$", + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + NULL, + OPEN_EXISTING, + 0, + NULL + ); + + if (new_handle != INVALID_HANDLE_VALUE) { + return new_handle; + } + + return INVALID_HANDLE_VALUE; +} + +static void php_io_terminal_close_native_stream(php_io_terminal_native_stream stream) +{ + if (stream != INVALID_HANDLE_VALUE && stream != NULL) { + CloseHandle(stream); + } +} +#else +static php_io_terminal_native_stream php_io_terminal_dup_stream(php_io_terminal_native_stream fd) +{ + int new_fd = -1; + +#if defined(F_DUPFD_CLOEXEC) + new_fd = fcntl(fd, F_DUPFD_CLOEXEC, 0); + if (new_fd >= 0) { + return new_fd; + } +#endif + + new_fd = dup(fd); + if (new_fd >= 0) { +#if defined(FD_CLOEXEC) + int flags = fcntl(new_fd, F_GETFD); + if (flags >= 0) { + fcntl(new_fd, F_SETFD, flags | FD_CLOEXEC); + } +#endif + return new_fd; + } + + return -1; +} + +static void php_io_terminal_close_native_stream(php_io_terminal_native_stream stream) +{ + if (stream >= 0) { + close(stream); + } +} +#endif + +static php_io_terminal_shared_mode *php_io_terminal_find_shared_mode(const php_io_terminal_identity *identity) +{ + php_io_terminal_shared_mode *curr = php_io_terminal_active_shared_modes; + while (curr != NULL) { + if (php_io_terminal_identities_match(&curr->identity, identity)) { + return curr; + } + curr = curr->next; + } + return NULL; +} + +static bool php_io_terminal_restore_shared_mode(const php_io_terminal_shared_mode *shared) +{ + if (!php_io_terminal_native_stream_is_valid(shared->restore_stream)) { + return false; + } + +#ifdef PHP_WIN32 + if (!SetConsoleMode(shared->restore_stream, shared->saved_mode)) { + return false; + } + DWORD actual_mode; + if (GetConsoleMode(shared->restore_stream, &actual_mode) && actual_mode != shared->saved_mode) { + return false; + } +#else + if (tcsetattr(shared->restore_stream, TCSANOW, &shared->saved_mode) != 0) { + return false; + } + struct termios actual_mode; + if (tcgetattr(shared->restore_stream, &actual_mode) == 0) { + if ((actual_mode.c_lflag & (ECHO | ICANON)) != (shared->saved_mode.c_lflag & (ECHO | ICANON))) { + return false; + } + } +#endif + return true; +} + +static void php_io_terminal_destroy_shared_mode(php_io_terminal_shared_mode *shared) +{ + if (shared->prev != NULL) { + shared->prev->next = shared->next; + } else { + php_io_terminal_active_shared_modes = shared->next; + } + + if (shared->next != NULL) { + shared->next->prev = shared->prev; + } + + shared->prev = NULL; + shared->next = NULL; + + if (php_io_terminal_native_stream_is_valid(shared->restore_stream)) { + php_io_terminal_close_native_stream(shared->restore_stream); + shared->restore_stream = PHP_IO_TERMINAL_INVALID_NATIVE_STREAM; + } + + efree(shared); +} + +static bool php_io_terminal_release_token_lease(php_io_terminal_mode_token_object *token_obj, const char **error_msg) +{ + php_io_terminal_shared_mode *shared = token_obj->shared; + + if (!token_obj->valid || shared == NULL) { + if (error_msg != NULL) { + *error_msg = "Terminal mode token is not active"; + } + return false; + } + + if (shared->lease_count > 1) { + shared->lease_count--; + token_obj->valid = false; + token_obj->shared = NULL; + return true; + } + + if (!php_io_terminal_restore_shared_mode(shared)) { + if (error_msg != NULL) { + *error_msg = "Failed to restore terminal mode"; + } + return false; + } + + token_obj->valid = false; + token_obj->shared = NULL; + + php_io_terminal_destroy_shared_mode(shared); + return true; +} + +static void php_io_terminal_mode_token_free_obj(zend_object *object) +{ + php_io_terminal_mode_token_object *intern = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZOBJ(object); + + if (intern->valid && intern->shared != NULL) { + if (!php_io_terminal_release_token_lease(intern, NULL)) { + intern->valid = false; + intern->shared = NULL; + } + } + + zend_object_std_dtor(&intern->std); +} + +static zend_object *php_io_terminal_mode_token_create_object(zend_class_entry *ce) +{ + php_io_terminal_mode_token_object *intern = zend_object_alloc(sizeof(*intern), ce); + + intern->shared = NULL; + intern->valid = false; + + zend_object_std_init(&intern->std, ce); + object_properties_init(&intern->std, ce); + intern->std.handlers = &php_io_terminal_mode_token_handlers; + + return &intern->std; +} + +static void php_io_terminal_create_mode_token(zval *return_value, php_io_terminal_shared_mode *shared) +{ + php_io_terminal_mode_token_object *intern; + + object_init_ex(return_value, php_io_terminal_system_mode_token_ce); + intern = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZV(return_value); + intern->shared = shared; + intern->valid = true; +} + +static void php_io_terminal_free_obj(zend_object *object) +{ + php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZOBJ(object); + + if (intern->active_mode_token != NULL) { + OBJ_RELEASE(intern->active_mode_token); + intern->active_mode_token = NULL; + } + + zval_ptr_dtor(&intern->input_stream_val); + zval_ptr_dtor(&intern->output_stream_val); + + zend_object_std_dtor(&intern->std); +} + +static zend_object *php_io_terminal_create_object(zend_class_entry *ce) +{ + php_io_terminal_object *intern = zend_object_alloc(sizeof(*intern), ce); + + ZVAL_UNDEF(&intern->input_stream_val); + ZVAL_UNDEF(&intern->output_stream_val); + memset(&intern->identity, 0, sizeof(intern->identity)); + intern->has_identity = false; + intern->active_mode_token = NULL; + intern->last_cols = 0; + intern->last_rows = 0; + intern->has_last_size = false; + +#ifdef PHP_WIN32 + memset(&intern->pending_key, 0, sizeof(intern->pending_key)); + intern->pending_key_high_surrogate = 0; + intern->pending_high_surrogate = 0; +#else + memset(&intern->pending_utf8, 0, sizeof(intern->pending_utf8)); +#endif + + zend_object_std_init(&intern->std, ce); + object_properties_init(&intern->std, ce); + intern->std.handlers = &php_io_terminal_object_handlers; + + return &intern->std; +} + + +static php_io_terminal_native_stream php_io_terminal_native_stream_from_php_stream(php_stream *stream) +{ +#ifdef PHP_WIN32 + php_socket_t descriptor = (php_socket_t) -1; + intptr_t os_handle; + + if (php_stream_cast(stream, PHP_STREAM_AS_FD | PHP_STREAM_CAST_INTERNAL, (void **) &descriptor, 0) != SUCCESS) { + return INVALID_HANDLE_VALUE; + } + + if (descriptor == INVALID_SOCKET || (uintptr_t) descriptor > INT_MAX) { + return INVALID_HANDLE_VALUE; + } + + os_handle = _get_osfhandle((int) descriptor); + return os_handle == -1 ? INVALID_HANDLE_VALUE : (HANDLE) os_handle; +#else + php_socket_t descriptor = (php_socket_t) -1; + + if (php_stream_cast(stream, PHP_STREAM_AS_FD | PHP_STREAM_CAST_INTERNAL, (void **) &descriptor, 0) != SUCCESS) { + return -1; + } + + if (descriptor < 0 || descriptor > INT_MAX) { + return -1; + } + + return (int) descriptor; +#endif +} + +static bool php_io_terminal_stream_target_init_ex( + zval *stream_arg, + bool is_input, + bool quiet, + php_io_terminal_stream_target *target +) +{ + memset(target, 0, sizeof(*target)); + + if (stream_arg == NULL || Z_ISUNDEF_P(stream_arg) || Z_TYPE_P(stream_arg) == IS_NULL) { + target->is_default = true; +#ifdef PHP_WIN32 + target->native_stream = is_input ? GetStdHandle(STD_INPUT_HANDLE) : GetStdHandle(STD_OUTPUT_HANDLE); +#else + target->native_stream = is_input ? STDIN_FILENO : STDOUT_FILENO; +#endif + if (is_input) { + zval *stdin_zv = zend_get_constant_str("STDIN", sizeof("STDIN") - 1); + if (stdin_zv != NULL && Z_TYPE_P(stdin_zv) == IS_RESOURCE) { + target->php_stream = (php_stream *) zend_fetch_resource2( + Z_RES_P(stdin_zv), + NULL, + php_file_le_stream(), + php_file_le_pstream() + ); + if (target->php_stream != NULL) { + target->stream_resource = stdin_zv; + } + } + } else { + zval *stdout_zv = zend_get_constant_str("STDOUT", sizeof("STDOUT") - 1); + if (stdout_zv != NULL && Z_TYPE_P(stdout_zv) == IS_RESOURCE) { + target->php_stream = (php_stream *) zend_fetch_resource2( + Z_RES_P(stdout_zv), + NULL, + php_file_le_stream(), + php_file_le_pstream() + ); + if (target->php_stream != NULL) { + target->stream_resource = stdout_zv; + } + } + } + return true; + } + + if (Z_TYPE_P(stream_arg) == IS_RESOURCE) { + target->php_stream = (php_stream *) zend_fetch_resource2( + Z_RES_P(stream_arg), + quiet ? NULL : "stream", + php_file_le_stream(), + php_file_le_pstream() + ); + if (target->php_stream == NULL) { + return false; + } + + target->is_default = false; + target->stream_resource = stream_arg; + target->native_stream = php_io_terminal_native_stream_from_php_stream(target->php_stream); + return true; + } + + return false; +} + +static zend_always_inline bool php_io_terminal_stream_target_init( + zval *stream_arg, + bool is_input, + php_io_terminal_stream_target *target +) +{ + return php_io_terminal_stream_target_init_ex(stream_arg, is_input, false, target); +} + +#ifdef PHP_WIN32 +static DWORD php_io_terminal_make_raw_mode(DWORD mode) +{ + return (mode & ~(ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT | ENABLE_PROCESSED_INPUT)) | ENABLE_WINDOW_INPUT; +} + +static php_io_terminal_shared_mode *php_io_terminal_acquire_raw_mode( + php_io_terminal_native_stream handle, + const php_io_terminal_identity *identity +) +{ + php_io_terminal_shared_mode *shared = php_io_terminal_find_shared_mode(identity); + if (shared != NULL) { + DWORD current_mode; + if (GetConsoleMode(handle, ¤t_mode) && (current_mode & (ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT)) != 0) { + DWORD raw = php_io_terminal_make_raw_mode(current_mode); + SetConsoleMode(handle, raw); + } + shared->lease_count++; + return shared; + } + + DWORD mode; + if (!php_io_terminal_native_stream_is_valid(handle) || !GetConsoleMode(handle, &mode)) { + return NULL; + } + + php_io_terminal_native_stream restore_stream = php_io_terminal_dup_stream(handle); + if (!php_io_terminal_native_stream_is_valid(restore_stream)) { + return NULL; + } + + DWORD raw_mode = php_io_terminal_make_raw_mode(mode); + if (!SetConsoleMode(handle, raw_mode)) { + php_io_terminal_close_native_stream(restore_stream); + return NULL; + } + + DWORD actual_mode; + if (GetConsoleMode(handle, &actual_mode) && (actual_mode & (ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT)) != 0) { + php_io_terminal_close_native_stream(restore_stream); + return NULL; + } + + shared = emalloc(sizeof(*shared)); + shared->identity = *identity; + shared->restore_stream = restore_stream; + shared->saved_mode = mode; + shared->lease_count = 1; + shared->prev = NULL; + shared->next = php_io_terminal_active_shared_modes; + if (php_io_terminal_active_shared_modes != NULL) { + php_io_terminal_active_shared_modes->prev = shared; + } + php_io_terminal_active_shared_modes = shared; + + return shared; +} + +static bool php_io_terminal_stream_size(php_io_terminal_native_stream handle, zend_long *columns, zend_long *rows) +{ + CONSOLE_SCREEN_BUFFER_INFO info; + + if (!php_io_terminal_native_stream_is_valid(handle) || !GetConsoleScreenBufferInfo(handle, &info)) { + return false; + } + + *columns = (zend_long) (info.srWindow.Right - info.srWindow.Left + 1); + *rows = (zend_long) (info.srWindow.Bottom - info.srWindow.Top + 1); + + return true; +} + +static zend_string *php_io_terminal_key_from_virtual_key(WORD vk) +{ + switch (vk) { + case VK_UP: + return ZSTR_INIT_LITERAL("up", false); + case VK_DOWN: + return ZSTR_INIT_LITERAL("down", false); + case VK_RIGHT: + return ZSTR_INIT_LITERAL("right", false); + case VK_LEFT: + return ZSTR_INIT_LITERAL("left", false); + case VK_RETURN: + return ZSTR_INIT_LITERAL("enter", false); + case VK_BACK: + return ZSTR_INIT_LITERAL("backspace", false); + case VK_ESCAPE: + return ZSTR_INIT_LITERAL("escape", false); + case VK_TAB: + return ZSTR_INIT_LITERAL("tab", false); + case VK_HOME: + return ZSTR_INIT_LITERAL("home", false); + case VK_END: + return ZSTR_INIT_LITERAL("end", false); + case VK_DELETE: + return ZSTR_INIT_LITERAL("delete", false); + case VK_PRIOR: + return ZSTR_INIT_LITERAL("pageup", false); + case VK_NEXT: + return ZSTR_INIT_LITERAL("pagedown", false); + case VK_F1: + return ZSTR_INIT_LITERAL("f1", false); + case VK_F2: + return ZSTR_INIT_LITERAL("f2", false); + case VK_F3: + return ZSTR_INIT_LITERAL("f3", false); + case VK_F4: + return ZSTR_INIT_LITERAL("f4", false); + case VK_F5: + return ZSTR_INIT_LITERAL("f5", false); + case VK_F6: + return ZSTR_INIT_LITERAL("f6", false); + case VK_F7: + return ZSTR_INIT_LITERAL("f7", false); + case VK_F8: + return ZSTR_INIT_LITERAL("f8", false); + case VK_F9: + return ZSTR_INIT_LITERAL("f9", false); + case VK_F10: + return ZSTR_INIT_LITERAL("f10", false); + case VK_F11: + return ZSTR_INIT_LITERAL("f11", false); + case VK_F12: + return ZSTR_INIT_LITERAL("f12", false); + default: + return NULL; + } +} + +static zend_string *php_io_terminal_key_from_wchar(WCHAR ch, WCHAR *high_surrogate) +{ + char buffer[8]; + WCHAR units[2]; + int units_len = 0; + int buffer_len; + + if (ch == 0) { + *high_surrogate = 0; + return NULL; + } + + if (ch >= 0xd800 && ch <= 0xdbff) { + *high_surrogate = ch; + return NULL; + } + + if (ch >= 0xdc00 && ch <= 0xdfff) { + if (*high_surrogate >= 0xd800 && *high_surrogate <= 0xdbff) { + units[0] = *high_surrogate; + units[1] = ch; + units_len = 2; + } else { + *high_surrogate = 0; + return NULL; + } + } else { + units[0] = ch; + units_len = 1; + } + + *high_surrogate = 0; + buffer_len = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, units, units_len, buffer, sizeof(buffer), NULL, NULL); + return buffer_len > 0 ? zend_string_init(buffer, (size_t) buffer_len, false) : NULL; +} + +static zend_string *php_io_terminal_key_from_input_record(const KEY_EVENT_RECORD *key, WCHAR *high_surrogate) +{ + zend_string *named_key = php_io_terminal_key_from_virtual_key(key->wVirtualKeyCode); + if (named_key != NULL) { + *high_surrogate = 0; + return named_key; + } + return php_io_terminal_key_from_wchar(key->uChar.UnicodeChar, high_surrogate); +} + +static bool php_io_terminal_read_console_record( + HANDLE handle, + DWORD wait_ms, + INPUT_RECORD *record, + WCHAR *high_surrogate, + INPUT_RECORD *pending_key, + WCHAR *pending_key_high_surrogate +) +{ + if (pending_key->Event.KeyEvent.wRepeatCount > 0) { + *record = *pending_key; + *high_surrogate = *pending_key_high_surrogate; + pending_key->Event.KeyEvent.wRepeatCount = 0; + return true; + } + + DWORD wait_res = WaitForSingleObject(handle, wait_ms); + if (wait_res == WAIT_TIMEOUT) { + return false; + } + if (wait_res != WAIT_OBJECT_0) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to wait on console input", 0); + return false; + } + + DWORD records_read; + if (!ReadConsoleInputW(handle, record, 1, &records_read) || records_read != 1) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read console input", 0); + return false; + } + + return true; +} + +static zend_string *php_io_terminal_read_stream_key( + php_io_terminal_native_stream input, + php_stream *stream, + DWORD wait_ms, + WCHAR *pending_high_surrogate, + INPUT_RECORD *pending_key, + WCHAR *pending_key_high_surrogate +) +{ + HANDLE handle = input; + DWORD mode = 0; + WCHAR high_surrogate = *pending_high_surrogate; + ULONGLONG deadline_ms = wait_ms == INFINITE ? 0 : GetTickCount64() + wait_ms; + zend_string *result = NULL; + bool mode_changed = false; + + if (handle == INVALID_HANDLE_VALUE || handle == NULL + || (stream != NULL && stream->writepos > stream->readpos)) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read key: input stream is not a terminal", 0); + return NULL; + } + + if (!GetConsoleMode(handle, &mode)) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read key: input stream is not a terminal", 0); + return NULL; + } + + DWORD raw_mode = php_io_terminal_make_raw_mode(mode) | ENABLE_WINDOW_INPUT; + if (raw_mode != mode && !SetConsoleMode(handle, raw_mode)) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to set console mode", 0); + return NULL; + } + mode_changed = raw_mode != mode; + + while (true) { + INPUT_RECORD record; + DWORD remaining_ms = INFINITE; + + if (wait_ms != INFINITE) { + ULONGLONG now = GetTickCount64(); + remaining_ms = now >= deadline_ms ? 0 : (DWORD) (deadline_ms - now); + } + + if (!php_io_terminal_read_console_record( + handle, + remaining_ms, + &record, + &high_surrogate, + pending_key, + pending_key_high_surrogate)) { + break; + } + + if (record.EventType == WINDOW_BUFFER_SIZE_EVENT) { + result = ZSTR_INIT_LITERAL("resize", false); + break; + } + + if (record.EventType == KEY_EVENT) { + KEY_EVENT_RECORD *key = &record.Event.KeyEvent; + if (!key->bKeyDown) { + continue; + } + + WCHAR prev_high = high_surrogate; + result = php_io_terminal_key_from_input_record(key, &high_surrogate); + if (result != NULL) { + if (key->wRepeatCount > 1) { + key->wRepeatCount--; + *pending_key = record; + *pending_key_high_surrogate = prev_high; + } + break; + } + } + } + + *pending_high_surrogate = high_surrogate; + + if (mode_changed && !SetConsoleMode(handle, mode)) { + if (result != NULL) { + zend_string_release(result); + } + if (!EG(exception)) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to restore console mode", 0); + } + return NULL; + } + + return result; +} + +static zend_string *php_io_terminal_read_stream_secret( + php_io_terminal_native_stream input, + php_stream *stream, + INPUT_RECORD *pending_key, + WCHAR *pending_key_high_surrogate, + DWORD wait_ms, + bool has_timeout, + bool *timed_out +) +{ + *timed_out = false; + HANDLE handle = input; + DWORD mode; + + if (handle == INVALID_HANDLE_VALUE || handle == NULL + || (stream != NULL && stream->writepos > stream->readpos) + || !GetConsoleMode(handle, &mode)) { + return NULL; + } + + DWORD raw_mode = php_io_terminal_make_raw_mode(mode); + if (raw_mode != mode && !SetConsoleMode(handle, raw_mode)) { + return NULL; + } + + bool mode_changed = raw_mode != mode; + WCHAR high_surrogate = 0; + smart_str secret = {0}; + bool success = false; + bool failed = false; + + zend_hrtime_t overall_deadline_ns = 0; + if (has_timeout) { + overall_deadline_ns = php_io_terminal_saturating_add_deadline( + zend_hrtime(), + ((zend_hrtime_t) wait_ms * 1000000ULL) + ); + } + + while (true) { + INPUT_RECORD record; + DWORD current_wait = INFINITE; + + if (has_timeout) { + zend_hrtime_t now = zend_hrtime(); + if (now >= overall_deadline_ns) { + current_wait = 0; + } else { + zend_hrtime_t remaining_ns = overall_deadline_ns - now; + uint64_t ms = (remaining_ns + 999999ULL) / 1000000ULL; + current_wait = ms >= (DWORD) PHP_IO_TERMINAL_MAX_WAIT_MS ? PHP_IO_TERMINAL_MAX_WAIT_MS : (DWORD) ms; + } + } + + if (!php_io_terminal_read_console_record( + handle, + current_wait, + &record, + &high_surrogate, + pending_key, + pending_key_high_surrogate)) { + if (has_timeout && (current_wait == 0 || zend_hrtime() >= overall_deadline_ns)) { + *timed_out = true; + } + failed = true; + break; + } + + if (record.EventType != KEY_EVENT) { + continue; + } + + KEY_EVENT_RECORD *key = &record.Event.KeyEvent; + if (!key->bKeyDown) { + continue; + } + + if (key->wVirtualKeyCode == VK_RETURN) { + success = true; + break; + } + + if (key->wVirtualKeyCode == VK_ESCAPE + || ((key->wVirtualKeyCode == 'C' || key->wVirtualKeyCode == 'D') + && (key->dwControlKeyState & (LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED)) != 0)) { + failed = true; + break; + } + + WORD repeats = key->wRepeatCount > 0 ? key->wRepeatCount : 1; + while (repeats-- > 0) { + if (key->wVirtualKeyCode == VK_BACK) { + if (secret.s != NULL && ZSTR_LEN(secret.s) > 0) { + size_t len = ZSTR_LEN(secret.s); + while (len > 0) { + unsigned char byte = (unsigned char) ZSTR_VAL(secret.s)[len - 1]; + len--; + if ((byte & 0xc0) != 0x80) { + break; + } + } + ZSTR_LEN(secret.s) = len; + } + continue; + } + + if (key->uChar.UnicodeChar != 0) { + zend_string *utf8 = php_io_terminal_key_from_wchar(key->uChar.UnicodeChar, &high_surrogate); + if (utf8 != NULL) { + smart_str_append(&secret, utf8); + zend_string_release(utf8); + } + } + } + } + + if (mode_changed && !SetConsoleMode(handle, mode)) { + failed = true; + *timed_out = false; + } + + if (success && !failed) { + return smart_str_extract(&secret); + } + + smart_str_free(&secret); + return NULL; +} +#else +/* POSIX implementation */ + +static void php_io_terminal_make_raw_mode(struct termios *mode) +{ + mode->c_iflag &= ~(IGNBRK | BRKINT | PARMRK | ISTRIP | INLCR | IGNCR | ICRNL | IXON); + mode->c_oflag &= ~OPOST; + mode->c_lflag &= ~(ECHO | ECHONL | ICANON | ISIG | IEXTEN); + mode->c_cflag &= ~(CSIZE | PARENB); + mode->c_cflag |= CS8; + mode->c_cc[VMIN] = 1; + mode->c_cc[VTIME] = 0; +} + +static php_io_terminal_shared_mode *php_io_terminal_acquire_raw_mode( + php_io_terminal_native_stream fd, + const php_io_terminal_identity *identity +) +{ + php_io_terminal_shared_mode *shared = php_io_terminal_find_shared_mode(identity); + if (shared != NULL) { + struct termios current_mode; + if (tcgetattr(fd, ¤t_mode) == 0 && (current_mode.c_lflag & (ECHO | ICANON)) != 0) { + struct termios raw = current_mode; + php_io_terminal_make_raw_mode(&raw); + tcsetattr(fd, TCSANOW, &raw); + } + shared->lease_count++; + return shared; + } + + struct termios mode; + if (!php_io_terminal_native_stream_is_valid(fd) || isatty(fd) != 1 || tcgetattr(fd, &mode) != 0) { + return NULL; + } + + php_io_terminal_native_stream restore_stream = php_io_terminal_dup_stream(fd); + if (!php_io_terminal_native_stream_is_valid(restore_stream)) { + return NULL; + } + + struct termios raw_mode = mode; + php_io_terminal_make_raw_mode(&raw_mode); + + if (tcsetattr(fd, TCSANOW, &raw_mode) != 0) { + php_io_terminal_close_native_stream(restore_stream); + return NULL; + } + + struct termios actual_mode; + if (tcgetattr(fd, &actual_mode) == 0 && (actual_mode.c_lflag & (ECHO | ICANON)) != 0) { + php_io_terminal_close_native_stream(restore_stream); + return NULL; + } + + shared = emalloc(sizeof(*shared)); + shared->identity = *identity; + shared->restore_stream = restore_stream; + shared->saved_mode = mode; + shared->lease_count = 1; + shared->prev = NULL; + shared->next = php_io_terminal_active_shared_modes; + if (php_io_terminal_active_shared_modes != NULL) { + php_io_terminal_active_shared_modes->prev = shared; + } + php_io_terminal_active_shared_modes = shared; + + return shared; +} + +static bool php_io_terminal_stream_size(php_io_terminal_native_stream fd, zend_long *columns, zend_long *rows) +{ + struct winsize ws; + + if (!php_io_terminal_native_stream_is_valid(fd)) { + return false; + } + + if (ioctl(fd, TIOCGWINSZ, &ws) != 0 || ws.ws_col == 0 || ws.ws_row == 0) { + return false; + } + + *columns = (zend_long) ws.ws_col; + *rows = (zend_long) ws.ws_row; + + return true; +} + +static size_t php_io_terminal_utf8_sequence_len(unsigned char byte) +{ + if ((byte & 0x80) == 0) { + return 1; + } + if ((byte & 0xe0) == 0xc0) { + return 2; + } + if ((byte & 0xf0) == 0xe0) { + return 3; + } + if ((byte & 0xf8) == 0xf0) { + return 4; + } + return 1; +} + +static void php_io_terminal_buffer_remove_last_utf8_char(smart_str *secret) +{ + size_t len; + + if (secret->s == NULL || ZSTR_LEN(secret->s) == 0) { + return; + } + + len = ZSTR_LEN(secret->s); + while (len > 0) { + unsigned char byte = (unsigned char) ZSTR_VAL(secret->s)[len - 1]; + len--; + if ((byte & 0xc0) != 0x80) { + break; + } + } + + ZSTR_LEN(secret->s) = len; +} + +static bool php_io_terminal_timespec_subtract_elapsed(struct timespec *remaining, zend_hrtime_t elapsed_ns) +{ + uint64_t elapsed_seconds = elapsed_ns / ZEND_NANO_IN_SEC; + uint64_t elapsed_nanoseconds = elapsed_ns % ZEND_NANO_IN_SEC; + + if (elapsed_seconds > (uint64_t) remaining->tv_sec + || (elapsed_seconds == (uint64_t) remaining->tv_sec + && elapsed_nanoseconds >= (uint64_t) remaining->tv_nsec)) { + remaining->tv_sec = 0; + remaining->tv_nsec = 0; + return false; + } + + remaining->tv_sec -= (time_t) elapsed_seconds; + if ((uint64_t) remaining->tv_nsec < elapsed_nanoseconds) { + remaining->tv_sec--; + remaining->tv_nsec += ZEND_NANO_IN_SEC; + } + remaining->tv_nsec -= (long) elapsed_nanoseconds; + + return true; +} + +static php_poll_ctx *php_io_terminal_create_poll_context(int fd) +{ + php_poll_ctx *poll_ctx = php_poll_create(PHP_POLL_BACKEND_AUTO, 0); + + if (poll_ctx == NULL) { + return NULL; + } + + if (php_poll_set_max_events_hint(poll_ctx, 1) == FAILURE + || php_poll_init(poll_ctx) == FAILURE + || php_poll_add(poll_ctx, fd, PHP_POLL_READ, NULL) == FAILURE) { + php_poll_destroy(poll_ctx); + return NULL; + } + + return poll_ctx; +} + +static int php_io_terminal_read_byte( + int fd, + php_stream *stream, + php_poll_ctx *poll_ctx, + unsigned char *byte, + const struct timespec *timeout, + bool allow_resize, + const sig_atomic_t *generation +) +{ + if (stream != NULL && stream->writepos > stream->readpos) { + size_t read_bytes = php_stream_read(stream, (char *) byte, 1); + if (read_bytes == 1) { + return PHP_IO_TERMINAL_READ_SUCCESS; + } + return php_stream_eof(stream) ? PHP_IO_TERMINAL_READ_EOF : PHP_IO_TERMINAL_READ_ERROR; + } + + if (poll_ctx == NULL) { + return PHP_IO_TERMINAL_READ_ERROR; + } + + struct timespec remaining; + if (timeout != NULL) { + remaining = *timeout; + } + + while (true) { + php_poll_event event; + const struct timespec *wait_timeout = NULL; + zend_hrtime_t start_ns = 0; + +#if defined(SIGWINCH) + if (allow_resize && generation != NULL && *generation != php_io_terminal_resize_generation) { + return PHP_IO_TERMINAL_READ_RESIZE; + } +#endif + + if (timeout != NULL) { + wait_timeout = &remaining; + start_ns = zend_hrtime(); + } + + int poll_result = php_poll_wait(poll_ctx, &event, 1, wait_timeout); + if (poll_result > 0) { + break; + } + if (poll_result == 0) { + return PHP_IO_TERMINAL_READ_TIMEOUT; + } + + if (php_poll_get_error(poll_ctx) == PHP_POLL_ERR_INTERRUPTED) { +#if defined(SIGWINCH) + if (allow_resize && generation != NULL && *generation != php_io_terminal_resize_generation) { + return PHP_IO_TERMINAL_READ_RESIZE; + } +#endif + if (timeout != NULL) { + zend_hrtime_t elapsed_ns = zend_hrtime() - start_ns; + if (UNEXPECTED(elapsed_ns == 0) + || !php_io_terminal_timespec_subtract_elapsed(&remaining, elapsed_ns)) { + return PHP_IO_TERMINAL_READ_TIMEOUT; + } + } + continue; + } + + return PHP_IO_TERMINAL_READ_ERROR; + } + + ssize_t bytes_read = read(fd, byte, 1); + if (bytes_read == 0) { + return PHP_IO_TERMINAL_READ_EOF; + } + if (bytes_read < 0) { + return PHP_IO_TERMINAL_READ_ERROR; + } + + return PHP_IO_TERMINAL_READ_SUCCESS; +} + +static zend_string *php_io_terminal_finish_utf8_sequence( + int fd, + php_stream *stream, + php_poll_ctx *poll_ctx, + php_io_terminal_utf8_pending *pending, + bool has_overall_deadline, + zend_hrtime_t overall_deadline_ns +) +{ + while (pending->length < pending->expected) { + unsigned char key; + struct timespec current_timeout; + const struct timespec *timeout = NULL; + + if (has_overall_deadline) { + zend_hrtime_t now = zend_hrtime(); + if (now >= overall_deadline_ns) { + current_timeout.tv_sec = 0; + current_timeout.tv_nsec = 0; + } else { + php_io_terminal_ns_to_timespec(overall_deadline_ns - now, ¤t_timeout); + } + timeout = ¤t_timeout; + } + + int result = php_io_terminal_read_byte(fd, stream, poll_ctx, &key, timeout, false, NULL); + if (result == PHP_IO_TERMINAL_READ_TIMEOUT) { + return NULL; + } + if (result == PHP_IO_TERMINAL_READ_EOF) { + zend_throw_exception(php_io_terminal_exception_ce, "End of file reached on terminal input stream", 0); + return NULL; + } + if (result != PHP_IO_TERMINAL_READ_SUCCESS) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read from terminal input stream", 0); + return NULL; + } + + pending->bytes[pending->length++] = key; + if ((key & 0xc0) != 0x80) { + break; + } + } + + zend_string *result = zend_string_init((const char *) pending->bytes, pending->length, false); + memset(pending, 0, sizeof(*pending)); + return result; +} + +static zend_string *php_io_terminal_key_from_escape_sequence( + int fd, + php_stream *stream, + php_poll_ctx *poll_ctx, + const struct timespec *sequence_timeout, + bool has_overall_deadline, + zend_hrtime_t overall_deadline_ns +) +{ + unsigned char seq[32]; + size_t seq_len = 0; + seq[seq_len++] = 0x1b; + + zend_hrtime_t now = zend_hrtime(); + zend_hrtime_t seq_timeout_ns = php_io_terminal_timespec_to_ns(sequence_timeout); + zend_hrtime_t deadline_ns = php_io_terminal_saturating_add_deadline(now, seq_timeout_ns); + if (has_overall_deadline && deadline_ns > overall_deadline_ns) { + deadline_ns = overall_deadline_ns; + } + + if (now >= deadline_ns) { + return ZSTR_INIT_LITERAL("escape", false); + } + + struct timespec remaining_ts; + php_io_terminal_ns_to_timespec(deadline_ns - now, &remaining_ts); + + int read_result = php_io_terminal_read_byte(fd, stream, poll_ctx, &seq[seq_len], &remaining_ts, false, NULL); + if (read_result == PHP_IO_TERMINAL_READ_TIMEOUT) { + return ZSTR_INIT_LITERAL("escape", false); + } + if (read_result == PHP_IO_TERMINAL_READ_EOF) { + zend_throw_exception(php_io_terminal_exception_ce, "End of file reached on terminal input stream", 0); + return NULL; + } + if (read_result != PHP_IO_TERMINAL_READ_SUCCESS) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read from terminal input stream", 0); + return NULL; + } + seq_len++; + + if (seq[1] == '[') { + while (seq_len < sizeof(seq)) { + now = zend_hrtime(); + if (now >= deadline_ns) { + break; + } + php_io_terminal_ns_to_timespec(deadline_ns - now, &remaining_ts); + + unsigned char next; + read_result = php_io_terminal_read_byte(fd, stream, poll_ctx, &next, &remaining_ts, false, NULL); + if (read_result != PHP_IO_TERMINAL_READ_SUCCESS) { + if (read_result == PHP_IO_TERMINAL_READ_EOF) { + zend_throw_exception(php_io_terminal_exception_ce, "End of file reached on terminal input stream", 0); + return NULL; + } + if (read_result == PHP_IO_TERMINAL_READ_ERROR) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read from terminal input stream", 0); + return NULL; + } + break; + } + + seq[seq_len++] = next; + if (next >= 0x40 && next <= 0x7e) { + break; + } + } + +#define PHP_IO_TERMINAL_CSI_IS(literal) \ + (memcmp(seq, literal, sizeof(literal) - 1) == 0) + + if (seq_len == 3) { + if (PHP_IO_TERMINAL_CSI_IS("\x1b[A")) return ZSTR_INIT_LITERAL("up", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[B")) return ZSTR_INIT_LITERAL("down", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[C")) return ZSTR_INIT_LITERAL("right", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[D")) return ZSTR_INIT_LITERAL("left", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[H")) return ZSTR_INIT_LITERAL("home", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[F")) return ZSTR_INIT_LITERAL("end", false); + } else if (seq_len == 4) { + if (PHP_IO_TERMINAL_CSI_IS("\x1b[1~")) return ZSTR_INIT_LITERAL("home", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[4~")) return ZSTR_INIT_LITERAL("end", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[3~")) return ZSTR_INIT_LITERAL("delete", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[5~")) return ZSTR_INIT_LITERAL("pageup", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[6~")) return ZSTR_INIT_LITERAL("pagedown", false); + } else if (seq_len == 5) { + if (PHP_IO_TERMINAL_CSI_IS("\x1b[11~")) return ZSTR_INIT_LITERAL("f1", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[12~")) return ZSTR_INIT_LITERAL("f2", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[13~")) return ZSTR_INIT_LITERAL("f3", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[14~")) return ZSTR_INIT_LITERAL("f4", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[15~")) return ZSTR_INIT_LITERAL("f5", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[17~")) return ZSTR_INIT_LITERAL("f6", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[18~")) return ZSTR_INIT_LITERAL("f7", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[19~")) return ZSTR_INIT_LITERAL("f8", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[20~")) return ZSTR_INIT_LITERAL("f9", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[21~")) return ZSTR_INIT_LITERAL("f10", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[23~")) return ZSTR_INIT_LITERAL("f11", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[24~")) return ZSTR_INIT_LITERAL("f12", false); + } + +#undef PHP_IO_TERMINAL_CSI_IS + } else if (seq[1] == 'O' && seq_len < sizeof(seq)) { + now = zend_hrtime(); + if (now < deadline_ns) { + php_io_terminal_ns_to_timespec(deadline_ns - now, &remaining_ts); + read_result = php_io_terminal_read_byte(fd, stream, poll_ctx, &seq[seq_len], &remaining_ts, false, NULL); + if (read_result == PHP_IO_TERMINAL_READ_SUCCESS) { + seq_len++; + switch (seq[2]) { + case 'P': return ZSTR_INIT_LITERAL("f1", false); + case 'Q': return ZSTR_INIT_LITERAL("f2", false); + case 'R': return ZSTR_INIT_LITERAL("f3", false); + case 'S': return ZSTR_INIT_LITERAL("f4", false); + case 'H': return ZSTR_INIT_LITERAL("home", false); + case 'F': return ZSTR_INIT_LITERAL("end", false); + } + } else if (read_result == PHP_IO_TERMINAL_READ_EOF) { + zend_throw_exception(php_io_terminal_exception_ce, "End of file reached on terminal input stream", 0); + return NULL; + } else if (read_result == PHP_IO_TERMINAL_READ_ERROR) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read from terminal input stream", 0); + return NULL; + } + } + } + + return zend_string_init((const char *) seq, seq_len, false); +} + +static zend_string *php_io_terminal_key_from_utf8_sequence( + int fd, + php_stream *stream, + php_poll_ctx *poll_ctx, + unsigned char key, + php_io_terminal_utf8_pending *pending, + bool has_overall_deadline, + zend_hrtime_t overall_deadline_ns +) +{ + size_t sequence_len = php_io_terminal_utf8_sequence_len(key); + + if (sequence_len == 1) { + return ZSTR_CHAR(key); + } + + memset(pending, 0, sizeof(*pending)); + pending->bytes[0] = key; + pending->length = 1; + pending->expected = sequence_len; + + return php_io_terminal_finish_utf8_sequence( + fd, stream, poll_ctx, pending, + has_overall_deadline, overall_deadline_ns + ); +} + +static zend_string *php_io_terminal_key_from_byte( + int fd, + php_stream *stream, + php_poll_ctx *poll_ctx, + unsigned char key, + const struct timespec *sequence_timeout, + php_io_terminal_utf8_pending *pending, + bool has_overall_deadline, + zend_hrtime_t overall_deadline_ns +) +{ + switch (key) { + case '\r': + case '\n': + return ZSTR_INIT_LITERAL("enter", false); + case '\t': + return ZSTR_INIT_LITERAL("tab", false); + case 0x7f: + case '\b': + return ZSTR_INIT_LITERAL("backspace", false); + case 0x1b: + return php_io_terminal_key_from_escape_sequence( + fd, stream, poll_ctx, sequence_timeout, has_overall_deadline, overall_deadline_ns + ); + default: + return php_io_terminal_key_from_utf8_sequence( + fd, stream, poll_ctx, key, pending, has_overall_deadline, overall_deadline_ns + ); + } +} + +static zend_string *php_io_terminal_read_stream_key( + php_io_terminal_native_stream input, + php_stream *stream, + const struct timespec *timeout, + const struct timespec *sequence_timeout, + php_io_terminal_utf8_pending *pending +) +{ + int fd = input; + if (fd < 0 || isatty(fd) != 1) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read key: input stream is not a terminal", 0); + return NULL; + } + + struct termios mode; + if (tcgetattr(fd, &mode) != 0) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read key: input stream is not a terminal", 0); + return NULL; + } + + php_poll_ctx *poll_ctx = php_io_terminal_create_poll_context(fd); + if (poll_ctx == NULL) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to initialize terminal poll context", 0); + return NULL; + } + + sig_atomic_t resize_generation = 0; +#if defined(SIGWINCH) + bool resize_handler_installed = php_io_terminal_install_resize_handler(&resize_generation); +#else + bool resize_handler_installed = false; +#endif + + struct termios raw_mode = mode; + php_io_terminal_make_raw_mode(&raw_mode); + bool mode_changed = memcmp(&raw_mode, &mode, sizeof(mode)) != 0; + + if (mode_changed && tcsetattr(fd, TCSANOW, &raw_mode) != 0) { +#if defined(SIGWINCH) + if (resize_handler_installed) { + php_io_terminal_restore_resize_handler(); + } +#endif + php_poll_destroy(poll_ctx); + zend_throw_exception(php_io_terminal_exception_ce, "Failed to set terminal raw mode", 0); + return NULL; + } + + bool has_overall_deadline = (timeout != NULL); + zend_hrtime_t overall_deadline_ns = 0; + if (has_overall_deadline) { + overall_deadline_ns = php_io_terminal_saturating_add_deadline( + zend_hrtime(), + php_io_terminal_timespec_to_ns(timeout) + ); + } + + zend_string *result = NULL; + + if (pending->length > 0) { + result = php_io_terminal_finish_utf8_sequence( + fd, stream, poll_ctx, pending, + has_overall_deadline, overall_deadline_ns + ); + } else { + unsigned char key; + int read_result = php_io_terminal_read_byte(fd, stream, poll_ctx, &key, timeout, true, + resize_handler_installed ? &resize_generation : NULL); + if (read_result == PHP_IO_TERMINAL_READ_RESIZE) { + result = ZSTR_INIT_LITERAL("resize", false); + } else if (read_result == PHP_IO_TERMINAL_READ_SUCCESS) { + result = php_io_terminal_key_from_byte( + fd, stream, poll_ctx, key, sequence_timeout, pending, + has_overall_deadline, overall_deadline_ns + ); + } else if (read_result == PHP_IO_TERMINAL_READ_EOF) { + zend_throw_exception(php_io_terminal_exception_ce, "End of file reached on terminal input stream", 0); + } else if (read_result == PHP_IO_TERMINAL_READ_ERROR) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read from terminal input stream", 0); + } + } + + if (mode_changed && tcsetattr(fd, TCSANOW, &mode) != 0) { +#if defined(SIGWINCH) + if (resize_handler_installed) { + php_io_terminal_restore_resize_handler(); + } +#endif + if (result != NULL) { + zend_string_release(result); + } + php_poll_destroy(poll_ctx); + if (!EG(exception)) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to restore terminal mode", 0); + } + return NULL; + } + +#if defined(SIGWINCH) + if (resize_handler_installed) { + php_io_terminal_restore_resize_handler(); + } +#endif + + php_poll_destroy(poll_ctx); + return result; +} + +static zend_string *php_io_terminal_read_stream_secret( + php_io_terminal_native_stream input, + php_stream *stream, + const struct timespec *timeout, + bool *timed_out +) +{ + *timed_out = false; + int fd = input; + if (fd < 0 || isatty(fd) != 1) { + return NULL; + } + + struct termios mode; + if (tcgetattr(fd, &mode) != 0) { + return NULL; + } + + php_poll_ctx *poll_ctx = php_io_terminal_create_poll_context(fd); + if (poll_ctx == NULL) { + return NULL; + } + + struct termios raw_mode = mode; + php_io_terminal_make_raw_mode(&raw_mode); + bool mode_changed = memcmp(&raw_mode, &mode, sizeof(mode)) != 0; + + if (mode_changed && tcsetattr(fd, TCSANOW, &raw_mode) != 0) { + php_poll_destroy(poll_ctx); + return NULL; + } + + bool has_overall_deadline = (timeout != NULL); + zend_hrtime_t overall_deadline_ns = 0; + if (has_overall_deadline) { + overall_deadline_ns = php_io_terminal_saturating_add_deadline( + zend_hrtime(), + php_io_terminal_timespec_to_ns(timeout) + ); + } + + smart_str secret = {0}; + bool success = false; + + while (true) { + unsigned char key; + struct timespec current_timeout; + const struct timespec *current_timeout_ptr = NULL; + + if (has_overall_deadline) { + zend_hrtime_t now = zend_hrtime(); + if (now >= overall_deadline_ns) { + current_timeout.tv_sec = 0; + current_timeout.tv_nsec = 0; + } else { + php_io_terminal_ns_to_timespec(overall_deadline_ns - now, ¤t_timeout); + } + current_timeout_ptr = ¤t_timeout; + } + + int read_result = php_io_terminal_read_byte(fd, stream, poll_ctx, &key, current_timeout_ptr, false, NULL); + if (read_result == PHP_IO_TERMINAL_READ_TIMEOUT) { + *timed_out = true; + goto restore; + } + if (read_result != PHP_IO_TERMINAL_READ_SUCCESS) { + *timed_out = false; + goto restore; + } + +process_key: + switch (key) { + case '\r': + case '\n': + success = true; + goto restore; + case 0x7f: + case 0x08: + php_io_terminal_buffer_remove_last_utf8_char(&secret); + break; + case 0x03: + case 0x04: + *timed_out = false; + goto restore; + case 0x1b: + { + struct timespec step_timeout; + const struct timespec *step_timeout_ptr = NULL; + if (has_overall_deadline) { + zend_hrtime_t now = zend_hrtime(); + if (now >= overall_deadline_ns) { + step_timeout.tv_sec = 0; + step_timeout.tv_nsec = 0; + } else { + zend_hrtime_t remaining_ns = overall_deadline_ns - now; + zend_hrtime_t step_ns = remaining_ns < 25000000ULL ? remaining_ns : 25000000ULL; + php_io_terminal_ns_to_timespec(step_ns, &step_timeout); + } + step_timeout_ptr = &step_timeout; + } else { + step_timeout.tv_sec = 0; + step_timeout.tv_nsec = 25000000L; + step_timeout_ptr = &step_timeout; + } + + unsigned char next; + int read_res = php_io_terminal_read_byte(fd, stream, poll_ctx, &next, step_timeout_ptr, false, NULL); + if (read_res == PHP_IO_TERMINAL_READ_TIMEOUT) { + /* Standalone Escape cancels */ + *timed_out = false; + goto restore; + } + if (read_res != PHP_IO_TERMINAL_READ_SUCCESS) { + /* Disconnect (EOF) or read error */ + *timed_out = false; + goto restore; + } + + if (next == 0x03 || next == 0x04 || next == 0x1b || next == '\r' || next == '\n') { + /* Escape followed immediately by control byte, Enter, or repeated Escape cancels */ + *timed_out = false; + goto restore; + } + + if (next == '[') { + while (true) { + if (has_overall_deadline) { + zend_hrtime_t now = zend_hrtime(); + if (now >= overall_deadline_ns) { + step_timeout.tv_sec = 0; + step_timeout.tv_nsec = 0; + } else { + zend_hrtime_t remaining_ns = overall_deadline_ns - now; + zend_hrtime_t step_ns = remaining_ns < 25000000ULL ? remaining_ns : 25000000ULL; + php_io_terminal_ns_to_timespec(step_ns, &step_timeout); + } + step_timeout_ptr = &step_timeout; + } else { + step_timeout.tv_sec = 0; + step_timeout.tv_nsec = 25000000L; + step_timeout_ptr = &step_timeout; + } + + unsigned char csi_byte; + read_res = php_io_terminal_read_byte(fd, stream, poll_ctx, &csi_byte, step_timeout_ptr, false, NULL); + if (read_res != PHP_IO_TERMINAL_READ_SUCCESS) { + if (has_overall_deadline && zend_hrtime() >= overall_deadline_ns) { + *timed_out = true; + goto restore; + } + if (read_res == PHP_IO_TERMINAL_READ_TIMEOUT) { + /* Internal sequence timeout: end sequence skipping and continue secret input! */ + break; + } + /* Disconnect (EOF) or read error */ + *timed_out = false; + goto restore; + } + if (csi_byte == 0x03 || csi_byte == 0x04 || csi_byte == 0x1b || csi_byte == '\r' || csi_byte == '\n') { + /* Cancellation byte inside CSI! */ + *timed_out = false; + goto restore; + } + if (csi_byte >= 0x40 && csi_byte <= 0x7e) { + break; + } + } + break; + } + + if (next == 'O') { + if (has_overall_deadline) { + zend_hrtime_t now = zend_hrtime(); + if (now >= overall_deadline_ns) { + step_timeout.tv_sec = 0; + step_timeout.tv_nsec = 0; + } else { + zend_hrtime_t remaining_ns = overall_deadline_ns - now; + zend_hrtime_t step_ns = remaining_ns < 25000000ULL ? remaining_ns : 25000000ULL; + php_io_terminal_ns_to_timespec(step_ns, &step_timeout); + } + step_timeout_ptr = &step_timeout; + } else { + step_timeout.tv_sec = 0; + step_timeout.tv_nsec = 25000000L; + step_timeout_ptr = &step_timeout; + } + + unsigned char ss3_byte; + read_res = php_io_terminal_read_byte(fd, stream, poll_ctx, &ss3_byte, step_timeout_ptr, false, NULL); + if (read_res != PHP_IO_TERMINAL_READ_SUCCESS) { + if (has_overall_deadline && zend_hrtime() >= overall_deadline_ns) { + *timed_out = true; + goto restore; + } + if (read_res == PHP_IO_TERMINAL_READ_TIMEOUT) { + /* Internal sequence timeout: continue secret input */ + break; + } + *timed_out = false; + goto restore; + } + if (ss3_byte == 0x03 || ss3_byte == 0x04 || ss3_byte == 0x1b || ss3_byte == '\r' || ss3_byte == '\n') { + /* Cancellation byte inside SS3! */ + *timed_out = false; + goto restore; + } + /* Valid SS3 key (like F1-F4): safely ignored */ + break; + } + + /* Any other trailing byte after escape: abort cleanly */ + *timed_out = false; + goto restore; + } + default: + { + size_t seq_len = php_io_terminal_utf8_sequence_len(key); + if (seq_len == 1) { + smart_str_appendc(&secret, (char) key); + } else { + unsigned char seq[4]; + size_t i; + bool invalid_continuation = false; + unsigned char invalid_byte = 0; + + seq[0] = key; + for (i = 1; i < seq_len; i++) { + struct timespec cont_timeout; + const struct timespec *cont_timeout_ptr = NULL; + if (has_overall_deadline) { + zend_hrtime_t now = zend_hrtime(); + if (now >= overall_deadline_ns) { + cont_timeout.tv_sec = 0; + cont_timeout.tv_nsec = 0; + } else { + php_io_terminal_ns_to_timespec(overall_deadline_ns - now, &cont_timeout); + } + cont_timeout_ptr = &cont_timeout; + } + + int cont_res = php_io_terminal_read_byte(fd, stream, poll_ctx, &seq[i], cont_timeout_ptr, false, NULL); + if (cont_res != PHP_IO_TERMINAL_READ_SUCCESS) { + if (has_overall_deadline && zend_hrtime() >= overall_deadline_ns) { + *timed_out = true; + goto restore; + } + *timed_out = false; + goto restore; + } + if ((seq[i] & 0xc0) != 0x80) { + invalid_continuation = true; + invalid_byte = seq[i]; + break; + } + } + if (invalid_continuation) { + smart_str_appendl(&secret, (const char *) seq, i); + key = invalid_byte; + goto process_key; + } + if (i == seq_len) { + smart_str_appendl(&secret, (const char *) seq, i); + } + } + break; + } + } + } + +restore: + if (mode_changed && tcsetattr(fd, TCSANOW, &mode) != 0) { + success = false; + *timed_out = false; + } + + php_poll_destroy(poll_ctx); + + if (success) { + return smart_str_extract(&secret); + } + + smart_str_free(&secret); + return NULL; +} +#endif /* !PHP_WIN32 */ + +static zend_object *php_io_terminal_key_enum_from_string(zend_string *key) +{ + if (zend_string_equals_literal(key, "up")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Up"); + } + if (zend_string_equals_literal(key, "down")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Down"); + } + if (zend_string_equals_literal(key, "right")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Right"); + } + if (zend_string_equals_literal(key, "left")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Left"); + } + if (zend_string_equals_literal(key, "enter")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Enter"); + } + if (zend_string_equals_literal(key, "backspace")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Backspace"); + } + if (zend_string_equals_literal(key, "escape")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Escape"); + } + if (zend_string_equals_literal(key, "tab")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Tab"); + } + if (zend_string_equals_literal(key, "home")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Home"); + } + if (zend_string_equals_literal(key, "end")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "End"); + } + if (zend_string_equals_literal(key, "delete")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Delete"); + } + if (zend_string_equals_literal(key, "pageup")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "PageUp"); + } + if (zend_string_equals_literal(key, "pagedown")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "PageDown"); + } + if (zend_string_equals_literal(key, "resize")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Resize"); + } + if (zend_string_equals_literal(key, "f1")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F1"); + } + if (zend_string_equals_literal(key, "f2")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F2"); + } + if (zend_string_equals_literal(key, "f3")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F3"); + } + if (zend_string_equals_literal(key, "f4")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F4"); + } + if (zend_string_equals_literal(key, "f5")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F5"); + } + if (zend_string_equals_literal(key, "f6")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F6"); + } + if (zend_string_equals_literal(key, "f7")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F7"); + } + if (zend_string_equals_literal(key, "f8")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F8"); + } + if (zend_string_equals_literal(key, "f9")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F9"); + } + if (zend_string_equals_literal(key, "f10")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F10"); + } + if (zend_string_equals_literal(key, "f11")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F11"); + } + if (zend_string_equals_literal(key, "f12")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F12"); + } + return NULL; +} + +/* Io\Terminal\TerminalSize methods */ +PHP_METHOD(Io_Terminal_TerminalSize, __construct) +{ + zend_long cols, rows; + + ZEND_PARSE_PARAMETERS_START(2, 2) + Z_PARAM_LONG(cols) + Z_PARAM_LONG(rows) + ZEND_PARSE_PARAMETERS_END(); + + if (cols <= 0) { + zend_argument_value_error(1, "must be greater than 0"); + RETURN_THROWS(); + } + + if (rows <= 0) { + zend_argument_value_error(2, "must be greater than 0"); + RETURN_THROWS(); + } + + zend_update_property_long(php_io_terminal_terminal_size_ce, Z_OBJ_P(ZEND_THIS), "cols", sizeof("cols") - 1, cols); + if (UNEXPECTED(EG(exception))) { + RETURN_THROWS(); + } + + zend_update_property_long(php_io_terminal_terminal_size_ce, Z_OBJ_P(ZEND_THIS), "rows", sizeof("rows") - 1, rows); + if (UNEXPECTED(EG(exception))) { + RETURN_THROWS(); + } +} + +/* Io\Terminal\SystemModeToken methods */ +PHP_METHOD(Io_Terminal_SystemModeToken, __construct) +{ + zend_throw_error(NULL, "Cannot directly construct Io\\Terminal\\SystemModeToken"); +} + +/* Io\Terminal\SystemTerminal methods */ +PHP_METHOD(Io_Terminal_SystemTerminal, __construct) +{ + zend_throw_error(NULL, "Cannot directly construct Io\\Terminal\\SystemTerminal"); +} + +PHP_METHOD(Io_Terminal_SystemTerminal, fromStdio) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + object_init_ex(return_value, php_io_terminal_system_terminal_ce); + php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(return_value); + + php_io_terminal_stream_target target; + if (php_io_terminal_stream_target_init(NULL, true, &target) + && php_io_terminal_native_stream_is_valid(target.native_stream)) { + intern->has_identity = php_io_terminal_get_identity(target.native_stream, &intern->identity); + } +} + +PHP_METHOD(Io_Terminal_SystemTerminal, fromStreams) +{ + zval *input_arg; + zval *output_arg = NULL; + + ZEND_PARSE_PARAMETERS_START(1, 2) + Z_PARAM_RESOURCE(input_arg) + Z_PARAM_OPTIONAL + Z_PARAM_RESOURCE_OR_NULL(output_arg) + ZEND_PARSE_PARAMETERS_END(); + + php_io_terminal_stream_target target; + if (!php_io_terminal_stream_target_init(input_arg, true, &target) || target.php_stream == NULL) { + if (!EG(exception)) { + zend_argument_value_error(1, "must be a valid stream resource"); + } + RETURN_THROWS(); + } + + if (output_arg != NULL && !Z_ISNULL_P(output_arg)) { + php_io_terminal_stream_target out_target; + if (!php_io_terminal_stream_target_init(output_arg, false, &out_target) || out_target.php_stream == NULL) { + if (!EG(exception)) { + zend_argument_value_error(2, "must be a valid stream resource or null"); + } + RETURN_THROWS(); + } + } + + object_init_ex(return_value, php_io_terminal_system_terminal_ce); + php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(return_value); + ZVAL_COPY(&intern->input_stream_val, input_arg); + + if (output_arg != NULL && !Z_ISNULL_P(output_arg)) { + ZVAL_COPY(&intern->output_stream_val, output_arg); + } else { + ZVAL_COPY(&intern->output_stream_val, input_arg); + } + + if (php_io_terminal_native_stream_is_valid(target.native_stream)) { + intern->has_identity = php_io_terminal_get_identity(target.native_stream, &intern->identity); + } +} + +PHP_METHOD(Io_Terminal_SystemTerminal, getSize) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); + php_io_terminal_stream_target stream; + + if (!php_io_terminal_stream_target_init(&intern->output_stream_val, false, &stream)) { + RETURN_THROWS(); + } + + zend_long columns = 0; + zend_long rows = 0; + + if (!php_io_terminal_stream_size(stream.native_stream, &columns, &rows)) { + RETURN_NULL(); + } + + intern->last_cols = columns; + intern->last_rows = rows; + intern->has_last_size = true; + + zval params[2]; + ZVAL_LONG(¶ms[0], columns); + ZVAL_LONG(¶ms[1], rows); + object_init_with_constructor(return_value, php_io_terminal_terminal_size_ce, 2, params, NULL); +} + +PHP_METHOD(Io_Terminal_SystemTerminal, enableRawMode) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); + php_io_terminal_stream_target stream; + + if (!php_io_terminal_stream_target_init(&intern->input_stream_val, true, &stream)) { + RETURN_THROWS(); + } + + if (!php_io_terminal_native_stream_is_valid(stream.native_stream) || !php_io_terminal_native_stream_is_tty(stream.native_stream)) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to enable terminal raw mode", 0); + RETURN_THROWS(); + } + + php_io_terminal_identity identity; + if (!php_io_terminal_get_identity(stream.native_stream, &identity)) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to enable terminal raw mode", 0); + RETURN_THROWS(); + } + + intern->identity = identity; + intern->has_identity = true; + + php_io_terminal_shared_mode *shared = php_io_terminal_acquire_raw_mode(stream.native_stream, &identity); + if (shared == NULL) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to enable terminal raw mode", 0); + RETURN_THROWS(); + } + + php_io_terminal_create_mode_token(return_value, shared); + + if (intern->active_mode_token != NULL) { + OBJ_RELEASE(intern->active_mode_token); + } + intern->active_mode_token = Z_OBJ_P(return_value); + GC_ADDREF(intern->active_mode_token); +} + +/* + * Invariant: A native SystemTerminal implementation may only restore a native + * SystemModeToken associated with the same underlying terminal/shared mode record. + * + * The native implementation intentionally allows a token to be restored by + * another SystemTerminal object that refers to the same underlying terminal identity + * or shared mode record. + * + * Terminal::restoreMode(?ModeToken $mode = null) accepts the + * interface to allow polymorphic userland terminal fakes (e.g. for unit testing). + * However, the native SystemTerminal implementation requires native C state attached to + * an internal SystemModeToken instance associated with the same underlying terminal or + * shared mode record; foreign userland token implementations must still be rejected + * with ValueError. + */ +PHP_METHOD(Io_Terminal_SystemTerminal, restoreMode) +{ + zval *mode_token = NULL; + + ZEND_PARSE_PARAMETERS_START(0, 1) + Z_PARAM_OPTIONAL + Z_PARAM_OBJECT_OF_CLASS_OR_NULL(mode_token, php_io_terminal_mode_token_ce) + ZEND_PARSE_PARAMETERS_END(); + + php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); + + if (mode_token != NULL && !Z_ISNULL_P(mode_token)) { + if (!instanceof_function(Z_OBJCE_P(mode_token), php_io_terminal_system_mode_token_ce)) { + zend_argument_value_error(1, "must be an active terminal mode token belonging to this terminal"); + RETURN_THROWS(); + } + php_io_terminal_mode_token_object *mode = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZV(mode_token); + + if (!mode->valid || mode->shared == NULL) { + zend_argument_value_error(1, "must be an active terminal mode token belonging to this terminal"); + RETURN_THROWS(); + } + + bool matches = false; + php_io_terminal_mode_token_object *active = php_io_terminal_get_active_mode_token(intern); + if (active != NULL && intern->active_mode_token == Z_OBJ_P(mode_token)) { + matches = true; + } else { + php_io_terminal_stream_target stream; + php_io_terminal_identity current_identity; + + if (php_io_terminal_stream_target_init_ex(&intern->input_stream_val, true, true, &stream) + && php_io_terminal_native_stream_is_valid(stream.native_stream) + && php_io_terminal_get_identity(stream.native_stream, ¤t_identity)) { + matches = php_io_terminal_identities_match(¤t_identity, &mode->shared->identity); + } else if (intern->has_identity) { + matches = php_io_terminal_identities_match(&intern->identity, &mode->shared->identity); + } + } + + if (!matches) { + zend_argument_value_error(1, "must be an active terminal mode token belonging to this terminal"); + RETURN_THROWS(); + } + + const char *err = NULL; + if (!php_io_terminal_release_token_lease(mode, &err)) { + zend_throw_exception(php_io_terminal_exception_ce, err != NULL ? err : "Failed to restore terminal mode", 0); + RETURN_THROWS(); + } + + if (intern->active_mode_token != NULL && intern->active_mode_token == Z_OBJ_P(mode_token)) { + OBJ_RELEASE(intern->active_mode_token); + intern->active_mode_token = NULL; + } + + RETURN_TRUE; + } + + php_io_terminal_mode_token_object *mode = php_io_terminal_get_active_mode_token(intern); + if (mode != NULL) { + const char *err = NULL; + if (!php_io_terminal_release_token_lease(mode, &err)) { + zend_throw_exception(php_io_terminal_exception_ce, err != NULL ? err : "Failed to restore terminal mode", 0); + RETURN_THROWS(); + } + + OBJ_RELEASE(intern->active_mode_token); + intern->active_mode_token = NULL; + + RETURN_TRUE; + } + + RETURN_FALSE; +} + +PHP_METHOD(Io_Terminal_SystemTerminal, readKey) +{ + php_date_time_duration *timeout_duration = NULL; + php_date_time_duration *seq_timeout_duration = NULL; + + ZEND_PARSE_PARAMETERS_START(0, 2) + Z_PARAM_OPTIONAL + Z_PARAM_DATE_TIME_DURATION_OR_NULL(timeout_duration) + Z_PARAM_DATE_TIME_DURATION_OR_NULL(seq_timeout_duration) + ZEND_PARSE_PARAMETERS_END(); + + if (timeout_duration != NULL && timeout_duration->duration.negative) { + zend_argument_value_error(1, "must not be negative"); + RETURN_THROWS(); + } + + if (seq_timeout_duration != NULL && seq_timeout_duration->duration.negative) { + zend_argument_value_error(2, "must not be negative"); + RETURN_THROWS(); + } + + php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); + php_io_terminal_stream_target out_stream; + + if (php_io_terminal_stream_target_init(&intern->output_stream_val, false, &out_stream)) { + zend_long cur_cols = 0, cur_rows = 0; + if (php_io_terminal_stream_size(out_stream.native_stream, &cur_cols, &cur_rows)) { + if (intern->has_last_size && (cur_cols != intern->last_cols || cur_rows != intern->last_rows)) { + intern->last_cols = cur_cols; + intern->last_rows = cur_rows; + RETURN_OBJ_COPY(zend_enum_get_case_cstr(php_io_terminal_key_ce, "Resize")); + } + intern->last_cols = cur_cols; + intern->last_rows = cur_rows; + intern->has_last_size = true; + } + } + + php_io_terminal_stream_target stream; + if (!php_io_terminal_stream_target_init(&intern->input_stream_val, true, &stream)) { + RETURN_THROWS(); + } + +#ifdef PHP_WIN32 + DWORD wait_ms = INFINITE; + if (timeout_duration != NULL) { + uint64_t sec = timeout_duration->duration.seconds; + uint32_t nsec = timeout_duration->duration.nanoseconds; + if (sec == 0 && nsec == 0) { + wait_ms = 0; + } else if (sec >= (DWORD) PHP_IO_TERMINAL_MAX_WAIT_MS / 1000) { + wait_ms = PHP_IO_TERMINAL_MAX_WAIT_MS; + } else { + uint64_t ms = sec * 1000 + (nsec + 999999) / 1000000; + wait_ms = ms >= (DWORD) PHP_IO_TERMINAL_MAX_WAIT_MS ? PHP_IO_TERMINAL_MAX_WAIT_MS : (DWORD) ms; + } + } + + zend_string *key = php_io_terminal_read_stream_key( + stream.native_stream, + stream.php_stream, + wait_ms, + &intern->pending_high_surrogate, + &intern->pending_key, + &intern->pending_key_high_surrogate + ); +#else + struct timespec timeout; + struct timespec sequence_timeout = {0, PHP_IO_TERMINAL_SEQUENCE_TIMEOUT_MS * 1000000L}; + const struct timespec *timeout_ptr = NULL; + bool is_non_blocking = false; + + if (timeout_duration != NULL) { + is_non_blocking = (timeout_duration->duration.seconds == 0 && timeout_duration->duration.nanoseconds == 0); + timeout.tv_sec = php_io_terminal_clamp_duration_seconds(timeout_duration->duration.seconds); + timeout.tv_nsec = timeout_duration->duration.nanoseconds; + timeout_ptr = &timeout; + } + + if (is_non_blocking) { + sequence_timeout.tv_sec = 0; + sequence_timeout.tv_nsec = 0; + } else if (seq_timeout_duration != NULL) { + sequence_timeout.tv_sec = php_io_terminal_clamp_duration_seconds(seq_timeout_duration->duration.seconds); + sequence_timeout.tv_nsec = seq_timeout_duration->duration.nanoseconds; + } + + zend_string *key = php_io_terminal_read_stream_key( + stream.native_stream, + stream.php_stream, + timeout_ptr, + &sequence_timeout, + &intern->pending_utf8 + ); +#endif + + if (EG(exception)) { + if (key != NULL) { + zend_string_release(key); + } + RETURN_THROWS(); + } + + if (key == NULL) { + RETURN_NULL(); + } + + zend_object *key_case = php_io_terminal_key_enum_from_string(key); + if (key_case != NULL) { + if (key_case == zend_enum_get_case_cstr(php_io_terminal_key_ce, "Resize")) { + php_io_terminal_stream_target out_s; + if (php_io_terminal_stream_target_init(&intern->output_stream_val, false, &out_s)) { + zend_long c = 0, r = 0; + if (php_io_terminal_stream_size(out_s.native_stream, &c, &r)) { + intern->last_cols = c; + intern->last_rows = r; + intern->has_last_size = true; + } + } + } + zend_string_release(key); + RETURN_OBJ_COPY(key_case); + } + + RETURN_STR(key); +} + +#ifdef PHP_WIN32 +static zend_string *php_io_terminal_read_line_windows( + php_io_terminal_native_stream handle, + php_stream *stream, + php_io_terminal_object *intern +) +{ + /* If there is already buffered data in php_stream, use php_stream_get_line */ + if (stream != NULL && stream->writepos > stream->readpos) { + size_t line_len = 0; + char *buf = php_stream_get_line(stream, NULL, 0, &line_len); + if (buf == NULL) { + if (php_stream_eof(stream)) { + return NULL; + } + if (!EG(exception)) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read line from stream", 0); + } + return NULL; + } + if (line_len >= 2 && buf[line_len - 2] == '\r' && buf[line_len - 1] == '\n') { + line_len -= 2; + } else if (line_len >= 1 && buf[line_len - 1] == '\n') { + line_len -= 1; + } + buf[line_len] = '\0'; + zend_string *res = zend_string_init(buf, line_len, 0); + efree(buf); + return res; + } + + DWORD original_mode = 0; + bool is_console = (handle != INVALID_HANDLE_VALUE && handle != NULL && GetConsoleMode(handle, &original_mode) != 0); + + if (!is_console) { + /* Redirected/non-console stream or handle */ + if (stream != NULL) { + size_t line_len = 0; + char *buf = php_stream_get_line(stream, NULL, 0, &line_len); + if (buf == NULL) { + if (php_stream_eof(stream)) { + return NULL; + } + if (!EG(exception)) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read line from stream", 0); + } + return NULL; + } + if (line_len >= 2 && buf[line_len - 2] == '\r' && buf[line_len - 1] == '\n') { + line_len -= 2; + } else if (line_len >= 1 && buf[line_len - 1] == '\n') { + line_len -= 1; + } + buf[line_len] = '\0'; + zend_string *res = zend_string_init(buf, line_len, 0); + efree(buf); + return res; + } + + /* Bare non-console handle */ + smart_str line = {0}; + char ch; + DWORD bytes_read; + while (1) { + if (!ReadFile(handle, &ch, 1, &bytes_read, NULL)) { + DWORD err = GetLastError(); + if (err == ERROR_BROKEN_PIPE || err == ERROR_HANDLE_EOF) { + break; /* EOF */ + } + smart_str_free(&line); + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read line from handle", 0); + return NULL; + } + if (bytes_read == 0) { + break; /* EOF */ + } + smart_str_appendc(&line, ch); + if (ch == '\n') { + break; + } + } + + if (line.s == NULL) { + return NULL; + } + + size_t len = ZSTR_LEN(line.s); + char *val = ZSTR_VAL(line.s); + if (len >= 2 && val[len - 2] == '\r' && val[len - 1] == '\n') { + len -= 2; + } else if (len >= 1 && val[len - 1] == '\n') { + len -= 1; + } + val[len] = '\0'; + zend_string *res = zend_string_init(val, len, 0); + smart_str_free(&line); + return res; + } + + /* Native Windows console input */ + DWORD cooked_mode = original_mode | (ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT | ENABLE_PROCESSED_INPUT); + bool mode_changed = false; + if (cooked_mode != original_mode) { + if (!SetConsoleMode(handle, cooked_mode)) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to set console mode for line reading", 0); + return NULL; + } + mode_changed = true; + } + + size_t wcapacity = 1024; + size_t wlen = 0; + WCHAR *wline = emalloc(wcapacity * sizeof(WCHAR)); + bool read_failed = false; + + while (true) { + DWORD chars_read = 0; + BOOL ok = ReadConsoleW(handle, wline + wlen, (DWORD)(wcapacity - wlen), &chars_read, NULL); + if (!ok) { + read_failed = true; + break; + } + if (chars_read == 0) { + break; /* EOF */ + } + wlen += chars_read; + if (wline[wlen - 1] == L'\n' || wline[wlen - 1] == 0x1a) { + break; + } + if (wlen == wcapacity) { + wcapacity *= 2; + wline = erealloc(wline, wcapacity * sizeof(WCHAR)); + } + } + + bool restore_failed = false; + if (mode_changed && !SetConsoleMode(handle, original_mode)) { + restore_failed = true; + } + + if (read_failed) { + efree(wline); + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read line from console", 0); + return NULL; + } + + if (restore_failed) { + efree(wline); + if (!EG(exception)) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to restore console mode", 0); + } + return NULL; + } + + if (wlen == 0 || (wlen == 1 && wline[0] == 0x1a)) { + efree(wline); + return NULL; /* EOF */ + } + + /* Normalize CRLF / LF */ + if (wlen >= 2 && wline[wlen - 2] == L'\r' && wline[wlen - 1] == L'\n') { + wlen -= 2; + } else if (wlen >= 1 && (wline[wlen - 1] == L'\n' || wline[wlen - 1] == 0x1a)) { + wlen -= 1; + } else if (wlen >= 1 && wline[wlen - 1] == L'\r') { + wlen -= 1; + } + + if (wlen == 0) { + efree(wline); + return zend_string_init("", 0, 0); + } + + int utf8_len = WideCharToMultiByte(CP_UTF8, 0, wline, (int) wlen, NULL, 0, NULL, NULL); + if (utf8_len <= 0) { + efree(wline); + zend_throw_exception(php_io_terminal_exception_ce, "Failed to convert console line to UTF-8", 0); + return NULL; + } + + zend_string *res = zend_string_alloc(utf8_len, 0); + int actual_len = WideCharToMultiByte(CP_UTF8, 0, wline, (int) wlen, ZSTR_VAL(res), utf8_len, NULL, NULL); + efree(wline); + + if (actual_len <= 0) { + zend_string_release(res); + zend_throw_exception(php_io_terminal_exception_ce, "Failed to convert console line to UTF-8", 0); + return NULL; + } + + ZSTR_VAL(res)[actual_len] = '\0'; + ZSTR_LEN(res) = actual_len; + return res; +} +#else +static bool php_io_terminal_wait_fd_readable(int fd, php_poll_ctx **poll_ctx) +{ + if (*poll_ctx == NULL) { + *poll_ctx = php_io_terminal_create_poll_context(fd); + if (*poll_ctx == NULL) { + return false; + } + } + + php_poll_event event; + int poll_result; + do { + if (EG(exception)) { + return false; + } + poll_result = php_poll_wait(*poll_ctx, &event, 1, NULL); + } while (poll_result < 0 && php_poll_get_error(*poll_ctx) == PHP_POLL_ERR_INTERRUPTED); + + if (EG(exception)) { + return false; + } + + return poll_result > 0; +} + +static char *php_io_terminal_read_line_from_fd(int fd, size_t *out_len, bool *is_eof) +{ + *is_eof = false; + *out_len = 0; + + smart_str line = {0}; + char ch = '\0'; + bool eof_reached = false; + bool error_reached = false; + php_poll_ctx *poll_ctx = NULL; + + while (1) { + errno = 0; + ssize_t n = read(fd, &ch, 1); + if (n > 0) { + smart_str_appendc(&line, ch); + if (ch == '\n') { + break; + } + } else if (n == 0) { + eof_reached = true; + break; /* EOF */ + } else { + if (errno == EINTR) { + continue; + } +#if defined(EWOULDBLOCK) && (EWOULDBLOCK != EAGAIN) + if (errno == EAGAIN || errno == EWOULDBLOCK) { +#else + if (errno == EAGAIN) { +#endif + if (!php_io_terminal_wait_fd_readable(fd, &poll_ctx)) { + error_reached = true; + break; + } + continue; + } + error_reached = true; + break; + } + } + + if (poll_ctx != NULL) { + php_poll_destroy(poll_ctx); + } + + if (error_reached) { + smart_str_free(&line); + *is_eof = false; + *out_len = 0; + return NULL; + } + + if (line.s == NULL) { + if (eof_reached) { + *is_eof = true; + } + *out_len = 0; + return NULL; + } + + *out_len = ZSTR_LEN(line.s); + char *res = estrndup(ZSTR_VAL(line.s), *out_len); + smart_str_free(&line); + return res; +} + +static char *php_io_terminal_read_line_posix( + php_io_terminal_native_stream fd, + php_stream *stream, + size_t *out_len, + bool *is_eof +) +{ + *is_eof = false; + *out_len = 0; + + bool is_tty = php_io_terminal_native_stream_is_tty(fd); + if (!is_tty) { + if (stream != NULL) { + char *buf = php_stream_get_line(stream, NULL, 0, out_len); + if (buf == NULL) { + if (php_stream_eof(stream)) { + *is_eof = true; + return NULL; + } + /* Read error */ + return NULL; + } + return buf; + } + + if (fd >= 0) { + return php_io_terminal_read_line_from_fd(fd, out_len, is_eof); + } + + return NULL; + } + + smart_str line = {0}; + char ch = '\0'; + bool eof_reached = false; + bool error_reached = false; + php_poll_ctx *poll_ctx = NULL; + + /* Drain any already-buffered bytes from php_stream first */ + if (stream != NULL && stream->writepos > stream->readpos) { + while (stream->writepos > stream->readpos) { + size_t read_bytes = php_stream_read(stream, &ch, 1); + if (read_bytes != 1) { + break; + } + smart_str_appendc(&line, ch); + if (ch == '\n') { + break; + } + } + } + + if (stream != NULL && php_stream_eof(stream)) { + eof_reached = true; + } + + /* If newline was not reached in buffered bytes and stream is not at EOF, read from terminal fd */ + if (ch != '\n' && !eof_reached) { + while (1) { + errno = 0; + ssize_t n = read(fd, &ch, 1); + if (n > 0) { + smart_str_appendc(&line, ch); + if (ch == '\n') { + break; + } + } else if (n == 0) { + eof_reached = true; + break; /* EOF */ + } else { + if (errno == EINTR) { + continue; + } +#if defined(EWOULDBLOCK) && (EWOULDBLOCK != EAGAIN) + if (errno == EAGAIN || errno == EWOULDBLOCK) { +#else + if (errno == EAGAIN) { +#endif + if (!php_io_terminal_wait_fd_readable(fd, &poll_ctx)) { + error_reached = true; + break; + } + continue; + } +#ifdef EIO + if (errno == EIO) { + /* On Linux, reading from a master PTY returns -1 with errno == EIO + * when the slave side has closed. For a terminal/PTY, this closure + * is end-of-input. */ + eof_reached = true; + break; + } +#endif + error_reached = true; + break; + } + } + } + + if (poll_ctx != NULL) { + php_poll_destroy(poll_ctx); + } + + if (error_reached) { + smart_str_free(&line); + *is_eof = false; + *out_len = 0; + return NULL; + } + + if (line.s == NULL) { + if (eof_reached) { + if (stream != NULL) { + stream->eof = 1; + } + *is_eof = true; + } + *out_len = 0; + return NULL; + } + + if (eof_reached && stream != NULL) { + stream->eof = 1; + } + + *out_len = ZSTR_LEN(line.s); + char *res = estrndup(ZSTR_VAL(line.s), *out_len); + smart_str_free(&line); + return res; +} +#endif + +PHP_METHOD(Io_Terminal_SystemTerminal, readLine) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); + + php_io_terminal_stream_target stream; + if (!php_io_terminal_stream_target_init(&intern->input_stream_val, true, &stream)) { + RETURN_THROWS(); + } + + if (!intern->has_identity && php_io_terminal_native_stream_is_valid(stream.native_stream)) { + intern->has_identity = php_io_terminal_get_identity(stream.native_stream, &intern->identity); + } + + if (php_io_terminal_get_active_mode_token(intern) != NULL || (intern->has_identity && php_io_terminal_find_shared_mode(&intern->identity) != NULL)) { + zend_throw_exception(php_io_terminal_exception_ce, "Cannot read a line while raw mode is active for this terminal", 0); + RETURN_THROWS(); + } + +#ifdef PHP_WIN32 + zend_string *line = php_io_terminal_read_line_windows(stream.native_stream, stream.php_stream, intern); + if (EG(exception)) { + if (line != NULL) { + zend_string_release(line); + } + RETURN_THROWS(); + } + if (line == NULL) { + RETURN_NULL(); + } + RETURN_STR(line); +#else + size_t out_len = 0; + bool is_eof = false; + char *buf = php_io_terminal_read_line_posix(stream.native_stream, stream.php_stream, &out_len, &is_eof); + if (EG(exception)) { + if (buf != NULL) { + efree(buf); + } + RETURN_THROWS(); + } + + if (buf == NULL) { + if (is_eof) { + if (intern->pending_utf8.length > 0) { + zend_string *res = zend_string_init((char *) intern->pending_utf8.bytes, intern->pending_utf8.length, 0); + intern->pending_utf8.length = 0; + intern->pending_utf8.expected = 0; + RETURN_STR(res); + } + RETURN_NULL(); + } + if (!EG(exception)) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read line from terminal", 0); + } + RETURN_THROWS(); + } + + /* Strip line ending */ + if (out_len >= 2 && buf[out_len - 2] == '\r' && buf[out_len - 1] == '\n') { + out_len -= 2; + } else if (out_len >= 1 && buf[out_len - 1] == '\n') { + out_len -= 1; + } + buf[out_len] = '\0'; + + zend_string *res; + if (intern->pending_utf8.length > 0) { + size_t total_len = intern->pending_utf8.length + out_len; + res = zend_string_alloc(total_len, 0); + memcpy(ZSTR_VAL(res), intern->pending_utf8.bytes, intern->pending_utf8.length); + if (out_len > 0) { + memcpy(ZSTR_VAL(res) + intern->pending_utf8.length, buf, out_len); + } + ZSTR_VAL(res)[total_len] = '\0'; + intern->pending_utf8.length = 0; + intern->pending_utf8.expected = 0; + } else { + res = zend_string_init(buf, out_len, 0); + } + efree(buf); + RETURN_STR(res); +#endif +} + +PHP_METHOD(Io_Terminal_SystemTerminal, readSecret) +{ + php_date_time_duration *timeout_duration = NULL; + + ZEND_PARSE_PARAMETERS_START(0, 1) + Z_PARAM_OPTIONAL + Z_PARAM_DATE_TIME_DURATION_OR_NULL(timeout_duration) + ZEND_PARSE_PARAMETERS_END(); + + if (timeout_duration != NULL && timeout_duration->duration.negative) { + zend_argument_value_error(1, "must not be negative"); + RETURN_THROWS(); + } + + php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); + +#ifdef PHP_WIN32 + memset(&intern->pending_key, 0, sizeof(intern->pending_key)); + intern->pending_key_high_surrogate = 0; + intern->pending_high_surrogate = 0; +#else + memset(&intern->pending_utf8, 0, sizeof(intern->pending_utf8)); +#endif + + php_io_terminal_stream_target stream; + if (!php_io_terminal_stream_target_init(&intern->input_stream_val, true, &stream)) { + RETURN_THROWS(); + } + + bool timed_out = false; +#ifdef PHP_WIN32 + DWORD wait_ms = INFINITE; + bool has_timeout = false; + if (timeout_duration != NULL) { + has_timeout = true; + uint64_t sec = timeout_duration->duration.seconds; + uint32_t nsec = timeout_duration->duration.nanoseconds; + if (sec == 0 && nsec == 0) { + wait_ms = 0; + } else if (sec >= (DWORD) PHP_IO_TERMINAL_MAX_WAIT_MS / 1000) { + wait_ms = PHP_IO_TERMINAL_MAX_WAIT_MS; + } else { + uint64_t ms = sec * 1000 + (nsec + 999999) / 1000000; + wait_ms = ms >= (DWORD) PHP_IO_TERMINAL_MAX_WAIT_MS ? PHP_IO_TERMINAL_MAX_WAIT_MS : (DWORD) ms; + } + } + zend_string *secret = php_io_terminal_read_stream_secret( + stream.native_stream, + stream.php_stream, + &intern->pending_key, + &intern->pending_key_high_surrogate, + wait_ms, + has_timeout, + &timed_out + ); +#else + struct timespec timeout; + const struct timespec *timeout_ptr = NULL; + if (timeout_duration != NULL) { + timeout.tv_sec = php_io_terminal_clamp_duration_seconds(timeout_duration->duration.seconds); + timeout.tv_nsec = timeout_duration->duration.nanoseconds; + timeout_ptr = &timeout; + } + zend_string *secret = php_io_terminal_read_stream_secret(stream.native_stream, stream.php_stream, timeout_ptr, &timed_out); +#endif + if (secret != NULL) { + RETURN_STR(secret); + } + + if (timed_out) { + RETURN_NULL(); + } + + if (!EG(exception)) { + zend_throw_exception(php_io_terminal_exception_ce, "Unable to read secret from terminal", 0); + } + RETURN_THROWS(); +} + +PHP_MINIT_FUNCTION(terminal) +{ + zend_class_entry *io_exception_ce = zend_hash_str_find_ptr( + CG(class_table), + "io\\ioexception", + sizeof("io\\ioexception") - 1 + ); + ZEND_ASSERT(io_exception_ce != NULL); + + php_io_terminal_exception_ce + = register_class_Io_Terminal_TerminalException(io_exception_ce); + + php_io_terminal_key_ce = register_class_Io_Terminal_Key(); + + php_io_terminal_terminal_size_ce = register_class_Io_Terminal_TerminalSize(); + + php_io_terminal_mode_token_ce = register_class_Io_Terminal_ModeToken(); + php_io_terminal_terminal_ce = register_class_Io_Terminal_Terminal(); + + php_io_terminal_system_mode_token_ce = register_class_Io_Terminal_SystemModeToken(php_io_terminal_mode_token_ce); + php_io_terminal_system_mode_token_ce->create_object = php_io_terminal_mode_token_create_object; + memcpy(&php_io_terminal_mode_token_handlers, zend_get_std_object_handlers(), sizeof(php_io_terminal_mode_token_handlers)); + php_io_terminal_mode_token_handlers.offset = offsetof(php_io_terminal_mode_token_object, std); + php_io_terminal_mode_token_handlers.free_obj = php_io_terminal_mode_token_free_obj; + php_io_terminal_mode_token_handlers.clone_obj = NULL; + + php_io_terminal_system_terminal_ce = register_class_Io_Terminal_SystemTerminal(php_io_terminal_terminal_ce); + php_io_terminal_system_terminal_ce->create_object = php_io_terminal_create_object; + memcpy(&php_io_terminal_object_handlers, zend_get_std_object_handlers(), sizeof(php_io_terminal_object_handlers)); + php_io_terminal_object_handlers.offset = offsetof(php_io_terminal_object, std); + php_io_terminal_object_handlers.free_obj = php_io_terminal_free_obj; + php_io_terminal_object_handlers.clone_obj = NULL; + +#ifdef ZTS +# ifndef PHP_WIN32 +# ifdef SIGWINCH + php_io_terminal_resize_mutex = tsrm_mutex_alloc(); +# endif +# ifndef PHP_IO_TERMINAL_HAVE_PTSNAME_R + php_io_terminal_ptsname_mutex = tsrm_mutex_alloc(); +# endif +# endif +#endif + + return SUCCESS; +} + +PHP_MSHUTDOWN_FUNCTION(terminal) +{ +#ifdef ZTS +# ifndef PHP_WIN32 +# ifdef SIGWINCH + if (php_io_terminal_resize_mutex != NULL) { + tsrm_mutex_free(php_io_terminal_resize_mutex); + php_io_terminal_resize_mutex = NULL; + } +# endif +# ifndef PHP_IO_TERMINAL_HAVE_PTSNAME_R + if (php_io_terminal_ptsname_mutex != NULL) { + tsrm_mutex_free(php_io_terminal_ptsname_mutex); + php_io_terminal_ptsname_mutex = NULL; + } +# endif +# endif +#endif + + return SUCCESS; +} + +PHP_RINIT_FUNCTION(terminal) +{ + php_io_terminal_active_shared_modes = NULL; + return SUCCESS; +} + +PHP_RSHUTDOWN_FUNCTION(terminal) +{ + while (php_io_terminal_active_shared_modes != NULL) { + php_io_terminal_shared_mode *shared = php_io_terminal_active_shared_modes; + php_io_terminal_restore_shared_mode(shared); + php_io_terminal_destroy_shared_mode(shared); + } + return SUCCESS; +} diff --git a/ext/standard/io_terminal.h b/ext/standard/io_terminal.h new file mode 100644 index 000000000000..2a9d93c988d9 --- /dev/null +++ b/ext/standard/io_terminal.h @@ -0,0 +1,25 @@ +/* + +----------------------------------------------------------------------+ + | Copyright (c) The PHP Group | + +----------------------------------------------------------------------+ + | This source file is subject to the Modified BSD License that is | + | bundled with this package in the file LICENSE, and is available | + | through the World Wide Web at . | + | | + | SPDX-License-Identifier: BSD-3-Clause | + +----------------------------------------------------------------------+ + | Author: Pratik Bhujel | + +----------------------------------------------------------------------+ +*/ + +#ifndef PHP_IO_TERMINAL_H +#define PHP_IO_TERMINAL_H + +#include "php.h" + +PHP_MINIT_FUNCTION(terminal); +PHP_MSHUTDOWN_FUNCTION(terminal); +PHP_RINIT_FUNCTION(terminal); +PHP_RSHUTDOWN_FUNCTION(terminal); + +#endif /* PHP_IO_TERMINAL_H */ diff --git a/ext/standard/io_terminal.stub.php b/ext/standard/io_terminal.stub.php new file mode 100644 index 000000000000..1e56d7eef25d --- /dev/null +++ b/ext/standard/io_terminal.stub.php @@ -0,0 +1,121 @@ + +--FILE-- +enableRawMode(); +var_dump($mTty instanceof ModeToken); + +// Descriptors to the same controlling terminal share identity +var_dump($tTty2->restoreMode($mTty)); + +// Mode was already restored; further restore returns false +var_dump($tTty1->restoreMode()); + +fclose($ttyFp2); +fclose($ttyFp1); +?> +--EXPECT-- +bool(true) +bool(true) +bool(false) diff --git a/ext/standard/tests/terminal/terminal_create.phpt b/ext/standard/tests/terminal/terminal_create.phpt new file mode 100644 index 000000000000..c211fc9ea803 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_create.phpt @@ -0,0 +1,113 @@ +--TEST-- +Io\Terminal\SystemTerminal: fromStdio, fromStreams, construction and serialization restrictions +--FILE-- +getMessage(), PHP_EOL; +} + +try { + SystemTerminal::fromStreams($fp, 123); +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +// Constructor privacy for SystemTerminal and SystemModeToken +foreach ([SystemTerminal::class, SystemModeToken::class] as $class) { + try { + new $class(); + } catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; + } +} + +// TerminalSize public constructor and validation +$size = new TerminalSize(80, 24); +var_dump($size->cols, $size->rows); + +foreach ([ + [0, 24], + [80, 0], + [-1, 24], + [80, -5], +] as [$cols, $rows]) { + try { + new TerminalSize($cols, $rows); + } catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; + } +} + +// Repeated TerminalSize construction must produce only the first error ($cols) +try { + $size->__construct(100, 50); +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +// TerminalSize serialization +$serialized = serialize($size); +$unserialized = unserialize($serialized); +var_dump($unserialized instanceof TerminalSize, $unserialized->cols, $unserialized->rows); + +// Clone rejection +foreach ([$t1, $t2] as $obj) { + try { + clone $obj; + } catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; + } +} + +// Serialization rejection for SystemTerminal +try { + serialize($t1); +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +?> +--EXPECT-- +object(Io\Terminal\SystemTerminal)#1 (0) { +} +object(Io\Terminal\SystemTerminal)#2 (0) { +} +object(Io\Terminal\SystemTerminal)#3 (0) { +} +TypeError: Io\Terminal\SystemTerminal::fromStreams(): Argument #1 ($input) must be of type resource, string given +TypeError: Io\Terminal\SystemTerminal::fromStreams(): Argument #2 ($output) must be of type resource or null, int given +Error: Call to private Io\Terminal\SystemTerminal::__construct() from global scope +Error: Call to private Io\Terminal\SystemModeToken::__construct() from global scope +int(80) +int(24) +ValueError: Io\Terminal\TerminalSize::__construct(): Argument #1 ($cols) must be greater than 0 +ValueError: Io\Terminal\TerminalSize::__construct(): Argument #2 ($rows) must be greater than 0 +ValueError: Io\Terminal\TerminalSize::__construct(): Argument #1 ($cols) must be greater than 0 +ValueError: Io\Terminal\TerminalSize::__construct(): Argument #2 ($rows) must be greater than 0 +Error: Cannot modify readonly property Io\Terminal\TerminalSize::$cols +bool(true) +int(80) +int(24) +Error: Trying to clone an uncloneable object of class Io\Terminal\SystemTerminal +Error: Trying to clone an uncloneable object of class Io\Terminal\SystemTerminal +Exception: Serialization of 'Io\Terminal\SystemTerminal' is not allowed diff --git a/ext/standard/tests/terminal/terminal_identity.phpt b/ext/standard/tests/terminal/terminal_identity.phpt new file mode 100644 index 000000000000..ce8c8f75c681 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_identity.phpt @@ -0,0 +1,121 @@ +--TEST-- +Io\Terminal\SystemTerminal: POSIX terminal identity and unrelated PTY rejection +--SKIPIF-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); +} catch (Throwable) { + die("skip PTY is not available"); +} +if (!is_resource($proc)) { + die("skip PTY is not available"); +} +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--FILE-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes1, +); + +$proc2 = proc_open( + [PHP_BINARY, '-r', 'fgets(STDIN);'], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes2, +); + +$t1 = SystemTerminal::fromStreams($pipes1[0]); +$t1_dup = SystemTerminal::fromStreams($pipes1[1]); +$t2 = SystemTerminal::fromStreams($pipes2[0]); + +$m1_dup = $t1->enableRawMode(); +var_dump($m1_dup instanceof ModeToken); +var_dump($t1_dup->restoreMode($m1_dup)); + +$m1 = $t1->enableRawMode(); +try { + $t2->restoreMode($m1); + echo "FAIL: unrelated PTY accepted token\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +var_dump($t1->restoreMode($m1)); +var_dump($t1->restoreMode()); + +// Descriptor reuse: hold an active token, close the PTY, and open a new PTY that reuses the fd number +$m_reuse = $t1->enableRawMode(); +fwrite($pipes1[0], "exit\n"); +unset($t1, $t1_dup); +foreach ($pipes1 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc1); + +$proc3 = proc_open( + [PHP_BINARY, '-r', 'fgets(STDIN);'], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes3, +); +$t3 = SystemTerminal::fromStreams($pipes3[0]); +try { + $t3->restoreMode($m_reuse); + echo "FAIL: reused fd accepted token\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +fwrite($pipes2[0], "exit\n"); +fwrite($pipes3[0], "exit\n"); +unset($t2, $t3); +foreach ($pipes2 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +foreach ($pipes3 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc2); +proc_close($proc3); +?> +--EXPECT-- +bool(true) +bool(true) +ValueError: Io\Terminal\SystemTerminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +bool(true) +bool(false) +ValueError: Io\Terminal\SystemTerminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal diff --git a/ext/standard/tests/terminal/terminal_interface_mock.phpt b/ext/standard/tests/terminal/terminal_interface_mock.phpt new file mode 100644 index 000000000000..b3b4521c58ac --- /dev/null +++ b/ext/standard/tests/terminal/terminal_interface_mock.phpt @@ -0,0 +1,97 @@ +--TEST-- +Io\Terminal\Terminal: mockable terminal boundary and foreign token rejection +--FILE-- +implementsInterface(Terminal::class)); + +$rcToken = new ReflectionClass(SystemModeToken::class); +var_dump($rcToken->implementsInterface(ModeToken::class)); + +// Application-level fake +class FakeModeToken implements ModeToken {} + +class FakeTerminal implements Terminal +{ + public bool $isRaw = false; + + public function getSize(): ?TerminalSize + { + return new TerminalSize(120, 40); + } + + public function enableRawMode(): ModeToken + { + $this->isRaw = true; + return new FakeModeToken(); + } + + public function restoreMode(?ModeToken $mode = null): bool + { + $this->isRaw = false; + return true; + } + + public function readKey( + ?Duration $timeout = null, + ?Duration $sequenceTimeout = null, + ): Key|string|null { + return Key::Up; + } + + public function readLine(): ?string + { + return "mocked-line"; + } + + public function readSecret(?Duration $timeout = null): ?string + { + return "mocked-secret"; + } +} + +// Application service consuming Terminal +function promptPassword(Terminal $term): string +{ + $size = $term->getSize(); + $token = $term->enableRawMode(); + try { + $secret = $term->readSecret(Duration::fromSeconds(5)); + return sprintf("size=%dx%d secret=%s", $size->cols, $size->rows, $secret ?? 'none'); + } finally { + $term->restoreMode($token); + } +} + +$fake = new FakeTerminal(); +echo promptPassword($fake), PHP_EOL; +var_dump($fake->isRaw); + +// Native restoreMode rejects foreign ModeToken implementations +$fp = fopen('php://temp', 'r+'); +$native = SystemTerminal::fromStreams($fp); + +try { + $native->restoreMode(new FakeModeToken()); + echo "FAIL: native accepted foreign ModeToken\n"; +} catch (ValueError $e) { + echo "Caught: ", $e->getMessage(), PHP_EOL; +} + +?> +--EXPECT-- +bool(true) +bool(true) +size=120x40 secret=mocked-secret +bool(false) +Caught: Io\Terminal\SystemTerminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal diff --git a/ext/standard/tests/terminal/terminal_overlapping_raw_mode.phpt b/ext/standard/tests/terminal/terminal_overlapping_raw_mode.phpt new file mode 100644 index 000000000000..3a2cd94cd5d6 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_overlapping_raw_mode.phpt @@ -0,0 +1,154 @@ +--TEST-- +Io\Terminal\SystemTerminal: overlapping raw-mode sessions, ownership validation, and out-of-order restoration +--SKIPIF-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); +} catch (Throwable) { + die("skip PTY is not available"); +} +if (!is_resource($proc)) { + die("skip PTY is not available"); +} +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--FILE-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, +); + +$proc2 = proc_open( + [PHP_BINARY, '-r', 'fgets(STDIN);'], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes2, +); + +// Two separate Terminal instances referencing descriptors on the same PTY +$t1 = SystemTerminal::fromStreams($pipes[0]); +$t2 = SystemTerminal::fromStreams($pipes[1]); +$tUnrelated = SystemTerminal::fromStreams($pipes2[0]); + +// Test 1: Same Terminal + token +$m = $t1->enableRawMode(); +var_dump($m instanceof ModeToken); +var_dump($t1->restoreMode($m)); + +// Test 2: Different Terminal on same device +$m1 = $t1->enableRawMode(); +var_dump($t2->restoreMode($m1)); + +// Test 3: Unrelated terminal rejection (ValueError) +$m1 = $t1->enableRawMode(); +try { + $tUnrelated->restoreMode($m1); + echo "FAIL: unrelated terminal accepted token\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} +var_dump($t1->restoreMode($m1)); + +// Test 4: Token survives Terminal destruction when held externally +$tTemp = SystemTerminal::fromStreams($pipes[0]); +$mTemp = $tTemp->enableRawMode(); +unset($tTemp); // Drops Terminal reference, but $mTemp remains active + +// $mTemp is still active and can be restored through another Terminal on the same device +var_dump($t1->restoreMode($mTemp)); + +// After restoration, the token is consumed; reusing it throws ValueError +try { + $t1->restoreMode($mTemp); + echo "FAIL: stale token after restoration accepted\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +// Test 5: Out-of-order restore (m1 then m2) +$m1 = $t1->enableRawMode(); +$m2 = $t2->enableRawMode(); + +var_dump($m1 instanceof ModeToken); +var_dump($m2 instanceof ModeToken); + +// Out-of-order: restore older token m1 first +var_dump($t1->restoreMode($m1)); + +// Now restore m2 +var_dump($t2->restoreMode($m2)); + +// Test 6: In-order restore (m2 then m1) +$m1 = $t1->enableRawMode(); +$m2 = $t2->enableRawMode(); + +var_dump($t2->restoreMode($m2)); +var_dump($t1->restoreMode($m1)); + +// Test 7: Destroying the final externally-held token restores canonical mode +$tLive = SystemTerminal::fromStreams($pipes[0]); +$mHeld = $tLive->enableRawMode(); +unset($tLive); // Terminal dropped; $mHeld keeps raw mode active +unset($mHeld); // Final token destroyed; ModeToken destructor restores canonical mode + +// Terminal is back in canonical mode; further restore returns false +$tVerify = SystemTerminal::fromStreams($pipes[0]); +var_dump($tVerify->restoreMode()); + +fwrite($pipes[0], "exit\n"); +fwrite($pipes2[0], "exit\n"); +unset($t1, $t2, $tUnrelated, $tVerify); +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +foreach ($pipes2 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +proc_close($proc2); +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) +ValueError: Io\Terminal\SystemTerminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +bool(true) +bool(true) +ValueError: Io\Terminal\SystemTerminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(false) diff --git a/ext/standard/tests/terminal/terminal_raw_mode.phpt b/ext/standard/tests/terminal/terminal_raw_mode.phpt new file mode 100644 index 000000000000..ed51fb1f4fff --- /dev/null +++ b/ext/standard/tests/terminal/terminal_raw_mode.phpt @@ -0,0 +1,151 @@ +--TEST-- +Io\Terminal\SystemTerminal: enableRawMode, restoreMode, session mode tracking and stale token protection +--SKIPIF-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); +} catch (Throwable) { + die("skip PTY is not available"); +} +if (!is_resource($proc)) { + die("skip PTY is not available"); +} +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--FILE-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, +); + +$terminal = SystemTerminal::fromStreams($pipes[0]); + +// Explicit token restore on valid terminal +$token = $terminal->enableRawMode(); +echo "enableRawMode: ", ($token instanceof ModeToken ? "ModeToken" : "false"), "\n"; + +// Restore explicitly with token +var_dump($terminal->restoreMode($token)); + +// Stale consumed token must throw ValueError +try { + $terminal->restoreMode($token); + echo "FAIL: stale token was accepted\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +// Session-managed restore without args +$token2 = $terminal->enableRawMode(); +var_dump($token2 instanceof ModeToken); +var_dump($terminal->restoreMode()); // Restores active token +var_dump($terminal->restoreMode()); // Already restored -> returns false + +// Auto-restoration on Terminal object destruction when token is unreferenced +$terminal2 = SystemTerminal::fromStreams($pipes[0]); +$terminal2->enableRawMode(); +unset($terminal2); // Terminal destructor drops reference; ModeToken destructor restores mode + +// Token survives Terminal object destruction when held externally +$terminal3 = SystemTerminal::fromStreams($pipes[0]); +$token3 = $terminal3->enableRawMode(); +var_dump($token3 instanceof ModeToken); +unset($terminal3); // Drops Terminal reference, but $token3 is held externally + +// Restoring the externally held token through another Terminal on the same terminal succeeds +var_dump($terminal->restoreMode($token3)); + +// Once restored, reusing the token throws ValueError +try { + $terminal->restoreMode($token3); + echo "FAIL: consumed token was accepted\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +// Closing the original PHP stream after enableRawMode() does NOT prevent successful restoration +// because the shared record owns its own duplicated restoration descriptor +$proc2 = proc_open( + [PHP_BINARY, '-r', 'fgets(STDIN);'], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes2, +); +$terminalClosed = SystemTerminal::fromStreams($pipes2[0]); +$tokenClosed = $terminalClosed->enableRawMode(); +var_dump($tokenClosed instanceof ModeToken); + +// Close the underlying stream +fclose($pipes2[0]); + +// Restoration succeeds via the record's owned descriptor +var_dump($terminalClosed->restoreMode($tokenClosed)); + +// Token is now consumed; reusing it throws ValueError +try { + $terminalClosed->restoreMode($tokenClosed); + echo "FAIL: consumed token was accepted\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +// Further restoreMode() returns false +var_dump($terminalClosed->restoreMode()); + +proc_terminate($proc2); +foreach ($pipes2 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc2); + +fwrite($pipes[0], "exit\n"); +unset($terminal, $token3, $terminalClosed, $tokenClosed); +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--EXPECT-- +enableRawMode: ModeToken +bool(true) +ValueError: Io\Terminal\SystemTerminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +bool(true) +bool(true) +bool(false) +bool(true) +bool(true) +ValueError: Io\Terminal\SystemTerminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +bool(true) +bool(true) +ValueError: Io\Terminal\SystemTerminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +bool(false) diff --git a/ext/standard/tests/terminal/terminal_raw_mode_non_tty.phpt b/ext/standard/tests/terminal/terminal_raw_mode_non_tty.phpt new file mode 100644 index 000000000000..b73108a0a465 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_raw_mode_non_tty.phpt @@ -0,0 +1,23 @@ +--TEST-- +Io\Terminal\SystemTerminal: enableRawMode throws TerminalException for non-terminal streams +--FILE-- +enableRawMode(); + echo "FAIL: enableRawMode on non-terminal did not throw\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} +var_dump($terminal->restoreMode()); + +fclose($fp); +?> +--EXPECT-- +Io\Terminal\TerminalException: Failed to enable terminal raw mode +bool(false) diff --git a/ext/standard/tests/terminal/terminal_read_key.phpt b/ext/standard/tests/terminal/terminal_read_key.phpt new file mode 100644 index 000000000000..84f5933e45e9 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_key.phpt @@ -0,0 +1,40 @@ +--TEST-- +Io\Terminal\SystemTerminal: readKey with Time\Duration timeouts and parameter validation +--FILE-- +readKey(Duration::fromSeconds(1)->negate()); + echo "FAIL: accepted negative timeout\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +try { + $t->readKey(Duration::fromSeconds(0), Duration::fromSeconds(1)->negate()); + echo "FAIL: accepted negative sequence timeout\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +// Non-terminal stream throws TerminalException +$fp = fopen('php://temp', 'r+'); +$nonTty = SystemTerminal::fromStreams($fp); +try { + $nonTty->readKey(Duration::fromSeconds(0)); + echo "FAIL: readKey on non-terminal did not throw\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +?> +--EXPECT-- +ValueError: Io\Terminal\SystemTerminal::readKey(): Argument #1 ($timeout) must not be negative +ValueError: Io\Terminal\SystemTerminal::readKey(): Argument #2 ($sequenceTimeout) must not be negative +Io\Terminal\TerminalException: Failed to read key: input stream is not a terminal diff --git a/ext/standard/tests/terminal/terminal_read_key_contracts.phpt b/ext/standard/tests/terminal/terminal_read_key_contracts.phpt new file mode 100644 index 000000000000..ddea9474ae05 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_key_contracts.phpt @@ -0,0 +1,118 @@ +--TEST-- +Io\Terminal\SystemTerminal: readKey null vs TerminalException contracts on POSIX PTY +--SKIPIF-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); +} catch (Throwable) { + die("skip PTY is not available"); +} +if (!is_resource($proc)) { + die("skip PTY is not available"); +} +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--FILE-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, +); + +$terminal = SystemTerminal::fromStreams($pipes[0]); +$terminal->enableRawMode(); + +// Contract 1: No input + zero timeout (non-blocking) returns null immediately +$start = hrtime(true); +$key = $terminal->readKey(Duration::fromSeconds(0)); +$elapsed_ms = (hrtime(true) - $start) / 1e6; + +var_dump($key === null); +var_dump($elapsed_ms < 20.0); + +// Contract 2: No input + small finite timeout returns null after duration +$start = hrtime(true); +$key = $terminal->readKey(Duration::fromMilliseconds(25)); +$elapsed_ms = (hrtime(true) - $start) / 1e6; + +var_dump($key === null); +var_dump($elapsed_ms >= 20.0); + +// Contract 3: Negative Duration throws ValueError +try { + $terminal->readKey(Duration::fromSeconds(1)->negate()); + echo "FAIL: negative timeout accepted\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +try { + $terminal->readKey(Duration::fromSeconds(0), Duration::fromSeconds(1)->negate()); + echo "FAIL: negative sequence timeout accepted\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +// Contract 4: Non-terminal input stream throws TerminalException +$fp = fopen('php://temp', 'r+'); +$nonTty = SystemTerminal::fromStreams($fp); +try { + $nonTty->readKey(Duration::fromSeconds(0)); + echo "FAIL: non-terminal did not throw\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} +fclose($fp); + +// Contract 5: EOF / stream termination throws TerminalException +fwrite($pipes[0], "exit\n"); +usleep(50000); // give child time to exit + +try { + $terminal->readKey(Duration::fromSeconds(1)); + echo "FAIL: readKey on closed/EOF terminal did not throw\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +unset($terminal); +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--EXPECTF-- +bool(true) +bool(true) +bool(true) +bool(true) +ValueError: Io\Terminal\SystemTerminal::readKey(): Argument #1 ($timeout) must not be negative +ValueError: Io\Terminal\SystemTerminal::readKey(): Argument #2 ($sequenceTimeout) must not be negative +Io\Terminal\TerminalException: Failed to read key: input stream is not a terminal +Io\Terminal\TerminalException: %s terminal input stream diff --git a/ext/standard/tests/terminal/terminal_read_key_partial_escape.phpt b/ext/standard/tests/terminal/terminal_read_key_partial_escape.phpt new file mode 100644 index 000000000000..5bb89bed0cf2 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_key_partial_escape.phpt @@ -0,0 +1,154 @@ +--TEST-- +Io\Terminal\Terminal: readKey partial escape sequence and overall deadline timing +--SKIPIF-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); +} catch (Throwable) { + die("skip PTY is not available"); +} +if (!is_resource($proc)) { + die("skip PTY is not available"); +} +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--FILE-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes1, +); + +$r0 = fgets($pipes1[2]); +$terminal1 = SystemTerminal::fromStreams($pipes1[0]); +$terminal1->enableRawMode(); + +fwrite($pipes1[0], "START\n"); +$r1 = fgets($pipes1[2]); + +$k1 = $terminal1->readKey(Duration::fromMilliseconds(30), Duration::fromMilliseconds(100)); + +fwrite($pipes1[0], "GO\n"); +$r2 = fgets($pipes1[2]); + +$k2 = $terminal1->readKey(Duration::fromMilliseconds(100)); + +echo "Case 1 k1 is string: ", var_export(is_string($k1), true), PHP_EOL; +echo "Case 1 k1 hex: ", bin2hex($k1), PHP_EOL; +echo "Case 1 k2: ", var_export($k2, true), PHP_EOL; + +fwrite($pipes1[0], "done\n"); +unset($terminal1); +foreach ($pipes1 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc1); + +// Case 2: Overall deadline 30ms, ESC sent at 0ms, [ arrives after release. +// Because [ does not arrive before the 30ms deadline, the first call returns Key::Escape. +// The subsequent call reads [ independently. +$code2 = ' +fwrite(STDERR, "STARTED\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\x1b"); +fflush(STDOUT); +fwrite(STDERR, "READY1\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "["); +fflush(STDOUT); +fwrite(STDERR, "READY2\n"); +fflush(STDERR); +fgets(STDIN); +'; +$proc2 = proc_open( + [PHP_BINARY, '-r', $code2], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes2, +); + +$r0 = fgets($pipes2[2]); +$terminal2 = SystemTerminal::fromStreams($pipes2[0]); +$terminal2->enableRawMode(); + +fwrite($pipes2[0], "START\n"); +$r1 = fgets($pipes2[2]); + +$k3 = $terminal2->readKey(Duration::fromMilliseconds(30), Duration::fromMilliseconds(100)); + +fwrite($pipes2[0], "GO\n"); +$r2 = fgets($pipes2[2]); + +$k4 = $terminal2->readKey(Duration::fromMilliseconds(100)); + +echo "Case 2 k3 is Escape: ", var_export($k3 === Key::Escape, true), PHP_EOL; +echo "Case 2 k4: ", var_export($k4, true), PHP_EOL; + +fwrite($pipes2[0], "done\n"); +unset($terminal2); +foreach ($pipes2 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc2); + +?> +--EXPECT-- +Case 1 k1 is string: true +Case 1 k1 hex: 1b5b +Case 1 k2: 'A' +Case 2 k3 is Escape: true +Case 2 k4: '[' diff --git a/ext/standard/tests/terminal/terminal_read_key_sequences.phpt b/ext/standard/tests/terminal/terminal_read_key_sequences.phpt new file mode 100644 index 000000000000..31c8ecd783a9 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_key_sequences.phpt @@ -0,0 +1,86 @@ +--TEST-- +Io\Terminal\SystemTerminal: readKey preserves unsupported CSI sequences +--SKIPIF-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); +} catch (Throwable) { + die("skip PTY is not available"); +} + +if (!is_resource($proc)) { + die("skip PTY is not available"); +} + +foreach ($pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } +} +proc_close($proc); +?> +--FILE-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, +); + +$terminal = SystemTerminal::fromStreams($pipes[0]); +$terminal->enableRawMode(); +$timeout = Duration::fromSeconds(1); + +foreach (range(1, 4) as $_) { + $key = $terminal->readKey($timeout); + + if ($key instanceof Key) { + echo $key->name, "\n"; + } elseif (is_string($key)) { + echo bin2hex($key), "\n"; + } else { + echo "false\n"; + } +} + +unset($terminal); +foreach ($pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } +} +proc_close($proc); +?> +--EXPECT-- +1b5b313b3541 +1b5b327e +F1 +Up diff --git a/ext/standard/tests/terminal/terminal_read_key_timeout.phpt b/ext/standard/tests/terminal/terminal_read_key_timeout.phpt new file mode 100644 index 000000000000..9ad908655854 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_key_timeout.phpt @@ -0,0 +1,75 @@ +--TEST-- +Io\Terminal\Terminal: readKey zero timeout returns Escape immediately without sequence delay +--SKIPIF-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); +} catch (Throwable) { + die("skip PTY is not available"); +} +if (!is_resource($proc)) { + die("skip PTY is not available"); +} +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--FILE-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, +); + +$terminal = SystemTerminal::fromStreams($pipes[0]); +$terminal->enableRawMode(); + +// Signal child to write lone escape +fwrite($pipes[0], "GO\n"); +$ready = fgets($pipes[2]); + +// Read with zero timeout (non-blocking) +$start = hrtime(true); +$key = $terminal->readKey(Duration::fromSeconds(0)); +$elapsed_ms = (hrtime(true) - $start) / 1e6; + +var_dump($key === Key::Escape); +// Verify it did not block for 25ms sequence timeout (should be < 15ms) +var_dump($elapsed_ms < 15.0); + +fwrite($pipes[0], "DONE\n"); +unset($terminal); +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--EXPECT-- +bool(true) +bool(true) diff --git a/ext/standard/tests/terminal/terminal_read_key_utf8_retain.phpt b/ext/standard/tests/terminal/terminal_read_key_utf8_retain.phpt new file mode 100644 index 000000000000..3382f116ab87 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_key_utf8_retain.phpt @@ -0,0 +1,214 @@ +--TEST-- +Io\Terminal\Terminal: readKey retains fragmented UTF-8 across calls on timeout +--SKIPIF-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); +} catch (Throwable) { + die("skip PTY is not available"); +} +if (!is_resource($proc)) { + die("skip PTY is not available"); +} +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--FILE-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes1, +); + +$r0 = fgets($pipes1[2]); +$terminal1 = SystemTerminal::fromStreams($pipes1[0]); +$terminal1->enableRawMode(); + +fwrite($pipes1[0], "START\n"); +$r1 = fgets($pipes1[2]); + +$k1 = $terminal1->readKey(Duration::fromMilliseconds(25)); + +fwrite($pipes1[0], "GO\n"); +$r2 = fgets($pipes1[2]); + +$k2 = $terminal1->readKey(Duration::fromMilliseconds(100)); + +echo "k1 is null: ", var_export($k1 === null, true), PHP_EOL; +echo "k2 is euro: ", var_export($k2 === "€", true), PHP_EOL; +echo "k2 hex: ", bin2hex($k2), PHP_EOL; + +fwrite($pipes1[0], "done\n"); +unset($terminal1); +foreach ($pipes1 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc1); + +// 2. 4-byte UTF-8 emoji (πŸ˜€: \xf0\x9f\x98\x80) split across timeout +$code2 = ' +fwrite(STDERR, "STARTED\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\xf0\x9f"); +fflush(STDOUT); +fwrite(STDERR, "READY1\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\x98\x80"); +fflush(STDOUT); +fwrite(STDERR, "READY2\n"); +fflush(STDERR); +fgets(STDIN); +'; +$proc2 = proc_open( + [PHP_BINARY, '-r', $code2], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes2, +); + +$r0 = fgets($pipes2[2]); +$terminal2 = SystemTerminal::fromStreams($pipes2[0]); +$terminal2->enableRawMode(); + +fwrite($pipes2[0], "START\n"); +$r1 = fgets($pipes2[2]); + +$k3 = $terminal2->readKey(Duration::fromMilliseconds(25)); + +fwrite($pipes2[0], "GO\n"); +$r2 = fgets($pipes2[2]); + +$k4 = $terminal2->readKey(Duration::fromMilliseconds(100)); + +echo "k3 is null: ", var_export($k3 === null, true), PHP_EOL; +echo "k4 is emoji: ", var_export($k4 === "πŸ˜€", true), PHP_EOL; +echo "k4 hex: ", bin2hex($k4), PHP_EOL; + +fwrite($pipes2[0], "done\n"); +unset($terminal2); +foreach ($pipes2 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc2); + +// 3. 4-byte UTF-8 sequence where more than one byte (2 bytes: \xf0\x9f) is already pending, +// and the remaining bytes arrive later than sequenceTimeout (20ms) but within the next call's +// overall timeout (1000ms). +$code3 = ' +fwrite(STDERR, "STARTED\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\xf0\x9f"); +fflush(STDOUT); +fwrite(STDERR, "READY1\n"); +fflush(STDERR); +fgets(STDIN); + +usleep(40000); +fwrite(STDOUT, "\x98\x80"); +fflush(STDOUT); +fwrite(STDERR, "READY2\n"); +fflush(STDERR); +fgets(STDIN); +'; +$proc3 = proc_open( + [PHP_BINARY, '-r', $code3], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes3, +); + +$r0 = fgets($pipes3[2]); +$terminal3 = SystemTerminal::fromStreams($pipes3[0]); +$terminal3->enableRawMode(); + +fwrite($pipes3[0], "START\n"); +$r1 = fgets($pipes3[2]); + +// Call 1 reads \xf0\x9f, waits for 3rd byte with sequenceTimeout 20ms, times out, retains 2 bytes. +$k5 = $terminal3->readKey(Duration::fromMilliseconds(30), Duration::fromMilliseconds(20)); + +// Release child: child sleeps 40ms (> 20ms sequenceTimeout) before writing remaining bytes. +fwrite($pipes3[0], "GO\n"); + +// Call 2 resumes sequence with 2 bytes already pending. The first continuation read of this invocation +// uses overall timeout (1000ms) rather than sequenceTimeout (20ms), successfully waiting for the 40ms delay. +$k6 = $terminal3->readKey(Duration::fromMilliseconds(1000), Duration::fromMilliseconds(20)); + +echo "k5 is null: ", var_export($k5 === null, true), PHP_EOL; +echo "k6 is emoji: ", var_export($k6 === "πŸ˜€", true), PHP_EOL; +echo "k6 hex: ", bin2hex($k6), PHP_EOL; + +$r2 = fgets($pipes3[2]); +fwrite($pipes3[0], "done\n"); +unset($terminal3); +foreach ($pipes3 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc3); + +?> +--EXPECT-- +k1 is null: true +k2 is euro: true +k2 hex: e282ac +k3 is null: true +k4 is emoji: true +k4 hex: f09f9880 +k5 is null: true +k6 is emoji: true +k6 hex: f09f9880 diff --git a/ext/standard/tests/terminal/terminal_read_key_utf8_timing.phpt b/ext/standard/tests/terminal/terminal_read_key_utf8_timing.phpt new file mode 100644 index 000000000000..489b43bd4cee --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_key_utf8_timing.phpt @@ -0,0 +1,226 @@ +--TEST-- +Io\Terminal\Terminal: readKey UTF-8 timing and overall timeout behavior +--SKIPIF-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); +} catch (Throwable) { + die("skip PTY is not available"); +} +if (!is_resource($proc)) { + die("skip PTY is not available"); +} +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--FILE-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); + $started = fgets($pipes[2]); + $terminal = SystemTerminal::fromStreams($pipes[0]); + $terminal->enableRawMode(); + return [$proc, $pipes, $terminal]; +} + +// Case 1: Complete queued 3-byte UTF-8 (€) with Duration::fromSeconds(0) +$code1 = ' +fwrite(STDERR, "STARTED\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\xe2\x82\xac"); +fflush(STDOUT); +fwrite(STDERR, "READY\n"); +fflush(STDERR); +fgets(STDIN); +'; +[$proc1, $pipes1, $terminal1] = spawn_pty_child($code1); +fwrite($pipes1[0], "START\n"); +$r1 = fgets($pipes1[2]); + +$k1 = $terminal1->readKey(Duration::fromSeconds(0)); +echo "Case 1: ", var_export($k1 === "€", true), " hex: ", bin2hex($k1), PHP_EOL; + +fwrite($pipes1[0], "done\n"); +$terminal1->restoreMode(); +unset($terminal1); +foreach ($pipes1 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc1); + +// Case 2: Complete queued 4-byte UTF-8 (πŸ˜€) with Duration::fromSeconds(0) +$code2 = ' +fwrite(STDERR, "STARTED\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\xf0\x9f\x98\x80"); +fflush(STDOUT); +fwrite(STDERR, "READY\n"); +fflush(STDERR); +fgets(STDIN); +'; +[$proc2, $pipes2, $terminal2] = spawn_pty_child($code2); +fwrite($pipes2[0], "START\n"); +$r2 = fgets($pipes2[2]); + +$k2 = $terminal2->readKey(Duration::fromSeconds(0)); +echo "Case 2: ", var_export($k2 === "πŸ˜€", true), " hex: ", bin2hex($k2), PHP_EOL; + +fwrite($pipes2[0], "done\n"); +$terminal2->restoreMode(); +unset($terminal2); +foreach ($pipes2 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc2); + +// Case 3: Retained UTF-8 lead byte followed by queued continuation bytes completed by subsequent Duration::fromSeconds(0) +$code3 = ' +fwrite(STDERR, "STARTED\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\xe2"); +fflush(STDOUT); +fwrite(STDERR, "READY1\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\x82\xac"); +fflush(STDOUT); +fwrite(STDERR, "READY2\n"); +fflush(STDERR); +fgets(STDIN); +'; +[$proc3, $pipes3, $terminal3] = spawn_pty_child($code3); +fwrite($pipes3[0], "START\n"); +$r3_1 = fgets($pipes3[2]); + +$k3_1 = $terminal3->readKey(Duration::fromMilliseconds(25)); +echo "Case 3 k3_1 is null: ", var_export($k3_1 === null, true), PHP_EOL; + +fwrite($pipes3[0], "GO\n"); +$r3_2 = fgets($pipes3[2]); + +$k3_2 = $terminal3->readKey(Duration::fromSeconds(0)); +echo "Case 3 k3_2 is euro: ", var_export($k3_2 === "€", true), " hex: ", bin2hex($k3_2), PHP_EOL; + +fwrite($pipes3[0], "done\n"); +$terminal3->restoreMode(); +unset($terminal3); +foreach ($pipes3 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc3); + +// Case 4: Fresh fragmented UTF-8 where continuation arrives later than sequenceTimeout (50ms vs 20ms) but before finite overall timeout (500ms) +$code4 = ' +fwrite(STDERR, "STARTED\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\xe2"); +fflush(STDOUT); +fwrite(STDERR, "READY1\n"); +fflush(STDERR); +fgets(STDIN); + +usleep(50000); +fwrite(STDOUT, "\x82\xac"); +fflush(STDOUT); +fwrite(STDERR, "READY2\n"); +fflush(STDERR); +fgets(STDIN); +'; +[$proc4, $pipes4, $terminal4] = spawn_pty_child($code4); +fwrite($pipes4[0], "START\n"); +$r4_1 = fgets($pipes4[2]); + +fwrite($pipes4[0], "GO\n"); +$k4 = $terminal4->readKey(Duration::fromMilliseconds(500), Duration::fromMilliseconds(20)); +echo "Case 4: ", var_export($k4 === "€", true), " hex: ", bin2hex($k4), PHP_EOL; + +$r4_2 = fgets($pipes4[2]); +fwrite($pipes4[0], "done\n"); +$terminal4->restoreMode(); +unset($terminal4); +foreach ($pipes4 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc4); + +// Case 5: readKey(timeout: null, sequenceTimeout: 25ms) where continuation arrives after >25ms (60ms); blocks and returns the complete character +$code5 = ' +fwrite(STDERR, "STARTED\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\xe2"); +fflush(STDOUT); +fwrite(STDERR, "READY1\n"); +fflush(STDERR); +fgets(STDIN); + +usleep(60000); +fwrite(STDOUT, "\x82\xac"); +fflush(STDOUT); +fwrite(STDERR, "READY2\n"); +fflush(STDERR); +fgets(STDIN); +'; +[$proc5, $pipes5, $terminal5] = spawn_pty_child($code5); +fwrite($pipes5[0], "START\n"); +$r5_1 = fgets($pipes5[2]); + +fwrite($pipes5[0], "GO\n"); +$k5 = $terminal5->readKey(timeout: null, sequenceTimeout: Duration::fromMilliseconds(25)); +echo "Case 5: ", var_export($k5 === "€", true), " hex: ", bin2hex($k5), PHP_EOL; + +$r5_2 = fgets($pipes5[2]); +fwrite($pipes5[0], "done\n"); +$terminal5->restoreMode(); +unset($terminal5); +foreach ($pipes5 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc5); + +?> +--EXPECT-- +Case 1: true hex: e282ac +Case 2: true hex: f09f9880 +Case 3 k3_1 is null: true +Case 3 k3_2 is euro: true hex: e282ac +Case 4: true hex: e282ac +Case 5: true hex: e282ac diff --git a/ext/standard/tests/terminal/terminal_read_line.phpt b/ext/standard/tests/terminal/terminal_read_line.phpt new file mode 100644 index 000000000000..07ae421d0f32 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_line.phpt @@ -0,0 +1,195 @@ +--TEST-- +Io\Terminal\SystemTerminal: readLine line ending, whitespace, buffering, and EOF stream contracts +--FILE-- +getNumberOfParameters()); +var_dump((string) $reflection->getReturnType()); + +// 1. Argument validation: readLine takes 0 parameters +$fp = fopen('php://temp', 'r+'); +$terminal = SystemTerminal::fromStreams($fp); +try { + $terminal->readLine('extra'); + echo "FAIL: readLine accepted argument\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +// 2. Immediate EOF returns null +var_dump($terminal->readLine()); + +// 3. Ordinary completed line ("hello\n") +fwrite($fp, "hello\n"); +rewind($fp); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); // EOF + +// 4. CRLF line ending ("world\r\n") +ftruncate($fp, 0); +rewind($fp); +fwrite($fp, "world\r\n"); +rewind($fp); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); // EOF + +// 5. Empty line ("\n" and "\r\n") +ftruncate($fp, 0); +rewind($fp); +fwrite($fp, "\n\r\n"); +rewind($fp); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); // EOF + +// 6. Leading and trailing whitespace preserved +ftruncate($fp, 0); +rewind($fp); +fwrite($fp, " spaced line \n"); +rewind($fp); +var_dump($terminal->readLine()); + +// 7. Tabs preserved +ftruncate($fp, 0); +rewind($fp); +fwrite($fp, "\t\tindented with tabs\t\t\r\n"); +rewind($fp); +var_dump($terminal->readLine()); + +// 8. Unterminated final line at EOF +ftruncate($fp, 0); +rewind($fp); +fwrite($fp, "final unterminated"); +rewind($fp); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); // EOF + +// 9. Multiple sequential lines +ftruncate($fp, 0); +rewind($fp); +fwrite($fp, "alpha\nbeta\r\ngamma\ndelta"); +rewind($fp); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); // EOF + +// 10. Unicode line behavior +ftruncate($fp, 0); +rewind($fp); +fwrite($fp, "γ“γ‚“γ«γ‘γ―δΈ–η•Œ\nCafΓ© au lait\r\nπŸ¦€ Rust & 🐘 PHP\n"); +rewind($fp); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); // EOF + +// 11. Already-buffered php_stream input is not lost +ftruncate($fp, 0); +rewind($fp); +fwrite($fp, "buffer line 1\nbuffer line 2\n"); +rewind($fp); +// Read single character via standard PHP stream function to populate php_stream internal buffer +$c = fgetc($fp); +var_dump($c); +// SystemTerminal::readLine must consume from php_stream buffer, not bypass it +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); // EOF + +fclose($fp); + +// 12. Memory stream (php://memory) +$mem = fopen('php://memory', 'r+'); +fwrite($mem, "memory line\n"); +rewind($mem); +$tMem = SystemTerminal::fromStreams($mem); +var_dump($tMem->readLine()); +fclose($mem); + +// 13. Multiple consecutive empty CRLF lines and trailing CRLF +$fp2 = fopen('php://temp', 'r+'); +fwrite($fp2, "\r\n\r\nline with crlf\r\n\r\n"); +rewind($fp2); +$t2 = SystemTerminal::fromStreams($fp2); +var_dump($t2->readLine()); +var_dump($t2->readLine()); +var_dump($t2->readLine()); +var_dump($t2->readLine()); +var_dump($t2->readLine()); // EOF + +// 14. Extended Unicode, surrogate pairs, and combining sequences +ftruncate($fp2, 0); +rewind($fp2); +fwrite($fp2, "πŸ˜πŸ¦€πŸš€\r\nHello \u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466} Family\r\ne\u{0301}cole\n"); +rewind($fp2); +var_dump($t2->readLine()); +var_dump($t2->readLine()); +var_dump($t2->readLine()); +var_dump($t2->readLine()); // EOF + +// 15. Mode restoration & terminal state usability after raw mode attempt +try { + $t2->enableRawMode(); +} catch (Throwable $e) { + echo $e->getMessage(), PHP_EOL; +} +var_dump($t2->restoreMode()); +ftruncate($fp2, 0); +rewind($fp2); +fwrite($fp2, "post restoration line\r\n"); +rewind($fp2); +var_dump($t2->readLine()); +fclose($fp2); + +?> +--EXPECT-- +bool(true) +int(0) +string(7) "?string" +ArgumentCountError: Io\Terminal\SystemTerminal::readLine() expects exactly 0 arguments, 1 given +NULL +string(5) "hello" +NULL +string(5) "world" +NULL +string(0) "" +string(0) "" +NULL +string(17) " spaced line " +string(22) " indented with tabs " +string(18) "final unterminated" +NULL +string(5) "alpha" +string(4) "beta" +string(5) "gamma" +string(5) "delta" +NULL +string(21) "γ“γ‚“γ«γ‘γ―δΈ–η•Œ" +string(13) "CafΓ© au lait" +string(20) "πŸ¦€ Rust & 🐘 PHP" +NULL +string(1) "b" +string(12) "uffer line 1" +string(13) "buffer line 2" +NULL +string(11) "memory line" +string(0) "" +string(0) "" +string(14) "line with crlf" +string(0) "" +NULL +string(12) "πŸ˜πŸ¦€πŸš€" +string(38) "Hello πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦ Family" +string(7) "école" +NULL +Failed to enable terminal raw mode +bool(false) +string(21) "post restoration line" diff --git a/ext/standard/tests/terminal/terminal_read_line_lease.phpt b/ext/standard/tests/terminal/terminal_read_line_lease.phpt new file mode 100644 index 000000000000..4cceffa17481 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_line_lease.phpt @@ -0,0 +1,157 @@ +--TEST-- +Io\Terminal\SystemTerminal: readLine raw-mode lease conflict, cross-instance validation, and PTY line contracts +--SKIPIF-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); +} catch (Throwable) { + die("skip PTY is not available"); +} +if (!is_resource($proc)) { + die("skip PTY is not available"); +} +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--FILE-- + ['pipe', 'r'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, +); + +fgets($pipes[2]); // Wait for STARTED + +$t1 = SystemTerminal::fromStreams($pipes[1]); +$t2 = SystemTerminal::fromStreams($pipes[1]); + +// 1. Same-instance active raw lease throws TerminalException +$token = $t1->enableRawMode(); +try { + $t1->readLine(); + echo "FAIL: t1->readLine() succeeded while raw mode is active\n"; +} catch (TerminalException $e) { + echo "Same-instance: ", $e->getMessage(), PHP_EOL; +} + +// 2. Cross-instance active raw lease on same terminal device throws TerminalException +try { + $t2->readLine(); + echo "FAIL: t2->readLine() succeeded while raw mode is active on t1\n"; +} catch (TerminalException $e) { + echo "Cross-instance: ", $e->getMessage(), PHP_EOL; +} + +// 3. Cross-instance restoreMode($token) succeeds and restores mode across wrappers +$restored = $t2->restoreMode($token); +var_dump($restored); + +// 4. readLine on original wrapper works normally after cross-wrapper restoration +fwrite($pipes[0], "GO\n"); +fgets($pipes[2]); // Wait for READY1 +var_dump($t1->readLine()); + +// 5. Subsequent readLine calls continue to work normally +fwrite($pipes[0], "GO\n"); +fgets($pipes[2]); // Wait for READY2 +var_dump($t2->readLine()); + +// 6. No-argument restoreMode() on a wrapper with a stale active token returns false +$tokenStale = $t1->enableRawMode(); +$t2->restoreMode($tokenStale); +var_dump($t1->restoreMode() === false); + +// 7. Fragmented UTF-8 prepending: pending UTF-8 bytes from readKey are prepended to readLine +$token2 = $t1->enableRawMode(); +fwrite($pipes[0], "GO\n"); +fgets($pipes[2]); // Wait for READY3 +$key = $t1->readKey(Duration::fromMilliseconds(25)); +var_dump($key === null); // Incomplete, retained in pending_utf8 + +// Restore raw mode so readLine can proceed +$t1->restoreMode($token2); + +// Provide continuation byte \xa9 followed by remainder of line +fwrite($pipes[0], "GO\n"); +fgets($pipes[2]); // Wait for READY4 +$line = $t1->readLine(); +var_dump($line); + +// 8. Immediate EOF on PTY returns null +fwrite($pipes[0], "DONE\n"); +usleep(50000); // Allow child process to finish +var_dump($t1->readLine()); + +unset($t1, $t2); +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); + +?> +--EXPECT-- +Same-instance: Cannot read a line while raw mode is active for this terminal +Cross-instance: Cannot read a line while raw mode is active for this terminal +bool(true) +string(14) "canonical line" +string(11) "second line" +bool(true) +bool(true) +string(7) "Γ©clair" +NULL diff --git a/ext/standard/tests/terminal/terminal_read_line_nonblocking.phpt b/ext/standard/tests/terminal/terminal_read_line_nonblocking.phpt new file mode 100644 index 000000000000..decc1741b975 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_line_nonblocking.phpt @@ -0,0 +1,163 @@ +--TEST-- +Io\Terminal\SystemTerminal: readLine nonblocking TTY EAGAIN waiting, partial lines, and EOF contracts +--SKIPIF-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); +} catch (Throwable) { + die("skip PTY is not available"); +} +if (!is_resource($proc)) { + die("skip PTY is not available"); +} +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--FILE-- + ['pipe', 'r'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes1, +); + +fgets($pipes1[2]); // Wait for STARTED +stream_set_blocking($pipes1[1], false); +$terminal1 = SystemTerminal::fromStreams($pipes1[1]); + +fwrite($pipes1[0], "GO\n"); +// Even though $pipes1[1] is nonblocking and child sleeps 40ms before writing, +// readLine() waits for readiness and returns the line instead of falsely returning null/EOF. +var_dump($terminal1->readLine()); + +fclose($pipes1[0]); +fclose($pipes1[1]); +fclose($pipes1[2]); +proc_close($proc1); + +// 2. Nonblocking TTY, partial bytes without newline: must not return them as a completed line +$proc2 = proc_open( + [PHP_BINARY, '-r', ' + fwrite(STDERR, "STARTED\n"); + fflush(STDERR); + fgets(STDIN); + fwrite(STDOUT, "partial_"); + fflush(STDOUT); + usleep(40000); + fwrite(STDOUT, "completed\n"); + fflush(STDOUT); + '], + [ + 0 => ['pipe', 'r'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes2, +); + +fgets($pipes2[2]); // Wait for STARTED +stream_set_blocking($pipes2[1], false); +$terminal2 = SystemTerminal::fromStreams($pipes2[1]); + +fwrite($pipes2[0], "GO\n"); +// Even though the child pauses after writing "partial_", readLine() must not return +// partial bytes as a completed line; it waits for the newline and returns the whole line. +var_dump($terminal2->readLine()); + +fclose($pipes2[0]); +fclose($pipes2[1]); +fclose($pipes2[2]); +proc_close($proc2); + +// 3. Actual EOF before data on nonblocking TTY returns null +$proc3 = proc_open( + [PHP_BINARY, '-r', 'exit(0);'], + [ + 0 => ['pipe', 'r'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes3, +); + +fclose($pipes3[0]); +fclose($pipes3[2]); +usleep(30000); // Allow child to exit and slave to close +stream_set_blocking($pipes3[1], false); +$terminal3 = SystemTerminal::fromStreams($pipes3[1]); + +var_dump($terminal3->readLine()); + +fclose($pipes3[1]); +proc_close($proc3); + +// 4. Partial bytes + actual EOF on nonblocking TTY returns final unterminated line, then null +$proc4 = proc_open( + [PHP_BINARY, '-r', ' + fwrite(STDERR, "STARTED\n"); + fflush(STDERR); + fgets(STDIN); + fwrite(STDOUT, "final unterminated"); + fflush(STDOUT); + exit(0); + '], + [ + 0 => ['pipe', 'r'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes4, +); + +fgets($pipes4[2]); // Wait for STARTED +stream_set_blocking($pipes4[1], false); +$terminal4 = SystemTerminal::fromStreams($pipes4[1]); + +fwrite($pipes4[0], "GO\n"); +usleep(30000); // Allow child to write and exit +var_dump($terminal4->readLine()); +var_dump($terminal4->readLine()); // Immediate EOF after unterminated line + +fclose($pipes4[0]); +fclose($pipes4[1]); +fclose($pipes4[2]); +proc_close($proc4); + +?> +--EXPECT-- +string(12) "delayed line" +string(17) "partial_completed" +NULL +string(18) "final unterminated" +NULL diff --git a/ext/standard/tests/terminal/terminal_read_line_windows.phpt b/ext/standard/tests/terminal/terminal_read_line_windows.phpt new file mode 100644 index 000000000000..d847fcc73a63 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_line_windows.phpt @@ -0,0 +1,67 @@ +--TEST-- +Io\Terminal\SystemTerminal: Windows console and stream readLine contracts +--SKIPIF-- + +--FILE-- +readLine()); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); // EOF + +// 2. Windows Unicode line handling with surrogate pairs +ftruncate($fp, 0); +rewind($fp); +fwrite($fp, "🐘 PHP \u{1F980} Rust\r\n東京\r\n"); +rewind($fp); + +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); // EOF + +// 3. Mode restoration behavior on Windows: +// Attempting raw mode on non-terminal throws TerminalException, restoreMode returns false, +// and readLine continues to function normally without mode corruption +try { + $terminal->enableRawMode(); +} catch (TerminalException $e) { + echo "enableRawMode exception: ", $e->getMessage(), PHP_EOL; +} +var_dump($terminal->restoreMode()); + +ftruncate($fp, 0); +rewind($fp); +fwrite($fp, "usable after mode check\r\n"); +rewind($fp); +var_dump($terminal->readLine()); + +fclose($fp); + +?> +--EXPECT-- +string(14) "windows line 1" +string(0) "" +string(14) "windows line 2" +NULL +string(18) "🐘 PHP πŸ¦€ Rust" +string(6) "東京" +NULL +enableRawMode exception: Failed to enable terminal raw mode +bool(false) +string(23) "usable after mode check" diff --git a/ext/standard/tests/terminal/terminal_read_secret.phpt b/ext/standard/tests/terminal/terminal_read_secret.phpt new file mode 100644 index 000000000000..0cf9efbbf9e6 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_secret.phpt @@ -0,0 +1,53 @@ +--TEST-- +Io\Terminal\SystemTerminal: readSecret parameter validation and non-tty failure handling +--FILE-- +readSecret(); + echo "FAIL: readSecret succeeded on non-tty\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +// readSecret rejects invalid argument type +try { + $nonTty->readSecret('prompt'); + echo "FAIL: readSecret accepted string argument\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +// readSecret rejects extra arguments +try { + $nonTty->readSecret(null, null); + echo "FAIL: readSecret accepted multiple arguments\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +// readSecret rejects negative timeout +try { + $nonTty->readSecret(Duration::fromSeconds(1)->negate()); + echo "FAIL: readSecret accepted negative timeout\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +?> +--EXPECTF-- +bool(true) +Io\Terminal\TerminalException: Unable to read secret from terminal +TypeError: Io\Terminal\SystemTerminal::readSecret(): Argument #1 ($timeout) must be of type ?Time\Duration, string given +ArgumentCountError: Io\Terminal\SystemTerminal::readSecret() expects at most 1 argument, 2 given +ValueError: Io\Terminal\SystemTerminal::readSecret(): Argument #1 ($timeout) must not be negative diff --git a/ext/standard/tests/terminal/terminal_read_secret_control.phpt b/ext/standard/tests/terminal/terminal_read_secret_control.phpt new file mode 100644 index 000000000000..f794cbdee1c3 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_secret_control.phpt @@ -0,0 +1,319 @@ +--TEST-- +Io\Terminal\Terminal: readSecret cancels and throws on malformed UTF-8 with control characters, fragmented UTF-8, and escape aborts +--SKIPIF-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); +} catch (Throwable) { + die("skip PTY is not available"); +} +if (!is_resource($proc)) { + die("skip PTY is not available"); +} +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--FILE-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes1, +); + +$terminal1 = SystemTerminal::fromStreams($pipes1[0]); + +try { + $secret = $terminal1->readSecret(); + echo "FAIL: secret was read: ", bin2hex($secret), "\n"; +} catch (TerminalException $e) { + echo "SUCCESS: caught ", $e::class, ": ", $e->getMessage(), "\n"; +} + +fwrite($pipes1[0], "done\n"); +unset($terminal1); +foreach ($pipes1 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc1); + +// 2. Fragmented UTF-8: \xC3 followed 60ms later by \xA9\n must not drop \xC3 +$code2 = 'fwrite(STDOUT, "\xc3"); fflush(STDOUT); usleep(60000); fwrite(STDOUT, "\xa9\n"); fflush(STDOUT); fgets(STDIN);'; +$proc2 = proc_open( + [PHP_BINARY, '-r', $code2], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes2, +); + +$terminal2 = SystemTerminal::fromStreams($pipes2[0]); +$secret2 = $terminal2->readSecret(); +echo "Fragmented UTF-8: ", bin2hex($secret2), "\n"; + +fwrite($pipes2[0], "done\n"); +unset($terminal2); +foreach ($pipes2 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc2); + +// 3. Escape followed immediately by PTY disconnect +$code3 = 'fwrite(STDOUT, "\x1b"); fflush(STDOUT); usleep(10000); exit(0);'; +$proc3 = proc_open( + [PHP_BINARY, '-r', $code3], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes3, +); + +$terminal3 = SystemTerminal::fromStreams($pipes3[0]); +try { + $terminal3->readSecret(); + echo "FAIL: expected exception on disconnect\n"; +} catch (TerminalException $e) { + echo "Disconnect: caught ", $e::class, ": ", $e->getMessage(), "\n"; +} + +unset($terminal3); +foreach ($pipes3 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc3); + +// 4. Escape followed immediately by Ctrl+C (\x03) +$code4 = 'fwrite(STDOUT, "\x1b\x03"); fflush(STDOUT); fgets(STDIN);'; +$proc4 = proc_open( + [PHP_BINARY, '-r', $code4], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes4, +); + +$terminal4 = SystemTerminal::fromStreams($pipes4[0]); +try { + $terminal4->readSecret(); + echo "FAIL: expected abort on escape+Ctrl+C\n"; +} catch (TerminalException $e) { + echo "Escape+Ctrl+C: caught ", $e::class, ": ", $e->getMessage(), "\n"; +} + +fwrite($pipes4[0], "done\n"); +unset($terminal4); +foreach ($pipes4 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc4); + +// 5. Escape followed immediately by Enter (\r) +$code5 = 'fwrite(STDOUT, "\x1b\r"); fflush(STDOUT); fgets(STDIN);'; +$proc5 = proc_open( + [PHP_BINARY, '-r', $code5], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes5, +); + +$terminal5 = SystemTerminal::fromStreams($pipes5[0]); +try { + $terminal5->readSecret(); + echo "FAIL: expected abort on escape+Enter\n"; +} catch (TerminalException $e) { + echo "Escape+Enter: caught ", $e::class, ": ", $e->getMessage(), "\n"; +} + +fwrite($pipes5[0], "done\n"); +unset($terminal5); +foreach ($pipes5 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc5); + +// 6. CSI followed by Ctrl+C (\x1b[\x03) cancels +$code6 = 'fwrite(STDOUT, "\x1b[\x03"); fflush(STDOUT); fgets(STDIN);'; +$proc6 = proc_open( + [PHP_BINARY, '-r', $code6], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes6, +); +$terminal6 = SystemTerminal::fromStreams($pipes6[0]); +try { + $terminal6->readSecret(); + echo "FAIL: expected abort on CSI+Ctrl+C\n"; +} catch (TerminalException $e) { + echo "CSI+Ctrl+C: caught ", $e::class, ": ", $e->getMessage(), "\n"; +} +fwrite($pipes6[0], "done\n"); +unset($terminal6); +foreach ($pipes6 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc6); + +// 7. CSI followed by Ctrl+D (\x1b[\x04) cancels +$code7 = 'fwrite(STDOUT, "\x1b[\x04"); fflush(STDOUT); fgets(STDIN);'; +$proc7 = proc_open( + [PHP_BINARY, '-r', $code7], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes7, +); +$terminal7 = SystemTerminal::fromStreams($pipes7[0]); +try { + $terminal7->readSecret(); + echo "FAIL: expected abort on CSI+Ctrl+D\n"; +} catch (TerminalException $e) { + echo "CSI+Ctrl+D: caught ", $e::class, ": ", $e->getMessage(), "\n"; +} +fwrite($pipes7[0], "done\n"); +unset($terminal7); +foreach ($pipes7 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc7); + +// 8. SS3 followed by Ctrl+C (\x1bO\x03) cancels +$code8 = 'fwrite(STDOUT, "\x1bO\x03"); fflush(STDOUT); fgets(STDIN);'; +$proc8 = proc_open( + [PHP_BINARY, '-r', $code8], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes8, +); +$terminal8 = SystemTerminal::fromStreams($pipes8[0]); +try { + $terminal8->readSecret(); + echo "FAIL: expected abort on SS3+Ctrl+C\n"; +} catch (TerminalException $e) { + echo "SS3+Ctrl+C: caught ", $e::class, ": ", $e->getMessage(), "\n"; +} +fwrite($pipes8[0], "done\n"); +unset($terminal8); +foreach ($pipes8 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc8); + +// 9. Valid CSI sequence (e.g. Up arrow \x1b[A) ignored and secret input continues +$code9 = 'fwrite(STDOUT, "\x1b[Asecret\n"); fflush(STDOUT); fgets(STDIN);'; +$proc9 = proc_open( + [PHP_BINARY, '-r', $code9], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes9, +); +$terminal9 = SystemTerminal::fromStreams($pipes9[0]); +$secret9 = $terminal9->readSecret(); +echo "Valid CSI Up ignored: ", bin2hex($secret9), "\n"; +fwrite($pipes9[0], "done\n"); +unset($terminal9); +foreach ($pipes9 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc9); + +// 10. Valid SS3 sequence (e.g. F1 \x1bOP) ignored and secret input continues +$code10 = 'fwrite(STDOUT, "\x1bOPpassword\n"); fflush(STDOUT); fgets(STDIN);'; +$proc10 = proc_open( + [PHP_BINARY, '-r', $code10], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes10, +); +$terminal10 = SystemTerminal::fromStreams($pipes10[0]); +$secret10 = $terminal10->readSecret(); +echo "Valid SS3 F1 ignored: ", bin2hex($secret10), "\n"; +fwrite($pipes10[0], "done\n"); +unset($terminal10); +foreach ($pipes10 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc10); + +// 11. Internal 25ms timeout inside incomplete CSI ends sequence skipping and continues secret +$code11 = 'fwrite(STDOUT, "\x1b["); fflush(STDOUT); usleep(35000); fwrite(STDOUT, "mysecret\n"); fflush(STDOUT); fgets(STDIN);'; +$proc11 = proc_open( + [PHP_BINARY, '-r', $code11], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes11, +); +$terminal11 = SystemTerminal::fromStreams($pipes11[0]); +$secret11 = $terminal11->readSecret(); +echo "Internal timeout continue: ", bin2hex($secret11), "\n"; +fwrite($pipes11[0], "done\n"); +unset($terminal11); +foreach ($pipes11 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc11); + +?> +--EXPECT-- +SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal +Fragmented UTF-8: c3a9 +Disconnect: caught Io\Terminal\TerminalException: Unable to read secret from terminal +Escape+Ctrl+C: caught Io\Terminal\TerminalException: Unable to read secret from terminal +Escape+Enter: caught Io\Terminal\TerminalException: Unable to read secret from terminal +CSI+Ctrl+C: caught Io\Terminal\TerminalException: Unable to read secret from terminal +CSI+Ctrl+D: caught Io\Terminal\TerminalException: Unable to read secret from terminal +SS3+Ctrl+C: caught Io\Terminal\TerminalException: Unable to read secret from terminal +Valid CSI Up ignored: 736563726574 +Valid SS3 F1 ignored: 70617373776f7264 +Internal timeout continue: 6d79736563726574 diff --git a/ext/standard/tests/terminal/terminal_read_secret_timeout.phpt b/ext/standard/tests/terminal/terminal_read_secret_timeout.phpt new file mode 100644 index 000000000000..700b2de4bdbc --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_secret_timeout.phpt @@ -0,0 +1,298 @@ +--TEST-- +Io\Terminal\SystemTerminal: readSecret timeout, empty submission, and cancellation contracts +--SKIPIF-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); +} catch (Throwable) { + die("skip PTY is not available"); +} +if (!is_resource($proc)) { + die("skip PTY is not available"); +} +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--FILE-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes1, +); +$terminal1 = SystemTerminal::fromStreams($pipes1[0]); +$start = hrtime(true); +$secret1 = $terminal1->readSecret(Duration::fromMilliseconds(25)); +$elapsed_ms = (hrtime(true) - $start) / 1e6; + +var_dump($secret1 === null); +var_dump($elapsed_ms >= 20.0); + +fwrite($pipes1[0], "done\n"); +unset($terminal1); +foreach ($pipes1 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc1); + +// 2. Empty submission returning empty string "" +$code2 = ' +fwrite(STDERR, "STARTED\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\n"); +fflush(STDOUT); +fwrite(STDERR, "READY\n"); +fflush(STDERR); +fgets(STDIN); +'; +$proc2 = proc_open( + [PHP_BINARY, '-r', $code2], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes2, +); +$r0 = fgets($pipes2[2]); +$terminal2 = SystemTerminal::fromStreams($pipes2[0]); +$terminal2->enableRawMode(); + +fwrite($pipes2[0], "START\n"); +$r1 = fgets($pipes2[2]); + +$secret2 = $terminal2->readSecret(Duration::fromMilliseconds(100)); + +var_dump($secret2 === ""); + +fwrite($pipes2[0], "done\n"); +unset($terminal2); +foreach ($pipes2 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc2); + +// 3. Normal submission returning text +$code3 = ' +fwrite(STDERR, "STARTED\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "secret123\n"); +fflush(STDOUT); +fwrite(STDERR, "READY\n"); +fflush(STDERR); +fgets(STDIN); +'; +$proc3 = proc_open( + [PHP_BINARY, '-r', $code3], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes3, +); +$r0 = fgets($pipes3[2]); +$terminal3 = SystemTerminal::fromStreams($pipes3[0]); +$terminal3->enableRawMode(); + +fwrite($pipes3[0], "START\n"); +$r1 = fgets($pipes3[2]); + +$secret3 = $terminal3->readSecret(Duration::fromMilliseconds(100)); + +var_dump($secret3 === "secret123"); + +fwrite($pipes3[0], "done\n"); +unset($terminal3); +foreach ($pipes3 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc3); + +// 4. Cancellation (Ctrl+C) throws TerminalException +$code4 = ' +fwrite(STDERR, "STARTED\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\x03"); +fflush(STDOUT); +fwrite(STDERR, "READY\n"); +fflush(STDERR); +fgets(STDIN); +'; +$proc4 = proc_open( + [PHP_BINARY, '-r', $code4], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes4, +); +$r0 = fgets($pipes4[2]); +$terminal4 = SystemTerminal::fromStreams($pipes4[0]); +$terminal4->enableRawMode(); + +fwrite($pipes4[0], "START\n"); +$r1 = fgets($pipes4[2]); + +try { + $terminal4->readSecret(Duration::fromMilliseconds(100)); + echo "FAIL: expected TerminalException on cancellation\n"; +} catch (TerminalException $e) { + echo "Cancellation caught: ", $e->getMessage(), PHP_EOL; +} + +fwrite($pipes4[0], "done\n"); +unset($terminal4); +foreach ($pipes4 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc4); + +// 5. Negative duration throws ValueError +$proc5 = proc_open( + [PHP_BINARY, '-r', 'fgets(STDIN);'], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes5, +); +$terminal5 = SystemTerminal::fromStreams($pipes5[0]); +try { + $terminal5->readSecret(Duration::fromSeconds(1)->negate()); + echo "FAIL: expected ValueError on negative duration\n"; +} catch (ValueError $e) { + echo "Negative timeout caught: ", $e->getMessage(), PHP_EOL; +} + +fwrite($pipes5[0], "done\n"); +unset($terminal5); +foreach ($pipes5 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc5); + +// 6. Complete secret plus newline already buffered: zero timeout consumes immediately available bytes without blocking +$code6 = ' +fwrite(STDERR, "STARTED\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "zerosecret\n"); +fflush(STDOUT); +fwrite(STDERR, "READY\n"); +fflush(STDERR); +fgets(STDIN); +'; +$proc6 = proc_open( + [PHP_BINARY, '-r', $code6], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes6, +); +$r0 = fgets($pipes6[2]); +$terminal6 = SystemTerminal::fromStreams($pipes6[0]); +$terminal6->enableRawMode(); + +fwrite($pipes6[0], "START\n"); +$r1 = fgets($pipes6[2]); + +$secret6 = $terminal6->readSecret(Duration::fromSeconds(0)); +var_dump($secret6 === "zerosecret"); + +fwrite($pipes6[0], "done\n"); +unset($terminal6); +foreach ($pipes6 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc6); + +// 7. Already-buffered standalone Escape consumed with zero timeout: follows cancellation contract and throws TerminalException +$code7 = ' +fwrite(STDERR, "STARTED\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\x1b"); +fflush(STDOUT); +fwrite(STDERR, "READY\n"); +fflush(STDERR); +fgets(STDIN); +'; +$proc7 = proc_open( + [PHP_BINARY, '-r', $code7], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes7, +); +$r0 = fgets($pipes7[2]); +$terminal7 = SystemTerminal::fromStreams($pipes7[0]); +$terminal7->enableRawMode(); + +fwrite($pipes7[0], "START\n"); +$r1 = fgets($pipes7[2]); + +try { + $terminal7->readSecret(Duration::fromSeconds(0)); + echo "FAIL: expected TerminalException on zero-timeout buffered escape\n"; +} catch (TerminalException $e) { + echo "Zero timeout escape caught: ", $e->getMessage(), PHP_EOL; +} + +fwrite($pipes7[0], "done\n"); +unset($terminal7); +foreach ($pipes7 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc7); + +?> +--EXPECTF-- +bool(true) +bool(true) +bool(true) +bool(true) +Cancellation caught: Unable to read secret from terminal +Negative timeout caught: Io\Terminal\SystemTerminal::readSecret(): Argument #1 ($timeout) must not be negative +bool(true) +Zero timeout escape caught: Unable to read secret from terminal diff --git a/ext/standard/tests/terminal/terminal_resize.phpt b/ext/standard/tests/terminal/terminal_resize.phpt new file mode 100644 index 000000000000..e53b22fc8192 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_resize.phpt @@ -0,0 +1,88 @@ +--TEST-- +Io\Terminal\Terminal: readKey detects resize and updates terminal size +--SKIPIF-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); +} catch (Throwable) { + die("skip PTY is not available"); +} +if (!is_resource($proc)) { + die("skip PTY is not available"); +} +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--FILE-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, +); + +$r1 = fgets($pipes[2]); +$terminal = SystemTerminal::fromStreams($pipes[0]); +$terminal->enableRawMode(); + +$s1 = $terminal->getSize(); +echo "s1: {$s1->cols}x{$s1->rows}\n"; + +// Signal child to change window size +fwrite($pipes[0], "next\n"); +$r2 = fgets($pipes[2]); + +// readKey should detect size change and return Key::Resize +$key = $terminal->readKey(Duration::fromSeconds(0)); +var_dump($key === Key::Resize); + +$s2 = $terminal->getSize(); +echo "s2: {$s2->cols}x{$s2->rows}\n"; + +fwrite($pipes[0], "done\n"); +unset($terminal); +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--EXPECT-- +s1: 100x30 +bool(true) +s2: 120x35 diff --git a/ext/standard/tests/terminal/terminal_size.phpt b/ext/standard/tests/terminal/terminal_size.phpt new file mode 100644 index 000000000000..6b857c8a5d06 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_size.phpt @@ -0,0 +1,18 @@ +--TEST-- +Io\Terminal\SystemTerminal: getSize returns null for non-terminal streams without environment fallback +--ENV-- +COLUMNS=100 +LINES=30 +--FILE-- +getSize()); + +?> +--EXPECT-- +NULL