From 762994a5f8adf10a346274c4df44b2bb416a6706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Mangano?= Date: Fri, 11 Sep 2026 11:22:15 +0900 Subject: [PATCH] Configure sessions declaratively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This: session = LibSSH::Session.new session.host = "…" Becomes: session = LibSSH::Session.new(host: "…") Right now only host, port and user are migrated. I will add the other options next. The proxy jump option will receive a LibSSH::Options, turning libssh_ruby_options into a linked list. The key function is libssh_ruby_apply_options, designed to be callable from a libssh proxy jump callback which cannot use the Ruby API. --- ext/libssh_ruby/libssh_ruby.c | 1 + ext/libssh_ruby/libssh_ruby.h | 14 +++- ext/libssh_ruby/options.c | 115 +++++++++++++++++++++++++++++++ ext/libssh_ruby/session.c | 66 +++++++----------- lib/libssh.rb | 2 + lib/libssh/options.rb | 21 ++++++ lib/libssh/session.rb | 11 +++ spec/integration/channel_spec.rb | 11 +-- spec/integration/session_spec.rb | 75 +++++++------------- 9 files changed, 216 insertions(+), 100 deletions(-) create mode 100644 ext/libssh_ruby/options.c create mode 100644 lib/libssh/options.rb create mode 100644 lib/libssh/session.rb diff --git a/ext/libssh_ruby/libssh_ruby.c b/ext/libssh_ruby/libssh_ruby.c index 9539fd2..0aebb44 100644 --- a/ext/libssh_ruby/libssh_ruby.c +++ b/ext/libssh_ruby/libssh_ruby.c @@ -77,6 +77,7 @@ void Init_libssh_ruby(void) { rb_define_singleton_method(rb_mLibSSH, "version", m_version, -1); + Init_libssh_options(); Init_libssh_session(); Init_libssh_channel(); Init_libssh_error(); diff --git a/ext/libssh_ruby/libssh_ruby.h b/ext/libssh_ruby/libssh_ruby.h index 76c9c40..d1702d5 100644 --- a/ext/libssh_ruby/libssh_ruby.h +++ b/ext/libssh_ruby/libssh_ruby.h @@ -11,20 +11,32 @@ extern VALUE rb_mLibSSH; extern VALUE rb_cLibSSHKey; void Init_libssh_ruby(void); +void Init_libssh_options(void); void Init_libssh_session(void); void Init_libssh_channel(void); void Init_libssh_error(void); void Init_libssh_key(void); void Init_libssh_pki(void); -[[noreturn]] void libssh_ruby_raise(ssh_session session); +// C equivalent of LibSSH::Options. +struct libssh_ruby_options { + char* host; // SSH_OPTIONS_HOST + unsigned int port; // SSH_OPTIONS_PORT + char* user; // SSH_OPTIONS_USER +}; + +struct libssh_ruby_options* libssh_ruby_clone_options(VALUE options); +int libssh_ruby_apply_options(struct libssh_ruby_options *options, ssh_session session, char **error); +void libssh_ruby_free_options(struct libssh_ruby_options *options); // Underlying structure behind LibSSH::Session. struct libssh_ruby_session { ssh_session session; + struct libssh_ruby_options *options; }; ssh_session libssh_ruby_get_session(VALUE session); +[[noreturn]] void libssh_ruby_raise(ssh_session session); struct KeyHolderStruct { ssh_key key; diff --git a/ext/libssh_ruby/options.c b/ext/libssh_ruby/options.c new file mode 100644 index 0000000..2a2b078 --- /dev/null +++ b/ext/libssh_ruby/options.c @@ -0,0 +1,115 @@ +#include "libssh_ruby.h" + +static ID id_host, id_port, id_user; + +void Init_libssh_options(void) { + id_host = rb_intern("host"); + id_port = rb_intern("port"); + id_user = rb_intern("user"); +} + +void libssh_ruby_free_options(struct libssh_ruby_options *options) { + if (!options) return; + ruby_xfree(options->host); + ruby_xfree(options->user); + ruby_xfree(options); +} + +/* + * Configure the session with the given options. + * Forward the return code of ssh_options_set. + * The caller must free() *error. + * Does not require the GVL. + */ +int libssh_ruby_apply_options(struct libssh_ruby_options *options, + ssh_session session, + char **error) { + int rc = SSH_OK; + *error = NULL; + + if (options->host) { + // Host is first because it may set the user and port too. + rc = ssh_options_set(session, SSH_OPTIONS_HOST, options->host); + if (rc < 0) { + if (asprintf(error, "Invalid host: %s", options->host) == -1) + *error = NULL; + return rc; + } + } + + if (options->port) { + rc = ssh_options_set(session, SSH_OPTIONS_PORT, &options->port); + if (rc < 0) { + if (asprintf(error, "Invalid port: %u", options->port) == -1) + *error = NULL; + return rc; + } + } + + if (options->user) { + rc = ssh_options_set(session, SSH_OPTIONS_USER, options->user); + if (rc < 0) { + if (asprintf(error, "Invalid user: %s", options->user) == -1) + *error = NULL; + return rc; + } + } + + return rc; +} + +// libssh_ruby_clone_options /////////////////////////////////////////////////// + +struct copy_options_args { + VALUE in; + struct libssh_ruby_options *out; +}; + +static char* clone_string(VALUE string) { + char* source = StringValuePtr(string); + size_t length = RSTRING_LEN(string); + char* copy = ruby_xmalloc(length + 1); + memcpy(copy, source, length); + copy[length] = '\0'; + return copy; +} + +static char* get_string(VALUE options, ID name) { + VALUE value = rb_funcallv_public(options, name, 0, NULL); + return NIL_P(value) ? NULL : clone_string(value); +} + +static unsigned int get_uint(VALUE options, ID name) { + VALUE value = rb_funcallv_public(options, name, 0, NULL); + return NIL_P(value) ? 0 : NUM2UINT(value); +} + +static VALUE copy_options(VALUE data) { + struct copy_options_args *args = (void*) data; + VALUE in = args->in; + struct libssh_ruby_options* out = args->out; + + out->host = get_string(in, id_host); + out->port = get_uint(in, id_port); + out->user = get_string(in, id_user); + + return Qnil; +} + +/* + * Convert Ruby’s LibSSH::Options into C’s libssh_ruby_options. + * The caller must free the returned value with libssh_ruby_free_options. + */ +struct libssh_ruby_options* libssh_ruby_clone_options(VALUE options) { + int state; + struct copy_options_args args = { + .in = options, + .out = RB_ZALLOC(struct libssh_ruby_options), + }; + rb_protect(copy_options, (VALUE) &args, &state); + if (state) { + libssh_ruby_free_options(args.out); + rb_jump_tag(state); + } + return args.out; +} diff --git a/ext/libssh_ruby/session.c b/ext/libssh_ruby/session.c index c0a9a46..dd632a6 100644 --- a/ext/libssh_ruby/session.c +++ b/ext/libssh_ruby/session.c @@ -40,10 +40,8 @@ static void session_mark(RB_UNUSED_VAR(void *arg)) {} static void session_free(void *arg) { struct libssh_ruby_session *holder = arg; - if (holder->session != NULL) { - ssh_free(holder->session); - holder->session = NULL; - } + ssh_free(holder->session); + libssh_ruby_free_options(holder->options); ruby_xfree(holder); } @@ -94,29 +92,6 @@ static VALUE set_string_option(VALUE self, enum ssh_options_e type, const char* return Qnil; } -/* - * @overload host=(host) - * Set the hostname or IP address to connect to. - * @param [String] host - * @return [nil] - * @see http://api.libssh.org/stable/group__libssh__session.html ssh_options_set(SSH_OPTIONS_HOST) - */ -static VALUE m_set_host(VALUE self, VALUE host) { - return set_string_option(self, SSH_OPTIONS_HOST, "host", host); -} - -/* - * @overload user=(user) - * Set the username for authentication. - * @since 0.2.0 - * @param [String] user - * @return [nil] - * @see http://api.libssh.org/stable/group__libssh__session.html ssh_options_set(SSH_OPTIONS_USER) - */ -static VALUE m_set_user(VALUE self, VALUE user) { - return set_string_option(self, SSH_OPTIONS_USER, "user", user); -} - static VALUE set_int_option(VALUE self, enum ssh_options_e type, VALUE i) { Check_Type(i, T_FIXNUM); int j = FIX2INT(i); @@ -128,18 +103,6 @@ static VALUE set_int_option(VALUE self, enum ssh_options_e type, VALUE i) { return Qnil; } -/* - * @overload port=(port) - * Set the port to connect to. - * @since 0.2.0 - * @param [Fixnum] port - * @return [nil] - * @see http://api.libssh.org/stable/group__libssh__session.html ssh_options_set(SSH_OPTIONS_PORT) - */ -static VALUE m_set_port(VALUE self, VALUE port) { - return set_int_option(self, SSH_OPTIONS_PORT, port); -} - static VALUE set_long_option(VALUE self, enum ssh_options_e type, VALUE i) { Check_Type(i, T_FIXNUM); long j = FIX2LONG(i); @@ -246,6 +209,26 @@ static VALUE m_set_stricthostkeycheck(VALUE self, VALUE enable) { INT2FIX(RTEST(enable) ? 1 : 0)); } +// LibSSH::Session#set_options(LibSSH::Options) +static VALUE m_set_options(VALUE self, VALUE value) { + struct libssh_ruby_options **options = &unwrap_session(self)->options; + if (*options) rb_raise(rb_eArgError, "Cannot set options twice."); + *options = libssh_ruby_clone_options(value); + + ssh_session session = libssh_ruby_get_session(self); + char *error; + int rc = libssh_ruby_apply_options(*options, session, &error); + if (error) { + VALUE exception_argv[1] = { rb_str_new_cstr(error) }; + free(error); + rb_exc_raise(rb_class_new_instance(1, exception_argv, rb_eArgError)); + } else if (rc < 0) { + libssh_ruby_raise(session); + } + + return Qnil; +} + struct nogvl_session_args { ssh_session session; int rc; @@ -475,9 +458,6 @@ void Init_libssh_session(void) { #undef I rb_define_method(rb_cLibSSHSession, "log_verbosity=", m_set_log_verbosity, 1); - rb_define_method(rb_cLibSSHSession, "host=", m_set_host, 1); - rb_define_method(rb_cLibSSHSession, "user=", m_set_user, 1); - rb_define_method(rb_cLibSSHSession, "port=", m_set_port, 1); rb_define_method(rb_cLibSSHSession, "timeout=", m_set_timeout, 1); rb_define_method(rb_cLibSSHSession, "key_exchange=", m_set_key_exchange, 1); rb_define_method(rb_cLibSSHSession, "hmac_c_s=", m_set_hmac_c_s, 1); @@ -497,4 +477,6 @@ void Init_libssh_session(void) { rb_define_method(rb_cLibSSHSession, "userauth_kbdint", m_userauth_kbdint, 0); rb_define_method(rb_cLibSSHSession, "userauth_kbdint_getnprompts", m_userauth_kbdint_getnpromts, 0); rb_define_method(rb_cLibSSHSession, "userauth_kbdint_setanswer", m_userauth_kbdint_setanswer, 2); + + rb_define_private_method(rb_cLibSSHSession, "set_options", m_set_options, 1); } diff --git a/lib/libssh.rb b/lib/libssh.rb index 716bd0c..2c2c6ce 100644 --- a/lib/libssh.rb +++ b/lib/libssh.rb @@ -1,4 +1,6 @@ require 'libssh/version' require 'libssh/libssh_ruby' require 'libssh/key' +require 'libssh/options' +require 'libssh/session' require 'libssh/channel' diff --git a/lib/libssh/options.rb b/lib/libssh/options.rb new file mode 100644 index 0000000..1f71b51 --- /dev/null +++ b/lib/libssh/options.rb @@ -0,0 +1,21 @@ +require "libssh/libssh_ruby" + +module LibSSH + # Declarative options for LibSSH::Session. + # + # LibSSH::Options.new( + # user: "alice", + # host: "localhost", + # port: 22, + # ) + # + class Options + attr_accessor :user, :host, :port + + def initialize(attrs) + attrs.each do |key, value| + send("#{key}=", value) + end + end + end +end diff --git a/lib/libssh/session.rb b/lib/libssh/session.rb new file mode 100644 index 0000000..b0d145e --- /dev/null +++ b/lib/libssh/session.rb @@ -0,0 +1,11 @@ +require "libssh/libssh_ruby" + +module LibSSH + class Session + # Configure the session with a LibSSH::Options or its Hash equivalent. + def initialize(options) + options = Options.new(options) unless options.is_a? Options + set_options(options) + end + end +end diff --git a/spec/integration/channel_spec.rb b/spec/integration/channel_spec.rb index c2f076e..1ebd573 100644 --- a/spec/integration/channel_spec.rb +++ b/spec/integration/channel_spec.rb @@ -2,10 +2,11 @@ RSpec.describe LibSSH::Channel do let(:session) do - @session = LibSSH::Session.new - @session.host = SshHelper.host - @session.port = DockerHelper.port - @session.user = SshHelper.user + @session = LibSSH::Session.new( + host: SshHelper.host, + port: DockerHelper.port, + user: SshHelper.user, + ) @session.connect @session.userauth_password(SshHelper.password) @session @@ -21,7 +22,7 @@ describe '#open_session' do context 'without connected session' do it 'raises an error' do - channel = described_class.new(LibSSH::Session.new) + channel = described_class.new(LibSSH::Session.new(host: SshHelper.host)) expect { channel.open_session { :ng } }.to raise_error(ArgumentError) end end diff --git a/spec/integration/session_spec.rb b/spec/integration/session_spec.rb index f25827e..7f44f3f 100644 --- a/spec/integration/session_spec.rb +++ b/spec/integration/session_spec.rb @@ -1,60 +1,46 @@ require 'spec_helper' RSpec.describe LibSSH::Session do - let(:session) { described_class.new } + def session + @session ||= build + end + + def build(options = {}) + @session = described_class.new( + host: SshHelper.host, + port: DockerHelper.port, + user: SshHelper.user, + **options, + ) + end after do - session.disconnect + @session&.disconnect + @session = nil end - describe '#user=' do - it 'is nullable' do - session.user = nil + describe "#initialize" do + specify "user is nullable" do + expect { build(user: nil) }.not_to raise_error end - end - describe '#host=' do - it 'raises error on bad host' do - expect { session.host = nil }.to raise_error ArgumentError, 'Invalid host: nil' - expect { session.host = "foo_bar" }.to raise_error ArgumentError, 'Invalid host: "foo_bar"' + it "raises an exception on bad host" do + expect { build(host: "foo_bar") }.to raise_error ArgumentError, 'Invalid host: foo_bar' end end describe '#connect' do - context 'without hostname' do - it 'raises an error' do - expect { session.connect }.to raise_error(LibSSH::Error) - end + specify "host is required" do + expect { build(host: nil).connect }.to raise_error LibSSH::Error end - context 'with wrong port number' do - before do - session.host = SshHelper.host - session.port = DockerHelper.port + 1 - end - - it 'raises an error' do - expect { session.connect }.to raise_error(LibSSH::Error) - end - end - - context 'with valid condition' do - before do - session.host = SshHelper.host - session.port = DockerHelper.port - end - - it 'succeeds' do - expect(session.connect).to be_nil - end + it "raises an exception on closed port" do + expect { build(port: 2).connect }.to raise_error LibSSH::Error end end describe '#userauth_list' do before do - session.host = SshHelper.host - session.port = DockerHelper.port - session.user = SshHelper.user session.connect end @@ -77,9 +63,6 @@ describe '#userauth_publickey' do before do - session.host = SshHelper.host - session.port = DockerHelper.port - session.user = SshHelper.user session.connect end @@ -91,12 +74,6 @@ end describe '#userauth_publickey_auto' do - before do - session.host = SshHelper.host - session.port = DockerHelper.port - session.user = SshHelper.user - end - context 'without valid private key' do it 'is denied' do session.connect @@ -107,9 +84,6 @@ describe '#userauth_password' do before do - session.host = SshHelper.host - session.port = DockerHelper.port - session.user = SshHelper.user session.connect end @@ -128,9 +102,6 @@ describe '#userauth_kbdint' do before do - session.host = SshHelper.host - session.port = DockerHelper.port - session.user = SshHelper.user session.connect end