From 6ca4ace6e61b8a15097abdd0308be3b8f84ed720 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah Date: Tue, 21 Jul 2026 17:15:05 +0000 Subject: [PATCH] fix: preserve SSH2 resource and stream lifecycles Guard every native channel, listener, public-key, and SFTP child operation with the owning session lifetime. Keep cleanup on the Zend side balanced after explicit disconnects, and prevent polling or stream operations from dereferencing released libssh2 state. Make direct wrapper connection and authentication ownership transactional, release temporary SFTP resources after one-shot operations, and correct the fetched-stream refcount rollback. Preserve channel and SFTP errors as errors, reserve EOF for clean zero-byte completion, normalize EAGAIN as nonblocking backpressure, and initialize accepted-channel timeout state. Add focused PHPT coverage for ownership, descriptor release, parent loss, polling, backpressure, errors, and clean completion. --- php_ssh2.h | 5 + ssh2.c | 47 ++++- ssh2_fopen_wrappers.c | 118 +++++++++---- ssh2_sftp.c | 124 ++++++++++--- tests/ssh2_channel_stream_semantics.phpt | 66 +++++++ tests/ssh2_resource_lifetime.phpt | 167 ++++++++++++++++++ tests/ssh2_sftp_stream_semantics.phpt | 96 ++++++++++ tests/ssh2_sftp_wrapper_auth_failure.phpt | 40 +++++ tests/ssh2_sftp_wrapper_release.phpt | 61 +++++++ .../ssh2_sftp_wrapper_session_ownership.phpt | 52 ++++++ 10 files changed, 706 insertions(+), 70 deletions(-) create mode 100644 tests/ssh2_channel_stream_semantics.phpt create mode 100644 tests/ssh2_resource_lifetime.phpt create mode 100644 tests/ssh2_sftp_stream_semantics.phpt create mode 100644 tests/ssh2_sftp_wrapper_auth_failure.phpt create mode 100644 tests/ssh2_sftp_wrapper_release.phpt create mode 100644 tests/ssh2_sftp_wrapper_session_ownership.phpt diff --git a/php_ssh2.h b/php_ssh2.h index c27e002..ad9015d 100644 --- a/php_ssh2.h +++ b/php_ssh2.h @@ -170,6 +170,11 @@ extern php_stream_wrapper php_ssh2_sftp_wrapper; extern int le_ssh2_session; extern int le_ssh2_sftp; +static inline zend_bool php_ssh2_session_is_open(zend_resource *session_rsrc) +{ + return session_rsrc && session_rsrc->ptr != NULL; +} + #if PHP_VERSION_ID < 70300 #define SSH2_URL_STR(a) (a) #define SSH2_URL_LEN(a) strlen(a) diff --git a/ssh2.c b/ssh2.c index 0bb7ce6..96eac7b 100644 --- a/ssh2.c +++ b/ssh2.c @@ -906,6 +906,10 @@ PHP_FUNCTION(ssh2_forward_accept) RETURN_FALSE; } + if (!php_ssh2_session_is_open(data->session_rsrc)) { + RETURN_FALSE; + } + channel = libssh2_channel_forward_accept(data->listener); if (!channel) { @@ -916,6 +920,7 @@ PHP_FUNCTION(ssh2_forward_accept) channel_data->channel = channel; channel_data->streamid = 0; channel_data->is_blocking = 0; + channel_data->timeout = 0; channel_data->session_rsrc = data->session_rsrc; channel_data->refcount = NULL; @@ -1010,12 +1015,28 @@ PHP_FUNCTION(ssh2_poll) res_type = Z_RES_P(tmpzval)->type; res = zend_fetch_resource_ex(tmpzval, "Poll Resource", res_type); if (res_type == le_ssh2_listener) { + php_ssh2_listener_data *listener_data = (php_ssh2_listener_data*)res; + + if (!php_ssh2_session_is_open(listener_data->session_rsrc)) { + php_error_docref(NULL, E_WARNING, "Cannot poll a listener after its session is closed"); + numfds--; + continue; + } + pollfds[i].type = LIBSSH2_POLLFD_LISTENER; - pollfds[i].fd.listener = ((php_ssh2_listener_data*)res)->listener; + pollfds[i].fd.listener = listener_data->listener; } else if ((res_type == le_stream || res_type == le_pstream) && ((php_stream*)res)->ops == &php_ssh2_channel_stream_ops) { + php_ssh2_channel_data *channel_data = (php_ssh2_channel_data*)((php_stream*)res)->abstract; + + if (!php_ssh2_session_is_open(channel_data->session_rsrc)) { + php_error_docref(NULL, E_WARNING, "Cannot poll a channel after its session is closed"); + numfds--; + continue; + } + pollfds[i].type = LIBSSH2_POLLFD_CHANNEL; - pollfds[i].fd.channel = ((php_ssh2_channel_data*)(((php_stream*)res)->abstract))->channel; + pollfds[i].fd.channel = channel_data->channel; /* TODO: Add the ability to select against other stream types */ } else { php_error_docref(NULL, E_WARNING, "Invalid resource type in subarray: %s", zend_rsrc_list_get_rsrc_type(Z_RES_P(tmpzval))); @@ -1114,6 +1135,10 @@ PHP_FUNCTION(ssh2_publickey_add) RETURN_FALSE; } + if (!php_ssh2_session_is_open(data->session_rsrc)) { + RETURN_FALSE; + } + if (zattrs) { HashPosition pos; zval *attr_val; @@ -1209,6 +1234,10 @@ PHP_FUNCTION(ssh2_publickey_remove) RETURN_FALSE; } + if (!php_ssh2_session_is_open(data->session_rsrc)) { + RETURN_FALSE; + } + if (libssh2_publickey_remove_ex(data->pkey, (unsigned char *) algo, algo_len, (unsigned char *) blob, blob_len)) { php_error_docref(NULL, E_WARNING, "Unable to remove %s key", algo); RETURN_FALSE; @@ -1236,6 +1265,10 @@ PHP_FUNCTION(ssh2_publickey_list) RETURN_FALSE; } + if (!php_ssh2_session_is_open(data->session_rsrc)) { + RETURN_FALSE; + } + if (libssh2_publickey_list_fetch(data->pkey, &num_keys, &keys)) { php_error_docref(NULL, E_WARNING, "Unable to list keys on remote server"); RETURN_FALSE; @@ -1389,7 +1422,10 @@ static void php_ssh2_listener_dtor(zend_resource *rsrc) php_ssh2_listener_data *data = (php_ssh2_listener_data*)rsrc->ptr; LIBSSH2_LISTENER *listener = data->listener; - libssh2_channel_forward_cancel(listener); + if (php_ssh2_session_is_open(data->session_rsrc)) { + libssh2_channel_forward_cancel(listener); + } + zend_list_delete(data->session_rsrc); efree(data); } @@ -1399,7 +1435,10 @@ static void php_ssh2_pkey_subsys_dtor(zend_resource *rsrc) php_ssh2_pkey_subsys_data *data = (php_ssh2_pkey_subsys_data*)rsrc->ptr; LIBSSH2_PUBLICKEY *pkey = data->pkey; - libssh2_publickey_shutdown(pkey); + if (php_ssh2_session_is_open(data->session_rsrc)) { + libssh2_publickey_shutdown(pkey); + } + zend_list_delete(data->session_rsrc); efree(data); } diff --git a/ssh2_fopen_wrappers.c b/ssh2_fopen_wrappers.c index f1b3ce1..33cf3be 100644 --- a/ssh2_fopen_wrappers.c +++ b/ssh2_fopen_wrappers.c @@ -48,14 +48,20 @@ static ssize_t php_ssh2_channel_stream_write(php_stream *stream, const char *buf { php_ssh2_channel_data *abstract = (php_ssh2_channel_data*)stream->abstract; ssize_t writestate; - LIBSSH2_SESSION *session; - libssh2_channel_set_blocking(abstract->channel, abstract->is_blocking); - session = (LIBSSH2_SESSION *)zend_fetch_resource(abstract->session_rsrc, PHP_SSH2_SESSION_RES_NAME, le_ssh2_session); +#ifdef PHP_SSH2_SESSION_TIMEOUT + LIBSSH2_SESSION *session; +#endif + if (!php_ssh2_session_is_open(abstract->session_rsrc)) { + return -1; + } + libssh2_channel_set_blocking(abstract->channel, abstract->is_blocking); #ifdef PHP_SSH2_SESSION_TIMEOUT + session = (LIBSSH2_SESSION *)zend_fetch_resource(abstract->session_rsrc, PHP_SSH2_SESSION_RES_NAME, le_ssh2_session); + if (abstract->is_blocking) { libssh2_session_set_timeout(session, abstract->timeout); } @@ -70,16 +76,8 @@ static ssize_t php_ssh2_channel_stream_write(php_stream *stream, const char *buf #endif if (writestate == LIBSSH2_ERROR_EAGAIN) { -#if PHP_VERSION_ID < 70400 writestate = 0; -#endif } else if (writestate < 0) { - char *error_msg = NULL; - if (libssh2_session_last_error(session, &error_msg, NULL, 0) == writestate) { - php_error_docref(NULL, E_WARNING, "Failure '%s' (%ld)", error_msg, writestate); - } - - stream->eof = 1; #if PHP_VERSION_ID < 70400 writestate = 0; #endif @@ -96,13 +94,21 @@ static ssize_t php_ssh2_channel_stream_read(php_stream *stream, char *buf, size_ { php_ssh2_channel_data *abstract = (php_ssh2_channel_data*)stream->abstract; ssize_t readstate; + +#ifdef PHP_SSH2_SESSION_TIMEOUT LIBSSH2_SESSION *session; +#endif + + if (!php_ssh2_session_is_open(abstract->session_rsrc)) { + stream->eof = 1; + return -1; + } - stream->eof = libssh2_channel_eof(abstract->channel); libssh2_channel_set_blocking(abstract->channel, abstract->is_blocking); - session = (LIBSSH2_SESSION *)zend_fetch_resource(abstract->session_rsrc, PHP_SSH2_SESSION_RES_NAME, le_ssh2_session); #ifdef PHP_SSH2_SESSION_TIMEOUT + session = (LIBSSH2_SESSION *)zend_fetch_resource(abstract->session_rsrc, PHP_SSH2_SESSION_RES_NAME, le_ssh2_session); + if (abstract->is_blocking) { libssh2_session_set_timeout(session, abstract->timeout); } @@ -117,17 +123,15 @@ static ssize_t php_ssh2_channel_stream_read(php_stream *stream, char *buf, size_ #endif if (readstate == LIBSSH2_ERROR_EAGAIN) { -#if PHP_VERSION_ID < 70400 + stream->eof = 0; readstate = 0; -#endif } else if (readstate < 0) { - char *error_msg = NULL; - if (libssh2_session_last_error(session, &error_msg, NULL, 0) == readstate) { - php_error_docref(NULL, E_WARNING, "Failure '%s' (%ld)", error_msg, readstate); - } - - stream->eof = 1; + stream->eof = 0; +#if PHP_VERSION_ID < 70400 readstate = 0; +#endif + } else { + stream->eof = (readstate == 0 && libssh2_channel_eof(abstract->channel)); } return readstate; } @@ -141,8 +145,10 @@ static int php_ssh2_channel_stream_close(php_stream *stream, int close_handle) if (abstract->refcount) { efree(abstract->refcount); } - libssh2_channel_eof(abstract->channel); - libssh2_channel_free(abstract->channel); + if (php_ssh2_session_is_open(abstract->session_rsrc)) { + libssh2_channel_free(abstract->channel); + } + zend_list_delete(abstract->session_rsrc); } efree(abstract); @@ -154,6 +160,10 @@ static int php_ssh2_channel_stream_flush(php_stream *stream) { php_ssh2_channel_data *abstract = (php_ssh2_channel_data*)stream->abstract; + if (!php_ssh2_session_is_open(abstract->session_rsrc)) { + return -1; + } + return libssh2_channel_flush_ex(abstract->channel, abstract->streamid); } @@ -163,6 +173,10 @@ static int php_ssh2_channel_stream_cast(php_stream *stream, int castas, void **r LIBSSH2_SESSION *session; php_ssh2_session_data **session_data; + if (!php_ssh2_session_is_open(abstract->session_rsrc)) { + return FAILURE; + } + session = (LIBSSH2_SESSION *)zend_fetch_resource(abstract->session_rsrc, PHP_SSH2_SESSION_RES_NAME, le_ssh2_session); session_data = (php_ssh2_session_data **)libssh2_session_abstract(session); @@ -192,8 +206,10 @@ static int php_ssh2_channel_stream_set_option(php_stream *stream, int option, in break; case PHP_STREAM_OPTION_META_DATA_API: - add_assoc_long((zval*)ptrparam, "exit_status", libssh2_channel_get_exit_status(abstract->channel)); - break; + if (php_ssh2_session_is_open(abstract->session_rsrc)) { + add_assoc_long((zval*)ptrparam, "exit_status", libssh2_channel_get_exit_status(abstract->channel)); + } + return PHP_STREAM_OPTION_RETURN_OK; case PHP_STREAM_OPTION_READ_TIMEOUT: ret = abstract->timeout; @@ -207,6 +223,10 @@ static int php_ssh2_channel_stream_set_option(php_stream *stream, int option, in break; case PHP_STREAM_OPTION_CHECK_LIVENESS: + if (!php_ssh2_session_is_open(abstract->session_rsrc)) { + return stream->eof = 1; + } + return stream->eof = libssh2_channel_eof(abstract->channel); break; } @@ -303,6 +323,11 @@ php_url *php_ssh2_fopen_wrapper_parse_path(const char *path, char *type, php_str /* suppress potential warning by passing NULL as resource_type_name */ sftp_data = (php_ssh2_sftp_data *)zend_fetch_resource(Z_RES_P(zresource), NULL, le_ssh2_sftp); if (sftp_data) { + if (!php_ssh2_session_is_open(sftp_data->session_rsrc)) { + php_url_free(resource); + return NULL; + } + /* Want the sftp layer */ Z_ADDREF_P(zresource); *psftp_rsrc = Z_RES_P(zresource); @@ -347,6 +372,11 @@ php_url *php_ssh2_fopen_wrapper_parse_path(const char *path, char *type, php_str php_ssh2_sftp_data *sftp_data; sftp_data = (php_ssh2_sftp_data *)zend_fetch_resource(Z_RES_P(tmpzval), PHP_SSH2_SFTP_RES_NAME, le_ssh2_sftp); if (sftp_data) { + if (!php_ssh2_session_is_open(sftp_data->session_rsrc)) { + php_url_free(resource); + return NULL; + } + Z_ADDREF_P(tmpzval); *psftp_rsrc = Z_RES_P(tmpzval); *psftp = sftp_data->sftp; @@ -454,6 +484,12 @@ php_url *php_ssh2_fopen_wrapper_parse_path(const char *path, char *type, php_str return NULL; } + if (pubkey_file && privkey_file && + (php_check_open_basedir(pubkey_file) || php_check_open_basedir(privkey_file))) { + php_url_free(resource); + return NULL; + } + session = php_ssh2_session_connect(SSH2_URL_STR(resource->host), resource->port, methods, callbacks); if (!session) { /* Unable to connect! */ @@ -461,13 +497,10 @@ php_url *php_ssh2_fopen_wrapper_parse_path(const char *path, char *type, php_str return NULL; } + ZVAL_RES(&zsession, zend_register_resource(session, le_ssh2_session)); + /* Authenticate */ if (pubkey_file && privkey_file) { - if (php_check_open_basedir(pubkey_file) || php_check_open_basedir(privkey_file)) { - php_url_free(resource); - return NULL; - } - /* Attempt pubkey authentication */ if (!libssh2_userauth_publickey_fromfile(session, username, pubkey_file, privkey_file, password)) { goto session_authed; @@ -483,14 +516,10 @@ php_url *php_ssh2_fopen_wrapper_parse_path(const char *path, char *type, php_str /* Auth failure */ php_url_free(resource); - if (Z_RES(zsession)) { - zend_list_delete(Z_RES(zsession)); - } + zend_list_delete(Z_RES(zsession)); return NULL; session_authed: - ZVAL_RES(&zsession, zend_register_resource(session, le_ssh2_session)); - if (psftp && psftp_rsrc) { LIBSSH2_SFTP *sftp; @@ -505,7 +534,6 @@ php_url *php_ssh2_fopen_wrapper_parse_path(const char *path, char *type, php_str sftp_data->session = session; sftp_data->sftp = sftp; sftp_data->session_rsrc = Z_RES(zsession); - Z_ADDREF(zsession); *psftp_rsrc = zend_register_resource(sftp_data, le_ssh2_sftp); *psftp = sftp; } @@ -785,6 +813,10 @@ PHP_FUNCTION(ssh2_shell_resize) data = (php_ssh2_channel_data*)parent->abstract; + if (!php_ssh2_session_is_open(data->session_rsrc)) { + RETURN_FALSE; + } + libssh2_channel_request_pty_size_ex(data->channel, width, height, width_px, height_px); RETURN_TRUE; @@ -1465,6 +1497,10 @@ PHP_FUNCTION(ssh2_fetch_stream) data = (php_ssh2_channel_data*)parent->abstract; + if (!php_ssh2_session_is_open(data->session_rsrc)) { + RETURN_FALSE; + } + if (!data->refcount) { data->refcount = emalloc(sizeof(unsigned char)); *(data->refcount) = 1; @@ -1485,7 +1521,7 @@ PHP_FUNCTION(ssh2_fetch_stream) if (!stream) { php_error_docref(NULL, E_WARNING, "Error opening substream"); efree(stream_data); - (data->refcount)--; + (*(data->refcount))--; RETURN_FALSE; } @@ -1521,6 +1557,10 @@ PHP_FUNCTION(ssh2_send_signal) RETURN_FALSE; } + if (!php_ssh2_session_is_open(data->session_rsrc)) { + RETURN_FALSE; + } + ssh2_ret = libssh2_channel_signal_ex(data->channel, signal->val, signal->len); if (ssh2_ret < 0) { php_error_docref(NULL, E_WARNING, "Couldn't send signal %s to channel (Return code %d)", signal->val, ssh2_ret); @@ -1558,6 +1598,10 @@ PHP_FUNCTION(ssh2_send_eof) RETURN_FALSE; } + if (!php_ssh2_session_is_open(data->session_rsrc)) { + RETURN_FALSE; + } + ssh2_ret = libssh2_channel_send_eof(data->channel); if (ssh2_ret < 0) { php_error_docref(NULL, E_WARNING, "Couldn't send EOF to channel (Return code %d)", ssh2_ret); diff --git a/ssh2_sftp.c b/ssh2_sftp.c index da4e6bf..14ec86f 100644 --- a/ssh2_sftp.c +++ b/ssh2_sftp.c @@ -36,7 +36,7 @@ void php_ssh2_sftp_dtor(zend_resource *rsrc) return; } - if (data->session_rsrc->ptr != NULL) { + if (php_ssh2_session_is_open(data->session_rsrc)) { libssh2_sftp_shutdown(data->sftp); } @@ -103,6 +103,19 @@ typedef struct _php_ssh2_sftp_handle_data { zend_resource *sftp_rsrc; } php_ssh2_sftp_handle_data; +static inline zend_bool php_ssh2_sftp_handle_session_is_open(php_ssh2_sftp_handle_data *data) +{ + php_ssh2_sftp_data *sftp_data; + + if (!data->sftp_rsrc || !data->sftp_rsrc->ptr) { + return 0; + } + + sftp_data = (php_ssh2_sftp_data*)data->sftp_rsrc->ptr; + + return php_ssh2_session_is_open(sftp_data->session_rsrc); +} + /* {{{ php_ssh2_sftp_stream_write */ #if PHP_VERSION_ID < 70400 @@ -114,6 +127,10 @@ static ssize_t php_ssh2_sftp_stream_write(php_stream *stream, const char *buf, s php_ssh2_sftp_handle_data *data = (php_ssh2_sftp_handle_data*)stream->abstract; ssize_t bytes_written; + if (!php_ssh2_sftp_handle_session_is_open(data)) { + return -1; + } + bytes_written = libssh2_sftp_write(data->handle, buf, count); #if PHP_VERSION_ID < 70400 @@ -135,9 +152,13 @@ static ssize_t php_ssh2_sftp_stream_read(php_stream *stream, char *buf, size_t c php_ssh2_sftp_handle_data *data = (php_ssh2_sftp_handle_data*)stream->abstract; ssize_t bytes_read; + if (!php_ssh2_sftp_handle_session_is_open(data)) { + return -1; + } + bytes_read = libssh2_sftp_read(data->handle, buf, count); - stream->eof = (bytes_read <= 0 && bytes_read != LIBSSH2_ERROR_EAGAIN); + stream->eof = (bytes_read == 0); #if PHP_VERSION_ID < 70400 return (size_t)(bytes_read<0 ? 0 : bytes_read); @@ -152,9 +173,7 @@ static ssize_t php_ssh2_sftp_stream_read(php_stream *stream, char *buf, size_t c static int php_ssh2_sftp_stream_close(php_stream *stream, int close_handle) { php_ssh2_sftp_handle_data *data = (php_ssh2_sftp_handle_data*)stream->abstract; - php_ssh2_sftp_data *sftp_data = (php_ssh2_sftp_data*)data->sftp_rsrc->ptr; - - if (sftp_data && sftp_data->session_rsrc->ptr != NULL) { + if (php_ssh2_sftp_handle_session_is_open(data)) { libssh2_sftp_close(data->handle); } zend_list_delete(data->sftp_rsrc); @@ -170,6 +189,10 @@ static int php_ssh2_sftp_stream_seek(php_stream *stream, zend_off_t offset, int { php_ssh2_sftp_handle_data *data = (php_ssh2_sftp_handle_data*)stream->abstract; + if (!php_ssh2_sftp_handle_session_is_open(data)) { + return -1; + } + switch (whence) { case SEEK_END: { @@ -214,6 +237,10 @@ static int php_ssh2_sftp_stream_fstat(php_stream *stream, php_stream_statbuf *ss php_ssh2_sftp_handle_data *data = (php_ssh2_sftp_handle_data*)stream->abstract; LIBSSH2_SFTP_ATTRIBUTES attrs; + if (!php_ssh2_sftp_handle_session_is_open(data)) { + return -1; + } + if (libssh2_sftp_fstat(data->handle, &attrs)) { return -1; } @@ -295,12 +322,27 @@ static ssize_t php_ssh2_sftp_dirstream_read(php_stream *stream, char *buf, size_ { php_ssh2_sftp_handle_data *data = (php_ssh2_sftp_handle_data*)stream->abstract; php_stream_dirent *ent = (php_stream_dirent*)buf; - int bytesread = libssh2_sftp_readdir(data->handle, ent->d_name, sizeof(ent->d_name) - 1, NULL); + int bytesread; zend_string *basename; - if (bytesread <= 0) { + if (!php_ssh2_sftp_handle_session_is_open(data)) { + stream->eof = 0; + return -1; + } + + bytesread = libssh2_sftp_readdir(data->handle, ent->d_name, sizeof(ent->d_name) - 1, NULL); + + if (bytesread < 0) { + stream->eof = 0; + return bytesread; + } + + if (bytesread == 0) { + stream->eof = 1; return 0; } + + stream->eof = 0; ent->d_name[bytesread] = 0; basename = php_basename(ent->d_name, bytesread, NULL, 0); @@ -322,9 +364,7 @@ static ssize_t php_ssh2_sftp_dirstream_read(php_stream *stream, char *buf, size_ static int php_ssh2_sftp_dirstream_close(php_stream *stream, int close_handle) { php_ssh2_sftp_handle_data *data = (php_ssh2_sftp_handle_data*)stream->abstract; - php_ssh2_sftp_data *sftp_data = (php_ssh2_sftp_data*)data->sftp_rsrc->ptr; - - if (sftp_data && sftp_data->session_rsrc->ptr != NULL) { + if (php_ssh2_sftp_handle_session_is_open(data)) { libssh2_sftp_close(data->handle); } zend_list_delete(data->sftp_rsrc); @@ -360,7 +400,7 @@ static php_stream *php_ssh2_sftp_dirstream_opener(php_stream_wrapper *wrapper, c php_url *resource; resource = php_ssh2_fopen_wrapper_parse_path(filename, "sftp", context, &session, &rsrc, &sftp, &sftp_rsrc); - if (!resource || !session || !sftp) { + if (!resource || !session || !sftp || !sftp_rsrc) { return NULL; } @@ -401,23 +441,22 @@ static int php_ssh2_sftp_urlstat(php_stream_wrapper *wrapper, const char *url, i LIBSSH2_SFTP *sftp = NULL; zend_resource *rsrc = NULL, *sftp_rsrc = NULL; php_url *resource; + int result; resource = php_ssh2_fopen_wrapper_parse_path(url, "sftp", context, &session, &rsrc, &sftp, &sftp_rsrc); if (!resource || !session || !sftp || !resource->path) { return -1; } - if (libssh2_sftp_stat_ex(sftp, SSH2_URL_STR(resource->path), SSH2_URL_LEN(resource->path), - (flags & PHP_STREAM_URL_STAT_LINK) ? LIBSSH2_SFTP_LSTAT : LIBSSH2_SFTP_STAT, &attrs)) { - php_url_free(resource); - //zend_list_delete(sftp_rsrcid); - return -1; - } + result = libssh2_sftp_stat_ex(sftp, SSH2_URL_STR(resource->path), SSH2_URL_LEN(resource->path), + (flags & PHP_STREAM_URL_STAT_LINK) ? LIBSSH2_SFTP_LSTAT : LIBSSH2_SFTP_STAT, &attrs); php_url_free(resource); + zend_list_delete(sftp_rsrc); - /* parse_path addrefs the resource, but we're not holding on to it so we have to delref it before we leave */ - //zend_list_delete(sftp_rsrcid); + if (result) { + return -1; + } return php_ssh2_sftp_attr2ssb(ssb, &attrs); } @@ -443,8 +482,7 @@ static int php_ssh2_sftp_unlink(php_stream_wrapper *wrapper, const char *url, in result = libssh2_sftp_unlink(sftp, SSH2_URL_STR(resource->path)); php_url_free(resource); - - //zend_list_delete(sftp_rsrcid); + zend_list_delete(sftp_rsrc); /* libssh2 uses 0 for success and the streams API uses 0 for failure, so invert */ return (result == 0) ? -1 : 0; @@ -486,8 +524,7 @@ static int php_ssh2_sftp_rename(php_stream_wrapper *wrapper, const char *url_fro result = libssh2_sftp_rename(sftp, SSH2_URL_STR(resource->path), SSH2_URL_STR(resource_to->path)); php_url_free(resource); php_url_free(resource_to); - - //zend_list_delete(sftp_rsrcid); + zend_list_delete(sftp_rsrc); /* libssh2 uses 0 for success and the streams API uses 0 for failure, so invert */ return (result == 0) ? -1 : 0; @@ -522,8 +559,7 @@ static int php_ssh2_sftp_mkdir(php_stream_wrapper *wrapper, const char *url, int result = libssh2_sftp_mkdir(sftp, SSH2_URL_STR(resource->path), mode); php_url_free(resource); - - //zend_list_delete(sftp_rsrcid); + zend_list_delete(sftp_rsrc); /* libssh2 uses 0 for success and the streams API uses 0 for failure, so invert */ return (result == 0) ? -1 : 0; @@ -550,8 +586,7 @@ static int php_ssh2_sftp_rmdir(php_stream_wrapper *wrapper, const char *url, int result = libssh2_sftp_rmdir(sftp, SSH2_URL_STR(resource->path)); php_url_free(resource); - - //zend_list_delete(sftp_rsrcid); + zend_list_delete(sftp_rsrc); /* libssh2 uses 0 for success and the streams API uses 0 for failure, so invert */ return (result == 0) ? -1 : 0; @@ -638,6 +673,10 @@ PHP_FUNCTION(ssh2_sftp_rename) RETURN_FALSE; } + if (!php_ssh2_session_is_open(data->session_rsrc)) { + RETURN_FALSE; + } + RETURN_BOOL(!libssh2_sftp_rename_ex(data->sftp, src->val, src->len, dst->val, dst->len, LIBSSH2_SFTP_RENAME_OVERWRITE | LIBSSH2_SFTP_RENAME_ATOMIC | LIBSSH2_SFTP_RENAME_NATIVE)); } @@ -659,6 +698,10 @@ PHP_FUNCTION(ssh2_sftp_unlink) RETURN_FALSE; } + if (!php_ssh2_session_is_open(data->session_rsrc)) { + RETURN_FALSE; + } + RETURN_BOOL(!libssh2_sftp_unlink_ex(data->sftp, filename->val, filename->len)); } /* }}} */ @@ -686,6 +729,10 @@ PHP_FUNCTION(ssh2_sftp_mkdir) RETURN_FALSE; } + if (!php_ssh2_session_is_open(data->session_rsrc)) { + RETURN_FALSE; + } + if (recursive) { /* Just attempt to make every directory, some will fail, but we only care about the last success/failure */ p = dirname->val; @@ -718,6 +765,10 @@ PHP_FUNCTION(ssh2_sftp_rmdir) RETURN_FALSE; } + if (!php_ssh2_session_is_open(data->session_rsrc)) { + RETURN_FALSE; + } + RETURN_BOOL(!libssh2_sftp_rmdir_ex(data->sftp, dirname->val, dirname->len)); } /* }}} */ @@ -744,6 +795,10 @@ PHP_FUNCTION(ssh2_sftp_chmod) RETURN_FALSE; } + if (!php_ssh2_session_is_open(data->session_rsrc)) { + RETURN_FALSE; + } + attrs.permissions = mode; attrs.flags = LIBSSH2_SFTP_ATTR_PERMISSIONS; @@ -771,6 +826,10 @@ static void php_ssh2_sftp_stat_func(INTERNAL_FUNCTION_PARAMETERS, int stat_type) RETURN_FALSE; } + if (!php_ssh2_session_is_open(data->session_rsrc)) { + RETURN_FALSE; + } + if (libssh2_sftp_stat_ex(data->sftp, path->val, path->len, stat_type, &attrs)) { php_error_docref(NULL, E_WARNING, "Failed to stat remote file"); RETURN_FALSE; @@ -835,6 +894,10 @@ PHP_FUNCTION(ssh2_sftp_symlink) RETURN_FALSE; } + if (!php_ssh2_session_is_open(data->session_rsrc)) { + RETURN_FALSE; + } + RETURN_BOOL(!libssh2_sftp_symlink_ex(data->sftp, targ->val, targ->len, link->val, link->len, LIBSSH2_SFTP_SYMLINK)); } /* }}} */ @@ -857,6 +920,10 @@ PHP_FUNCTION(ssh2_sftp_readlink) RETURN_FALSE; } + if (!php_ssh2_session_is_open(data->session_rsrc)) { + RETURN_FALSE; + } + if ((targ_len = libssh2_sftp_symlink_ex(data->sftp, link->val, link->len, targ, 8192, LIBSSH2_SFTP_READLINK)) < 0) { php_error_docref(NULL, E_WARNING, "Unable to read link '%s'", ZSTR_VAL(link)); RETURN_FALSE; @@ -884,7 +951,7 @@ PHP_FUNCTION(ssh2_sftp_realpath) RETURN_FALSE; } - if (data->session_rsrc->ptr == NULL) { + if (!php_ssh2_session_is_open(data->session_rsrc)) { RETURN_FALSE; } @@ -905,4 +972,3 @@ PHP_FUNCTION(ssh2_sftp_realpath) * indent-tabs-mode: t * End: */ - diff --git a/tests/ssh2_channel_stream_semantics.phpt b/tests/ssh2_channel_stream_semantics.phpt new file mode 100644 index 0000000..1080ee9 --- /dev/null +++ b/tests/ssh2_channel_stream_semantics.phpt @@ -0,0 +1,66 @@ +--TEST-- +SSH2 channel streams distinguish backpressure, errors, and clean EOF +--SKIPIF-- + +--FILE-- +/dev/null'); + +if ($writeChannel === false) { + throw new RuntimeException('Failed to open the SSH write channel.'); +} + +stream_set_blocking($writeChannel, true); + +var_dump(ssh2_send_eof($writeChannel)); +var_dump(@fwrite($writeChannel, 'x') === false); +var_dump(!feof($writeChannel)); + +fclose($writeChannel); + +$readChannel = ssh2_exec($session, 'true'); + +if ($readChannel === false) { + throw new RuntimeException('Failed to open the SSH read channel.'); +} + +stream_set_blocking($readChannel, true); + +var_dump(fread($readChannel, 1) === ''); +var_dump(feof($readChannel)); + +fclose($readChannel); +ssh2_disconnect($session); +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) diff --git a/tests/ssh2_resource_lifetime.phpt b/tests/ssh2_resource_lifetime.phpt new file mode 100644 index 0000000..0a87085 --- /dev/null +++ b/tests/ssh2_resource_lifetime.phpt @@ -0,0 +1,167 @@ +--TEST-- +SSH2 child resources fail safely after their parent session is closed +--SKIPIF-- + +--FILE-- + $channel, 'events' => SSH2_POLLIN]]; +var_dump(@ssh2_poll($poll, 0) === 0); +var_dump(fclose($stderr)); +var_dump(fclose($channel)); + +unset($stderr, $channel, $channelSession); + +$sftpSession = $connect(); +$sftp = ssh2_sftp($sftpSession); + +if ($sftp === false) { + throw new RuntimeException('Failed to open the SFTP subsystem.'); +} + +$root = ssh2t_tempnam(); +$filePath = $root . '/file.txt'; +$otherPath = $root . '/other.txt'; +$linkPath = $root . '/link.txt'; + +if (!ssh2_sftp_mkdir($sftp, $root, 0700)) { + throw new RuntimeException('Failed to create the SFTP fixture directory.'); +} + +$file = fopen('ssh2.sftp://' . (int) $sftp . $filePath, 'w+'); +$directory = opendir('ssh2.sftp://' . (int) $sftp . $root); + +if ($file === false || $directory === false) { + throw new RuntimeException('Failed to open the SFTP fixture streams.'); +} + +var_dump(ssh2_disconnect($sftpSession)); +var_dump(@fwrite($file, 'test') === false); +var_dump(@fread($file, 1) === false); +var_dump(fseek($file, 0, SEEK_END) === -1); +var_dump(fstat($file) === false); +var_dump(readdir($directory) === false); +var_dump(feof($directory) === false); +var_dump(!ssh2_sftp_rename($sftp, $filePath, $otherPath)); +var_dump(!ssh2_sftp_unlink($sftp, $filePath)); +var_dump(!ssh2_sftp_mkdir($sftp, $otherPath)); +var_dump(!ssh2_sftp_rmdir($sftp, $root)); +var_dump(!ssh2_sftp_chmod($sftp, $filePath, 0600)); +var_dump(ssh2_sftp_stat($sftp, $filePath) === false); +var_dump(ssh2_sftp_lstat($sftp, $filePath) === false); +var_dump(!ssh2_sftp_symlink($sftp, $filePath, $linkPath)); +var_dump(ssh2_sftp_readlink($sftp, $linkPath) === false); +var_dump(ssh2_sftp_realpath($sftp, $filePath) === false); +var_dump(fclose($file)); +closedir($directory); + +unset($directory, $file, $sftp, $sftpSession); + +$cleanupSession = $connect(); +$cleanupSftp = ssh2_sftp($cleanupSession); + +if ($cleanupSftp === false + || !ssh2_sftp_unlink($cleanupSftp, $filePath) + || !ssh2_sftp_rmdir($cleanupSftp, $root)) { + throw new RuntimeException('Failed to clean up the SFTP fixture.'); +} + +unset($cleanupSftp); +ssh2_disconnect($cleanupSession); +unset($cleanupSession); + +$listenerSession = $connect(); +$listener = ssh2_forward_listen($listenerSession, 0); + +if ($listener === false) { + throw new RuntimeException('Failed to open an SSH remote-forward listener.'); +} + +var_dump(ssh2_disconnect($listenerSession)); +var_dump(ssh2_forward_accept($listener) === false); + +$poll = [['resource' => $listener, 'events' => SSH2_POLLIN]]; +var_dump(@ssh2_poll($poll, 0) === 0); + +unset($listener, $listenerSession); +gc_collect_cycles(); + +var_dump(count(get_resources('SSH2 SFTP')) === $sftpBaseline); +var_dump(count(get_resources('SSH2 Session')) === $sessionBaseline); +?> +--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) +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/tests/ssh2_sftp_stream_semantics.phpt b/tests/ssh2_sftp_stream_semantics.phpt new file mode 100644 index 0000000..3b74ba2 --- /dev/null +++ b/tests/ssh2_sftp_stream_semantics.phpt @@ -0,0 +1,96 @@ +--TEST-- +SFTP streams distinguish errors from clean completion +--SKIPIF-- + +--FILE-- + +--EXPECT-- +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) diff --git a/tests/ssh2_sftp_wrapper_auth_failure.phpt b/tests/ssh2_sftp_wrapper_auth_failure.phpt new file mode 100644 index 0000000..e70585a --- /dev/null +++ b/tests/ssh2_sftp_wrapper_auth_failure.phpt @@ -0,0 +1,40 @@ +--TEST-- +Direct SFTP wrapper authentication failures release their sessions +--SKIPIF-- + +--FILE-- + [ + 'username' => TEST_SSH2_USER, + 'password' => 'invalid-ssh2-test-password', + ], +]); +$url = sprintf( + 'ssh2.sftp://%s:%d%s/missing.txt', + TEST_SSH2_HOSTNAME, + TEST_SSH2_PORT, + rtrim(TEST_SSH2_TEMPDIR, '/'), +); +$fileDescriptorCount = static fn (): int => count(scandir('/proc/self/fd')) - 2; + +$baseline = $fileDescriptorCount(); +$authenticationFailed = @fopen($url, 'rb', false, $context) === false; + +gc_collect_cycles(); + +var_dump($authenticationFailed); +var_dump($fileDescriptorCount() === $baseline); +?> +--EXPECT-- +bool(true) +bool(true) diff --git a/tests/ssh2_sftp_wrapper_release.phpt b/tests/ssh2_sftp_wrapper_release.phpt new file mode 100644 index 0000000..e8a1268 --- /dev/null +++ b/tests/ssh2_sftp_wrapper_release.phpt @@ -0,0 +1,61 @@ +--TEST-- +One-shot SFTP wrapper operations release their resource references +--SKIPIF-- + +--FILE-- + +--EXPECT-- +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) diff --git a/tests/ssh2_sftp_wrapper_session_ownership.phpt b/tests/ssh2_sftp_wrapper_session_ownership.phpt new file mode 100644 index 0000000..2c2952e --- /dev/null +++ b/tests/ssh2_sftp_wrapper_session_ownership.phpt @@ -0,0 +1,52 @@ +--TEST-- +Direct SFTP wrapper sessions are released with their streams +--SKIPIF-- + +--FILE-- + [ + 'username' => TEST_SSH2_USER, + 'password' => TEST_SSH2_PASS, + ], +]); + +$stream = fopen($url, 'wb', false, $context); + +var_dump(is_resource($stream)); +var_dump(fwrite($stream, 'test') === 4); +var_dump(fclose($stream)); + +gc_collect_cycles(); + +var_dump(count(get_resources('SSH2 SFTP')) === $sftpBaseline); +var_dump(count(get_resources('SSH2 Session')) === $sessionBaseline); + +$session = ssh2_connect(TEST_SSH2_HOSTNAME, TEST_SSH2_PORT); + +if ($session === false || !ssh2t_auth($session)) { + throw new RuntimeException('Failed to connect to the SSH fixture.'); +} + +$sftp = ssh2_sftp($session); + +if ($sftp === false || !ssh2_sftp_unlink($sftp, $path)) { + throw new RuntimeException('Failed to clean up the SFTP fixture.'); +} +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) +bool(true) +bool(true)