net-ssh 7.3.6 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/.github/pull_request_template.md +1 -0
- data/.github/workflows/changelog.yml +43 -0
- data/.github/workflows/ci-with-docker.yml +0 -3
- data/.github/workflows/ci.yml +2 -17
- data/.github/workflows/rubocop.yml +3 -1
- data/.rubocop.yml +1 -1
- data/.rubocop_todo.yml +1 -0
- data/CHANGES.txt +73 -7
- data/CONTRIBUTING.md +35 -0
- data/Dockerfile +3 -3
- data/Dockerfile.openssl3 +1 -1
- data/Dockerfile.zlib_ng +1 -1
- data/README.md +71 -57
- data/Rakefile +66 -12
- data/appveyor.yml +4 -16
- data/changelog.d/README.md +1 -0
- data/docker-compose.yml +1 -7
- data/lib/net/ssh/authentication/agent.rb +35 -11
- data/lib/net/ssh/authentication/ed25519.rb +137 -44
- data/lib/net/ssh/authentication/ed25519_loader.rb +14 -9
- data/lib/net/ssh/authentication/key_manager.rb +22 -11
- data/lib/net/ssh/authentication/methods/abstract.rb +2 -8
- data/lib/net/ssh/authentication/methods/publickey.rb +3 -1
- data/lib/net/ssh/authentication/security_key.rb +71 -0
- data/lib/net/ssh/authentication/session.rb +29 -17
- data/lib/net/ssh/buffer.rb +6 -1
- data/lib/net/ssh/buffered_io.rb +40 -3
- data/lib/net/ssh/config.rb +28 -12
- data/lib/net/ssh/connection/channel.rb +49 -2
- data/lib/net/ssh/connection/session.rb +58 -8
- data/lib/net/ssh/errors.rb +24 -6
- data/lib/net/ssh/key_factory.rb +7 -1
- data/lib/net/ssh/known_hosts.rb +18 -8
- data/lib/net/ssh/multiplex/client.rb +162 -0
- data/lib/net/ssh/multiplex/control_path.rb +120 -0
- data/lib/net/ssh/multiplex/proxy_transport.rb +218 -0
- data/lib/net/ssh/multiplex.rb +130 -0
- data/lib/net/ssh/prompt.rb +3 -3
- data/lib/net/ssh/proxy/jump.rb +40 -2
- data/lib/net/ssh/service/forward.rb +29 -24
- data/lib/net/ssh/test.rb +2 -1
- data/lib/net/ssh/transport/algorithms.rb +88 -33
- data/lib/net/ssh/transport/chacha20_poly1305_cipher.rb +89 -16
- data/lib/net/ssh/transport/chacha20_poly1305_cipher_loader.rb +20 -1
- data/lib/net/ssh/transport/cipher_factory.rb +23 -1
- data/lib/net/ssh/transport/constants.rb +1 -0
- data/lib/net/ssh/transport/gcm_cipher.rb +27 -6
- data/lib/net/ssh/transport/hmac/sha1_etm.rb +12 -0
- data/lib/net/ssh/transport/hmac.rb +2 -0
- data/lib/net/ssh/transport/kex/curve25519_sha256.rb +41 -9
- data/lib/net/ssh/transport/kex/curve25519_sha256_loader.rb +14 -6
- data/lib/net/ssh/transport/kex/diffie_hellman_group1_sha1.rb +8 -0
- data/lib/net/ssh/transport/kex/diffie_hellman_group_exchange_sha1.rb +45 -15
- data/lib/net/ssh/transport/openssl.rb +2 -1
- data/lib/net/ssh/transport/packet_stream.rb +12 -0
- data/lib/net/ssh/transport/session.rb +41 -44
- data/lib/net/ssh/verifiers/always.rb +0 -3
- data/lib/net/ssh/version.rb +3 -3
- data/lib/net/ssh.rb +139 -57
- data/net-ssh.gemspec +5 -8
- data/rakelib/changelog.rb +229 -0
- data.tar.gz.sig +0 -0
- metadata +37 -36
- metadata.gz.sig +0 -0
- data/Gemfile.noed25519 +0 -12
- data/Gemfile.norbnacl +0 -12
data/lib/net/ssh.rb
CHANGED
|
@@ -8,6 +8,7 @@ require 'net/ssh/loggable'
|
|
|
8
8
|
require 'net/ssh/transport/session'
|
|
9
9
|
require 'net/ssh/authentication/session'
|
|
10
10
|
require 'net/ssh/connection/session'
|
|
11
|
+
require 'net/ssh/multiplex'
|
|
11
12
|
require 'net/ssh/prompt'
|
|
12
13
|
|
|
13
14
|
module Net
|
|
@@ -62,14 +63,15 @@ module Net
|
|
|
62
63
|
auth_methods bind_address compression compression_level config
|
|
63
64
|
encryption forward_agent hmac host_key identity_agent remote_user
|
|
64
65
|
keepalive keepalive_interval keepalive_maxcount kex keys key_data
|
|
65
|
-
keycerts keycert_data languages logger
|
|
66
|
+
keycerts keycert_data languages logger password port proxy
|
|
66
67
|
rekey_blocks_limit rekey_limit rekey_packet_limit timeout verbose
|
|
67
68
|
known_hosts global_known_hosts_file user_known_hosts_file host_key_alias
|
|
68
69
|
host_name user properties passphrase keys_only max_pkt_size
|
|
69
70
|
max_win_size send_env set_env use_agent number_of_password_prompts
|
|
70
71
|
append_all_supported_algorithms non_interactive password_prompt
|
|
71
72
|
agent_socket_factory minimum_dh_bits verify_host_key
|
|
72
|
-
fingerprint_hash check_host_ip pubkey_algorithms
|
|
73
|
+
fingerprint_hash check_host_ip pubkey_algorithms no_delay
|
|
74
|
+
control_master control_path
|
|
73
75
|
]
|
|
74
76
|
|
|
75
77
|
# The standard means of starting a new SSH connection. When used with a
|
|
@@ -98,7 +100,10 @@ module Net
|
|
|
98
100
|
# ssh.loop
|
|
99
101
|
# end
|
|
100
102
|
#
|
|
101
|
-
# This method accepts the following options (all are optional)
|
|
103
|
+
# This method accepts the following options (all are optional). Omit an
|
|
104
|
+
# option to use its default or ssh_config value; passing +nil+ (or an array
|
|
105
|
+
# containing +nil+) raises ArgumentError, except for :password,
|
|
106
|
+
# :passphrase, :logger and :password_prompt, where +nil+ means the default.
|
|
102
107
|
#
|
|
103
108
|
# * :auth_methods => an array of authentication methods to try
|
|
104
109
|
# * :bind_address => the IP address on the connecting machine to use in
|
|
@@ -109,6 +114,40 @@ module Net
|
|
|
109
114
|
# * :compression => the compression algorithm to use, or +true+ to use
|
|
110
115
|
# whatever is supported.
|
|
111
116
|
# * :compression_level => the compression level to use when sending data
|
|
117
|
+
# * :control_master => set to +:auto+ to run the session through an
|
|
118
|
+
# already running OpenSSH ControlMaster for the same host, port and user
|
|
119
|
+
# (started e.g. with <tt>ssh -M</tt> or <tt>ControlMaster auto</tt>),
|
|
120
|
+
# using its proxy mode (OpenSSH 7.4 or later). This skips the TCP
|
|
121
|
+
# connection, key exchange and authentication. If there is no usable
|
|
122
|
+
# master (no ControlPath, no socket, refused, timed out, owned by another
|
|
123
|
+
# user) Net::SSH logs why and connects as usual; it never starts a master
|
|
124
|
+
# itself. The master's own settings then apply instead of the options
|
|
125
|
+
# for setting up a connection (:keys, :key_data, :password, :passphrase,
|
|
126
|
+
# :auth_methods, :verify_host_key, :known_hosts, :host_key, :kex,
|
|
127
|
+
# :encryption, :hmac, :compression, :rekey_*, :proxy, :keepalive and the
|
|
128
|
+
# like); explicitly passed ones are named in a logger warning. Over the
|
|
129
|
+
# master, remote port forwarding (Service::Forward#remote and
|
|
130
|
+
# #cancel_remote) raises Net::SSH::NotSupportedOverControlMaster, as the
|
|
131
|
+
# master consumes the global request reply, so success, failure and the
|
|
132
|
+
# port allocated for port 0 can't be observed; other global requests
|
|
133
|
+
# raise it too. Agent and X11 forwarding requests raise it
|
|
134
|
+
# too, and :forward_agent can't be combined with it, because the master
|
|
135
|
+
# serves them from its own agent or display only if it has ForwardAgent
|
|
136
|
+
# or ForwardX11 enabled itself, and rejects them otherwise. Keepalive and
|
|
137
|
+
# rekeying are left to the master. ssh_config's ControlMaster is
|
|
138
|
+
# ignored, only this option enables it. An invalid ControlPath raises
|
|
139
|
+
# ArgumentError. A missing or stale socket is logged at info level, a
|
|
140
|
+
# socket that can't be used (another owner, refused, wrong protocol,
|
|
141
|
+
# timed out) at warn level. Has no effect on Windows. Defaults to
|
|
142
|
+
# +false+.
|
|
143
|
+
# * :control_path => the ControlMaster socket to use with
|
|
144
|
+
# :control_master, with the same ~, ${ENV} and % tokens as ssh_config's
|
|
145
|
+
# ControlPath (%C, %d, %h, %i, %j, %k, %L, %l, %n, %p, %r, %u, %%).
|
|
146
|
+
# The master at that path decides which host and user the session is
|
|
147
|
+
# connected to, so a fixed path without %h, %p, %r or %C sends every
|
|
148
|
+
# host through the same master and the explicitly passed host key and
|
|
149
|
+
# authentication options are ignored. Defaults to ControlPath from
|
|
150
|
+
# ssh_config; +"none"+ disables the master.
|
|
112
151
|
# * :config => set to +true+ to load the default OpenSSH config files
|
|
113
152
|
# (~/.ssh/config, /etc/ssh_config), or to +false+ to not load them, or to
|
|
114
153
|
# a file-name (or array of file-names) to load those specific configuration
|
|
@@ -137,7 +176,11 @@ module Net
|
|
|
137
176
|
# Defaults to +300+ seconds.
|
|
138
177
|
# * :keepalive_maxcount => the maximun number of keepalive packet miss allowed.
|
|
139
178
|
# Defaults to 3
|
|
140
|
-
# * :kex => the key exchange algorithm (or algorithms) to use
|
|
179
|
+
# * :kex => the key exchange algorithm (or algorithms) to use. Like
|
|
180
|
+
# KexAlgorithms, entries starting with +, - or ^ modify the default list.
|
|
181
|
+
# diffie-hellman-group14-sha1 is supported but not offered unless listed
|
|
182
|
+
# here (e.g. '+diffie-hellman-group14-sha1') or
|
|
183
|
+
# :append_all_supported_algorithms is set.
|
|
141
184
|
# * :keys => an array of file names of private keys to use for publickey
|
|
142
185
|
# and hostbased authentication
|
|
143
186
|
# * :keycerts => an array of file names of key certificates to use
|
|
@@ -155,12 +198,24 @@ module Net
|
|
|
155
198
|
# packet. Default is 0x8000 (32768 bytes). Increase to 0x10000 (65536 bytes)
|
|
156
199
|
# for better performance if your SSH server supports it (most do).
|
|
157
200
|
# * :max_win_size => maximum size we tell the other side that is supported for
|
|
158
|
-
# the window.
|
|
201
|
+
# the window, i.e. how much the server may send on a channel before net-ssh
|
|
202
|
+
# reads it. Default is 0x200000 (2 MB), as in OpenSSH. Smaller values limit
|
|
203
|
+
# how much data is in flight; some servers (e.g. Mikrotik) truncate output
|
|
204
|
+
# when the window is small.
|
|
205
|
+
# * :minimum_dh_bits => the smallest group, in bits, accepted from the server
|
|
206
|
+
# in diffie-hellman-group-exchange key exchange. Defaults to 2048, as in
|
|
207
|
+
# OpenSSH; lower it (e.g. to 1024) only to reach legacy servers that offer
|
|
208
|
+
# nothing larger.
|
|
209
|
+
# * :no_delay => turns Nagle's algorithm off on the connection's socket
|
|
210
|
+
# (TCP_NODELAY), so a packet written while the previous one is still
|
|
211
|
+
# unacknowledged is sent right away instead of waiting for the server's
|
|
212
|
+
# ACK. OpenSSH does the same for every connection. Set to +false+ to keep
|
|
213
|
+
# Nagle's algorithm on. Has no effect when the socket comes from a
|
|
214
|
+
# ProxyCommand, whose command owns the TCP connection. Defaults to +true+.
|
|
159
215
|
# * :non_interactive => set to true if your app is non interactive and prefers
|
|
160
216
|
# authentication failure vs password prompt. Non-interactive applications
|
|
161
217
|
# should set it to true to prefer failing a password/etc auth methods vs.
|
|
162
218
|
# asking for password.
|
|
163
|
-
# * :paranoid => deprecated alias for :verify_host_key
|
|
164
219
|
# * :passphrase => the passphrase to use when loading a private key (default
|
|
165
220
|
# is +nil+, for no passphrase)
|
|
166
221
|
# * :password => the password to use to login
|
|
@@ -168,11 +223,13 @@ module Net
|
|
|
168
223
|
# * :properties => a hash of key/value pairs to add to the new connection's
|
|
169
224
|
# properties (see Net::SSH::Connection::Session#properties)
|
|
170
225
|
# * :proxy => a proxy instance (see Proxy) to use when connecting
|
|
171
|
-
# * :pubkey_algorithms => the public key authentication algorithms to
|
|
172
|
-
#
|
|
173
|
-
#
|
|
174
|
-
#
|
|
175
|
-
#
|
|
226
|
+
# * :pubkey_algorithms => the public key authentication algorithms to offer,
|
|
227
|
+
# in order of preference (e.g. %w[ssh-ed25519 rsa-sha2-512 rsa-sha2-256]).
|
|
228
|
+
# Keys whose type isn't listed are skipped. Like PubkeyAcceptedAlgorithms,
|
|
229
|
+
# entries starting with +, - or ^ modify the default list. ssh-rsa (SHA-1),
|
|
230
|
+
# ssh-rsa-cert-v01@openssh.com and ssh-dss are supported but not offered
|
|
231
|
+
# unless listed here (e.g. '+ssh-rsa') or :append_all_supported_algorithms
|
|
232
|
+
# is set.
|
|
176
233
|
# * :rekey_blocks_limit => the max number of blocks to process before rekeying
|
|
177
234
|
# * :rekey_limit => the max number of bytes to process before rekeying
|
|
178
235
|
# * :rekey_packet_limit => the max number of packets to process before rekeying
|
|
@@ -184,7 +241,8 @@ module Net
|
|
|
184
241
|
# * :user => the user name to log in as; this overrides the +user+
|
|
185
242
|
# parameter, and is primarily only useful when provided via an SSH
|
|
186
243
|
# configuration file.
|
|
187
|
-
# * :remote_user => used for substitution into the '%r' part of a ProxyCommand
|
|
244
|
+
# * :remote_user => used for substitution into the '%r' part of a ProxyCommand;
|
|
245
|
+
# defaults to the user name the connection logs in as.
|
|
188
246
|
# * :user_known_hosts_file => the location of the user known hosts file.
|
|
189
247
|
# Set to an array to specify multiple user known hosts files.
|
|
190
248
|
# Defaults to %w(~/.ssh/known_hosts ~/.ssh/known_hosts2).
|
|
@@ -196,7 +254,9 @@ module Net
|
|
|
196
254
|
# default. The symbols :debug, :info, :warn, :error, and :fatal are also
|
|
197
255
|
# supported and are translated to the corresponding Logger constant.
|
|
198
256
|
# * :append_all_supported_algorithms => set to +true+ to append all supported
|
|
199
|
-
# algorithms by net-ssh
|
|
257
|
+
# algorithms by net-ssh, except the +none+ cipher and MAC, which must be
|
|
258
|
+
# named explicitly (e.g. encryption: '+none'). Was the default behaviour
|
|
259
|
+
# until 2.10
|
|
200
260
|
# * :number_of_password_prompts => Number of prompts for the password
|
|
201
261
|
# authentication method defaults to 3 set to 0 to disable prompt for
|
|
202
262
|
# password auth method
|
|
@@ -211,21 +271,32 @@ module Net
|
|
|
211
271
|
# * :accept_new_or_local_tunnel (insecure) ::Net::SSH::Verifiers::AcceptNewOrLocalTunnel
|
|
212
272
|
# * :accept_new (insecure) ::Net::SSH::Verifiers::AcceptNew
|
|
213
273
|
# * :always (secure) ::Net::SSH::Verifiers::Always
|
|
214
|
-
# You can also provide an own Object which responds to +verify
|
|
215
|
-
# given to +verify+ is a hash consisting
|
|
216
|
-
# the +:fingerprint+ and the +:session+.
|
|
217
|
-
# returning false declines it and
|
|
274
|
+
# You can also provide an own Object which responds to +verify+ and
|
|
275
|
+
# +verify_signature+. The argument given to +verify+ is a hash consisting
|
|
276
|
+
# of the +:key+, the +:key_blob+, the +:fingerprint+ and the +:session+.
|
|
277
|
+
# Returning true accepts the host key, returning false declines it and
|
|
278
|
+
# closes the connection. +verify_signature+ is given a block that checks
|
|
279
|
+
# the server's host key signature and must return the block's result.
|
|
218
280
|
# * :fingerprint_hash => 'MD5' or 'SHA256', defaults to 'SHA256'
|
|
219
281
|
# If +user+ parameter is nil it defaults to USER from ssh_config, or
|
|
220
282
|
# local username
|
|
221
283
|
def self.start(host, user = nil, options = {}, &block)
|
|
284
|
+
if options.key?(:paranoid)
|
|
285
|
+
raise ArgumentError, ":paranoid has been removed, use :verify_host_key (same values)"
|
|
286
|
+
end
|
|
287
|
+
|
|
222
288
|
invalid_options = options.keys - VALID_OPTIONS
|
|
223
289
|
if invalid_options.any?
|
|
224
290
|
raise ArgumentError, "invalid option(s): #{invalid_options.join(', ')}"
|
|
225
291
|
end
|
|
226
292
|
|
|
293
|
+
reject_nil_options(options)
|
|
294
|
+
Multiplex.validate_options!(options)
|
|
295
|
+
explicit_options = options.keys
|
|
296
|
+
host_arg = host
|
|
297
|
+
|
|
298
|
+
options = options.dup
|
|
227
299
|
assign_defaults(options)
|
|
228
|
-
_sanitize_options(options)
|
|
229
300
|
|
|
230
301
|
options[:user] = user if user
|
|
231
302
|
options = configuration_for(host, options.fetch(:config, true)).merge(options)
|
|
@@ -237,8 +308,6 @@ module Net
|
|
|
237
308
|
options[:number_of_password_prompts] = 0
|
|
238
309
|
end
|
|
239
310
|
|
|
240
|
-
_support_deprecated_option_paranoid(options)
|
|
241
|
-
|
|
242
311
|
if options[:verbose]
|
|
243
312
|
options[:logger].level = case options[:verbose]
|
|
244
313
|
when Integer then options[:verbose]
|
|
@@ -251,26 +320,35 @@ module Net
|
|
|
251
320
|
end
|
|
252
321
|
end
|
|
253
322
|
|
|
254
|
-
transport = Transport::Session.new(host, options)
|
|
255
|
-
auth = Authentication::Session.new(transport, options)
|
|
256
|
-
|
|
257
323
|
user = options.fetch(:user, user) || Etc.getpwuid.name
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
324
|
+
options[:remote_user] ||= user
|
|
325
|
+
|
|
326
|
+
connection = Multiplex.connect(host_arg, host, user, options, explicit: explicit_options) if options[:control_master]
|
|
327
|
+
connection ||= connect_directly(host, user, options)
|
|
328
|
+
|
|
329
|
+
if block_given?
|
|
330
|
+
begin
|
|
331
|
+
yield connection
|
|
332
|
+
ensure
|
|
333
|
+
connection.close unless connection.closed?
|
|
268
334
|
end
|
|
269
335
|
else
|
|
336
|
+
connection
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def self.connect_directly(host, user, options)
|
|
341
|
+
transport = Transport::Session.new(host, options)
|
|
342
|
+
auth = Authentication::Session.new(transport, options)
|
|
343
|
+
|
|
344
|
+
unless auth.authenticate("ssh-connection", user, options[:password])
|
|
270
345
|
transport.close
|
|
271
346
|
raise AuthenticationFailed, "Authentication failed for user #{user}@#{host}"
|
|
272
347
|
end
|
|
348
|
+
|
|
349
|
+
Connection::Session.new(transport, options)
|
|
273
350
|
end
|
|
351
|
+
private_class_method :connect_directly
|
|
274
352
|
|
|
275
353
|
# Returns a hash of the configuration options for the given host, as read
|
|
276
354
|
# from the SSH configuration file(s). If +use_ssh_config+ is true (the
|
|
@@ -304,31 +382,35 @@ module Net
|
|
|
304
382
|
end
|
|
305
383
|
end
|
|
306
384
|
|
|
307
|
-
|
|
308
|
-
invalid_option_values = [nil, [nil]]
|
|
309
|
-
unless (options.values & invalid_option_values).empty?
|
|
310
|
-
nil_options = options.select { |_k, v| invalid_option_values.include?(v) }.map(&:first)
|
|
311
|
-
Kernel.warn "#{caller_locations(2, 1)[0]}: Passing nil, or [nil] to Net::SSH.start is deprecated for keys: #{nil_options.join(', ')}"
|
|
312
|
-
end
|
|
313
|
-
end
|
|
314
|
-
private_class_method :_sanitize_options
|
|
385
|
+
NIL_ALLOWED_OPTIONS = %i[password passphrase logger password_prompt].freeze
|
|
315
386
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
387
|
+
NIL_OPTION_HINTS = {
|
|
388
|
+
config: "config: false to skip ssh_config files",
|
|
389
|
+
proxy: "proxy: false to connect without a proxy",
|
|
390
|
+
keys: "keys: [] to use no key files",
|
|
391
|
+
use_agent: "use_agent: false to not use ssh-agent",
|
|
392
|
+
forward_agent: "forward_agent: false to not forward ssh-agent",
|
|
393
|
+
keepalive: "keepalive: false to send no keepalives",
|
|
394
|
+
compression: "compression: false to disable compression",
|
|
395
|
+
control_master: "control_master: false to not use a ControlMaster",
|
|
396
|
+
control_path: "control_path: 'none' to not use a ControlMaster socket",
|
|
397
|
+
send_env: "send_env: [] to send no environment variables",
|
|
398
|
+
set_env: "set_env: {} to set no environment variables"
|
|
399
|
+
}.freeze
|
|
400
|
+
private_constant :NIL_ALLOWED_OPTIONS, :NIL_OPTION_HINTS
|
|
401
|
+
|
|
402
|
+
def self.reject_nil_options(options)
|
|
403
|
+
nil_keys = options.select do |key, value|
|
|
404
|
+
!NIL_ALLOWED_OPTIONS.include?(key) && (value.nil? || (value.is_a?(Array) && value.include?(nil)))
|
|
405
|
+
end.keys
|
|
406
|
+
return if nil_keys.empty?
|
|
407
|
+
|
|
408
|
+
message = "nil is not allowed for #{nil_keys.map(&:inspect).join(', ')}; " \
|
|
409
|
+
"omit #{nil_keys.one? ? 'it' : 'them'} to use the default or ssh_config value"
|
|
410
|
+
hints = NIL_OPTION_HINTS.values_at(*nil_keys).compact
|
|
411
|
+
message += ", or pass #{hints.join(', or ')}" if hints.any?
|
|
412
|
+
raise ArgumentError, message
|
|
331
413
|
end
|
|
332
|
-
private_class_method :
|
|
414
|
+
private_class_method :reject_nil_options
|
|
333
415
|
end
|
|
334
416
|
end
|
data/net-ssh.gemspec
CHANGED
|
@@ -15,7 +15,7 @@ Gem::Specification.new do |spec|
|
|
|
15
15
|
spec.description = %q{Net::SSH: a pure-Ruby implementation of the SSH2 client protocol. It allows you to write programs that invoke and interact with processes on remote servers, via SSH2.}
|
|
16
16
|
spec.homepage = "https://github.com/net-ssh/net-ssh"
|
|
17
17
|
spec.license = "MIT"
|
|
18
|
-
spec.required_ruby_version = Gem::Requirement.new(">= 2.
|
|
18
|
+
spec.required_ruby_version = Gem::Requirement.new(">= 2.7")
|
|
19
19
|
spec.metadata = {
|
|
20
20
|
"changelog_uri" => "https://github.com/net-ssh/net-ssh/blob/master/CHANGES.txt"
|
|
21
21
|
}
|
|
@@ -30,18 +30,15 @@ Gem::Specification.new do |spec|
|
|
|
30
30
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
|
31
31
|
spec.require_paths = ["lib"]
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
spec.add_development_dependency("ed25519", "~> 1.2")
|
|
36
|
-
spec.add_development_dependency('x25519') unless RUBY_PLATFORM == 'java'
|
|
37
|
-
end
|
|
33
|
+
spec.add_dependency("logger")
|
|
34
|
+
spec.add_dependency("openssl", ">= 3.2.0")
|
|
38
35
|
|
|
39
|
-
spec.add_development_dependency(
|
|
36
|
+
spec.add_development_dependency("bcrypt_pbkdf", "~> 1.0") unless RUBY_PLATFORM == "java"
|
|
40
37
|
|
|
41
38
|
spec.add_development_dependency "base64"
|
|
42
39
|
spec.add_development_dependency "bundler", ">= 1.17"
|
|
43
40
|
spec.add_development_dependency "minitest", "~> 5.19"
|
|
44
|
-
spec.add_development_dependency "mocha", "
|
|
41
|
+
spec.add_development_dependency "mocha", ">= 2.1", "< 4"
|
|
45
42
|
spec.add_development_dependency "ostruct"
|
|
46
43
|
spec.add_development_dependency "rake", "~> 12.0"
|
|
47
44
|
spec.add_development_dependency "rubocop", "~> 1.28.0"
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'open3'
|
|
5
|
+
|
|
6
|
+
# Builds CHANGES.txt sections from the fragment files in changelog.d/ (see CONTRIBUTING.md).
|
|
7
|
+
class Changelog
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
# Output order of the groups in a release section; `none` fragments are never emitted.
|
|
11
|
+
TYPES = {
|
|
12
|
+
'breaking-security' => 'BREAKING: Security: ',
|
|
13
|
+
'breaking' => 'BREAKING: ',
|
|
14
|
+
'security' => 'Security: ',
|
|
15
|
+
'change' => '',
|
|
16
|
+
'none' => nil
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
FILENAME = /\A(?<number>[1-9]\d*)\.(?<type>[a-z-]+)(?:\.(?<seq>[1-9]\d*))?\.txt\z/.freeze
|
|
20
|
+
REF = /#\d+|GHSA(?:-[a-z0-9]{4}){3}|CVE-\d{4}-\d+/i.freeze
|
|
21
|
+
REF_LIST = /\[(?:#{REF})(?:,\s*(?:#{REF}))*\]\z/.freeze
|
|
22
|
+
UNRELEASED = '=== Unreleased'
|
|
23
|
+
PLACEHOLDER = 'TODO: describe the change as it should read in CHANGES.txt'
|
|
24
|
+
|
|
25
|
+
Fragment = Struct.new(:path, :number, :type, :seq, :text) do
|
|
26
|
+
def entry
|
|
27
|
+
"#{TYPES.fetch(type)}#{text}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def sort_key
|
|
31
|
+
[-number, seq]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
attr_reader :dir, :changes_path
|
|
36
|
+
|
|
37
|
+
def initialize(root)
|
|
38
|
+
@root = root
|
|
39
|
+
@dir = File.join(root, 'changelog.d')
|
|
40
|
+
@changes_path = File.join(root, 'CHANGES.txt')
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Raises Error listing every invalid fragment; returns all fragments, including `none` ones.
|
|
44
|
+
def fragments
|
|
45
|
+
errors = []
|
|
46
|
+
fragments = []
|
|
47
|
+
entries_in_dir.each do |name|
|
|
48
|
+
result = parse_fragment(name)
|
|
49
|
+
result.is_a?(Fragment) ? fragments << result : errors << "changelog.d/#{name}: #{result}"
|
|
50
|
+
end
|
|
51
|
+
raise Error, "Invalid changelog fragments:\n #{errors.join("\n ")}" unless errors.empty?
|
|
52
|
+
|
|
53
|
+
fragments.sort_by(&:sort_key)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Validates the fragments and the `=== Unreleased` section of CHANGES.txt; returns the fragments.
|
|
57
|
+
def check
|
|
58
|
+
all = fragments
|
|
59
|
+
split_changes(read_changes)
|
|
60
|
+
all
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The text of the section for +heading+, or nil for a pre-release with nothing to release.
|
|
64
|
+
def section(heading)
|
|
65
|
+
assemble(heading, fragments, split_changes(read_changes))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Writes the section for +heading+ to the top of CHANGES.txt, folding in any `=== Unreleased`
|
|
69
|
+
# entries, and removes all fragments; returns the section, or nil when CHANGES.txt was left unchanged.
|
|
70
|
+
def release(heading)
|
|
71
|
+
all = fragments
|
|
72
|
+
content = read_changes
|
|
73
|
+
raise Error, "CHANGES.txt already has a '=== #{heading}' section" if content.lines.any? { |l| l.chomp == "=== #{heading}" }
|
|
74
|
+
|
|
75
|
+
parts = split_changes(content)
|
|
76
|
+
section = assemble(heading, all, parts)
|
|
77
|
+
if section
|
|
78
|
+
rest = parts[:rest].empty? ? '' : "\n#{parts[:rest]}"
|
|
79
|
+
File.write(changes_path, "#{parts[:preamble]}#{section}#{rest}")
|
|
80
|
+
end
|
|
81
|
+
remove(all.map(&:path))
|
|
82
|
+
section
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Creates a fragment for PR +number+ and returns its path; picks the next free `.N` suffix if needed.
|
|
86
|
+
def new_fragment(number, type, text = nil)
|
|
87
|
+
path = (1..).lazy.map { |seq| File.join(dir, fragment_name(number, type, seq)) }.find { |p| !File.exist?(p) }
|
|
88
|
+
error = validate_name(File.basename(path))
|
|
89
|
+
raise Error, error if error
|
|
90
|
+
|
|
91
|
+
FileUtils.mkdir_p(dir)
|
|
92
|
+
text = PLACEHOLDER if (text.nil? || text.strip.empty?) && type != 'none'
|
|
93
|
+
File.write(path, "#{text}\n")
|
|
94
|
+
path
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.heading(major, minor, tiny, pre)
|
|
98
|
+
["#{major}.#{minor}.#{tiny}", pre].compact.join(' ')
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def entries_in_dir
|
|
104
|
+
return [] unless Dir.exist?(dir)
|
|
105
|
+
|
|
106
|
+
Dir.children(dir).sort.reject { |name| name == 'README.md' || name.start_with?('.') }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def fragment_name(number, type, seq)
|
|
110
|
+
seq == 1 ? "#{number}.#{type}.txt" : "#{number}.#{type}.#{seq}.txt"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def validate_name(name)
|
|
114
|
+
match = FILENAME.match(name)
|
|
115
|
+
return "file name must be <PR number>.<type>[.<n>].txt, types: #{TYPES.keys.join(', ')}" unless match
|
|
116
|
+
return "unknown type '#{match[:type]}', expected one of: #{TYPES.keys.join(', ')}" unless TYPES.key?(match[:type])
|
|
117
|
+
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Returns a Fragment, or an error message.
|
|
122
|
+
def parse_fragment(name)
|
|
123
|
+
path = File.join(dir, name)
|
|
124
|
+
return 'is not a file' unless File.file?(path)
|
|
125
|
+
|
|
126
|
+
error = validate_name(name)
|
|
127
|
+
return error if error
|
|
128
|
+
|
|
129
|
+
match = FILENAME.match(name)
|
|
130
|
+
fragment = Fragment.new(path, match[:number].to_i, match[:type], (match[:seq] || 1).to_i, nil)
|
|
131
|
+
return fragment if fragment.type == 'none'
|
|
132
|
+
|
|
133
|
+
text = parse_text(read_utf8(path))
|
|
134
|
+
return text[:error] if text[:error]
|
|
135
|
+
|
|
136
|
+
fragment.text = text[:text]
|
|
137
|
+
fragment.text = "#{fragment.text} [##{fragment.number}]" unless fragment.text.match?(REF_LIST)
|
|
138
|
+
fragment
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def parse_text(raw)
|
|
142
|
+
return { error: 'is not valid UTF-8' } unless raw.valid_encoding?
|
|
143
|
+
|
|
144
|
+
lines = raw.strip.lines.map(&:strip)
|
|
145
|
+
return { error: 'is empty' } if lines.empty?
|
|
146
|
+
return { error: 'must be a single paragraph (no blank lines)' } if lines.include?('')
|
|
147
|
+
|
|
148
|
+
text = lines.join(' ')
|
|
149
|
+
return { error: 'still contains the placeholder text' } if text == PLACEHOLDER
|
|
150
|
+
return { error: "should not start with a bullet, it's added when assembling" } if text.match?(/\A[*-] /)
|
|
151
|
+
return { error: 'should not start with BREAKING:/Security:, the type adds the prefix' } if text.match?(/\A(BREAKING|Security):/i)
|
|
152
|
+
|
|
153
|
+
{ text: text }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def read_changes
|
|
157
|
+
File.exist?(changes_path) ? read_utf8(changes_path).gsub("\r\n", "\n") : ''
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Explicit so a non-UTF-8 locale (e.g. LANG=C) doesn't break on non-ASCII text.
|
|
161
|
+
def read_utf8(path)
|
|
162
|
+
File.read(path, mode: 'r:bom|utf-8')
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# A final release (a heading without a pre-release suffix) always gets a section, so every published version has a heading.
|
|
166
|
+
def assemble(heading, fragments, parts)
|
|
167
|
+
entries = grouped_entries(fragments, parts[:unreleased])
|
|
168
|
+
if entries.empty?
|
|
169
|
+
return nil unless heading.match?(/\A\d+\.\d+\.\d+\z/)
|
|
170
|
+
|
|
171
|
+
previous = parts[:rest][/\A=== (.+)$/, 1]
|
|
172
|
+
entries = [" * #{previous ? "No changes since #{previous}." : 'No changes.'}\n"]
|
|
173
|
+
end
|
|
174
|
+
"=== #{heading}\n\n#{entries.join}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Splits CHANGES.txt into the text before the first heading, the entries of a leading
|
|
178
|
+
# `=== Unreleased` section (as lists of raw lines) and everything from the first released heading on.
|
|
179
|
+
def split_changes(content)
|
|
180
|
+
lines = content.lines
|
|
181
|
+
first = lines.index { |l| l.start_with?('=== ') } || lines.size
|
|
182
|
+
preamble = lines[0...first].join
|
|
183
|
+
return { preamble: preamble, unreleased: [], rest: lines[first..].join } unless lines[first]&.chomp == UNRELEASED
|
|
184
|
+
|
|
185
|
+
body_end = lines.index.with_index { |l, i| i > first && l.start_with?('=== ') } || lines.size
|
|
186
|
+
{ preamble: preamble, unreleased: unreleased_entries(lines[(first + 1)...body_end]), rest: lines[body_end..].join }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def unreleased_entries(lines)
|
|
190
|
+
lines.each_with_object([]) do |line, entries|
|
|
191
|
+
next if line.strip.empty?
|
|
192
|
+
|
|
193
|
+
if line.match?(/\A\s*\* /)
|
|
194
|
+
entries << [line]
|
|
195
|
+
elsif entries.empty?
|
|
196
|
+
raise Error, "Cannot parse the Unreleased section of CHANGES.txt near: #{line.strip}"
|
|
197
|
+
else
|
|
198
|
+
entries.last << line
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Fragments come before the hand-written Unreleased entries of the same group.
|
|
204
|
+
def grouped_entries(fragments, unreleased)
|
|
205
|
+
TYPES.keys.flat_map do |type|
|
|
206
|
+
from_fragments = fragments.select { |f| f.type == type && type != 'none' }.map { |f| " * #{f.entry}\n" }
|
|
207
|
+
from_unreleased = unreleased.select { |lines| unreleased_type(lines.first) == type }.map do |lines|
|
|
208
|
+
lines.join.end_with?("\n") ? lines.join : "#{lines.join}\n"
|
|
209
|
+
end
|
|
210
|
+
from_fragments + from_unreleased
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def unreleased_type(line)
|
|
215
|
+
text = line.sub(/\A\s*\* /, '')
|
|
216
|
+
TYPES.find { |_type, prefix| prefix && !prefix.empty? && text.start_with?(prefix) }&.first || 'change'
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# `git rm` so the deletions are staged with the version bump; plain delete outside a git checkout.
|
|
220
|
+
def remove(paths)
|
|
221
|
+
return if paths.empty?
|
|
222
|
+
|
|
223
|
+
relative = paths.map { |p| p.delete_prefix("#{@root}/") }
|
|
224
|
+
_out, status = Open3.capture2e('git', '-C', @root, 'rm', '-q', '--', *relative)
|
|
225
|
+
FileUtils.rm_f(paths) unless status.success?
|
|
226
|
+
rescue SystemCallError
|
|
227
|
+
FileUtils.rm_f(paths)
|
|
228
|
+
end
|
|
229
|
+
end
|
data.tar.gz.sig
CHANGED
|
Binary file
|