From 34fdd018ed7f227c5805df02bf6f69e4325cf91e Mon Sep 17 00:00:00 2001 From: Kazuki Yamaguchi Date: Thu, 27 Aug 2026 20:56:29 +0900 Subject: [PATCH 1/3] ssl: implement blocking SSLSocket methods in Ruby Let the extension only provide the most basic non-blocking form, SSLSocket#ssl_connect, #ssl_accept, #ssl_read, #ssl_write. Implement SSLSocket#connect, #accept, #sysread, and #syswrite in lib/openssl/ssl.rb on top of these primitives and #wait_{read,writ}able. Also, handle the keyword arguments for the public #*_nonblock methods in Ruby to simplify and to avoid the Hash allocation. This prepares for the upcoming changes to SSLSocket to add proper timeout support and socket closed state tracking. These would be easier to do in Ruby. No user-visible behavior change is intended with this patch. --- ext/openssl/ossl_ssl.c | 545 ++++++++++------------------------------- lib/openssl/ssl.rb | 183 ++++++++++++++ 2 files changed, 317 insertions(+), 411 deletions(-) diff --git a/ext/openssl/ossl_ssl.c b/ext/openssl/ossl_ssl.c index 91aadf0b6..d1047a4c1 100644 --- a/ext/openssl/ossl_ssl.c +++ b/ext/openssl/ossl_ssl.c @@ -37,7 +37,7 @@ static VALUE eSSLErrorWaitReadable; static VALUE eSSLErrorWaitWritable; static ID id_call, ID_callback_state, id_npn_protocols_encoded, id_each; -static VALUE sym_exception, sym_wait_readable, sym_wait_writable; +static VALUE sym_wait_readable, sym_wait_writable; static ID id_i_cert_store, id_i_ca_file, id_i_ca_path, id_i_verify_mode, id_i_verify_depth, id_i_verify_callback, id_i_client_ca, @@ -1772,255 +1772,99 @@ errno_mapped(void) #endif } -static void -write_would_block(int nonblock) -{ - if (nonblock) - ossl_raise(eSSLErrorWaitWritable, "write would block"); -} - -static void -read_would_block(int nonblock) -{ - if (nonblock) - ossl_raise(eSSLErrorWaitReadable, "read would block"); -} - -static int -no_exception_p(VALUE opts) -{ - if (RB_TYPE_P(opts, T_HASH) && - rb_hash_lookup2(opts, sym_exception, Qundef) == Qfalse) - return 1; - return 0; -} - -// Provided by Ruby 3.2.0 and later in order to support the default IO#timeout. -#ifndef RUBY_IO_TIMEOUT_DEFAULT -#define RUBY_IO_TIMEOUT_DEFAULT Qnil -#endif - -#ifdef HAVE_RB_IO_TIMEOUT -#define IO_TIMEOUT_ERROR rb_eIOTimeoutError -#else -#define IO_TIMEOUT_ERROR rb_eIOError -#endif - - -static void -io_wait_writable(VALUE io) -{ -#ifdef HAVE_RB_IO_MAYBE_WAIT - if (!rb_io_wait(io, INT2NUM(RUBY_IO_WRITABLE), RUBY_IO_TIMEOUT_DEFAULT)) { - rb_raise(IO_TIMEOUT_ERROR, "Timed out while waiting to become writable!"); - } -#else - rb_io_t *fptr; - GetOpenFile(io, fptr); - rb_thread_fd_writable(fptr->fd); -#endif -} - -static void -io_wait_readable(VALUE io) -{ -#ifdef HAVE_RB_IO_MAYBE_WAIT - if (!rb_io_wait(io, INT2NUM(RUBY_IO_READABLE), RUBY_IO_TIMEOUT_DEFAULT)) { - rb_raise(IO_TIMEOUT_ERROR, "Timed out while waiting to become readable!"); - } -#else - rb_io_t *fptr; - GetOpenFile(io, fptr); - rb_thread_wait_fd(fptr->fd); -#endif -} - static VALUE -ossl_start_ssl(VALUE self, int (*func)(SSL *), const char *funcname, VALUE opts) +ossl_start_ssl(VALUE self, int (*func)(SSL *), const char *funcname) { SSL *ssl; VALUE cb_state; - int nonblock = opts != Qfalse; rb_ivar_set(self, ID_callback_state, Qnil); GetSSL(self, ssl); - VALUE io = rb_attr_get(self, id_i_io); - for (;;) { - int ret = func(ssl); - int saved_errno = errno_mapped(); +#ifdef __APPLE__ + retry:; +#endif + int ret = func(ssl); + int saved_errno = errno_mapped(); - cb_state = rb_attr_get(self, ID_callback_state); - if (!NIL_P(cb_state)) { - /* must cleanup OpenSSL error stack before re-raising */ - ossl_clear_error(); - rb_jump_tag(NUM2INT(cb_state)); - } + cb_state = rb_attr_get(self, ID_callback_state); + if (!NIL_P(cb_state)) { + /* must cleanup OpenSSL error stack before re-raising */ + ossl_clear_error(); + rb_jump_tag(NUM2INT(cb_state)); + } + + if (ret > 0) + return self; - if (ret > 0) - break; - - int code = SSL_get_error(ssl, ret); - switch (code) { - case SSL_ERROR_WANT_WRITE: - if (no_exception_p(opts)) { return sym_wait_writable; } - write_would_block(nonblock); - io_wait_writable(io); - continue; - case SSL_ERROR_WANT_READ: - if (no_exception_p(opts)) { return sym_wait_readable; } - read_would_block(nonblock); - io_wait_readable(io); - continue; - case SSL_ERROR_SYSCALL: + int code = SSL_get_error(ssl, ret); + switch (code) { + case SSL_ERROR_WANT_WRITE: + return sym_wait_writable; + case SSL_ERROR_WANT_READ: + return sym_wait_readable; + case SSL_ERROR_SYSCALL: #ifdef __APPLE__ - /* See ossl_ssl_write_internal() */ - if (saved_errno == EPROTOTYPE) - continue; + /* See ossl_ssl_write_internal() */ + if (saved_errno == EPROTOTYPE) + goto retry; #endif - if (saved_errno) - rb_exc_raise(rb_syserr_new(saved_errno, funcname)); - /* fallthrough */ - default: { - VALUE error_append = Qnil; + if (saved_errno) + rb_exc_raise(rb_syserr_new(saved_errno, funcname)); + /* fallthrough */ + default: { + VALUE error_append = Qnil; #if defined(SSL_R_CERTIFICATE_VERIFY_FAILED) - unsigned long err = ERR_peek_last_error(); - if (ERR_GET_LIB(err) == ERR_LIB_SSL && - ERR_GET_REASON(err) == SSL_R_CERTIFICATE_VERIFY_FAILED) { - const char *err_msg = ERR_reason_error_string(err), - *verify_msg = X509_verify_cert_error_string(SSL_get_verify_result(ssl)); - if (!err_msg) - err_msg = "(null)"; - if (!verify_msg) - verify_msg = "(null)"; - ossl_clear_error(); /* let ossl_raise() not append message */ - error_append = rb_sprintf(": %s (%s)", err_msg, verify_msg); - } -#endif - ossl_raise(eSSLError, - "%s%s returned=%d errno=%d peeraddr=%"PRIsVALUE" state=%s%"PRIsVALUE, - funcname, - code == SSL_ERROR_SYSCALL ? " SYSCALL" : "", - code, - saved_errno, - peeraddr_ip_str(io), - SSL_state_string_long(ssl), - error_append); + unsigned long err = ERR_peek_last_error(); + if (ERR_GET_LIB(err) == ERR_LIB_SSL && + ERR_GET_REASON(err) == SSL_R_CERTIFICATE_VERIFY_FAILED) { + long verify_result = SSL_get_verify_result(ssl); + const char *err_msg = ERR_reason_error_string(err), + *verify_msg = X509_verify_cert_error_string(verify_result); + if (!err_msg) + err_msg = "(null)"; + if (!verify_msg) + verify_msg = "(null)"; + ossl_clear_error(); /* let ossl_raise() not append message */ + error_append = rb_sprintf(": %s (%s)", err_msg, verify_msg); } - } +#endif + VALUE io = rb_attr_get(self, id_i_io); + ossl_raise(eSSLError, + "%s%s returned=%d errno=%d peeraddr=%"PRIsVALUE" state=%s%"PRIsVALUE, + funcname, + code == SSL_ERROR_SYSCALL ? " SYSCALL" : "", + code, + saved_errno, + peeraddr_ip_str(io), + SSL_state_string_long(ssl), + error_append); + } } - - return self; } -/* - * call-seq: - * ssl.connect => self - * - * Initiates an SSL/TLS handshake with a server. - */ static VALUE ossl_ssl_connect(VALUE self) { ossl_ssl_setup(self); - - return ossl_start_ssl(self, SSL_connect, "SSL_connect", Qfalse); + return ossl_start_ssl(self, SSL_connect, "SSL_connect"); } -/* - * call-seq: - * ssl.connect_nonblock([options]) => self - * - * Initiates the SSL/TLS handshake as a client in non-blocking manner. - * - * # emulates blocking connect - * begin - * ssl.connect_nonblock - * rescue IO::WaitReadable - * IO.select([s2]) - * retry - * rescue IO::WaitWritable - * IO.select(nil, [s2]) - * retry - * end - * - * By specifying a keyword argument _exception_ to +false+, you can indicate - * that connect_nonblock should not raise an IO::WaitReadable or - * IO::WaitWritable exception, but return the symbol +:wait_readable+ or - * +:wait_writable+ instead. - */ -static VALUE -ossl_ssl_connect_nonblock(int argc, VALUE *argv, VALUE self) -{ - VALUE opts; - rb_scan_args(argc, argv, "0:", &opts); - - ossl_ssl_setup(self); - - return ossl_start_ssl(self, SSL_connect, "SSL_connect", opts); -} - -/* - * call-seq: - * ssl.accept => self - * - * Waits for a SSL/TLS client to initiate a handshake. - */ static VALUE ossl_ssl_accept(VALUE self) { ossl_ssl_setup(self); - - return ossl_start_ssl(self, SSL_accept, "SSL_accept", Qfalse); + return ossl_start_ssl(self, SSL_accept, "SSL_accept"); } -/* - * call-seq: - * ssl.accept_nonblock([options]) => self - * - * Initiates the SSL/TLS handshake as a server in non-blocking manner. - * - * # emulates blocking accept - * begin - * ssl.accept_nonblock - * rescue IO::WaitReadable - * IO.select([s2]) - * retry - * rescue IO::WaitWritable - * IO.select(nil, [s2]) - * retry - * end - * - * By specifying a keyword argument _exception_ to +false+, you can indicate - * that accept_nonblock should not raise an IO::WaitReadable or - * IO::WaitWritable exception, but return the symbol +:wait_readable+ or - * +:wait_writable+ instead. - */ static VALUE -ossl_ssl_accept_nonblock(int argc, VALUE *argv, VALUE self) -{ - VALUE opts; - - rb_scan_args(argc, argv, "0:", &opts); - ossl_ssl_setup(self); - - return ossl_start_ssl(self, SSL_accept, "SSL_accept", opts); -} - -static VALUE -ossl_ssl_read_internal(int argc, VALUE *argv, VALUE self, int nonblock) +ossl_ssl_read(VALUE self, VALUE len, VALUE str) { SSL *ssl; int ilen; - VALUE len, str, cb_state; - VALUE opts = Qnil; + VALUE cb_state; - if (nonblock) { - rb_scan_args(argc, argv, "11:", &len, &str, &opts); - } else { - rb_scan_args(argc, argv, "11", &len, &str); - } GetSSL(self, ssl); if (!ssl_started(ssl)) rb_raise(eSSLError, "SSL session is not started yet"); @@ -2041,224 +1885,109 @@ ossl_ssl_read_internal(int argc, VALUE *argv, VALUE self, int nonblock) return str; } - VALUE io = rb_attr_get(self, id_i_io); + rb_str_locktmp(str); + int nread = SSL_read(ssl, RSTRING_PTR(str), ilen); + rb_str_unlocktmp(str); + int saved_errno = errno_mapped(); - for (;;) { - rb_str_locktmp(str); - int nread = SSL_read(ssl, RSTRING_PTR(str), ilen); - int saved_errno = errno_mapped(); - rb_str_unlocktmp(str); - - cb_state = rb_attr_get(self, ID_callback_state); - if (!NIL_P(cb_state)) { - rb_ivar_set(self, ID_callback_state, Qnil); - ossl_clear_error(); - rb_jump_tag(NUM2INT(cb_state)); - } + cb_state = rb_attr_get(self, ID_callback_state); + if (!NIL_P(cb_state)) { + rb_ivar_set(self, ID_callback_state, Qnil); + ossl_clear_error(); + rb_jump_tag(NUM2INT(cb_state)); + } - switch (SSL_get_error(ssl, nread)) { - case SSL_ERROR_NONE: - rb_str_set_len(str, nread); - return str; - case SSL_ERROR_ZERO_RETURN: - if (no_exception_p(opts)) { return Qnil; } - rb_eof_error(); - case SSL_ERROR_WANT_WRITE: - if (nonblock) { - if (no_exception_p(opts)) { return sym_wait_writable; } - write_would_block(nonblock); - } - io_wait_writable(io); - break; - case SSL_ERROR_WANT_READ: - if (nonblock) { - if (no_exception_p(opts)) { return sym_wait_readable; } - read_would_block(nonblock); - } - io_wait_readable(io); - break; - case SSL_ERROR_SYSCALL: - if (!ERR_peek_error()) { - if (saved_errno) - rb_exc_raise(rb_syserr_new(saved_errno, "SSL_read")); - else { - /* - * The underlying BIO returned 0. This is actually a - * protocol error. But unfortunately, not all - * implementations cleanly shutdown the TLS connection - * but just shutdown/close the TCP connection. So report - * EOF for now... - */ - if (no_exception_p(opts)) { return Qnil; } - rb_eof_error(); - } + switch (SSL_get_error(ssl, nread)) { + case SSL_ERROR_NONE: + rb_str_set_len(str, nread); + return str; + case SSL_ERROR_ZERO_RETURN: + return Qnil; + case SSL_ERROR_WANT_WRITE: + return sym_wait_writable; + case SSL_ERROR_WANT_READ: + return sym_wait_readable; + case SSL_ERROR_SYSCALL: + if (!ERR_peek_error()) { + if (saved_errno) + rb_exc_raise(rb_syserr_new(saved_errno, "SSL_read")); + else { + /* + * The underlying BIO returned 0. This is actually a + * protocol error. But unfortunately, not all + * implementations cleanly shutdown the TLS connection + * but just shutdown/close the TCP connection. So report + * EOF for now... + */ + return Qnil; } - /* fall through */ - default: - ossl_raise(eSSLError, "SSL_read"); } - - // Ensure the buffer is not modified during io_wait_*able() - rb_str_modify(str); - if (rb_str_capacity(str) < (size_t)ilen) - rb_raise(eSSLError, "read buffer was modified"); + /* fall through */ + default: + ossl_raise(eSSLError, "SSL_read"); } } -/* - * call-seq: - * ssl.sysread(length) => string - * ssl.sysread(length, buffer) => buffer - * - * Reads _length_ bytes from the SSL connection. If a pre-allocated _buffer_ - * is provided the data will be written into it. - */ static VALUE -ossl_ssl_read(int argc, VALUE *argv, VALUE self) -{ - return ossl_ssl_read_internal(argc, argv, self, 0); -} - -/* - * call-seq: - * ssl.sysread_nonblock(length) => string - * ssl.sysread_nonblock(length, buffer) => buffer - * ssl.sysread_nonblock(length[, buffer [, opts]) => buffer - * - * A non-blocking version of #sysread. Raises an SSLError if reading would - * block. If "exception: false" is passed, this method returns a symbol of - * :wait_readable, :wait_writable, or nil, rather than raising an exception. - * - * Reads _length_ bytes from the SSL connection. If a pre-allocated _buffer_ - * is provided the data will be written into it. - */ -static VALUE -ossl_ssl_read_nonblock(int argc, VALUE *argv, VALUE self) -{ - return ossl_ssl_read_internal(argc, argv, self, 1); -} - -static VALUE -ossl_ssl_write_internal_safe(VALUE _args) +ossl_ssl_write(VALUE self, VALUE str) { - VALUE *args = (VALUE*)_args; - VALUE self = args[0]; - VALUE str = args[1]; - VALUE opts = args[2]; - SSL *ssl; - rb_io_t *fptr; - int num, nonblock = opts != Qfalse; VALUE cb_state; GetSSL(self, ssl); if (!ssl_started(ssl)) rb_raise(eSSLError, "SSL session is not started yet"); - VALUE io = rb_attr_get(self, id_i_io); - GetOpenFile(io, fptr); - + StringValue(str); /* SSL_write(3ssl) manpage states num == 0 is undefined */ - num = RSTRING_LENINT(str); + int num = RSTRING_LENINT(str); if (num == 0) return INT2FIX(0); - for (;;) { - int nwritten = SSL_write(ssl, RSTRING_PTR(str), num); - int saved_errno = errno_mapped(); - - cb_state = rb_attr_get(self, ID_callback_state); - if (!NIL_P(cb_state)) { - rb_ivar_set(self, ID_callback_state, Qnil); - ossl_clear_error(); - rb_jump_tag(NUM2INT(cb_state)); - } - - switch (SSL_get_error(ssl, nwritten)) { - case SSL_ERROR_NONE: - return INT2NUM(nwritten); - case SSL_ERROR_WANT_WRITE: - if (no_exception_p(opts)) { return sym_wait_writable; } - write_would_block(nonblock); - io_wait_writable(io); - continue; - case SSL_ERROR_WANT_READ: - if (no_exception_p(opts)) { return sym_wait_readable; } - read_would_block(nonblock); - io_wait_readable(io); - continue; - case SSL_ERROR_SYSCALL: #ifdef __APPLE__ - /* - * It appears that send syscall can return EPROTOTYPE if the - * socket is being torn down. Retry to get a proper errno to - * make the error handling in line with the socket library. - * [Bug #14713] https://bugs.ruby-lang.org/issues/14713 - */ - if (saved_errno == EPROTOTYPE) - continue; + retry:; #endif - if (saved_errno) - rb_exc_raise(rb_syserr_new(saved_errno, "SSL_write")); - /* fallthrough */ - default: - ossl_raise(eSSLError, "SSL_write"); - } - } -} - - -static VALUE -ossl_ssl_write_internal(VALUE self, VALUE str, VALUE opts) -{ - StringValue(str); int frozen = RB_OBJ_FROZEN(str); if (!frozen) { rb_str_locktmp(str); } - int state; - VALUE args[3] = {self, str, opts}; - VALUE result = rb_protect(ossl_ssl_write_internal_safe, (VALUE)args, &state); + int nwritten = SSL_write(ssl, RSTRING_PTR(str), num); if (!frozen) { rb_str_unlocktmp(str); } + int saved_errno = errno_mapped(); - if (state) { - rb_jump_tag(state); + cb_state = rb_attr_get(self, ID_callback_state); + if (!NIL_P(cb_state)) { + rb_ivar_set(self, ID_callback_state, Qnil); + ossl_clear_error(); + rb_jump_tag(NUM2INT(cb_state)); } - return result; -} -/* - * call-seq: - * ssl.syswrite(string) => Integer - * - * Writes _string_ to the SSL connection. - */ -static VALUE -ossl_ssl_write(VALUE self, VALUE str) -{ - return ossl_ssl_write_internal(self, str, Qfalse); -} - -/* - * call-seq: - * ssl.syswrite_nonblock(string) => Integer - * ssl.syswrite_nonblock(string, opts) => Integer - * - * Writes _string_ to the SSL connection in a non-blocking manner. Raises an - * SSLError if writing would block. If "exception: false" is passed, this - * method returns a symbol of :wait_readable or :wait_writable, rather than - * raising an exception. - */ -static VALUE -ossl_ssl_write_nonblock(int argc, VALUE *argv, VALUE self) -{ - VALUE str, opts; - - rb_scan_args(argc, argv, "1:", &str, &opts); - - return ossl_ssl_write_internal(self, str, opts); + switch (SSL_get_error(ssl, nwritten)) { + case SSL_ERROR_NONE: + return INT2NUM(nwritten); + case SSL_ERROR_WANT_WRITE: + return sym_wait_writable; + case SSL_ERROR_WANT_READ: + return sym_wait_readable; + case SSL_ERROR_SYSCALL: +#ifdef __APPLE__ + /* + * It appears that send syscall can return EPROTOTYPE if the + * socket is being torn down. Retry to get a proper errno to + * make the error handling in line with the socket library. + * [Bug #14713] https://bugs.ruby-lang.org/issues/14713 + */ + if (saved_errno == EPROTOTYPE) + goto retry; +#endif + if (saved_errno) + rb_exc_raise(rb_syserr_new(saved_errno, "SSL_write")); + /* fallthrough */ + default: + ossl_raise(eSSLError, "SSL_write"); + } } /* @@ -3182,14 +2911,10 @@ Init_ossl_ssl(void) rb_define_alloc_func(cSSLSocket, ossl_ssl_s_alloc); rb_define_method(cSSLSocket, "initialize", ossl_ssl_initialize, -1); rb_undef_method(cSSLSocket, "initialize_copy"); - rb_define_method(cSSLSocket, "connect", ossl_ssl_connect, 0); - rb_define_method(cSSLSocket, "connect_nonblock", ossl_ssl_connect_nonblock, -1); - rb_define_method(cSSLSocket, "accept", ossl_ssl_accept, 0); - rb_define_method(cSSLSocket, "accept_nonblock", ossl_ssl_accept_nonblock, -1); - rb_define_method(cSSLSocket, "sysread", ossl_ssl_read, -1); - rb_define_private_method(cSSLSocket, "sysread_nonblock", ossl_ssl_read_nonblock, -1); - rb_define_method(cSSLSocket, "syswrite", ossl_ssl_write, 1); - rb_define_private_method(cSSLSocket, "syswrite_nonblock", ossl_ssl_write_nonblock, -1); + rb_define_private_method(cSSLSocket, "ssl_connect", ossl_ssl_connect, 0); + rb_define_private_method(cSSLSocket, "ssl_accept", ossl_ssl_accept, 0); + rb_define_private_method(cSSLSocket, "ssl_read", ossl_ssl_read, 2); + rb_define_private_method(cSSLSocket, "ssl_write", ossl_ssl_write, 1); rb_define_private_method(cSSLSocket, "stop", ossl_ssl_stop, 0); rb_define_method(cSSLSocket, "cert", ossl_ssl_get_cert, 0); rb_define_method(cSSLSocket, "peer_cert", ossl_ssl_get_peer_cert, 0); @@ -3367,8 +3092,6 @@ Init_ossl_ssl(void) /* TLS 1.3 */ rb_define_const(mSSL, "TLS1_3_VERSION", INT2NUM(TLS1_3_VERSION)); - - sym_exception = ID2SYM(rb_intern_const("exception")); sym_wait_readable = ID2SYM(rb_intern_const("wait_readable")); sym_wait_writable = ID2SYM(rb_intern_const("wait_writable")); diff --git a/lib/openssl/ssl.rb b/lib/openssl/ssl.rb index 486bdf858..fa4c26c55 100644 --- a/lib/openssl/ssl.rb +++ b/lib/openssl/ssl.rb @@ -360,6 +360,189 @@ def sysclose io.close if sync_close end + # Ruby 3.2 + IO_TimeoutError = defined?(IO::TimeoutError) ? IO::TimeoutError : IOError + private_constant :IO_TimeoutError + + private def check_nonblock(ret) + case ret + when :wait_readable + raise SSLErrorWaitReadable, "read would block" + when :wait_writable + raise SSLErrorWaitWritable, "write would block" + when nil + raise EOFError, "end of file reached" + else + ret + end + end + + # :call-seq: + # ssl.connect -> self + # + # Initiates an SSL/TLS handshake with a server. + def connect + while true + case ret = ssl_connect + when :wait_readable + wait_readable or + raise IO_TimeoutError, "Timed out while waiting to become readable!" + when :wait_writable + wait_writable or + raise IO_TimeoutError, "Timed out while waiting to become writable!" + else + return ret + end + end + end + + # :call-seq: + # ssl.connect_nonblock -> self + # ssl.connect_nonblock(exception: false) -> self | :wait_readable | :wait_writable + # + # Initiates the SSL/TLS handshake as a client in non-blocking manner. + # + # # emulates blocking connect + # begin + # ssl.connect_nonblock + # rescue IO::WaitReadable + # IO.select([s2]) + # retry + # rescue IO::WaitWritable + # IO.select(nil, [s2]) + # retry + # end + # + # By specifying a keyword argument _exception_ to +false+, you can + # indicate that connect_nonblock should not raise an IO::WaitReadable or + # IO::WaitWritable exception, but return the symbol +:wait_readable+ or + # +:wait_writable+ instead. + def connect_nonblock(exception: true) + ret = ssl_connect + check_nonblock(ret) if exception + ret + end + + # :call-seq: + # ssl.accept -> self + # + # Waits for a SSL/TLS client to initiate a handshake. + def accept + while true + case ret = ssl_accept + when :wait_readable + wait_readable or + raise IO_TimeoutError, "Timed out while waiting to become readable!" + when :wait_writable + wait_writable or + raise IO_TimeoutError, "Timed out while waiting to become writable!" + else + return ret + end + end + end + + # :call-seq: + # ssl.accept_nonblock -> self + # ssl.accept_nonblock(exception: false) -> self | :wait_readable | :wait_writable + # + # Initiates the SSL/TLS handshake as a server in non-blocking manner. + # + # # emulates blocking accept + # begin + # ssl.accept_nonblock + # rescue IO::WaitReadable + # IO.select([s2]) + # retry + # rescue IO::WaitWritable + # IO.select(nil, [s2]) + # retry + # end + # + # By specifying a keyword argument _exception_ to +false+, you can + # indicate that accept_nonblock should not raise an IO::WaitReadable or + # IO::WaitWritable exception, but return the symbol +:wait_readable+ or + # +:wait_writable+ instead. + def accept_nonblock(exception: true) + ret = ssl_accept + check_nonblock(ret) if exception + ret + end + + # :call-seq: + # ssl.sysread(length) -> string + # ssl.sysread(length, buffer) -> buffer + # + # Reads _length_ bytes from the SSL connection. If a pre-allocated + # _buffer_ is provided the data will be written into it. + def sysread(length, buffer = nil) + while true + case ret = ssl_read(length, buffer) + when :wait_readable + wait_readable or + raise IO_TimeoutError, "Timed out while waiting to become readable!" + when :wait_writable + wait_writable or + raise IO_TimeoutError, "Timed out while waiting to become writable!" + when nil + raise EOFError, "end of file reached" + else + return ret + end + end + end + + # :call-seq: + # ssl.sysread_nonblock(length) -> string + # ssl.sysread_nonblock(length, buffer) -> buffer + # ssl.sysread_nonblock(length, buffer, exception: false) -> buffer | :wait_readable | :wait_writable | nil + # + # A non-blocking version of #sysread. Raises an SSLError if reading + # would block. If "exception: false" is passed, this method returns a + # symbol of :wait_readable, :wait_writable, or nil, rather than raising + # an exception. + # + # Reads _length_ bytes from the SSL connection. If a pre-allocated + # _buffer_ is provided the data will be written into it. + private def sysread_nonblock(length, buffer = nil, exception: true) + ret = ssl_read(length, buffer) + check_nonblock(ret) if exception + ret + end + + # :call-seq: + # ssl.syswrite(string) -> Integer + # + # Writes _string_ to the SSL connection. + def syswrite(string) + while true + case ret = ssl_write(string) + when :wait_readable + wait_readable or + raise IO_TimeoutError, "Timed out while waiting to become readable!" + when :wait_writable + wait_writable or + raise IO_TimeoutError, "Timed out while waiting to become writable!" + else + return ret + end + end + end + + # :call-seq: + # ssl.syswrite_nonblock(string) -> Integer + # ssl.syswrite_nonblock(string, exception: false) -> Integer | :wait_readable | :wait_writable + # + # Writes _string_ to the SSL connection in a non-blocking manner. Raises + # an SSLError if writing would block. If "exception: false" is passed, + # this method returns a symbol of :wait_readable or :wait_writable, + # rather than raising an exception. + private def syswrite_nonblock(string, exception: true) + ret = ssl_write(string) + check_nonblock(ret) if exception + ret + end + # call-seq: # ssl.post_connection_check(hostname) -> true # From 6be48c3653bc107fa29f0c44c69b68c53fa85d67 Mon Sep 17 00:00:00 2001 From: Kazuki Yamaguchi Date: Wed, 2 Sep 2026 03:11:42 +0900 Subject: [PATCH 2/3] ssl: raise IOError when reading from or writing to closed SSLSocket Core IO and socket classes raise IOError when a read or write is attempted on a closed stream. Make SSLSocket follow this behavior and use consistent error messages. Currently, reading from or writing to to a closed SSLSocket may raise OpenSSL::SSL::SSLError, a SystemCallError, or IOError, depending on the internal state of the OpenSSL library and the underlying socket. This changes SSLSocket#closed? to report the internally tracked state of the SSLSocket rather than the underlying socket. As a result, it may return false if the underlying socket is unexpectedly closed directly with IO#close. --- lib/openssl/ssl.rb | 51 ++++++++++++++++++++++++++++++--------- test/openssl/test_pair.rb | 19 ++++++++++++++- test/openssl/test_ssl.rb | 2 ++ 3 files changed, 60 insertions(+), 12 deletions(-) diff --git a/lib/openssl/ssl.rb b/lib/openssl/ssl.rb index fa4c26c55..ed7ee47d2 100644 --- a/lib/openssl/ssl.rb +++ b/lib/openssl/ssl.rb @@ -207,10 +207,6 @@ def fcntl(*args) to_io.fcntl(*args) end - def closed? - to_io.closed? - end - def do_not_reverse_lookup=(flag) to_io.do_not_reverse_lookup = flag end @@ -356,6 +352,7 @@ class SSLSocket # If sync_close is set to +true+, the underlying IO is also closed. def sysclose return if closed? + @closed_read = @closed_write = true stop io.close if sync_close end @@ -377,6 +374,20 @@ def sysclose end end + private def assert_readable + if @closed_read + raise IOError, "closed stream" if @closed_write + raise IOError, "not opened for reading" + end + end + + private def assert_writable + if @closed_write + raise IOError, "closed stream" if @closed_read + raise IOError, "not opened for writing" + end + end + # :call-seq: # ssl.connect -> self # @@ -476,6 +487,7 @@ def accept_nonblock(exception: true) # Reads _length_ bytes from the SSL connection. If a pre-allocated # _buffer_ is provided the data will be written into it. def sysread(length, buffer = nil) + assert_readable while true case ret = ssl_read(length, buffer) when :wait_readable @@ -505,6 +517,7 @@ def sysread(length, buffer = nil) # Reads _length_ bytes from the SSL connection. If a pre-allocated # _buffer_ is provided the data will be written into it. private def sysread_nonblock(length, buffer = nil, exception: true) + assert_readable ret = ssl_read(length, buffer) check_nonblock(ret) if exception ret @@ -515,6 +528,7 @@ def sysread(length, buffer = nil) # # Writes _string_ to the SSL connection. def syswrite(string) + assert_writable while true case ret = ssl_write(string) when :wait_readable @@ -538,6 +552,7 @@ def syswrite(string) # this method returns a symbol of :wait_readable or :wait_writable, # rather than raising an exception. private def syswrite_nonblock(string, exception: true) + assert_writable ret = ssl_write(string) check_nonblock(ret) if exception ret @@ -577,16 +592,28 @@ def session nil end - # Close the stream for reading. - # This method is ignored by OpenSSL as there is no reasonable way to - # implement it, but exists for compatibility with IO. + # Returns +true+ if the SSL/TLS connection has been closed, +false+ + # otherwise. + # + # Before version 4.1, this method returned whether the underlying socket + # has been closed. + def closed? + !!(@closed_read && @closed_write) + end + + # Closes the stream for reading. Any further attempts to read from this + # SSLSocket will raise IOError. def close_read - # Unsupported and ignored. - # Just don't read any more. + return sysclose if @closed_write + @closed_read = true + nil end - # Closes the stream for writing. The behavior of this method depends on - # the version of OpenSSL and the TLS protocol in use. + # Closes the stream for writing. Any further attempts to write to this + # SSLSocket will raise IOError. + # + # The behavior of this method depends on the version of OpenSSL and the + # TLS protocol in use. # # - Sends a 'close_notify' alert to the peer. # - Does not wait for the peer's 'close_notify' alert in response. @@ -600,6 +627,8 @@ def close_read # completely shut down. On TLS 1.3, the connection will remain open for # reading only. def close_write + return sysclose if @closed_read + @closed_write = true stop end diff --git a/test/openssl/test_pair.rb b/test/openssl/test_pair.rb index 8cb1060e4..7e0974c51 100644 --- a/test/openssl/test_pair.rb +++ b/test/openssl/test_pair.rb @@ -386,10 +386,27 @@ def test_close_write message = "abc"*1024 s1.write(message) s1.close_write + assert_raise_with_message(IOError, /writing/) { s1.write(message) } assert_equal(message, s2.read) s2.write(message) s2.close_write - assert_equal(message, s1.read) + assert_equal(message, s1.read(message.bytesize)) + s1.close_read + assert_raise_with_message(IOError, /closed stream/) { s1.read(1) } + } + end + + def test_close_read + ssl_pair { |s1, s2| + message = "abc"*1024 + s1.close_read + s1.write(message) + assert_equal(message, s2.read(message.bytesize)) + s2.write(message) + s2.close_write + assert_raise_with_message(IOError, /reading/) { s1.read(1) } + s1.close_write + assert_raise_with_message(IOError, /closed stream/) { s1.write(message) } } end end diff --git a/test/openssl/test_ssl.rb b/test/openssl/test_ssl.rb index ae3c4502e..53a6bfead 100644 --- a/test/openssl/test_ssl.rb +++ b/test/openssl/test_ssl.rb @@ -388,6 +388,7 @@ def test_sync_close ssl.connect ssl.puts "abc"; assert_equal "abc\n", ssl.gets ssl.close + assert_predicate ssl, :closed? assert_not_predicate sock, :closed? ensure sock&.close @@ -400,6 +401,7 @@ def test_sync_close ssl.connect ssl.puts "abc"; assert_equal "abc\n", ssl.gets ssl.close + assert_predicate ssl, :closed? assert_predicate sock, :closed? ensure sock&.close From 6b309b1eca7662b9c3fe0bbcf3ecb53130257708 Mon Sep 17 00:00:00 2001 From: Kazuki Yamaguchi Date: Wed, 2 Sep 2026 21:46:42 +0900 Subject: [PATCH 3/3] fixup! ssl: raise IOError when reading from or writing to closed SSLSocket --- lib/openssl/ssl.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/openssl/ssl.rb b/lib/openssl/ssl.rb index ed7ee47d2..84e81dad4 100644 --- a/lib/openssl/ssl.rb +++ b/lib/openssl/ssl.rb @@ -343,6 +343,13 @@ class SSLSocket # connection is shut down. This defaults to +false+. attr_accessor :sync_close + # :stopdoc: + def initialize(*args) + super + @closed_read = @closed_write = false + end if RUBY_VERSION < "3.0" + # :startdoc: + # call-seq: # ssl.sysclose => nil #