diff --git a/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt b/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt index c497855f7347..c0b95563215e 100644 --- a/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt +++ b/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt @@ -30,6 +30,11 @@ Io\Poll\InactiveWatcherException Io\Poll\InvalidHandleException Io\Poll\PollException Io\Poll\Watcher +Io\Terminal\Key +Io\Terminal\ModeToken +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..072d5be06907 --- /dev/null +++ b/ext/standard/io_terminal.c @@ -0,0 +1,4167 @@ +/* + +----------------------------------------------------------------------+ + | 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) +#endif + +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; + +#ifdef PHP_WIN32 +typedef struct php_io_terminal_identity { + php_io_terminal_native_stream stream; + bool is_console; + bool is_non_tty_stream; + zend_ulong stream_res_id; + php_stream *php_stream_ptr; +} 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; + bool is_non_tty_stream; + zend_ulong stream_res_id; + php_stream *php_stream_ptr; +} 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; + uint32_t refcount; + pid_t owner_pid; + bool is_non_tty; + bool is_restored; + 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; + pid_t owner_pid; + 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_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; +#endif + php_io_terminal_utf8_pending pending_utf8; + bool last_secret_ended_in_cr; + 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_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; + +#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 + +#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 + +#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 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 +} + +static void php_io_terminal_shared_mode_addref(php_io_terminal_shared_mode *shared) +{ + shared->refcount++; +} + +static void php_io_terminal_close_native_stream(php_io_terminal_native_stream stream); + +static void php_io_terminal_shared_mode_delref(php_io_terminal_shared_mode *shared) +{ + if (--shared->refcount == 0) { + 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 void php_io_terminal_remove_active_shared_mode(php_io_terminal_shared_mode *shared) +{ + if (shared->prev != NULL) { + shared->prev->next = shared->next; + } else if (php_io_terminal_active_shared_modes == shared) { + php_io_terminal_active_shared_modes = shared->next; + } + + if (shared->next != NULL) { + shared->next->prev = shared->prev; + } + + shared->prev = NULL; + shared->next = NULL; +} + +static php_io_terminal_native_stream php_io_terminal_native_stream_from_php_stream(php_stream *stream); + +#ifdef PHP_WIN32 +static bool php_io_terminal_get_identity(php_io_terminal_native_stream handle, php_io_terminal_identity *identity) +{ + memset(identity, 0, sizeof(*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) +{ + if (first->is_non_tty_stream && second->is_non_tty_stream) { + if (first->stream_res_id != 0 && second->stream_res_id != 0) { + return first->stream_res_id == second->stream_res_id; + } + return first->php_stream_ptr == second->php_stream_ptr; + } + return first->is_console && second->is_console && !first->is_non_tty_stream && !second->is_non_tty_stream; +} +#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->is_non_tty_stream && second->is_non_tty_stream) { + if (first->stream_res_id != 0 && second->stream_res_id != 0) { + return first->stream_res_id == second->stream_res_id; + } + return first->php_stream_ptr == second->php_stream_ptr; + } + + if (first->is_non_tty_stream || second->is_non_tty_stream) { + return false; + } + + 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; + pid_t current_pid = getpid(); + while (curr != NULL) { + if (curr->owner_pid == current_pid && php_io_terminal_identities_match(&curr->identity, identity)) { + return curr; + } + curr = curr->next; + } + return NULL; +} + +/* Fault-injection hooks are compiled only into debug builds (ZEND_DEBUG is + * always defined by php-src, as 0 or 1, so its value must be tested) or into + * private builds that explicitly define PHP_TERMINAL_TEST_BUILD. */ +#if ZEND_DEBUG || defined(PHP_TERMINAL_TEST_BUILD) +# define PHP_IO_TERMINAL_TEST_SEAM_ENABLED 1 +#else +# define PHP_IO_TERMINAL_TEST_SEAM_ENABLED 0 +#endif + +static inline const char *php_io_terminal_get_test_seam(void) +{ +#if PHP_IO_TERMINAL_TEST_SEAM_ENABLED + return getenv("PHP_TERMINAL_TEST_SEAM"); +#else + return NULL; +#endif +} + +static inline void php_io_terminal_clear_test_seam(void) +{ +#if PHP_IO_TERMINAL_TEST_SEAM_ENABLED +#ifndef PHP_WIN32 + unsetenv("PHP_TERMINAL_TEST_SEAM"); +#else + SetEnvironmentVariableA("PHP_TERMINAL_TEST_SEAM", NULL); +#endif +#endif +} + +static bool php_io_terminal_restore_shared_mode(const php_io_terminal_shared_mode *shared) +{ + if (shared->is_non_tty) { + return true; + } + if (!php_io_terminal_native_stream_is_valid(shared->restore_stream)) { + return false; + } + + const char *seam = php_io_terminal_get_test_seam(); + if (seam != NULL && strcmp(seam, "fail_restore_once") == 0) { + php_io_terminal_clear_test_seam(); + 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 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 (token_obj->owner_pid != getpid()) { + if (error_msg != NULL) { + *error_msg = "Cannot restore terminal mode from another process"; + } + return false; + } + + if (shared->is_non_tty) { + if (shared->lease_count > 1) { + shared->lease_count--; + } else { + shared->lease_count = 0; + shared->is_restored = true; + php_io_terminal_remove_active_shared_mode(shared); + php_io_terminal_shared_mode_delref(shared); + } + token_obj->valid = false; + token_obj->shared = NULL; + php_io_terminal_shared_mode_delref(shared); + return true; + } + + if (shared->lease_count > 1) { + shared->lease_count--; + token_obj->valid = false; + token_obj->shared = NULL; + php_io_terminal_shared_mode_delref(shared); + return true; + } + + /* lease_count == 1: attempt hardware/OS restore */ + if (!php_io_terminal_restore_shared_mode(shared)) { + if (error_msg != NULL) { + *error_msg = "Failed to restore terminal mode"; + } + return false; + } + + shared->lease_count = 0; + shared->is_restored = true; + php_io_terminal_remove_active_shared_mode(shared); + php_io_terminal_shared_mode_delref(shared); + + token_obj->valid = false; + token_obj->shared = NULL; + php_io_terminal_shared_mode_delref(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 (intern->owner_pid == getpid()) { + if (!php_io_terminal_release_token_lease(intern, NULL)) { + intern->valid = false; + php_io_terminal_shared_mode_delref(intern->shared); + intern->shared = NULL; + } + } else { + intern->valid = false; + php_io_terminal_shared_mode_delref(intern->shared); + 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; + intern->owner_pid = getpid(); + + 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_mode_token_ce); + intern = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZV(return_value); + php_io_terminal_shared_mode_addref(shared); + intern->shared = shared; + intern->valid = true; + intern->owner_pid = getpid(); +} + +static void php_io_terminal_free_obj(zend_object *object) +{ + php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZOBJ(object); + + 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->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; +#endif + memset(&intern->pending_utf8, 0, sizeof(intern->pending_utf8)); + intern->last_secret_ended_in_cr = false; + + 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; + + 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) { + return INVALID_HANDLE_VALUE; + } + + int opt = 0; + int optlen = sizeof(opt); + if (getsockopt((SOCKET) descriptor, SOL_SOCKET, SO_TYPE, (char *) &opt, &optlen) == 0) { + return (HANDLE)(intptr_t) descriptor; + } + + if ((uintptr_t) descriptor > INT_MAX) { + return INVALID_HANDLE_VALUE; + } + + intptr_t 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, + php_stream *stream, + const php_io_terminal_identity *identity, + bool is_console +) +{ + php_io_terminal_shared_mode *shared = php_io_terminal_find_shared_mode(identity); + if (shared != NULL) { + const char *seam = php_io_terminal_get_test_seam(); + if (seam != NULL && strcmp(seam, "fail_reapply") == 0) { + return NULL; + } + if (is_console) { + DWORD current_mode; + if (!GetConsoleMode(handle, ¤t_mode)) { + return NULL; + } + DWORD raw = php_io_terminal_make_raw_mode(current_mode); + if (current_mode != raw) { + if (!SetConsoleMode(handle, raw)) { + return NULL; + } + } + } + shared->lease_count++; + return shared; + } + + if (!is_console) { + shared = emalloc(sizeof(*shared)); + shared->identity = *identity; + shared->restore_stream = PHP_IO_TERMINAL_INVALID_NATIVE_STREAM; + shared->saved_mode = 0; + shared->lease_count = 1; + shared->refcount = 1; + shared->owner_pid = getpid(); + shared->is_non_tty = true; + shared->is_restored = false; + 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; + } + + 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); + const char *seam = php_io_terminal_get_test_seam(); + if ((seam != NULL && strcmp(seam, "fail_first_acquire") == 0) || !SetConsoleMode(handle, raw_mode)) { + php_io_terminal_close_native_stream(restore_stream); + return NULL; + } + + DWORD actual_mode; + if ((seam != NULL && strcmp(seam, "fail_postcondition") == 0) || + (GetConsoleMode(handle, &actual_mode) && (actual_mode & (ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT)) != 0)) { + /* Roll back to original saved mode before closing restore handle */ + if (!SetConsoleMode(handle, mode)) { + /* Rollback failed: preserve restoration state so cleanup or retry can restore it */ + shared = emalloc(sizeof(*shared)); + shared->identity = *identity; + shared->restore_stream = restore_stream; + shared->saved_mode = mode; + shared->lease_count = 0; + shared->refcount = 1; + shared->owner_pid = getpid(); + shared->is_non_tty = false; + shared->is_restored = false; + 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 NULL; + } + 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->refcount = 1; + shared->owner_pid = getpid(); + shared->is_non_tty = false; + shared->is_restored = false; + 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_INSERT: + return ZSTR_INIT_LITERAL("insert", 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) +{ + if (key->wVirtualKeyCode == VK_TAB && (key->dwControlKeyState & SHIFT_PRESSED) != 0) { + *high_surrogate = 0; + return ZSTR_INIT_LITERAL("shifttab", false); + } + + 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_console_key( + php_io_terminal_native_stream input, + 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 (!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_console_secret( + php_io_terminal_native_stream input, + INPUT_RECORD *pending_key, + WCHAR *pending_key_high_surrogate, + WCHAR *pending_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 || !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 = *pending_high_surrogate; + 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); + } + } + } + } + + *pending_high_surrogate = high_surrogate; + + 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 raw mode & size */ + +static void php_io_terminal_make_raw_mode(struct termios *mode) +{ + mode->c_iflag &= ~(IGNBRK | BRKINT | PARMRK | ISTRIP | INLCR | IGNCR | ICRNL | IXON); + /* Do NOT disable OPOST; preserve output processing to avoid staircase newlines */ + 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 bool php_io_terminal_termios_needs_raw_mode(const struct termios *current, const struct termios *desired) +{ + tcflag_t iflag_mask = IGNBRK | BRKINT | PARMRK | ISTRIP | INLCR | IGNCR | ICRNL | IXON; + tcflag_t lflag_mask = ECHO | ECHONL | ICANON | ISIG | IEXTEN; + tcflag_t cflag_mask = CSIZE | PARENB; + + if ((current->c_iflag & iflag_mask) != (desired->c_iflag & iflag_mask)) { + return true; + } + if ((current->c_lflag & lflag_mask) != (desired->c_lflag & lflag_mask)) { + return true; + } + if ((current->c_cflag & cflag_mask) != (desired->c_cflag & cflag_mask)) { + return true; + } + if (current->c_cc[VMIN] != desired->c_cc[VMIN]) { + return true; + } + if (current->c_cc[VTIME] != desired->c_cc[VTIME]) { + return true; + } + return false; +} + +static php_io_terminal_shared_mode *php_io_terminal_acquire_raw_mode( + php_io_terminal_native_stream fd, + php_stream *stream, + const php_io_terminal_identity *identity, + bool is_tty +) +{ + php_io_terminal_shared_mode *shared = php_io_terminal_find_shared_mode(identity); + if (shared != NULL) { + const char *seam = php_io_terminal_get_test_seam(); + if (seam != NULL && strcmp(seam, "fail_reapply") == 0) { + return NULL; + } + if (is_tty) { + struct termios current_mode; + if (tcgetattr(fd, ¤t_mode) != 0) { + return NULL; + } + struct termios raw = current_mode; + php_io_terminal_make_raw_mode(&raw); + if (php_io_terminal_termios_needs_raw_mode(¤t_mode, &raw)) { + if (tcsetattr(fd, TCSANOW, &raw) != 0) { + return NULL; + } + } + } + shared->lease_count++; + return shared; + } + + if (!is_tty) { + shared = emalloc(sizeof(*shared)); + shared->identity = *identity; + shared->restore_stream = PHP_IO_TERMINAL_INVALID_NATIVE_STREAM; + memset(&shared->saved_mode, 0, sizeof(shared->saved_mode)); + shared->lease_count = 1; + shared->refcount = 1; + shared->owner_pid = getpid(); + shared->is_non_tty = true; + shared->is_restored = false; + 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; + } + + 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); + + const char *seam = php_io_terminal_get_test_seam(); + if ((seam != NULL && strcmp(seam, "fail_first_acquire") == 0) || tcsetattr(fd, TCSANOW, &raw_mode) != 0) { + php_io_terminal_close_native_stream(restore_stream); + return NULL; + } + + struct termios actual_mode; + if ((seam != NULL && strcmp(seam, "fail_postcondition") == 0) || + (tcgetattr(fd, &actual_mode) == 0 && (actual_mode.c_lflag & (ECHO | ICANON)) != 0)) { + /* Postcondition failure: roll back to original saved mode before closing restore stream */ + if (tcsetattr(fd, TCSANOW, &mode) != 0) { + /* Rollback failed: preserve restoration state so cleanup or retry can restore it */ + shared = emalloc(sizeof(*shared)); + shared->identity = *identity; + shared->restore_stream = restore_stream; + shared->saved_mode = mode; + shared->lease_count = 0; + shared->refcount = 1; + shared->owner_pid = getpid(); + shared->is_non_tty = false; + shared->is_restored = false; + 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 NULL; + } + 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->refcount = 1; + shared->owner_pid = getpid(); + shared->is_non_tty = false; + shared->is_restored = false; + 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; +} +#endif + +/* Common byte-decoding utilities (used on POSIX and for non-console streams on Windows) */ + +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; +} + +#ifndef PHP_WIN32 +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) +{ + struct stat st; + if (fstat(fd, &st) == 0 && S_ISREG(st.st_mode)) { + return NULL; + } + + 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; +} +#endif + +static int php_io_terminal_read_byte( + php_io_terminal_native_stream fd, + php_stream *stream, + void *poll_ctx_ptr, + unsigned char *byte, + const void *timeout_ptr, + bool allow_resize, + const void *generation_ptr +) +{ + 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; + } + +#ifdef PHP_WIN32 + (void) poll_ctx_ptr; + (void) allow_resize; + (void) generation_ptr; + + HANDLE h = fd; + if ((h == INVALID_HANDLE_VALUE || h == NULL) && stream != NULL) { + h = php_io_terminal_native_stream_from_php_stream(stream); + } + + if (h != INVALID_HANDLE_VALUE && h != NULL) { + int opt = 0; + int optlen = sizeof(opt); + if (getsockopt((SOCKET) h, SOL_SOCKET, SO_TYPE, (char *) &opt, &optlen) == 0) { + if (timeout_ptr != NULL) { + const struct timespec *to = (const struct timespec *) timeout_ptr; + uint64_t ms = (uint64_t) to->tv_sec * 1000 + (to->tv_nsec + 999999) / 1000000; + DWORD wait_ms = ms >= (DWORD) PHP_IO_TERMINAL_MAX_WAIT_MS ? PHP_IO_TERMINAL_MAX_WAIT_MS : (DWORD) ms; + fd_set rset; + FD_ZERO(&rset); + FD_SET((SOCKET) h, &rset); + struct timeval tv; + tv.tv_sec = (long) (wait_ms / 1000); + tv.tv_usec = (long) ((wait_ms % 1000) * 1000); + int sel_res = select(0, &rset, NULL, NULL, &tv); + if (sel_res == 0) { + return PHP_IO_TERMINAL_READ_TIMEOUT; + } + if (sel_res < 0) { + return PHP_IO_TERMINAL_READ_ERROR; + } + } + if (stream != NULL) { + 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; + } + int recvd = recv((SOCKET) h, (char *) byte, 1, 0); + if (recvd == 1) { + return PHP_IO_TERMINAL_READ_SUCCESS; + } + return recvd == 0 ? PHP_IO_TERMINAL_READ_EOF : PHP_IO_TERMINAL_READ_ERROR; + } + } + + if (h != INVALID_HANDLE_VALUE && h != NULL && GetFileType(h) == FILE_TYPE_PIPE) { + DWORD bytes_avail = 0; + DWORD wait_ms = 0; + bool has_timeout = false; + if (timeout_ptr != NULL) { + const struct timespec *to = (const struct timespec *) timeout_ptr; + has_timeout = true; + uint64_t ms = (uint64_t) to->tv_sec * 1000 + (to->tv_nsec + 999999) / 1000000; + wait_ms = ms >= (DWORD) PHP_IO_TERMINAL_MAX_WAIT_MS ? PHP_IO_TERMINAL_MAX_WAIT_MS : (DWORD) ms; + } + + if (PeekNamedPipe(h, NULL, 0, NULL, &bytes_avail, NULL)) { + zend_hrtime_t start_time = zend_hrtime(); + while (true) { + if (bytes_avail > 0) { + break; + } + if (has_timeout) { + if (wait_ms == 0) { + return PHP_IO_TERMINAL_READ_TIMEOUT; + } + zend_hrtime_t elapsed_ms = (zend_hrtime() - start_time) / 1000000; + if (elapsed_ms >= wait_ms) { + return PHP_IO_TERMINAL_READ_TIMEOUT; + } + DWORD sleep_ms = (wait_ms - (DWORD) elapsed_ms > 5) ? 5 : (DWORD)(wait_ms - elapsed_ms); + Sleep(sleep_ms); + } else { + Sleep(10); + } + if (!PeekNamedPipe(h, NULL, 0, NULL, &bytes_avail, NULL)) { + DWORD err = GetLastError(); + if (err == ERROR_BROKEN_PIPE || err == ERROR_PIPE_NOT_CONNECTED) { + return PHP_IO_TERMINAL_READ_EOF; + } + return PHP_IO_TERMINAL_READ_ERROR; + } + } + } else { + DWORD err = GetLastError(); + if (err == ERROR_BROKEN_PIPE || err == ERROR_PIPE_NOT_CONNECTED) { + return PHP_IO_TERMINAL_READ_EOF; + } + if (err == ERROR_INVALID_FUNCTION) { + /* Socket descriptor on Windows returns FILE_TYPE_PIPE: use select() */ + fd_set rset; + FD_ZERO(&rset); + FD_SET((SOCKET) h, &rset); + struct timeval tv; + struct timeval *tv_ptr = NULL; + if (has_timeout) { + tv.tv_sec = (long) (wait_ms / 1000); + tv.tv_usec = (long) ((wait_ms % 1000) * 1000); + tv_ptr = &tv; + } + int sel_res = select(0, &rset, NULL, NULL, tv_ptr); + if (sel_res == 0) { + return PHP_IO_TERMINAL_READ_TIMEOUT; + } + if (sel_res < 0) { + return PHP_IO_TERMINAL_READ_ERROR; + } + } else { + return PHP_IO_TERMINAL_READ_ERROR; + } + } + } + + if (stream != NULL) { + size_t read_bytes = php_stream_read(stream, (char *) byte, 1); + if (read_bytes == 1) { + return PHP_IO_TERMINAL_READ_SUCCESS; + } + if (timeout_ptr != NULL && !php_stream_eof(stream)) { + return PHP_IO_TERMINAL_READ_TIMEOUT; + } + return php_stream_eof(stream) ? PHP_IO_TERMINAL_READ_EOF : PHP_IO_TERMINAL_READ_ERROR; + } + + if (fd != INVALID_HANDLE_VALUE && fd != NULL) { + DWORD bytes_read = 0; + if (ReadFile(fd, byte, 1, &bytes_read, NULL) && bytes_read == 1) { + return PHP_IO_TERMINAL_READ_SUCCESS; + } + if (timeout_ptr != NULL) { + return PHP_IO_TERMINAL_READ_TIMEOUT; + } + return PHP_IO_TERMINAL_READ_EOF; + } + + return PHP_IO_TERMINAL_READ_ERROR; +#else + php_poll_ctx *poll_ctx = (php_poll_ctx *) poll_ctx_ptr; + const struct timespec *timeout = (const struct timespec *) timeout_ptr; + const sig_atomic_t *generation = (const sig_atomic_t *) generation_ptr; + + if (poll_ctx == NULL) { + if (stream != NULL) { + int old_flags = stream->flags; + stream->flags |= PHP_STREAM_FLAG_SUPPRESS_ERRORS; + size_t read_bytes = php_stream_read(stream, (char *) byte, 1); + stream->flags = old_flags; + if (read_bytes == 1) { + return PHP_IO_TERMINAL_READ_SUCCESS; + } + if (php_stream_eof(stream)) { + return PHP_IO_TERMINAL_READ_EOF; + } + if (timeout != NULL) { + return PHP_IO_TERMINAL_READ_TIMEOUT; + } + return PHP_IO_TERMINAL_READ_EOF; + } + + if (fd >= 0) { + ssize_t bytes_read = read(fd, byte, 1); + if (bytes_read == 1) { + return PHP_IO_TERMINAL_READ_SUCCESS; + } + if (timeout != NULL) { + return PHP_IO_TERMINAL_READ_TIMEOUT; + } + return bytes_read == 0 ? PHP_IO_TERMINAL_READ_EOF : PHP_IO_TERMINAL_READ_ERROR; + } + + 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; + } + + if (stream != NULL) { + int old_flags = stream->flags; + stream->flags |= PHP_STREAM_FLAG_SUPPRESS_ERRORS; + size_t n = php_stream_read(stream, (char *) byte, 1); + stream->flags = old_flags; + if (n == 1) { + return PHP_IO_TERMINAL_READ_SUCCESS; + } + if (php_stream_eof(stream)) { + return PHP_IO_TERMINAL_READ_EOF; + } + 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; +#endif +} + +static zend_string *php_io_terminal_finish_utf8_sequence( + php_io_terminal_native_stream fd, + php_stream *stream, + void *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) { +#ifndef PHP_WIN32 + if (fd >= 0 && php_io_terminal_native_stream_is_tty(fd)) { + zend_throw_exception(php_io_terminal_exception_ce, "End of file reached on terminal input stream", 0); + } +#endif + 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( + php_io_terminal_native_stream fd, + php_stream *stream, + void *poll_ctx, + const void *sequence_timeout_ptr, + bool has_overall_deadline, + zend_hrtime_t overall_deadline_ns +) +{ + unsigned char seq[32]; + size_t seq_len = 0; + seq[seq_len++] = 0x1b; + + const struct timespec *sequence_timeout = (const struct timespec *) sequence_timeout_ptr; + 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 || read_result == PHP_IO_TERMINAL_READ_EOF) { + return ZSTR_INIT_LITERAL("escape", false); + } + 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); + 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) { + if (read_result == PHP_IO_TERMINAL_READ_EOF) { + break; + } + 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; + } + + unsigned char next = seq[seq_len++]; + 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); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[Z")) return ZSTR_INIT_LITERAL("shifttab", 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[2~")) return ZSTR_INIT_LITERAL("insert", 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); + } else { + read_result = PHP_IO_TERMINAL_READ_TIMEOUT; + } + 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_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( + php_io_terminal_native_stream fd, + php_stream *stream, + void *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( + php_io_terminal_native_stream fd, + php_stream *stream, + void *poll_ctx, + unsigned char key, + const void *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_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, "insert")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Insert"); + } + 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, "shifttab")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "ShiftTab"); + } + 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; +} + +#ifndef PHP_WIN32 +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_object *intern +) +{ + php_io_terminal_utf8_pending *pending = &intern->pending_utf8; + int fd = input; + bool is_tty = (fd >= 0 && isatty(fd) == 1); + struct termios mode; + struct termios raw_mode; + bool mode_changed = false; + php_poll_ctx *poll_ctx = NULL; + + if (is_tty) { + if (tcgetattr(fd, &mode) != 0) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to get terminal mode", 0); + return NULL; + } + raw_mode = mode; + php_io_terminal_make_raw_mode(&raw_mode); + if (php_io_terminal_termios_needs_raw_mode(&mode, &raw_mode)) { + if (tcsetattr(fd, TCSANOW, &raw_mode) == 0) { + mode_changed = true; + } else { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to set terminal raw mode", 0); + return NULL; + } + } + } + + if (fd >= 0) { + poll_ctx = php_io_terminal_create_poll_context(fd); + } + + sig_atomic_t resize_generation = 0; + bool resize_handler_installed = false; +#if defined(SIGWINCH) + if (is_tty) { + resize_handler_installed = php_io_terminal_install_resize_handler(&resize_generation); + } +#endif + + 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 { + while (1) { + 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); + break; + } else if (read_result == PHP_IO_TERMINAL_READ_SUCCESS) { + if (intern->last_secret_ended_in_cr) { + intern->last_secret_ended_in_cr = false; + if (key == '\n') { + continue; + } + } + result = php_io_terminal_key_from_byte( + fd, stream, poll_ctx, key, sequence_timeout, pending, + has_overall_deadline, overall_deadline_ns + ); + break; + } 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); + break; + } else if (read_result == PHP_IO_TERMINAL_READ_TIMEOUT) { + result = NULL; + break; + } 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); + break; + } + } + } + + 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); + } + if (poll_ctx != NULL) { + 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 + + if (poll_ctx != NULL) { + 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, + php_io_terminal_object *intern, + bool *timed_out +) +{ + *timed_out = false; + int fd = input; + bool is_tty = (fd >= 0 && isatty(fd) == 1); + struct termios mode; + struct termios raw_mode; + bool mode_changed = false; + php_poll_ctx *poll_ctx = NULL; + + if (is_tty) { + if (tcgetattr(fd, &mode) != 0) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to get terminal mode", 0); + return NULL; + } + raw_mode = mode; + php_io_terminal_make_raw_mode(&raw_mode); + if (php_io_terminal_termios_needs_raw_mode(&mode, &raw_mode)) { + if (tcsetattr(fd, TCSANOW, &raw_mode) != 0) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to set terminal raw mode", 0); + return NULL; + } + mode_changed = true; + } + } + + if (fd >= 0) { + poll_ctx = php_io_terminal_create_poll_context(fd); + } + + bool has_overall_deadline = (timeout != NULL); + zend_hrtime_t overall_deadline_ns = 0; + bool is_positive_timeout = false; + if (has_overall_deadline) { + overall_deadline_ns = php_io_terminal_saturating_add_deadline( + zend_hrtime(), + php_io_terminal_timespec_to_ns(timeout) + ); + is_positive_timeout = (timeout->tv_sec > 0 || timeout->tv_nsec > 0); + } + + smart_str secret = {0}; + bool success = false; + unsigned char key = 0; + + /* If pending UTF-8 bytes exist from previous readKey, process them first */ + if (intern != NULL && intern->pending_utf8.length > 0) { + unsigned char terminating_key = 0; + bool has_terminating_key = false; + + while (intern->pending_utf8.length < intern->pending_utf8.expected) { + unsigned char next_b; + struct timespec cur_ts; + const struct timespec *cur_ts_ptr = NULL; + if (has_overall_deadline) { + zend_hrtime_t now = zend_hrtime(); + if (now >= overall_deadline_ns) { + *timed_out = true; + goto restore; + } + php_io_terminal_ns_to_timespec(overall_deadline_ns - now, &cur_ts); + cur_ts_ptr = &cur_ts; + } + int r = php_io_terminal_read_byte(fd, stream, poll_ctx, &next_b, cur_ts_ptr, false, NULL); + if (r == PHP_IO_TERMINAL_READ_TIMEOUT) { + *timed_out = true; + goto restore; + } + if (r != PHP_IO_TERMINAL_READ_SUCCESS) { + *timed_out = false; + goto restore; + } + if ((next_b & 0xc0) == 0x80) { + intern->pending_utf8.bytes[intern->pending_utf8.length++] = next_b; + } else { + terminating_key = next_b; + has_terminating_key = true; + break; + } + } + + smart_str_appendl(&secret, (char *) intern->pending_utf8.bytes, intern->pending_utf8.length); + memset(&intern->pending_utf8, 0, sizeof(intern->pending_utf8)); + + if (has_terminating_key) { + key = terminating_key; + goto process_key; + } + } + + while (true) { + 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) { + if (is_positive_timeout) { + *timed_out = true; + goto restore; + } + 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: + if (intern != NULL && intern->last_secret_ended_in_cr) { + intern->last_secret_ended_in_cr = false; + if (key == '\n') { + continue; + } + } + + switch (key) { + case '\r': + case '\n': + if (key == '\r') { + /* CRLF is one terminator. Consume the LF now only if it is already + * visible in the PHP read buffer (no read, no unread). Otherwise defer: + * the next reader on this Terminal skips a single leading LF. A byte is + * never read ahead here, so nothing has to be pushed back. */ + if (stream != NULL && stream->writepos > stream->readpos && stream->readbuf != NULL && stream->readbuf[stream->readpos] == '\n') { + stream->readpos++; + } else if (intern != NULL) { + intern->last_secret_ended_in_cr = true; + } + } + 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) { + *timed_out = false; + goto restore; + } + + if (next == 0x03 || next == 0x04 || next == 0x1b || next == '\r' || next == '\n') { + *timed_out = false; + goto restore; + } + + if (next == '[') { + while (true) { + if (has_overall_deadline) { + zend_hrtime_t now = zend_hrtime(); + if (now >= overall_deadline_ns) { + if (is_positive_timeout) { + *timed_out = true; + goto restore; + } + 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) { + break; + } + *timed_out = false; + goto restore; + } + if (has_overall_deadline && is_positive_timeout && zend_hrtime() >= overall_deadline_ns) { + *timed_out = true; + goto restore; + } + if (csi_byte == 0x03 || csi_byte == 0x04 || csi_byte == 0x1b || csi_byte == '\r' || csi_byte == '\n') { + *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) { + if (is_positive_timeout) { + *timed_out = true; + goto restore; + } + 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) { + break; + } + *timed_out = false; + goto restore; + } + if (has_overall_deadline && is_positive_timeout && zend_hrtime() >= overall_deadline_ns) { + *timed_out = true; + goto restore; + } + if (ss3_byte == 0x03 || ss3_byte == 0x04 || ss3_byte == 0x1b || ss3_byte == '\r' || ss3_byte == '\n') { + *timed_out = false; + goto restore; + } + break; + } + + *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; + if (!EG(exception)) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to restore terminal mode", 0); + } + } + + if (poll_ctx != NULL) { + php_poll_destroy(poll_ctx); + } + + if (success) { + return smart_str_extract(&secret); + } + + smart_str_free(&secret); + return NULL; +} +#endif /* !PHP_WIN32 */ + +/* 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\ModeToken methods */ +PHP_METHOD(Io_Terminal_ModeToken, __construct) +{ + zend_throw_error(NULL, "Cannot directly construct Io\\Terminal\\ModeToken"); +} + +/* Io\Terminal\Terminal methods */ +PHP_METHOD(Io_Terminal_Terminal, __construct) +{ + zend_throw_error(NULL, "Cannot directly construct Io\\Terminal\\Terminal"); +} + +PHP_METHOD(Io_Terminal_Terminal, fromStdio) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + object_init_ex(return_value, php_io_terminal_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)) { + bool is_tty = php_io_terminal_native_stream_is_tty(target.native_stream); + if (is_tty) { + intern->has_identity = php_io_terminal_get_identity(target.native_stream, &intern->identity); + } else { + intern->identity.is_non_tty_stream = true; + intern->identity.php_stream_ptr = target.php_stream; + if (target.stream_resource != NULL && Z_TYPE_P(target.stream_resource) == IS_RESOURCE) { + intern->identity.stream_res_id = Z_RES_HANDLE_P(target.stream_resource); + } + intern->has_identity = true; + } + } +} + +PHP_METHOD(Io_Terminal_Terminal, 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_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); + } + + bool is_tty = php_io_terminal_native_stream_is_tty(target.native_stream); + if (is_tty) { + intern->has_identity = php_io_terminal_get_identity(target.native_stream, &intern->identity); + } else { + memset(&intern->identity, 0, sizeof(intern->identity)); + intern->identity.is_non_tty_stream = true; + intern->identity.php_stream_ptr = target.php_stream; + if (target.stream_resource != NULL && Z_TYPE_P(target.stream_resource) == IS_RESOURCE) { + intern->identity.stream_res_id = Z_RES_HANDLE_P(target.stream_resource); + } + intern->has_identity = true; + } +} + +PHP_METHOD(Io_Terminal_Terminal, 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_Terminal, 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(); + } + + bool is_tty = php_io_terminal_native_stream_is_tty(stream.native_stream); + php_io_terminal_identity identity; + memset(&identity, 0, sizeof(identity)); + + if (is_tty) { + 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(); + } + } else { + identity.is_non_tty_stream = true; + identity.php_stream_ptr = stream.php_stream; + if (stream.stream_resource != NULL && Z_TYPE_P(stream.stream_resource) == IS_RESOURCE) { + identity.stream_res_id = Z_RES_HANDLE_P(stream.stream_resource); + } + } + + intern->identity = identity; + intern->has_identity = true; + + php_io_terminal_shared_mode *shared = php_io_terminal_acquire_raw_mode( + stream.native_stream, + stream.php_stream, + &identity, + is_tty + ); + 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); +} + +PHP_METHOD(Io_Terminal_Terminal, restoreMode) +{ + zval *mode_token_zv; + + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_OBJECT_OF_CLASS(mode_token_zv, php_io_terminal_mode_token_ce) + ZEND_PARSE_PARAMETERS_END(); + + php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); + php_io_terminal_mode_token_object *mode = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZV(mode_token_zv); + + if (!mode->valid || mode->shared == NULL) { + zend_argument_value_error(1, "must be an active terminal mode token belonging to this terminal"); + RETURN_THROWS(); + } + + if (mode->owner_pid != getpid()) { + zend_argument_value_error(1, "must be an active terminal mode token belonging to this terminal"); + RETURN_THROWS(); + } + + bool matches = false; + 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)) { + bool is_tty = php_io_terminal_native_stream_is_tty(stream.native_stream); + if (is_tty && php_io_terminal_get_identity(stream.native_stream, ¤t_identity)) { + matches = php_io_terminal_identities_match(¤t_identity, &mode->shared->identity); + } else if (!is_tty) { + memset(¤t_identity, 0, sizeof(current_identity)); + current_identity.is_non_tty_stream = true; + current_identity.php_stream_ptr = stream.php_stream; + if (stream.stream_resource != NULL && Z_TYPE_P(stream.stream_resource) == IS_RESOURCE) { + current_identity.stream_res_id = Z_RES_HANDLE_P(stream.stream_resource); + } + 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(); + } +} + +PHP_METHOD(Io_Terminal_Terminal, 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; + } + } + + DWORD console_mode; + bool is_console = (stream.native_stream != INVALID_HANDLE_VALUE && stream.native_stream != NULL && GetConsoleMode(stream.native_stream, &console_mode) != 0); + zend_string *key = NULL; + + if (is_console) { + key = php_io_terminal_read_console_key( + stream.native_stream, + wait_ms, + &intern->pending_high_surrogate, + &intern->pending_key, + &intern->pending_key_high_surrogate + ); + } else { + /* Non-console stream on Windows: use byte decoding */ + struct timespec win_timeout; + struct timespec win_seq_timeout = {0, PHP_IO_TERMINAL_SEQUENCE_TIMEOUT_MS * 1000000L}; + const struct timespec *win_timeout_ptr = NULL; + bool win_is_non_blocking = false; + + if (timeout_duration != NULL) { + win_is_non_blocking = (timeout_duration->duration.seconds == 0 && timeout_duration->duration.nanoseconds == 0); + win_timeout.tv_sec = php_io_terminal_clamp_duration_seconds(timeout_duration->duration.seconds); + win_timeout.tv_nsec = timeout_duration->duration.nanoseconds; + win_timeout_ptr = &win_timeout; + } + + if (win_is_non_blocking) { + win_seq_timeout.tv_sec = 0; + win_seq_timeout.tv_nsec = 0; + } else if (seq_timeout_duration != NULL) { + win_seq_timeout.tv_sec = php_io_terminal_clamp_duration_seconds(seq_timeout_duration->duration.seconds); + win_seq_timeout.tv_nsec = seq_timeout_duration->duration.nanoseconds; + } + + bool has_overall_deadline = (win_timeout_ptr != 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(win_timeout_ptr) + ); + } + + if (intern->pending_utf8.length > 0) { + key = php_io_terminal_finish_utf8_sequence( + stream.native_stream, stream.php_stream, NULL, &intern->pending_utf8, + has_overall_deadline, overall_deadline_ns + ); + } else { + while (1) { + unsigned char byte; + int read_result = php_io_terminal_read_byte( + stream.native_stream, stream.php_stream, NULL, &byte, win_timeout_ptr, false, NULL + ); + if (read_result == PHP_IO_TERMINAL_READ_SUCCESS) { + if (intern->last_secret_ended_in_cr) { + intern->last_secret_ended_in_cr = false; + if (byte == '\n') { + continue; + } + } + key = php_io_terminal_key_from_byte( + stream.native_stream, stream.php_stream, NULL, byte, &win_seq_timeout, &intern->pending_utf8, + has_overall_deadline, overall_deadline_ns + ); + break; + } 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); + break; + } else if (read_result == PHP_IO_TERMINAL_READ_TIMEOUT) { + key = NULL; + break; + } 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); + break; + } + } + } + } +#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 + ); +#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; + } + bool remainder_is_lf = (line_len == 1 && buf[0] == '\n'); + 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'; + if (remainder_is_lf && intern != NULL && intern->pending_utf8.length > 0 && intern->pending_utf8.bytes[intern->pending_utf8.length - 1] == '\r') { + intern->pending_utf8.length--; + } + 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; + } + bool remainder_is_lf = (line_len == 1 && buf[0] == '\n'); + 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'; + if (remainder_is_lf && intern != NULL && intern->pending_utf8.length > 0 && intern->pending_utf8.bytes[intern->pending_utf8.length - 1] == '\r') { + intern->pending_utf8.length--; + } + 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; + } + 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) { + 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_Terminal, 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(); + } + + bool is_tty = php_io_terminal_native_stream_is_tty(stream.native_stream); + if (!intern->has_identity) { + if (is_tty) { + intern->has_identity = php_io_terminal_get_identity(stream.native_stream, &intern->identity); + } else { + memset(&intern->identity, 0, sizeof(intern->identity)); + intern->identity.is_non_tty_stream = true; + intern->identity.php_stream_ptr = stream.php_stream; + if (stream.stream_resource != NULL && Z_TYPE_P(stream.stream_resource) == IS_RESOURCE) { + intern->identity.stream_res_id = Z_RES_HANDLE_P(stream.stream_resource); + } + intern->has_identity = true; + } + } + + if (intern->has_identity && is_tty) { + php_io_terminal_shared_mode *shared = php_io_terminal_find_shared_mode(&intern->identity); + if (shared != NULL && !shared->is_non_tty && !shared->is_restored && shared->owner_pid == getpid()) { + 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 + if (intern->last_secret_ended_in_cr) { + intern->last_secret_ended_in_cr = false; + if (stream.php_stream != NULL) { + if (stream.php_stream->writepos > stream.php_stream->readpos && stream.php_stream->readbuf != NULL) { + if (stream.php_stream->readbuf[stream.php_stream->readpos] == '\n') { + stream.php_stream->readpos++; + } + } else { + int ch = php_stream_getc(stream.php_stream); + if (ch != '\n' && ch != EOF) { + intern->pending_utf8.bytes[intern->pending_utf8.length++] = (unsigned char) ch; + } + } + } + } + + 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) { + 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 (intern->pending_utf8.length > 0) { + size_t total_len = intern->pending_utf8.length + ZSTR_LEN(line); + zend_string *res = zend_string_alloc(total_len, 0); + memcpy(ZSTR_VAL(res), intern->pending_utf8.bytes, intern->pending_utf8.length); + memcpy(ZSTR_VAL(res) + intern->pending_utf8.length, ZSTR_VAL(line), ZSTR_LEN(line)); + ZSTR_VAL(res)[total_len] = '\0'; + intern->pending_utf8.length = 0; + intern->pending_utf8.expected = 0; + zend_string_release(line); + RETURN_STR(res); + } + RETURN_STR(line); +#else + if (intern->last_secret_ended_in_cr) { + intern->last_secret_ended_in_cr = false; + if (stream.php_stream != NULL) { + if (stream.php_stream->writepos > stream.php_stream->readpos && stream.php_stream->readbuf != NULL) { + if (stream.php_stream->readbuf[stream.php_stream->readpos] == '\n') { + stream.php_stream->readpos++; + } + } else { + int ch = php_stream_getc(stream.php_stream); + if (ch != '\n' && ch != EOF) { + intern->pending_utf8.bytes[intern->pending_utf8.length++] = (unsigned char) ch; + } + } + } + } + + 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 */ + bool remainder_is_lf = (out_len == 1 && buf[0] == '\n'); + 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'; + + if (remainder_is_lf && intern->pending_utf8.length > 0 && intern->pending_utf8.bytes[intern->pending_utf8.length - 1] == '\r') { + intern->pending_utf8.length--; + } + + 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_Terminal, 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); + + 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; + } + } + + DWORD console_mode; + bool is_console = (stream.native_stream != INVALID_HANDLE_VALUE && stream.native_stream != NULL && GetConsoleMode(stream.native_stream, &console_mode) != 0); + zend_string *secret = NULL; + + if (is_console) { + secret = php_io_terminal_read_console_secret( + stream.native_stream, + &intern->pending_key, + &intern->pending_key_high_surrogate, + &intern->pending_high_surrogate, + wait_ms, + has_timeout, + &timed_out + ); + } else { + /* Non-console stream on Windows */ + smart_str sec_str = {0}; + bool success = false; + unsigned char byte = 0; + struct timespec win_sec_timeout; + const struct timespec *win_sec_timeout_ptr = NULL; + bool win_has_deadline = false; + zend_hrtime_t win_deadline_ns = 0; + bool win_is_positive_timeout = false; + + if (timeout_duration != NULL) { + win_sec_timeout.tv_sec = php_io_terminal_clamp_duration_seconds(timeout_duration->duration.seconds); + win_sec_timeout.tv_nsec = timeout_duration->duration.nanoseconds; + win_sec_timeout_ptr = &win_sec_timeout; + win_has_deadline = true; + win_deadline_ns = php_io_terminal_saturating_add_deadline( + zend_hrtime(), + php_io_terminal_timespec_to_ns(&win_sec_timeout) + ); + win_is_positive_timeout = (win_sec_timeout.tv_sec > 0 || win_sec_timeout.tv_nsec > 0); + } + + /* If pending UTF-8 bytes exist from previous readKey, process them first */ + if (intern->pending_utf8.length > 0) { + unsigned char terminating_key = 0; + bool has_terminating_key = false; + + while (intern->pending_utf8.length < intern->pending_utf8.expected) { + unsigned char next_b; + struct timespec cur_ts; + const struct timespec *cur_ts_ptr = NULL; + if (win_has_deadline) { + zend_hrtime_t now = zend_hrtime(); + if (now >= win_deadline_ns) { + timed_out = true; + goto win_secret_done; + } + php_io_terminal_ns_to_timespec(win_deadline_ns - now, &cur_ts); + cur_ts_ptr = &cur_ts; + } + int r = php_io_terminal_read_byte(stream.native_stream, stream.php_stream, NULL, &next_b, cur_ts_ptr, false, NULL); + if (r == PHP_IO_TERMINAL_READ_TIMEOUT) { + timed_out = true; + goto win_secret_done; + } + if (r != PHP_IO_TERMINAL_READ_SUCCESS) { + timed_out = false; + goto win_secret_done; + } + if ((next_b & 0xc0) == 0x80) { + intern->pending_utf8.bytes[intern->pending_utf8.length++] = next_b; + } else { + terminating_key = next_b; + has_terminating_key = true; + break; + } + } + + smart_str_appendl(&sec_str, (char *) intern->pending_utf8.bytes, intern->pending_utf8.length); + memset(&intern->pending_utf8, 0, sizeof(intern->pending_utf8)); + + if (has_terminating_key) { + byte = terminating_key; + goto win_process_key; + } + } + + while (true) { + struct timespec cur_to; + const struct timespec *cur_to_ptr = NULL; + if (win_has_deadline) { + zend_hrtime_t now = zend_hrtime(); + if (now >= win_deadline_ns) { + if (win_is_positive_timeout) { + timed_out = true; + break; + } + cur_to.tv_sec = 0; + cur_to.tv_nsec = 0; + } else { + php_io_terminal_ns_to_timespec(win_deadline_ns - now, &cur_to); + } + cur_to_ptr = &cur_to; + } + int read_result = php_io_terminal_read_byte( + stream.native_stream, stream.php_stream, NULL, &byte, cur_to_ptr, false, NULL + ); + if (read_result == PHP_IO_TERMINAL_READ_TIMEOUT) { + timed_out = true; + break; + } + if (read_result != PHP_IO_TERMINAL_READ_SUCCESS) { + timed_out = false; + break; + } + +win_process_key: + if (intern->last_secret_ended_in_cr) { + intern->last_secret_ended_in_cr = false; + if (byte == '\n') { + continue; + } + } + + if (byte == '\r' || byte == '\n') { + if (byte == '\r') { + if (stream.php_stream != NULL && stream.php_stream->writepos > stream.php_stream->readpos && stream.php_stream->readbuf != NULL) { + if (stream.php_stream->readbuf[stream.php_stream->readpos] == '\n') { + stream.php_stream->readpos++; + } + } else { + intern->last_secret_ended_in_cr = true; + } + } + success = true; + break; + } + if (byte == 0x7f || byte == 0x08) { + php_io_terminal_buffer_remove_last_utf8_char(&sec_str); + continue; + } + if (byte == 0x03 || byte == 0x04) { + timed_out = false; + break; + } + if (byte == 0x1b) { + struct timespec esc_to; + if (win_has_deadline) { + zend_hrtime_t now = zend_hrtime(); + if (now >= win_deadline_ns) { + esc_to.tv_sec = 0; + esc_to.tv_nsec = 0; + } else { + zend_hrtime_t rem_ns = win_deadline_ns - now; + zend_hrtime_t step_ns = rem_ns < 25000000ULL ? rem_ns : 25000000ULL; + php_io_terminal_ns_to_timespec(step_ns, &esc_to); + } + } else { + esc_to.tv_sec = 0; + esc_to.tv_nsec = 25000000L; + } + + unsigned char next_b; + int rnext = php_io_terminal_read_byte(stream.native_stream, stream.php_stream, NULL, &next_b, &esc_to, false, NULL); + if (rnext != PHP_IO_TERMINAL_READ_SUCCESS) { + /* Standalone Escape cancels */ + timed_out = false; + goto win_secret_done; + } + if (next_b == 0x03 || next_b == 0x04 || next_b == 0x1b || next_b == '\r' || next_b == '\n') { + timed_out = false; + goto win_secret_done; + } + if (next_b == '[') { + while (true) { + if (win_has_deadline) { + zend_hrtime_t now = zend_hrtime(); + if (now >= win_deadline_ns) { + if (win_is_positive_timeout) { + timed_out = true; + goto win_secret_done; + } + esc_to.tv_sec = 0; + esc_to.tv_nsec = 0; + } else { + zend_hrtime_t rem_ns = win_deadline_ns - now; + zend_hrtime_t step_ns = rem_ns < 25000000ULL ? rem_ns : 25000000ULL; + php_io_terminal_ns_to_timespec(step_ns, &esc_to); + } + } else { + esc_to.tv_sec = 0; + esc_to.tv_nsec = 25000000L; + } + + unsigned char csi_ch; + int csi_r = php_io_terminal_read_byte(stream.native_stream, stream.php_stream, NULL, &csi_ch, &esc_to, false, NULL); + if (csi_r != PHP_IO_TERMINAL_READ_SUCCESS) { + if (win_has_deadline && zend_hrtime() >= win_deadline_ns) { + timed_out = true; + goto win_secret_done; + } + break; + } + if (win_has_deadline && win_is_positive_timeout && zend_hrtime() >= win_deadline_ns) { + timed_out = true; + goto win_secret_done; + } + if (csi_ch == 0x03 || csi_ch == 0x04 || csi_ch == 0x1b || csi_ch == '\r' || csi_ch == '\n') { + timed_out = false; + goto win_secret_done; + } + if (csi_ch >= 0x40 && csi_ch <= 0x7e) break; + } + continue; + } + if (next_b == 'O') { + if (win_has_deadline) { + zend_hrtime_t now = zend_hrtime(); + if (now >= win_deadline_ns) { + if (win_is_positive_timeout) { + timed_out = true; + goto win_secret_done; + } + esc_to.tv_sec = 0; + esc_to.tv_nsec = 0; + } else { + zend_hrtime_t rem_ns = win_deadline_ns - now; + zend_hrtime_t step_ns = rem_ns < 25000000ULL ? rem_ns : 25000000ULL; + php_io_terminal_ns_to_timespec(step_ns, &esc_to); + } + } else { + esc_to.tv_sec = 0; + esc_to.tv_nsec = 25000000L; + } + + unsigned char ss3_ch; + int ss3_r = php_io_terminal_read_byte(stream.native_stream, stream.php_stream, NULL, &ss3_ch, &esc_to, false, NULL); + if (ss3_r != PHP_IO_TERMINAL_READ_SUCCESS) { + if (win_has_deadline && zend_hrtime() >= win_deadline_ns) { + timed_out = true; + goto win_secret_done; + } + continue; + } + if (win_has_deadline && win_is_positive_timeout && zend_hrtime() >= win_deadline_ns) { + timed_out = true; + goto win_secret_done; + } + if (ss3_ch == 0x03 || ss3_ch == 0x04 || ss3_ch == 0x1b || ss3_ch == '\r' || ss3_ch == '\n') { + timed_out = false; + goto win_secret_done; + } + continue; + } + continue; + } + smart_str_appendc(&sec_str, (char) byte); + } + +win_secret_done: + if (success) { + secret = smart_str_extract(&sec_str); + } else { + smart_str_free(&sec_str); + } + } +#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, intern, &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_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_terminal_ce = register_class_Io_Terminal_Terminal(); + php_io_terminal_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_remove_active_shared_mode(shared); + if (shared->owner_pid == getpid() && !shared->is_non_tty && !shared->is_restored) { + php_io_terminal_restore_shared_mode(shared); + shared->is_restored = true; + } + php_io_terminal_shared_mode_delref(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..ca405fde4140 --- /dev/null +++ b/ext/standard/io_terminal.stub.php @@ -0,0 +1,101 @@ + +--FILE-- +readKey(Duration::fromSeconds(0)); +var_dump($k1 === null); + +// Terminal 2 now attempts to read the stream without $term1's decoder state +fwrite($fp, "\x82\xAC"); +fseek($fp, 1); + +// $term2 sees continuation bytes without the initial prefix +$k2 = $term2->readKey(Duration::fromSeconds(0)); +// $term2 cannot assemble the € symbol because it does not share $term1's pending buffer +var_dump($k2 !== "€"); + +// $term1, given its continuation, assembles the full sequence +fseek($fp, 1); +$k1_res = $term1->readKey(Duration::fromSeconds(0)); +var_dump($k1_res === "€"); + +fclose($fp); + +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) diff --git a/ext/standard/tests/terminal/terminal_controlling_tty.phpt b/ext/standard/tests/terminal/terminal_controlling_tty.phpt new file mode 100644 index 000000000000..cb0445b1f5df --- /dev/null +++ b/ext/standard/tests/terminal/terminal_controlling_tty.phpt @@ -0,0 +1,58 @@ +--TEST-- +Io\Terminal\Terminal: /dev/tty controlling terminal identity +--SKIPIF-- + +--FILE-- +enableRawMode(); +var_dump($mTty instanceof ModeToken); + +// Descriptors to the same controlling terminal share identity +$tTty2->restoreMode($mTty); +echo "restored via tTty2 +"; + +// Mode was already restored; reusing the token throws ValueError +try { + $tTty1->restoreMode($mTty); + echo "FAIL: consumed token was accepted +"; +} catch (ValueError $e) { + echo "Caught: ", $e->getMessage(), " +"; +} + +fclose($ttyFp2); +fclose($ttyFp1); +?> +--EXPECT-- +bool(true) +restored via tTty2 +Caught: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal diff --git a/ext/standard/tests/terminal/terminal_create.phpt b/ext/standard/tests/terminal/terminal_create.phpt new file mode 100644 index 000000000000..9ddf03b35ad9 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_create.phpt @@ -0,0 +1,113 @@ +--TEST-- +Io\Terminal\Terminal: fromStdio, fromStreams, construction and serialization restrictions +--FILE-- +getMessage(), PHP_EOL; +} + +try { + Terminal::fromStreams($fp, 123); +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +// Constructor privacy for Terminal and ModeToken +foreach ([Terminal::class, ModeToken::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 Terminal +try { + serialize($t1); +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +?> +--EXPECT-- +object(Io\Terminal\Terminal)#1 (0) { +} +object(Io\Terminal\Terminal)#2 (0) { +} +object(Io\Terminal\Terminal)#3 (0) { +} +TypeError: Io\Terminal\Terminal::fromStreams(): Argument #1 ($input) must be of type resource, string given +TypeError: Io\Terminal\Terminal::fromStreams(): Argument #2 ($output) must be of type resource or null, int given +Error: Call to private Io\Terminal\Terminal::__construct() from global scope +Error: Call to private Io\Terminal\ModeToken::__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\Terminal +Error: Trying to clone an uncloneable object of class Io\Terminal\Terminal +Exception: Serialization of 'Io\Terminal\Terminal' 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..205b3fea6161 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_identity.phpt @@ -0,0 +1,137 @@ +--TEST-- +Io\Terminal\Terminal: 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 = Terminal::fromStreams($pipes1[0]); +$t1_dup = Terminal::fromStreams($pipes1[1]); +$t2 = Terminal::fromStreams($pipes2[0]); + +$m1_dup = $t1->enableRawMode(); +var_dump($m1_dup instanceof ModeToken); +$t1_dup->restoreMode($m1_dup); +echo "restored via t1_dup +"; + +$m1 = $t1->enableRawMode(); +try { + $t2->restoreMode($m1); + echo "FAIL: unrelated PTY accepted token +"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +$t1->restoreMode($m1); +echo "restored via t1 +"; + +try { + $t1->restoreMode($m1); + echo "FAIL: consumed token accepted +"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +// 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 +"); +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 = Terminal::fromStreams($pipes3[0]); +try { + $t3->restoreMode($m_reuse); + echo "FAIL: reused fd accepted token +"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +fwrite($pipes2[0], "exit +"); +fwrite($pipes3[0], "exit +"); +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) +restored via t1_dup +ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +restored via t1 +ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +ValueError: Io\Terminal\Terminal::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..9097dddbbdc8 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_interface_mock.phpt @@ -0,0 +1,71 @@ +--TEST-- +Io\Terminal\Terminal: final class boundary, dependency injection, and testing with streams +--FILE-- +isFinal()); +var_dump($rcTerm->isInterface()); + +$rcToken = new ReflectionClass(ModeToken::class); +var_dump($rcToken->isFinal()); +var_dump($rcToken->isInterface()); + +// Consumer accepts Terminal directly via dependency injection +function readUserCommand(Terminal $term): string +{ + $token = $term->enableRawMode(); + try { + $key = $term->readKey(Duration::fromSeconds(1)); + if ($key instanceof Key) { + return "key:" . $key->name; + } + return "char:" . ($key ?? "none"); + } finally { + $term->restoreMode($token); + } +} + +// Testing with php://memory stream in pure PHP (Larry & Tim testing approach) +$in = fopen('php://memory', 'w+'); +fwrite($in, ""); // Key::Up +rewind($in); + +$term = Terminal::fromStreams($in); +echo readUserCommand($term), PHP_EOL; + +// Non-tty raw mode token is valid ModeToken and restores cleanly +$memStream = fopen('php://memory', 'w+'); +$memTerm = Terminal::fromStreams($memStream); +$token = $memTerm->enableRawMode(); +var_dump($token instanceof ModeToken); +$memTerm->restoreMode($token); +echo "restored successfully +"; + +// Reusing consumed token throws ValueError +try { + $memTerm->restoreMode($token); + echo "FAIL: accepted consumed token +"; +} catch (ValueError $e) { + echo "Caught: ", $e->getMessage(), PHP_EOL; +} + +?> +--EXPECT-- +bool(true) +bool(false) +bool(true) +bool(false) +key:Up +bool(true) +restored successfully +Caught: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal diff --git a/ext/standard/tests/terminal/terminal_key_line_handoff.phpt b/ext/standard/tests/terminal/terminal_key_line_handoff.phpt new file mode 100644 index 000000000000..57f5be3142b7 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_key_line_handoff.phpt @@ -0,0 +1,47 @@ +--TEST-- +Io\Terminal\Terminal: pending UTF-8 handoff from readKey to readLine +--SKIPIF-- + +--FILE-- +readKey(Duration::fromSeconds(0)); + fwrite($fp, $continuation); + fseek($fp, 1); + return [$term, $fp]; +} + +// 1. Complete UTF-8 sequence: \xE2 + \x82\xACline\n => €line +[$t1, $fp1] = stageLineHandoff("\x82\xACline\n"); +$line1 = $t1->readLine(); +var_dump($line1 === "€line"); +fclose($fp1); + +// 2. Incomplete UTF-8 at EOF: \xE2 at EOF returns pending byte +[$t2, $fp2] = stageLineHandoff(""); +$line2 = $t2->readLine(); +var_dump($line2 === "\xE2"); +fclose($fp2); + +// 3. Malformed UTF-8: \xE2 followed by non-continuation ASCII +[$t3, $fp3] = stageLineHandoff("Xline\n"); +$line3 = $t3->readLine(); +var_dump($line3 === "\xE2Xline"); +fclose($fp3); + +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) diff --git a/ext/standard/tests/terminal/terminal_key_secret_handoff.phpt b/ext/standard/tests/terminal/terminal_key_secret_handoff.phpt new file mode 100644 index 000000000000..4703430b38a3 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_key_secret_handoff.phpt @@ -0,0 +1,86 @@ +--TEST-- +Io\Terminal\Terminal: pending UTF-8 handoff from readKey to readSecret +--SKIPIF-- + +--FILE-- +readKey(Duration::fromSeconds(0)); + // Verify \xE2 timed out and is retained + if ($k !== null) { + echo "Unexpected key: " . bin2hex($k) . "\n"; + } + fwrite($fp, $continuation); + fseek($fp, 1); + return $term; +} + +// 1. Valid continuation: \xE2 + \x82\xAC + pass\n => €pass +$t1 = stageHandoff("\x82\xACpass\n"); +$s1 = $t1->readSecret(); +var_dump($s1 === "€pass"); + +// 2. Non-continuation Ctrl+C: must trigger cancellation, NOT swallow \x03 into secret +$t2 = stageHandoff("\x03\n"); +try { + $s2 = $t2->readSecret(); + echo "FAIL: Ctrl+C swallowed into secret: " . bin2hex($s2) . "\n"; +} catch (TerminalException $e) { + echo "PASS: Ctrl+C cancelled after incomplete UTF-8\n"; +} + +// 3. Non-continuation Ctrl+D: must trigger cancellation, NOT swallow \x04 into secret +$t3 = stageHandoff("\x04\n"); +try { + $s3 = $t3->readSecret(); + echo "FAIL: Ctrl+D swallowed into secret: " . bin2hex($s3) . "\n"; +} catch (TerminalException $e) { + echo "PASS: Ctrl+D cancelled after incomplete UTF-8\n"; +} + +// 4. Non-continuation Escape: must trigger cancellation, NOT swallow \x1B into secret +$t4 = stageHandoff("\x1b\n"); +try { + $s4 = $t4->readSecret(); + echo "FAIL: Escape swallowed into secret: " . bin2hex($s4) . "\n"; +} catch (TerminalException $e) { + echo "PASS: Escape cancelled after incomplete UTF-8\n"; +} + +// 5. Non-continuation Enter: must terminate secret, NOT swallow newline into secret text +$t5 = stageHandoff("\nsecond\n"); +try { + $s5 = $t5->readSecret(); + // The secret must be terminated by the first newline + var_dump($s5 !== null && strpos($s5, "\n") === false); +} catch (TerminalException $e) { + // Or throw if invalid UTF-8 at terminator + echo "PASS: Enter terminated/threw on incomplete UTF-8\n"; +} + +// 6. Non-continuation Backspace: must not appear in secret content +$t6 = stageHandoff("\x08pass\n"); +$s6 = $t6->readSecret(); +var_dump(strpos($s6, "\x08") === false); + +?> +--EXPECTF-- +bool(true) +PASS: Ctrl+C cancelled after incomplete UTF-8 +PASS: Ctrl+D cancelled after incomplete UTF-8 +PASS: Escape cancelled after incomplete UTF-8 +bool(true) +bool(true) 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..d8d6fe43986e --- /dev/null +++ b/ext/standard/tests/terminal/terminal_overlapping_raw_mode.phpt @@ -0,0 +1,197 @@ +--TEST-- +Io\Terminal\Terminal: 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, +); + +// Helper to check actual OS termios raw mode (ICANON/ECHO disabled) +function isPtyRaw($stream): bool { + $sub = proc_open(['stty', '-a'], [0 => $stream, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (!is_resource($sub)) return false; + $out = stream_get_contents($pipes[1]); + fclose($pipes[1]); fclose($pipes[2]); proc_close($sub); + return strpos($out, "-icanon") !== false && strpos($out, "-echo") !== false; +} + +// Two separate Terminal instances referencing descriptors on the same PTY +$t1 = Terminal::fromStreams($pipes[0]); +$t2 = Terminal::fromStreams($pipes[1]); +$tUnrelated = Terminal::fromStreams($pipes2[0]); + +// Test 1: Same Terminal + token +$m = $t1->enableRawMode(); +var_dump($m instanceof ModeToken); +var_dump(isPtyRaw($pipes[0])); +$t1->restoreMode($m); +var_dump(!isPtyRaw($pipes[0])); +echo "restored m\n"; + +// Test 2: Different Terminal on same device +$m1 = $t1->enableRawMode(); +$t2->restoreMode($m1); +echo "restored m1 via t2 +"; + +// Test 3: Unrelated terminal rejection (ValueError) +$m1 = $t1->enableRawMode(); +try { + $tUnrelated->restoreMode($m1); + echo "FAIL: unrelated terminal accepted token +"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} +$t1->restoreMode($m1); +echo "restored m1 via t1 +"; + +// Test 4: Token survives Terminal destruction when held externally +$tTemp = Terminal::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 +$t1->restoreMode($mTemp); +echo "restored mTemp via t1 +"; + +// After restoration, the token is consumed; reusing it throws ValueError +try { + $t1->restoreMode($mTemp); + echo "FAIL: stale token after restoration accepted +"; +} 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); +var_dump(isPtyRaw($pipes[0])); + +// Out-of-order: restore older token m1 first +$t1->restoreMode($m1); +// Intermediate lease release: OS mode must remain raw +var_dump(isPtyRaw($pipes[0])); +echo "restored m1 out-of-order\n"; + +// Now restore m2 (final lease) +$t2->restoreMode($m2); +// Final lease release: OS mode must be restored to canonical +var_dump(!isPtyRaw($pipes[0])); +echo "restored m2 out-of-order\n"; + +// Test 6: In-order restore (m2 then m1) +$m1 = $t1->enableRawMode(); +$m2 = $t2->enableRawMode(); + +$t2->restoreMode($m2); +echo "restored m2 in-order\n"; +$t1->restoreMode($m1); +echo "restored m1 in-order\n"; + +// Test 7: Destroying the final externally-held token restores canonical mode +$tLive = Terminal::fromStreams($pipes[0]); +$mHeld = $tLive->enableRawMode(); +var_dump(isPtyRaw($pipes[0])); +unset($tLive); // Terminal dropped; $mHeld keeps raw mode active +var_dump(isPtyRaw($pipes[0])); +unset($mHeld); // Final token destroyed; ModeToken destructor restores canonical mode +var_dump(!isPtyRaw($pipes[0])); + +// Reusing consumed m1 throws ValueError +try { + $t1->restoreMode($m1); +} catch (ValueError $e) { + echo "Reusing consumed token caught: ", $e->getMessage(), "\n"; +} + +fwrite($pipes[0], "exit\n"); +fwrite($pipes2[0], "exit\n"); +unset($t1, $t2, $tUnrelated); +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) +restored m +restored m1 via t2 +ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +restored m1 via t1 +restored mTemp via t1 +ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +bool(true) +bool(true) +bool(true) +bool(true) +restored m1 out-of-order +bool(true) +restored m2 out-of-order +restored m2 in-order +restored m1 in-order +bool(true) +bool(true) +bool(true) +Reusing consumed token caught: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal 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..dbfbc85befc7 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_raw_mode.phpt @@ -0,0 +1,144 @@ +--TEST-- +Io\Terminal\Terminal: 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 = Terminal::fromStreams($pipes[0]); + +// Explicit token restore on valid terminal +$token = $terminal->enableRawMode(); +echo "enableRawMode: ", ($token instanceof ModeToken ? "ModeToken" : "false"), " +"; + +// Restore explicitly with token +$terminal->restoreMode($token); +echo "restored token +"; + +// Stale consumed token must throw ValueError +try { + $terminal->restoreMode($token); + echo "FAIL: stale token was accepted +"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +// Token survives Terminal object destruction when held externally +$terminal3 = Terminal::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 +$terminal->restoreMode($token3); +echo "restored token3 via terminal +"; + +// Once restored, reusing the token throws ValueError +try { + $terminal->restoreMode($token3); + echo "FAIL: consumed token was accepted +"; +} 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 = Terminal::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 +$terminalClosed->restoreMode($tokenClosed); +echo "restored tokenClosed via owned descriptor +"; + +// Token is now consumed; reusing it throws ValueError +try { + $terminalClosed->restoreMode($tokenClosed); + echo "FAIL: consumed token was accepted +"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +proc_terminate($proc2); +foreach ($pipes2 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc2); + +fwrite($pipes[0], "exit +"); +unset($terminal, $terminalClosed); +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--EXPECT-- +enableRawMode: ModeToken +restored token +ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +bool(true) +restored token3 via terminal +ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +bool(true) +restored tokenClosed via owned descriptor +ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal diff --git a/ext/standard/tests/terminal/terminal_raw_mode_failure_injection.phpt b/ext/standard/tests/terminal/terminal_raw_mode_failure_injection.phpt new file mode 100644 index 000000000000..5f1d8ffd52a7 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_raw_mode_failure_injection.phpt @@ -0,0 +1,126 @@ +--TEST-- +Io\Terminal\Terminal: raw mode acquisition and restoration failure rollback and retry +--SKIPIF-- + ['pty'], 1 => ['pty'], 2 => ['pipe', 'w']], + $pipes + ); +} catch (Throwable) { + die("skip PTY not available"); +} +if (!is_resource($proc)) { + die("skip PTY not available"); +} +foreach ($pipes as $p) { if (is_resource($p)) fclose($p); } +proc_close($proc); +?> +--FILE-- + $stream, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (!is_resource($sub)) return false; + $out = stream_get_contents($pipes[1]); + fclose($pipes[1]); fclose($pipes[2]); proc_close($sub); + return strpos($out, "-icanon") !== false && strpos($out, "-echo") !== false; +} + +$proc = proc_open( + [PHP_BINARY, '-r', 'fgets(STDIN);'], + [0 => ['pty'], 1 => ['pty'], 2 => ['pipe', 'w']], + $pipes +); +$term = Terminal::fromStreams($pipes[0]); + +// Test 1: Reapplication failure must not leak lease count +$m1 = $term->enableRawMode(); +var_dump(isPtyRaw($pipes[0])); + +putenv("PHP_TERMINAL_TEST_SEAM=fail_reapply"); +try { + $m2 = $term->enableRawMode(); + echo "FAIL: reapplication succeeded despite failure seam\n"; +} catch (Throwable $e) { + echo "PASS: reapplication rejected: " . $e->getMessage() . "\n"; +} +putenv("PHP_TERMINAL_TEST_SEAM"); + +// Because reapplication failed, lease count was NOT incremented. +// Restoring $m1 must therefore restore canonical mode immediately! +$term->restoreMode($m1); +var_dump(!isPtyRaw($pipes[0])); + +// Test 2: Failed explicit restoration leaves retryable token +$mRetry = $term->enableRawMode(); +var_dump(isPtyRaw($pipes[0])); + +putenv("PHP_TERMINAL_TEST_SEAM=fail_restore_once"); +try { + $term->restoreMode($mRetry); + echo "FAIL: restore succeeded despite failure seam\n"; +} catch (TerminalException $e) { + echo "PASS: restore threw: " . $e->getMessage() . "\n"; +} +putenv("PHP_TERMINAL_TEST_SEAM"); + +// Token must still be active and terminal still in raw mode +var_dump(isPtyRaw($pipes[0])); + +// Retry restoration succeeds and restores mode +$term->restoreMode($mRetry); +var_dump(!isPtyRaw($pipes[0])); + +// Third restore attempt fails because token was consumed +try { + $term->restoreMode($mRetry); + echo "FAIL: consumed token accepted on retry\n"; +} catch (ValueError $e) { + echo "PASS: token consumed on successful retry\n"; +} + +// Test 3: Postcondition failure rolls back initial mode +putenv("PHP_TERMINAL_TEST_SEAM=fail_postcondition"); +try { + $mFail = $term->enableRawMode(); + echo "FAIL: acquisition succeeded despite postcondition failure\n"; +} catch (Throwable $e) { + echo "PASS: postcondition failure caught: " . $e->getMessage() . "\n"; +} +putenv("PHP_TERMINAL_TEST_SEAM"); +// Terminal must NOT be left in raw mode after failed acquisition +var_dump(!isPtyRaw($pipes[0])); + +fwrite($pipes[0], "\n"); +fclose($pipes[0]); +if (isset($pipes[1]) && is_resource($pipes[1])) fclose($pipes[1]); +if (isset($pipes[2]) && is_resource($pipes[2])) fclose($pipes[2]); +proc_terminate($proc); +proc_close($proc); + +?> +--EXPECTF-- +bool(true) +PASS: reapplication rejected: %s +bool(true) +bool(true) +PASS: restore threw: Failed to restore terminal mode +bool(true) +bool(true) +PASS: token consumed on successful retry +PASS: postcondition failure caught: %s +bool(true) diff --git a/ext/standard/tests/terminal/terminal_raw_mode_fork.phpt b/ext/standard/tests/terminal/terminal_raw_mode_fork.phpt new file mode 100644 index 000000000000..6fcddde61e88 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_raw_mode_fork.phpt @@ -0,0 +1,114 @@ +--TEST-- +Io\Terminal\Terminal: raw mode token ownership and child process fork isolation +--SKIPIF-- + ['pty'], 1 => ['pty'], 2 => ['pipe', 'w']], + $pipes + ); +} catch (Throwable) { + die("skip PTY not available"); +} +if (!is_resource($proc)) { + die("skip PTY not available"); +} +foreach ($pipes as $p) { if (is_resource($p)) fclose($p); } +proc_close($proc); +?> +--FILE-- + ['pty'], 1 => ['pty'], 2 => ['pipe', 'w']], + $pipes +); + +$term = Terminal::fromStreams($pipes[0]); + +function isPtyRaw($stream): bool { + $sub = proc_open(['stty', '-a'], [0 => $stream, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (!is_resource($sub)) return false; + $out = stream_get_contents($pipes[1]); + fclose($pipes[1]); fclose($pipes[2]); proc_close($sub); + return strpos($out, "-icanon") !== false && strpos($out, "-echo") !== false; +} + +// 1. Parent enables raw mode +$token = $term->enableRawMode(); +var_dump($token !== null); + +// 2. Fork child +$pid = pcntl_fork(); +if ($pid === -1) { + die("Failed to fork"); +} + +if ($pid === 0) { + // Child process: + // A. Explicit restore by child must be rejected + try { + $term->restoreMode($token); + echo "CHILD_RESTORE_FAIL: restore succeeded in child\n"; + } catch (\Throwable $e) { + echo "CHILD_RESTORE_PASS: " . $e->getMessage() . "\n"; + } + + // B. Child unsets inherited token and terminates + unset($token); + exit(0); +} + +// Parent process waits for child to exit +pcntl_waitpid($pid, $status); + +// Verify actual OS mode after child exit and before parent restoration +var_dump(isPtyRaw($pipes[0])); + +// 3. Parent verifies its token is still active and can be restored +try { + $term->restoreMode($token); + echo "PARENT_RESTORE_PASS\n"; +} catch (\Throwable $e) { + echo "PARENT_RESTORE_FAIL: " . $e->getMessage() . "\n"; +} + +// Verify actual OS mode after parent restoration +var_dump(!isPtyRaw($pipes[0])); + +// 4. Token cannot be used a second time +try { + $term->restoreMode($token); + echo "REUSE_FAIL\n"; +} catch (\Throwable $e) { + echo "REUSE_PASS\n"; +} + +fwrite($pipes[0], "\n"); +fclose($pipes[0]); +if (isset($pipes[1]) && is_resource($pipes[1])) fclose($pipes[1]); +if (isset($pipes[2]) && is_resource($pipes[2])) fclose($pipes[2]); +proc_terminate($proc); +proc_close($proc); + +?> +--EXPECTF-- +bool(true) +CHILD_RESTORE_PASS: %smust be an active terminal mode token belonging to this terminal +bool(true) +PARENT_RESTORE_PASS +bool(true) +REUSE_PASS diff --git a/ext/standard/tests/terminal/terminal_raw_mode_isig_checks.phpt b/ext/standard/tests/terminal/terminal_raw_mode_isig_checks.phpt new file mode 100644 index 000000000000..2dd11d857364 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_raw_mode_isig_checks.phpt @@ -0,0 +1,264 @@ +--TEST-- +Io\Terminal\Terminal: raw mode configuration when ECHO and ICANON are disabled but ISIG is enabled +--SKIPIF-- + ['pty'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes + ); +} catch (Throwable) { + die("skip PTY not available"); +} +if (!is_resource($proc)) { + die("skip PTY not available"); +} +foreach ($pipes as $p) { if (is_resource($p)) fclose($p); } +proc_close($proc); +?> +--FILE-- + 0) { + $line = fgets($stream); + return ($line !== false) ? $line : null; + } + break; + } + return null; +} + +function runIsolatedChild(string $code, ?string $inputToSend = null): string { + $descriptors = [ + 0 => ['pty'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $proc = proc_open([PHP_BINARY, '-r', $code], $descriptors, $pipes); + if (!is_resource($proc)) { + return "FAIL: proc_open failed"; + } + + $cleaned = false; + $cleanup = function () use ($proc, &$pipes, &$cleaned): void { + if ($cleaned) { + return; + } + $cleaned = true; + foreach ($pipes as $p) { + if (is_resource($p)) { + fclose($p); + } + } + if (is_resource($proc)) { + $status = proc_get_status($proc); + if ($status['running']) { + $exitDeadline = getMonotonicTime() + 0.2; + while (proc_get_status($proc)['running'] && getMonotonicTime() < $exitDeadline) { + usleep(2000); + } + } + $status = proc_get_status($proc); + if ($status['running']) { + @proc_terminate($proc); + $termDeadline = getMonotonicTime() + 0.3; + while (proc_get_status($proc)['running'] && getMonotonicTime() < $termDeadline) { + usleep(2000); + } + if (proc_get_status($proc)['running'] && defined('SIGKILL')) { + @proc_terminate($proc, SIGKILL); + usleep(2000); + } + } + proc_close($proc); + } + }; + + // Readiness synchronization: wait for child to signal READY + $ready = readLineWithTimeout($pipes[1], 3.0); + if ($ready === null || trim($ready) !== 'READY') { + stream_set_blocking($pipes[2], false); + $stderr = stream_get_contents($pipes[2]); + $cleanup(); + return "FAIL: child not ready: " . var_export($ready, true) . (!empty($stderr) ? " (stderr: " . trim($stderr) . ")" : ""); + } + + if ($inputToSend !== null) { + $isigDisabled = false; + $obsDeadline = getMonotonicTime() + 1.5; + while (getMonotonicTime() < $obsDeadline) { + $sub = @proc_open(['stty', '-a'], [0 => $pipes[0], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $subPipes); + if (is_resource($sub)) { + $out = stream_get_contents($subPipes[1]); + fclose($subPipes[1]); + fclose($subPipes[2]); + $exitCode = proc_close($sub); + if ($exitCode === 0 && preg_match('/(?:\s|^)-isig(?:\s|$)/', $out) === 1) { + $isigDisabled = true; + break; + } + } + usleep(5000); + } + + if (!$isigDisabled) { + stream_set_blocking($pipes[2], false); + $stderr = stream_get_contents($pipes[2]); + $cleanup(); + return "FAIL: timed out waiting for child to disable ISIG" . (!empty($stderr) ? " (stderr: " . trim($stderr) . ")" : ""); + } + + fwrite($pipes[0], $inputToSend); + fflush($pipes[0]); + } + + // Read result with bounded timeout + $result = readLineWithTimeout($pipes[1], 3.0); + + // Drain stderr non-blockingly + stream_set_blocking($pipes[2], false); + $stderr = stream_get_contents($pipes[2]); + + $cleanup(); + + if ($result === null) { + return "FAIL: timed out or EOF reading child result" . (!empty($stderr) ? " (stderr: " . trim($stderr) . ")" : ""); + } + + if (!empty($stderr)) { + return "STDERR: " . trim($stderr); + } + + return trim($result); +} + +// Child helper functions template +$setupHelper = ' +function setSttyMode(): void { + $sub = proc_open(["stty", "-echo", "-icanon", "isig"], [0 => STDIN, 1 => ["pipe", "w"], 2 => ["pipe", "w"]], $sp); + if (is_resource($sub)) { + fclose($sp[1]); fclose($sp[2]); proc_close($sub); + } +} +function isPrecedingModeRestored(): bool { + $sub = proc_open(["stty", "-a"], [0 => STDIN, 1 => ["pipe", "w"], 2 => ["pipe", "w"]], $sp); + if (!is_resource($sub)) return false; + $out = stream_get_contents($sp[1]); + fclose($sp[1]); fclose($sp[2]); proc_close($sub); + $hasIsig = preg_match("/(?:\s|^)isig(?:\s|$)/", $out) === 1 && preg_match("/(?:\s|^)-isig(?:\s|$)/", $out) !== 1; + $hasMinusEcho = strpos($out, "-echo") !== false; + $hasMinusIcanon = strpos($out, "-icanon") !== false; + return $hasIsig && $hasMinusEcho && $hasMinusIcanon; +} +'; + +// Case 1: readKey() receives Ctrl+C as input byte and restores preceding mode +$codeKey = $setupHelper . ' +use Io\Terminal\Terminal; +use Time\Duration; +if (function_exists("pcntl_alarm")) pcntl_alarm(5); +setSttyMode(); +$t = Terminal::fromStdio(); +fwrite(STDOUT, "READY\n"); +fflush(STDOUT); +$key = $t->readKey(Duration::fromSeconds(2)); +$restored = isPrecedingModeRestored(); +echo ($key === "\x03" && $restored) ? "PASS: readKey received Ctrl+C and restored mode" : "FAIL: key=" . bin2hex((string)$key) . " restored=" . ($restored ? "1" : "0"); +'; + +echo runIsolatedChild($codeKey, "\x03"), "\n"; + +// Case 2: readSecret() handles Ctrl+C through TerminalException and restores preceding mode +$codeSecret = $setupHelper . ' +use Io\Terminal\Terminal; +use Io\Terminal\TerminalException; +use Time\Duration; +if (function_exists("pcntl_alarm")) pcntl_alarm(5); +setSttyMode(); +$t = Terminal::fromStdio(); +fwrite(STDOUT, "READY\n"); +fflush(STDOUT); +$caught = false; +try { + $t->readSecret(Duration::fromSeconds(2)); +} catch (TerminalException $e) { + $caught = true; +} +$restored = isPrecedingModeRestored(); +echo ($caught && $restored) ? "PASS: readSecret threw TerminalException on Ctrl+C and restored mode" : "FAIL: caught=" . ($caught ? "1" : "0") . " restored=" . ($restored ? "1" : "0"); +'; + +echo runIsolatedChild($codeSecret, "\x03"), "\n"; + +// Case 3: Shared-lease reapplication path applies raw mode when ISIG is enabled and restores afterward +$codeLease = $setupHelper . ' +use Io\Terminal\Terminal; +if (function_exists("pcntl_alarm")) pcntl_alarm(5); +setSttyMode(); +$term = Terminal::fromStdio(); +fwrite(STDOUT, "READY\n"); +fflush(STDOUT); +$t1 = $term->enableRawMode(); + +// Alter terminal mode back to -echo -icanon isig +setSttyMode(); + +// Reapply lease while first lease is still active +$t2 = $term->enableRawMode(); + +// Verify ISIG is now disabled in terminal +$sub = proc_open(["stty", "-a"], [0 => STDIN, 1 => ["pipe", "w"], 2 => ["pipe", "w"]], $sp); +$out = stream_get_contents($sp[1]); +fclose($sp[1]); fclose($sp[2]); proc_close($sub); +$hasMinusIsig = strpos($out, "-isig") !== false; + +$term->restoreMode($t2); +$term->restoreMode($t1); + +$restored = isPrecedingModeRestored(); +echo ($hasMinusIsig && $restored) ? "PASS: lease reapplication disabled ISIG and restored mode" : "FAIL: raw=" . ($hasMinusIsig ? "1" : "0") . " restored=" . ($restored ? "1" : "0"); +'; + +echo runIsolatedChild($codeLease, null), "\n"; +?> +--EXPECT-- +PASS: readKey received Ctrl+C and restored mode +PASS: readSecret threw TerminalException on Ctrl+C and restored mode +PASS: lease reapplication disabled ISIG and restored mode 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..79b1bd0fcf97 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_raw_mode_non_tty.phpt @@ -0,0 +1,39 @@ +--TEST-- +Io\Terminal\Terminal: no-op raw-mode leases on non-terminal streams +--FILE-- +enableRawMode(); +var_dump($token instanceof ModeToken); + +// getSize() continues to return null on non-terminal streams +var_dump($terminal->getSize()); + +// Restoring the token succeeds +$terminal->restoreMode($token); +echo "restored non-tty token +"; + +// Re-restoring the consumed token throws ValueError +try { + $terminal->restoreMode($token); + echo "FAIL: re-restoring consumed non-tty token accepted +"; +} catch (ValueError $e) { + echo "Caught: ", $e->getMessage(), PHP_EOL; +} + +fclose($fp); +?> +--EXPECT-- +bool(true) +NULL +restored non-tty token +Caught: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal diff --git a/ext/standard/tests/terminal/terminal_raw_mode_output_flags.phpt b/ext/standard/tests/terminal/terminal_raw_mode_output_flags.phpt new file mode 100644 index 000000000000..9a86dec8924a --- /dev/null +++ b/ext/standard/tests/terminal/terminal_raw_mode_output_flags.phpt @@ -0,0 +1,117 @@ +--TEST-- +Io\Terminal\Terminal: c_oflag is preserved when entering raw mode with OPOST on and off +--SKIPIF-- + ['pty'], 1 => ['pty'], 2 => ['pipe', 'w']], + $pipes + ); +} catch (Throwable) { + die("skip PTY not available"); +} +if (!is_resource($proc)) { + die("skip PTY not available"); +} +foreach ($pipes as $p) { if (is_resource($p)) fclose($p); } +proc_close($proc); +?> +--FILE-- + $stream, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (!is_resource($sub)) { + return false; + } + $out = stream_get_contents($pipes[1]); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($sub); + $hasOpost = preg_match('/(?:\s|^)opost(?:\s|$)/', $out) === 1; + $hasMinusOpost = preg_match('/(?:\s|^)-opost(?:\s|$)/', $out) === 1; + return $hasOpost && !$hasMinusOpost; +} + +function setOpostState($stream, bool $enable): void { + $cmd = $enable ? ['stty', 'opost'] : ['stty', '-opost']; + $sub = proc_open($cmd, [0 => $stream, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (is_resource($sub)) { + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($sub); + } +} + +// Case 1: OPOST is initially ON +$proc1 = proc_open( + [PHP_BINARY, '-r', 'fgets(STDIN);'], + [0 => ['pty'], 1 => ['pty'], 2 => ['pipe', 'w']], + $pipes1 +); +$term1 = Terminal::fromStreams($pipes1[0]); + +$before1 = getOpostState($pipes1[0]); +$token1 = $term1->enableRawMode(); +$during1 = getOpostState($pipes1[0]); +$term1->restoreMode($token1); +$after1 = getOpostState($pipes1[0]); + +// OPOST was ON before, must stay ON during raw mode and after restore +var_dump($before1 === true); +var_dump($during1 === true); +var_dump($after1 === true); + +fwrite($pipes1[0], "\n"); +fclose($pipes1[0]); +if (isset($pipes1[1]) && is_resource($pipes1[1])) fclose($pipes1[1]); +if (isset($pipes1[2]) && is_resource($pipes1[2])) fclose($pipes1[2]); +proc_terminate($proc1); +proc_close($proc1); + +// Case 2: OPOST is initially OFF +$proc2 = proc_open( + [PHP_BINARY, '-r', 'fgets(STDIN);'], + [0 => ['pty'], 1 => ['pty'], 2 => ['pipe', 'w']], + $pipes2 +); +$term2 = Terminal::fromStreams($pipes2[0]); + +// Turn OPOST OFF explicitly before acquiring raw mode +setOpostState($pipes2[0], false); + +$before2 = getOpostState($pipes2[0]); +$token2 = $term2->enableRawMode(); +$during2 = getOpostState($pipes2[0]); +$term2->restoreMode($token2); +$after2 = getOpostState($pipes2[0]); + +// OPOST was OFF before, must NOT be forced ON during raw mode, and remain OFF after restore +var_dump($before2 === false); +var_dump($during2 === false); +var_dump($after2 === false); + +fwrite($pipes2[0], "\n"); +fclose($pipes2[0]); +if (isset($pipes2[1]) && is_resource($pipes2[1])) fclose($pipes2[1]); +if (isset($pipes2[2]) && is_resource($pipes2[2])) fclose($pipes2[2]); +proc_terminate($proc2); +proc_close($proc2); + +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) 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..abf3877c59e7 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_key.phpt @@ -0,0 +1,41 @@ +--TEST-- +Io\Terminal\Terminal: readKey with Time\Duration timeouts and parameter validation +--FILE-- +readKey(Duration::fromSeconds(1)->negate()); + echo "FAIL: accepted negative timeout +"; +} 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 +"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +// Non-terminal stream reads keys without throwing +$fp = fopen('php://temp', 'r+'); +fwrite($fp, "x"); +rewind($fp); +$nonTty = Terminal::fromStreams($fp); +$key = $nonTty->readKey(Duration::fromSeconds(0)); +var_dump($key === "x"); +fclose($fp); + +?> +--EXPECT-- +ValueError: Io\Terminal\Terminal::readKey(): Argument #1 ($timeout) must not be negative +ValueError: Io\Terminal\Terminal::readKey(): Argument #2 ($sequenceTimeout) must not be negative +bool(true) 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..1a532f6524b2 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_key_contracts.phpt @@ -0,0 +1,123 @@ +--TEST-- +Io\Terminal\Terminal: 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 = Terminal::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 +"; +} 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 +"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +// Contract 4: Non-terminal input stream reads keys normally +$fp = fopen('php://temp', 'r+'); +fwrite($fp, "z"); +rewind($fp); +$nonTty = Terminal::fromStreams($fp); +$keyNonTty = $nonTty->readKey(Duration::fromSeconds(0)); +var_dump($keyNonTty === "z"); +fclose($fp); + +// Contract 5: EOF / stream termination throws TerminalException on real TTY +if (isset($pipes[1]) && is_resource($pipes[1])) fclose($pipes[1]); +if (isset($pipes[2]) && is_resource($pipes[2])) fclose($pipes[2]); +proc_terminate($proc); +$deadline = hrtime(true) + 1_000_000_000; +while (proc_get_status($proc)['running'] && hrtime(true) < $deadline) { + usleep(5000); +} + +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\Terminal::readKey(): Argument #1 ($timeout) must not be negative +ValueError: Io\Terminal\Terminal::readKey(): Argument #2 ($sequenceTimeout) must not be negative +bool(true) +Io\Terminal\TerminalException: %s terminal input stream diff --git a/ext/standard/tests/terminal/terminal_read_key_ordinary_stream_eof.phpt b/ext/standard/tests/terminal/terminal_read_key_ordinary_stream_eof.phpt new file mode 100644 index 000000000000..708b478616c2 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_key_ordinary_stream_eof.phpt @@ -0,0 +1,158 @@ +--TEST-- +Io\Terminal\Terminal: readKey EOF behavior on ordinary streams +--SKIPIF-- + +--FILE-- +readKey(); + echo "FAIL: default timeout on empty stream did not throw\n"; +} catch (TerminalException $e) { + echo "PASS: default timeout immediate EOF: " . $e->getMessage() . "\n"; +} +fclose($fp); + +// Zero timeout +$fp = fopen('php://temp', 'r+'); +$term = Terminal::fromStreams($fp); +try { + $term->readKey(Duration::fromSeconds(0)); + echo "FAIL: zero timeout on empty stream did not throw\n"; +} catch (TerminalException $e) { + echo "PASS: zero timeout immediate EOF: " . $e->getMessage() . "\n"; +} +fclose($fp); + +// Positive timeout +$fp = fopen('php://temp', 'r+'); +$term = Terminal::fromStreams($fp); +try { + $term->readKey(Duration::fromMilliseconds(50)); + echo "FAIL: positive timeout on empty stream did not throw\n"; +} catch (TerminalException $e) { + echo "PASS: positive timeout immediate EOF: " . $e->getMessage() . "\n"; +} +fclose($fp); + +// 2. Reading an available character successfully, then encountering EOF: +// Zero timeout on subsequent read +$fp = fopen('php://temp', 'r+'); +fwrite($fp, "A"); +rewind($fp); +$term = Terminal::fromStreams($fp); +$key = $term->readKey(Duration::fromSeconds(0)); +var_dump($key === "A"); +try { + $term->readKey(Duration::fromSeconds(0)); + echo "FAIL: subsequent zero timeout read did not throw\n"; +} catch (TerminalException $e) { + echo "PASS: subsequent zero timeout read EOF: " . $e->getMessage() . "\n"; +} +fclose($fp); + +// Positive timeout on subsequent read +$fp = fopen('php://temp', 'r+'); +fwrite($fp, "B"); +rewind($fp); +$term = Terminal::fromStreams($fp); +$key = $term->readKey(Duration::fromMilliseconds(50)); +var_dump($key === "B"); +try { + $term->readKey(Duration::fromMilliseconds(50)); + echo "FAIL: subsequent positive timeout read did not throw\n"; +} catch (TerminalException $e) { + echo "PASS: subsequent positive timeout read EOF: " . $e->getMessage() . "\n"; +} +fclose($fp); + +// Default timeout on subsequent read +$fp = fopen('php://temp', 'r+'); +fwrite($fp, "C"); +rewind($fp); +$term = Terminal::fromStreams($fp); +$key = $term->readKey(); +var_dump($key === "C"); +try { + $term->readKey(); + echo "FAIL: subsequent default timeout read did not throw\n"; +} catch (TerminalException $e) { + echo "PASS: subsequent default timeout read EOF: " . $e->getMessage() . "\n"; +} +fclose($fp); + +// 3. An open input with no data yet still returning null on timeout: +$pair = stream_socket_pair( + PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0 +); +if ($pair === false) { + die("Failed to create socket pair"); +} + +$readStream = $pair[0]; +$writeStream = $pair[1]; +$socketTerm = Terminal::fromStreams($readStream); + +// Zero timeout returns null immediately when open but no data +$kZero = $socketTerm->readKey(Duration::fromSeconds(0)); +var_dump($kZero === null); + +// Positive timeout returns null when open but no data +$kPos = $socketTerm->readKey(Duration::fromMilliseconds(30)); +var_dump($kPos === null); + +// Close writer to signal EOF on the open reader +fclose($writeStream); + +// Now that writer is closed, reading encounters EOF and throws TerminalException +try { + $socketTerm->readKey(Duration::fromSeconds(0)); + echo "FAIL: socket zero timeout EOF did not throw\n"; +} catch (TerminalException $e) { + echo "PASS: socket zero timeout EOF: " . $e->getMessage() . "\n"; +} + +try { + $socketTerm->readKey(Duration::fromMilliseconds(30)); + echo "FAIL: socket positive timeout EOF did not throw\n"; +} catch (TerminalException $e) { + echo "PASS: socket positive timeout EOF: " . $e->getMessage() . "\n"; +} + +try { + $socketTerm->readKey(); + echo "FAIL: socket default timeout EOF did not throw\n"; +} catch (TerminalException $e) { + echo "PASS: socket default timeout EOF: " . $e->getMessage() . "\n"; +} + +fclose($readStream); +?> +--EXPECT-- +PASS: default timeout immediate EOF: End of file reached on terminal input stream +PASS: zero timeout immediate EOF: End of file reached on terminal input stream +PASS: positive timeout immediate EOF: End of file reached on terminal input stream +bool(true) +PASS: subsequent zero timeout read EOF: End of file reached on terminal input stream +bool(true) +PASS: subsequent positive timeout read EOF: End of file reached on terminal input stream +bool(true) +PASS: subsequent default timeout read EOF: End of file reached on terminal input stream +bool(true) +bool(true) +PASS: socket zero timeout EOF: End of file reached on terminal input stream +PASS: socket positive timeout EOF: End of file reached on terminal input stream +PASS: socket default timeout EOF: End of file reached on 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..67f069947a4e --- /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 = Terminal::fromStreams($pipes1[0]); +$token1 = $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 = Terminal::fromStreams($pipes2[0]); +$token2 = $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..1967a7893e73 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_key_sequences.phpt @@ -0,0 +1,86 @@ +--TEST-- +Io\Terminal\Terminal: readKey preserves unsupported CSI sequences and decodes Insert +--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 = Terminal::fromStreams($pipes[0]); +$token = $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 +Insert +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..83fcbc99b7f0 --- /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 = Terminal::fromStreams($pipes[0]); +$token = $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..94c6b36a162e --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_key_utf8_retain.phpt @@ -0,0 +1,210 @@ +--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 = Terminal::fromStreams($pipes1[0]); +$token1 = $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 = Terminal::fromStreams($pipes2[0]); +$token2 = $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 = Terminal::fromStreams($pipes3[0]); +$token3 = $terminal3->enableRawMode(); + +fwrite($pipes3[0], "START\n"); +$r1 = fgets($pipes3[2]); + +$k5 = $terminal3->readKey(Duration::fromMilliseconds(30), Duration::fromMilliseconds(20)); + +fwrite($pipes3[0], "GO\n"); + +$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..e502868ede63 --- /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 = Terminal::fromStreams($pipes[0]); + $token = $terminal->enableRawMode(); + return [$proc, $pipes, $terminal, $token]; +} + +// 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, $token1] = 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($token1); +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, $token2] = 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($token2); +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, $token3] = 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($token3); +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, $token4] = 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($token4); +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, $token5] = 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($token5); +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..ee2d9fb2c51e --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_line.phpt @@ -0,0 +1,189 @@ +--TEST-- +Io\Terminal\Terminal: readLine line ending, whitespace, buffering, and EOF stream contracts +--FILE-- +isFinal()); +$reflection = new ReflectionMethod(Terminal::class, 'readLine'); +var_dump($reflection->getNumberOfParameters()); +var_dump((string) $reflection->getReturnType()); + +// 1. Argument validation: readLine takes 0 parameters +$fp = fopen('php://temp', 'r+'); +$terminal = Terminal::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); +// Terminal::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 = Terminal::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 = Terminal::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 on non-terminal stream +$token2 = $t2->enableRawMode(); +$t2->restoreMode($token2); +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\Terminal::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 +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..c4167f8729ab --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_line_lease.phpt @@ -0,0 +1,162 @@ +--TEST-- +Io\Terminal\Terminal: 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 = Terminal::fromStreams($pipes[1]); +$t2 = Terminal::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 +$t2->restoreMode($token); +echo "restored via t2\n"; + +// 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. Restoring an already consumed token throws ValueError +$tokenStale = $t1->enableRawMode(); +$t2->restoreMode($tokenStale); +try { + $t1->restoreMode($tokenStale); + echo "FAIL: stale token was accepted\n"; +} catch (ValueError $e) { + echo "Stale token caught: ", $e->getMessage(), PHP_EOL; +} + +// 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 +restored via t2 +string(14) "canonical line" +string(11) "second line" +Stale token caught: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +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..9b44c3101078 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_line_nonblocking.phpt @@ -0,0 +1,159 @@ +--TEST-- +Io\Terminal\Terminal: 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 = Terminal::fromStreams($pipes1[1]); + +fwrite($pipes1[0], "GO\n"); +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 = Terminal::fromStreams($pipes2[1]); + +fwrite($pipes2[0], "GO\n"); +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 = Terminal::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 = Terminal::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..baf63b40ccec --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_line_windows.phpt @@ -0,0 +1,62 @@ +--TEST-- +Io\Terminal\Terminal: 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. No-op raw mode lease on non-terminal stream +$token = $terminal->enableRawMode(); +var_dump($token instanceof ModeToken); +$terminal->restoreMode($token); +echo "restored non-tty token\n"; + +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 +bool(true) +restored non-tty token +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..14b28590608a --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_secret.phpt @@ -0,0 +1,55 @@ +--TEST-- +Io\Terminal\Terminal: readSecret parameter validation and empty non-tty handling +--FILE-- +readSecret(); + echo "FAIL: readSecret succeeded on empty 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; +} + +fclose($fp); + +?> +--EXPECTF-- +bool(true) +Io\Terminal\TerminalException: Unable to read secret from terminal +TypeError: Io\Terminal\Terminal::readSecret(): Argument #1 ($timeout) must be of type ?Time\Duration, string given +ArgumentCountError: Io\Terminal\Terminal::readSecret() expects at most 1 argument, 2 given +ValueError: Io\Terminal\Terminal::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..26ad3f7ade50 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_secret_control.phpt @@ -0,0 +1,358 @@ +--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 = Terminal::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. Escape followed by Ctrl+C (\x1b\x03) cancels secret input immediately +$code2 = 'fwrite(STDOUT, "\x1b\x03\r"); fflush(STDOUT); fgets(STDIN);'; +$proc2 = proc_open( + [PHP_BINARY, '-r', $code2], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes2, +); + +$terminal2 = Terminal::fromStreams($pipes2[0]); + +try { + $secret = $terminal2->readSecret(); + echo "FAIL: secret was read: ", bin2hex($secret), "\n"; +} catch (TerminalException $e) { + echo "SUCCESS: caught ", $e::class, ": ", $e->getMessage(), "\n"; +} + +fwrite($pipes2[0], "done\n"); +unset($terminal2); +foreach ($pipes2 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc2); + +// 3. CSI sequence containing Ctrl+C aborts +$code3 = 'fwrite(STDOUT, "\x1b[\x03A\r"); fflush(STDOUT); fgets(STDIN);'; +$proc3 = proc_open( + [PHP_BINARY, '-r', $code3], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes3, +); + +$terminal3 = Terminal::fromStreams($pipes3[0]); + +try { + $secret = $terminal3->readSecret(); + echo "FAIL: secret was read: ", bin2hex($secret), "\n"; +} catch (TerminalException $e) { + echo "SUCCESS: caught ", $e::class, ": ", $e->getMessage(), "\n"; +} + +fwrite($pipes3[0], "done\n"); +unset($terminal3); +foreach ($pipes3 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc3); + +// 4. SS3 sequence containing Ctrl+C aborts +$code4 = 'fwrite(STDOUT, "\x1bO\x03\r"); fflush(STDOUT); fgets(STDIN);'; +$proc4 = proc_open( + [PHP_BINARY, '-r', $code4], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes4, +); + +$terminal4 = Terminal::fromStreams($pipes4[0]); + +try { + $secret = $terminal4->readSecret(); + echo "FAIL: secret was read: ", bin2hex($secret), "\n"; +} catch (TerminalException $e) { + echo "SUCCESS: 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 by enter (\x1b\r) aborts +$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 = Terminal::fromStreams($pipes5[0]); + +try { + $secret = $terminal5->readSecret(); + echo "FAIL: secret was read: ", bin2hex($secret), "\n"; +} catch (TerminalException $e) { + echo "SUCCESS: 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. Escape followed by escape (\x1b\x1b) aborts +$code6 = 'fwrite(STDOUT, "\x1b\x1b\r"); fflush(STDOUT); fgets(STDIN);'; +$proc6 = proc_open( + [PHP_BINARY, '-r', $code6], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes6, +); + +$terminal6 = Terminal::fromStreams($pipes6[0]); + +try { + $secret = $terminal6->readSecret(); + echo "FAIL: secret was read: ", bin2hex($secret), "\n"; +} catch (TerminalException $e) { + echo "SUCCESS: 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. Escape followed by Ctrl+D (\x1b\x04) aborts +$code7 = 'fwrite(STDOUT, "\x1b\x04\r"); fflush(STDOUT); fgets(STDIN);'; +$proc7 = proc_open( + [PHP_BINARY, '-r', $code7], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes7, +); + +$terminal7 = Terminal::fromStreams($pipes7[0]); + +try { + $secret = $terminal7->readSecret(); + echo "FAIL: secret was read: ", bin2hex($secret), "\n"; +} catch (TerminalException $e) { + echo "SUCCESS: 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. CSI sequence with cancellation byte \x04 aborts +$code8 = 'fwrite(STDOUT, "\x1b[\x04\r"); fflush(STDOUT); fgets(STDIN);'; +$proc8 = proc_open( + [PHP_BINARY, '-r', $code8], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes8, +); + +$terminal8 = Terminal::fromStreams($pipes8[0]); + +try { + $secret = $terminal8->readSecret(); + echo "FAIL: secret was read: ", bin2hex($secret), "\n"; +} catch (TerminalException $e) { + echo "SUCCESS: 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. CSI sequence with cancellation byte \x1b aborts +$code9 = 'fwrite(STDOUT, "\x1b[\x1b\r"); fflush(STDOUT); fgets(STDIN);'; +$proc9 = proc_open( + [PHP_BINARY, '-r', $code9], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes9, +); + +$terminal9 = Terminal::fromStreams($pipes9[0]); + +try { + $secret = $terminal9->readSecret(); + echo "FAIL: secret was read: ", bin2hex($secret), "\n"; +} catch (TerminalException $e) { + echo "SUCCESS: caught ", $e::class, ": ", $e->getMessage(), "\n"; +} + +fwrite($pipes9[0], "done\n"); +unset($terminal9); +foreach ($pipes9 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc9); + +// 10. CSI sequence with cancellation byte \r aborts +$code10 = 'fwrite(STDOUT, "\x1b[\r"); fflush(STDOUT); fgets(STDIN);'; +$proc10 = proc_open( + [PHP_BINARY, '-r', $code10], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes10, +); + +$terminal10 = Terminal::fromStreams($pipes10[0]); + +try { + $secret = $terminal10->readSecret(); + echo "FAIL: secret was read: ", bin2hex($secret), "\n"; +} catch (TerminalException $e) { + echo "SUCCESS: caught ", $e::class, ": ", $e->getMessage(), "\n"; +} + +fwrite($pipes10[0], "done\n"); +unset($terminal10); +foreach ($pipes10 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc10); + +// 11. SS3 sequence with cancellation byte \x04 aborts +$code11 = 'fwrite(STDOUT, "\x1bO\x04\r"); fflush(STDOUT); fgets(STDIN);'; +$proc11 = proc_open( + [PHP_BINARY, '-r', $code11], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes11, +); + +$terminal11 = Terminal::fromStreams($pipes11[0]); + +try { + $secret = $terminal11->readSecret(); + echo "FAIL: secret was read: ", bin2hex($secret), "\n"; +} catch (TerminalException $e) { + echo "SUCCESS: caught ", $e::class, ": ", $e->getMessage(), "\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 +SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal +SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal +SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal +SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal +SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal +SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal +SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal +SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal +SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal +SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal 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..b0fc296ecf04 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_secret_timeout.phpt @@ -0,0 +1,298 @@ +--TEST-- +Io\Terminal\Terminal: 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 = Terminal::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 = Terminal::fromStreams($pipes2[0]); +$token2 = $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 = Terminal::fromStreams($pipes3[0]); +$token3 = $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 = Terminal::fromStreams($pipes4[0]); +$token4 = $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 = Terminal::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 = Terminal::fromStreams($pipes6[0]); +$token6 = $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 = Terminal::fromStreams($pipes7[0]); +$token7 = $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\Terminal::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..f0fb31f81cb8 --- /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 = Terminal::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_secret_crlf_handoff.phpt b/ext/standard/tests/terminal/terminal_secret_crlf_handoff.phpt new file mode 100644 index 000000000000..4fdab3f5ee64 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_secret_crlf_handoff.phpt @@ -0,0 +1,189 @@ +--TEST-- +Io\Terminal\Terminal: CRLF consumption across secret boundaries and chunk splits +--SKIPIF-- + +--FILE-- +readSecret(); +$s2 = $term1->readSecret(); +$k1 = $term1->readKey(); + +var_dump($s1 === "secretA"); +var_dump($s2 === "secretB"); +var_dump($k1 === "K"); +fclose($fp); + +// Part 2: Split CRLF across write chunks +$pair = stream_socket_pair( + PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0 +); +$readStream = $pair[0]; +$writeStream = $pair[1]; +$term2 = Terminal::fromStreams($readStream); + +// First write terminates with \r only +fwrite($writeStream, "alpha\r"); +$sAlpha = $term2->readSecret(); +var_dump($sAlpha === "alpha"); + +// Second write begins with the split \n, then beta\r\n, then trailing key +fwrite($writeStream, "\nbeta\r\nQ"); +$sBeta = $term2->readSecret(); +var_dump($sBeta === "beta"); + +$kQ = $term2->readKey(Duration::fromMilliseconds(100)); +var_dump($kQ === "Q"); + +fclose($readStream); +fclose($writeStream); + +// Part 3: Split CRLF followed by readLine() +$pair3 = stream_socket_pair( + PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0 +); +$readStream3 = $pair3[0]; +$writeStream3 = $pair3[1]; +$term3 = Terminal::fromStreams($readStream3); + +fwrite($writeStream3, "password\r"); +$sPass = $term3->readSecret(); +var_dump($sPass === "password"); + +// Next chunk arrives with split \n, then followed by line content and newline +fwrite($writeStream3, "\nmy input line\n"); +$line3 = $term3->readLine(); +var_dump($line3 === "my input line"); + +// Part 4: Chunk size 1, CR then later "next\n" +$p4 = stream_socket_pair( + PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0 +); +stream_set_chunk_size($p4[0], 1); +$t4 = Terminal::fromStreams($p4[0]); +fwrite($p4[1], "password\r"); +var_dump($t4->readSecret() === "password"); +fwrite($p4[1], "next\n"); +var_dump($t4->readLine() === "next"); +fclose($p4[0]); +fclose($p4[1]); + +// Part 5: Chunk size 1, CR then later "\nnext\n" +$p5 = stream_socket_pair( + PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0 +); +stream_set_chunk_size($p5[0], 1); +$t5 = Terminal::fromStreams($p5[0]); +fwrite($p5[1], "password\r"); +var_dump($t5->readSecret() === "password"); +fwrite($p5[1], "\nnext\n"); +var_dump($t5->readLine() === "next"); +fclose($p5[0]); +fclose($p5[1]); + +// Part 6: Chunk size 1, CR with next line already queued in same write +$p6 = stream_socket_pair( + PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0 +); +stream_set_chunk_size($p6[0], 1); +$t6 = Terminal::fromStreams($p6[0]); +fwrite($p6[1], "password\rnext\n"); +var_dump($t6->readSecret() === "password"); +var_dump($t6->readLine() === "next"); +fclose($p6[0]); +fclose($p6[1]); + +// Part 7: Chunk size 1, read "password\r", then supply "\r\nnext\n" -> readLine() returns "" and "next" +$p7 = stream_socket_pair( + PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0 +); +stream_set_chunk_size($p7[0], 1); +$t7 = Terminal::fromStreams($p7[0]); +fwrite($p7[1], "password\r"); +var_dump($t7->readSecret() === "password"); +fwrite($p7[1], "\r\nnext\n"); +var_dump($t7->readLine() === ""); +var_dump($t7->readLine() === "next"); +fclose($p7[0]); +fclose($p7[1]); + +// Part 8: A data CR before CRLF must survive split-prefix normalization. +$p8 = stream_socket_pair( + PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0 +); +stream_set_chunk_size($p8[0], 1); +$t8 = Terminal::fromStreams($p8[0]); +fwrite($p8[1], "password\r"); +var_dump($t8->readSecret() === "password"); +fwrite($p8[1], "\r\r\nnext\n"); +var_dump($t8->readLine() === "\r"); +var_dump($t8->readLine() === "next"); +fclose($p8[0]); +fclose($p8[1]); + +// Part 9: A bare saved CR at EOF remains unterminated line data. +$p9 = stream_socket_pair( + PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0 +); +stream_set_chunk_size($p9[0], 1); +$t9 = Terminal::fromStreams($p9[0]); +fwrite($p9[1], "password\r"); +var_dump($t9->readSecret() === "password"); +fwrite($p9[1], "\r"); +fclose($p9[1]); +var_dump($t9->readLine() === "\r"); +var_dump($t9->readLine() === null); +fclose($p9[0]); + +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) diff --git a/ext/standard/tests/terminal/terminal_size.phpt b/ext/standard/tests/terminal/terminal_size.phpt new file mode 100644 index 000000000000..76fb73d038b4 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_size.phpt @@ -0,0 +1,18 @@ +--TEST-- +Io\Terminal\Terminal: getSize returns null for non-terminal streams without environment fallback +--ENV-- +COLUMNS=100 +LINES=30 +--FILE-- +getSize()); + +?> +--EXPECT-- +NULL diff --git a/ext/standard/tests/terminal/terminal_stream_secret_controls.phpt b/ext/standard/tests/terminal/terminal_stream_secret_controls.phpt new file mode 100644 index 000000000000..e7863379d966 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_stream_secret_controls.phpt @@ -0,0 +1,119 @@ +--TEST-- +Io\Terminal\Terminal: stream readSecret controls, cancellation, navigation, and UTF-8 backspace +--SKIPIF-- + +--FILE-- +readSecret(); + echo "FAIL: Ctrl+C returned secret: " . bin2hex($sec) . "\n"; +} catch (TerminalException $e) { + echo "PASS: Ctrl+C cancelled\n"; +} + +// 2. Ctrl+D followed by newline: must cancel, not return secret with 0x04 +try { + $t = makeStreamTerminal("\x04\n"); + $sec = $t->readSecret(); + echo "FAIL: Ctrl+D returned secret: " . bin2hex($sec) . "\n"; +} catch (TerminalException $e) { + echo "PASS: Ctrl+D cancelled\n"; +} + +// 3. Escape followed by newline: must cancel, not return secret with 0x1B +try { + $t = makeStreamTerminal("\x1b\n"); + $sec = $t->readSecret(); + echo "FAIL: Escape returned secret: " . bin2hex($sec) . "\n"; +} catch (TerminalException $e) { + echo "PASS: Escape cancelled\n"; +} + +// 4. Navigation sequence \x1b[A followed by text must ignore navigation and return text +$t = makeStreamTerminal("\x1b[Aabc\n"); +$sec = $t->readSecret(); +var_dump($sec === "abc"); + +// 5. UTF-8 backspace with chr(8) removes 3-byte Euro symbol +$t = makeStreamTerminal("€" . chr(8) . "b\n"); +$sec = $t->readSecret(); +var_dump($sec === "b"); + +// 6. ASCII backspace with 0x7F removes preceding byte +$t = makeStreamTerminal("ab\x7fc\n"); +$sec = $t->readSecret(); +var_dump($sec === "ac"); + +// 7. SS3 F1 followed by text must ignore F1 and return text (no 'P' corruption) +$t = makeStreamTerminal("\x1bOPpass\n"); +$sec = $t->readSecret(); +var_dump($sec === "pass"); + +// 8. CSI control cancellation: Ctrl+C inside CSI sequence must cancel +try { + $t = makeStreamTerminal("\x1b[\x03pass\n"); + $sec = $t->readSecret(); + echo "FAIL: CSI Ctrl+C returned secret: " . bin2hex($sec) . "\n"; +} catch (TerminalException $e) { + echo "PASS: CSI Ctrl+C cancelled\n"; +} + +// 9. SS3 control cancellation: Ctrl+C inside SS3 sequence must cancel +try { + $t = makeStreamTerminal("\x1bO\x03pass\n"); + $sec = $t->readSecret(); + echo "FAIL: SS3 Ctrl+C returned secret: " . bin2hex($sec) . "\n"; +} catch (TerminalException $e) { + echo "PASS: SS3 Ctrl+C cancelled\n"; +} + +// 10. Zero timeout standalone Escape followed by newline must cancel +try { + $t = makeStreamTerminal("\x1b\n"); + $sec = $t->readSecret(Duration::fromSeconds(0)); + echo "FAIL: Zero timeout Escape returned secret: " . bin2hex($sec) . "\n"; +} catch (TerminalException $e) { + echo "PASS: Zero timeout Escape cancelled\n"; +} + +// 11. Zero timeout navigation sequence \x1b[A followed by text must ignore navigation and return text +$t = makeStreamTerminal("\x1b[Apass\n"); +$sec = $t->readSecret(Duration::fromSeconds(0)); +var_dump($sec === "pass"); + +// 12. Zero timeout SS3 F1 followed by text must ignore F1 and return text +$t = makeStreamTerminal("\x1bOPpass\n"); +$sec = $t->readSecret(Duration::fromSeconds(0)); +var_dump($sec === "pass"); + +?> +--EXPECT-- +PASS: Ctrl+C cancelled +PASS: Ctrl+D cancelled +PASS: Escape cancelled +bool(true) +bool(true) +bool(true) +bool(true) +PASS: CSI Ctrl+C cancelled +PASS: SS3 Ctrl+C cancelled +PASS: Zero timeout Escape cancelled +bool(true) +bool(true) diff --git a/ext/standard/tests/terminal/terminal_stream_sequence_timeout.phpt b/ext/standard/tests/terminal/terminal_stream_sequence_timeout.phpt new file mode 100644 index 000000000000..2c6d2b8663a4 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_stream_sequence_timeout.phpt @@ -0,0 +1,129 @@ +--TEST-- +Io\Terminal\Terminal: ordinary-stream sequence timeout on partial Escape/CSI/UTF-8 +--SKIPIF-- + +--FILE-- +readKey(Duration::fromSeconds(0), Duration::fromSeconds(0)); +$elapsed0 = (microtime(true) - $t0) * 1000; +var_dump($key0 === Key::Escape); +var_dump($elapsed0 < 100); + +// Test 2: Standalone Escape with sequence timeout returns Escape after sequence delay, not blocking forever +fwrite($writeStream, "\x1b"); +$t1 = microtime(true); +$key1 = $term->readKey(Duration::fromMilliseconds(500), Duration::fromMilliseconds(60)); +$elapsed1 = (microtime(true) - $t1) * 1000; +var_dump($key1 === Key::Escape); +var_dump($elapsed1 >= 40 && $elapsed1 < 400); + +// Test 3: Partial CSI "\x1b[" on pipe returns after sequence timeout +fwrite($writeStream, "\x1b["); +$t2 = microtime(true); +$key2 = $term->readKey(Duration::fromMilliseconds(500), Duration::fromMilliseconds(60)); +$elapsed2 = (microtime(true) - $t2) * 1000; +var_dump($key2 === "\x1b["); +var_dump($elapsed2 >= 40 && $elapsed2 < 400); + +// Test 4: Incomplete UTF-8 prefix "\xE2" times out within deadline and retains prefix +fwrite($writeStream, "\xE2"); +$t3 = microtime(true); +$key3 = $term->readKey(Duration::fromMilliseconds(60)); +$elapsed3 = (microtime(true) - $t3) * 1000; +var_dump($key3 === null); +var_dump($elapsed3 >= 40 && $elapsed3 < 400); + +// Supply completion bytes +fwrite($writeStream, "\x82\xAC"); +$key4 = $term->readKey(Duration::fromMilliseconds(100)); +var_dump($key4 === "€"); + +fclose($readStream); +fclose($writeStream); + +// Test 5: Incomplete CSI sequence in readSecret times out within overall deadline +$pair5 = stream_socket_pair( + PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0 +); +$read5 = $pair5[0]; +$write5 = $pair5[1]; +$term5 = Terminal::fromStreams($read5); + +fwrite($write5, "\x1b[1;2;3"); +$t5 = microtime(true); +$sec5 = $term5->readSecret(Duration::fromMilliseconds(150)); +$elapsed5 = (microtime(true) - $t5) * 1000; +var_dump($sec5 === null); +var_dump($elapsed5 >= 100 && $elapsed5 < 600); + +fclose($read5); +fclose($write5); + +// Test 6: A producer continuously supplies non-final CSI bytes for roughly two seconds; +// readSecret with a 150ms timeout must return null well before producer finishes +$proc6 = proc_open( + [PHP_BINARY, "-n", "-r", "fwrite(STDOUT, \"\\x1b[\"); \$t = microtime(true); while (microtime(true) - \$t < 2.0) { fwrite(STDOUT, \"1;2\"); usleep(5000); } fwrite(STDOUT, \"m\");"], + [["pipe", "r"], ["pipe", "w"], ["pipe", "w"]], + $pipes6 +); + +if (is_resource($proc6)) { + $read6 = $pipes6[1]; + $term6 = Terminal::fromStreams($read6); + $t6 = microtime(true); + $sec6 = $term6->readSecret(Duration::fromMilliseconds(150)); + $elapsed6 = (microtime(true) - $t6) * 1000; + var_dump($sec6 === null); + var_dump($elapsed6 >= 100 && $elapsed6 < 800); + fclose($pipes6[0]); + fclose($pipes6[1]); + fclose($pipes6[2]); + proc_terminate($proc6); + proc_close($proc6); +} + +?> +--EXPECTF-- +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) diff --git a/ext/standard/tests/terminal/terminal_stream_timeout.phpt b/ext/standard/tests/terminal/terminal_stream_timeout.phpt new file mode 100644 index 000000000000..0373a27d0684 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_stream_timeout.phpt @@ -0,0 +1,121 @@ +--TEST-- +Io\Terminal\Terminal: ordinary-stream timeout on empty pipe with open writer +--SKIPIF-- + +--FILE-- +readKey(Duration::fromSeconds(0)); +$elapsed0 = (microtime(true) - $t0) * 1000; +var_dump($key0 === null); +var_dump($elapsed0 < 200); + +// Test 2: Empty pipe with finite timeout returns null after bounded wait +$t1 = microtime(true); +$key1 = $term->readKey(Duration::fromMilliseconds(50)); +$elapsed1 = (microtime(true) - $t1) * 1000; +var_dump($key1 === null); +var_dump($elapsed1 >= 30 && $elapsed1 < 300); + +// Test 3: Queued byte is immediately readable +fwrite($writeStream, "Z"); +$t2 = microtime(true); +$key2 = $term->readKey(Duration::fromMilliseconds(200)); +$elapsed2 = (microtime(true) - $t2) * 1000; +var_dump($key2 === "Z"); +var_dump($elapsed2 < 100); + +// Test 4: readSecret on empty pipe with finite timeout returns null after bounded wait +$t3 = microtime(true); +$sec3 = $term->readSecret(Duration::fromMilliseconds(50)); +$elapsed3 = (microtime(true) - $t3) * 1000; +var_dump($sec3 === null); +var_dump($elapsed3 >= 30 && $elapsed3 < 300); + +fclose($readStream); +fclose($writeStream); + +// Part 2: Anonymous pipe via proc_open +$proc = proc_open( + [PHP_BINARY, '-r', 'usleep(2000000);'], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $procPipes +); +if (!is_resource($proc)) { + die("Failed to proc_open child"); +} + +$pipeTerm = Terminal::fromStreams($procPipes[1]); + +// Test 5: Empty anonymous pipe with zero timeout returns null immediately (not blocking) +$t4 = microtime(true); +$key4 = $pipeTerm->readKey(Duration::fromSeconds(0)); +$elapsed4 = (microtime(true) - $t4) * 1000; +var_dump($key4 === null); +var_dump($elapsed4 < 200); + +// Test 6: Empty anonymous pipe with finite timeout returns null after bounded wait +$t5 = microtime(true); +$key5 = $pipeTerm->readKey(Duration::fromMilliseconds(50)); +$elapsed5 = (microtime(true) - $t5) * 1000; +var_dump($key5 === null); +var_dump($elapsed5 >= 30 && $elapsed5 < 300); + +// Test 7: readSecret on empty anonymous pipe with finite timeout returns null after bounded wait +$t6 = microtime(true); +$sec6 = $pipeTerm->readSecret(Duration::fromMilliseconds(50)); +$elapsed6 = (microtime(true) - $t6) * 1000; +var_dump($sec6 === null); +var_dump($elapsed6 >= 30 && $elapsed6 < 300); + +fclose($procPipes[0]); +fclose($procPipes[1]); +fclose($procPipes[2]); +proc_terminate($proc); +proc_close($proc); + +?> +--EXPECTF-- +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true)