net-ssh 8.0.0.rc3 → 8.0.0.rc5

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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +4 -1
  3. data/.github/pull_request_template.md +1 -0
  4. data/.github/workflows/changelog.yml +43 -0
  5. data/CHANGES.txt +40 -0
  6. data/CONTRIBUTING.md +35 -0
  7. data/README.md +65 -48
  8. data/Rakefile +48 -0
  9. data/changelog.d/README.md +1 -0
  10. data/lib/net/ssh/authentication/agent.rb +4 -1
  11. data/lib/net/ssh/authentication/ed25519.rb +1 -1
  12. data/lib/net/ssh/authentication/key_manager.rb +22 -12
  13. data/lib/net/ssh/authentication/methods/abstract.rb +2 -8
  14. data/lib/net/ssh/authentication/methods/publickey.rb +3 -1
  15. data/lib/net/ssh/authentication/security_key.rb +71 -0
  16. data/lib/net/ssh/authentication/session.rb +29 -17
  17. data/lib/net/ssh/buffer.rb +6 -1
  18. data/lib/net/ssh/buffered_io.rb +40 -3
  19. data/lib/net/ssh/config.rb +16 -1
  20. data/lib/net/ssh/connection/channel.rb +49 -2
  21. data/lib/net/ssh/connection/session.rb +58 -8
  22. data/lib/net/ssh/errors.rb +24 -6
  23. data/lib/net/ssh/key_factory.rb +7 -1
  24. data/lib/net/ssh/known_hosts.rb +18 -8
  25. data/lib/net/ssh/multiplex/client.rb +162 -0
  26. data/lib/net/ssh/multiplex/control_path.rb +120 -0
  27. data/lib/net/ssh/multiplex/proxy_transport.rb +218 -0
  28. data/lib/net/ssh/multiplex.rb +130 -0
  29. data/lib/net/ssh/proxy/jump.rb +40 -2
  30. data/lib/net/ssh/service/forward.rb +26 -21
  31. data/lib/net/ssh/test.rb +2 -1
  32. data/lib/net/ssh/transport/algorithms.rb +71 -23
  33. data/lib/net/ssh/transport/hmac/sha1_etm.rb +12 -0
  34. data/lib/net/ssh/transport/hmac.rb +2 -0
  35. data/lib/net/ssh/transport/kex/diffie_hellman_group1_sha1.rb +8 -0
  36. data/lib/net/ssh/transport/kex/diffie_hellman_group_exchange_sha1.rb +45 -15
  37. data/lib/net/ssh/transport/openssl.rb +2 -1
  38. data/lib/net/ssh/transport/session.rb +21 -44
  39. data/lib/net/ssh/verifiers/always.rb +0 -3
  40. data/lib/net/ssh/version.rb +1 -1
  41. data/lib/net/ssh.rb +132 -56
  42. data/net-ssh.gemspec +1 -1
  43. data/rakelib/changelog.rb +229 -0
  44. data.tar.gz.sig +0 -0
  45. metadata +23 -6
  46. metadata.gz.sig +0 -0
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,7 +63,7 @@ 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 paranoid password port proxy
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
@@ -70,6 +71,7 @@ module Net
70
71
  append_all_supported_algorithms non_interactive password_prompt
71
72
  agent_socket_factory minimum_dh_bits verify_host_key
72
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,7 +198,14 @@ 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.
159
209
  # * :no_delay => turns Nagle's algorithm off on the connection's socket
160
210
  # (TCP_NODELAY), so a packet written while the previous one is still
161
211
  # unacknowledged is sent right away instead of waiting for the server's
@@ -166,7 +216,6 @@ module Net
166
216
  # authentication failure vs password prompt. Non-interactive applications
167
217
  # should set it to true to prefer failing a password/etc auth methods vs.
168
218
  # asking for password.
169
- # * :paranoid => deprecated alias for :verify_host_key
170
219
  # * :passphrase => the passphrase to use when loading a private key (default
171
220
  # is +nil+, for no passphrase)
172
221
  # * :password => the password to use to login
@@ -174,11 +223,13 @@ module Net
174
223
  # * :properties => a hash of key/value pairs to add to the new connection's
175
224
  # properties (see Net::SSH::Connection::Session#properties)
176
225
  # * :proxy => a proxy instance (see Proxy) to use when connecting
177
- # * :pubkey_algorithms => the public key authentication algorithms to use for
178
- # this connection. Valid values are 'rsa-sha2-256-cert-v01@openssh.com',
179
- # 'ssh-rsa-cert-v01@openssh.com', 'rsa-sha2-256', 'ssh-rsa'. Currently, this
180
- # option is only used for RSA public key authentication and ignored for other
181
- # types.
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.
182
233
  # * :rekey_blocks_limit => the max number of blocks to process before rekeying
183
234
  # * :rekey_limit => the max number of bytes to process before rekeying
184
235
  # * :rekey_packet_limit => the max number of packets to process before rekeying
@@ -190,7 +241,8 @@ module Net
190
241
  # * :user => the user name to log in as; this overrides the +user+
191
242
  # parameter, and is primarily only useful when provided via an SSH
192
243
  # configuration file.
193
- # * :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.
194
246
  # * :user_known_hosts_file => the location of the user known hosts file.
195
247
  # Set to an array to specify multiple user known hosts files.
196
248
  # Defaults to %w(~/.ssh/known_hosts ~/.ssh/known_hosts2).
@@ -202,7 +254,9 @@ module Net
202
254
  # default. The symbols :debug, :info, :warn, :error, and :fatal are also
203
255
  # supported and are translated to the corresponding Logger constant.
204
256
  # * :append_all_supported_algorithms => set to +true+ to append all supported
205
- # algorithms by net-ssh. Was the default behaviour until 2.10
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
206
260
  # * :number_of_password_prompts => Number of prompts for the password
207
261
  # authentication method defaults to 3 set to 0 to disable prompt for
208
262
  # password auth method
@@ -217,21 +271,32 @@ module Net
217
271
  # * :accept_new_or_local_tunnel (insecure) ::Net::SSH::Verifiers::AcceptNewOrLocalTunnel
218
272
  # * :accept_new (insecure) ::Net::SSH::Verifiers::AcceptNew
219
273
  # * :always (secure) ::Net::SSH::Verifiers::Always
220
- # You can also provide an own Object which responds to +verify+. The argument
221
- # given to +verify+ is a hash consisting of the +:key+, the +:key_blob+,
222
- # the +:fingerprint+ and the +:session+. Returning true accepts the host key,
223
- # returning false declines it and closes the connection.
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.
224
280
  # * :fingerprint_hash => 'MD5' or 'SHA256', defaults to 'SHA256'
225
281
  # If +user+ parameter is nil it defaults to USER from ssh_config, or
226
282
  # local username
227
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
+
228
288
  invalid_options = options.keys - VALID_OPTIONS
229
289
  if invalid_options.any?
230
290
  raise ArgumentError, "invalid option(s): #{invalid_options.join(', ')}"
231
291
  end
232
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
233
299
  assign_defaults(options)
234
- _sanitize_options(options)
235
300
 
236
301
  options[:user] = user if user
237
302
  options = configuration_for(host, options.fetch(:config, true)).merge(options)
@@ -243,8 +308,6 @@ module Net
243
308
  options[:number_of_password_prompts] = 0
244
309
  end
245
310
 
246
- _support_deprecated_option_paranoid(options)
247
-
248
311
  if options[:verbose]
249
312
  options[:logger].level = case options[:verbose]
250
313
  when Integer then options[:verbose]
@@ -257,26 +320,35 @@ module Net
257
320
  end
258
321
  end
259
322
 
260
- transport = Transport::Session.new(host, options)
261
- auth = Authentication::Session.new(transport, options)
262
-
263
323
  user = options.fetch(:user, user) || Etc.getpwuid.name
264
- if auth.authenticate("ssh-connection", user, options[:password])
265
- connection = Connection::Session.new(transport, options)
266
- if block_given?
267
- begin
268
- yield connection
269
- ensure
270
- connection.close unless connection.closed?
271
- end
272
- else
273
- return connection
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?
274
334
  end
275
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])
276
345
  transport.close
277
346
  raise AuthenticationFailed, "Authentication failed for user #{user}@#{host}"
278
347
  end
348
+
349
+ Connection::Session.new(transport, options)
279
350
  end
351
+ private_class_method :connect_directly
280
352
 
281
353
  # Returns a hash of the configuration options for the given host, as read
282
354
  # from the SSH configuration file(s). If +use_ssh_config+ is true (the
@@ -310,31 +382,35 @@ module Net
310
382
  end
311
383
  end
312
384
 
313
- def self._sanitize_options(options)
314
- invalid_option_values = [nil, [nil]]
315
- unless (options.values & invalid_option_values).empty?
316
- nil_options = options.select { |_k, v| invalid_option_values.include?(v) }.map(&:first)
317
- Kernel.warn "#{caller_locations(2, 1)[0]}: Passing nil, or [nil] to Net::SSH.start is deprecated for keys: #{nil_options.join(', ')}"
318
- end
319
- end
320
- private_class_method :_sanitize_options
385
+ NIL_ALLOWED_OPTIONS = %i[password passphrase logger password_prompt].freeze
321
386
 
322
- def self._support_deprecated_option_paranoid(options)
323
- if options.key?(:paranoid)
324
- Kernel.warn(
325
- ":paranoid is deprecated, please use :verify_host_key. Supported " \
326
- "values are exactly the same, only the name of the option has changed."
327
- )
328
- if options.key?(:verify_host_key)
329
- Kernel.warn(
330
- "Both :paranoid and :verify_host_key were specified. " \
331
- ":verify_host_key takes precedence, :paranoid will be ignored."
332
- )
333
- else
334
- options[:verify_host_key] = options.delete(:paranoid)
335
- end
336
- end
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
337
413
  end
338
- private_class_method :_support_deprecated_option_paranoid
414
+ private_class_method :reject_nil_options
339
415
  end
340
416
  end
data/net-ssh.gemspec CHANGED
@@ -38,7 +38,7 @@ Gem::Specification.new do |spec|
38
38
  spec.add_development_dependency "base64"
39
39
  spec.add_development_dependency "bundler", ">= 1.17"
40
40
  spec.add_development_dependency "minitest", "~> 5.19"
41
- spec.add_development_dependency "mocha", "~> 2.1.0"
41
+ spec.add_development_dependency "mocha", ">= 2.1", "< 4"
42
42
  spec.add_development_dependency "ostruct"
43
43
  spec.add_development_dependency "rake", "~> 12.0"
44
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: net-ssh
3
3
  version: !ruby/object:Gem::Version
4
- version: 8.0.0.rc3
4
+ version: 8.0.0.rc5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jamis Buck
@@ -32,7 +32,7 @@ cert_chain:
32
32
  ufHf4AX2UIkJbh7zCPkiNCqIr7MSWLNFG/9lOlHYsEJM8XujT1ofPobYx6YSFx/C
33
33
  7HBrI7UX7awt6UvBZebhcHzyMHxg/B5PVQllPA==
34
34
  -----END CERTIFICATE-----
35
- date: 2026-10-11 00:00:00.000000000 Z
35
+ date: 2026-10-20 00:00:00.000000000 Z
36
36
  dependencies:
37
37
  - !ruby/object:Gem::Dependency
38
38
  name: logger
@@ -122,16 +122,22 @@ dependencies:
122
122
  name: mocha
123
123
  requirement: !ruby/object:Gem::Requirement
124
124
  requirements:
125
- - - "~>"
125
+ - - ">="
126
+ - !ruby/object:Gem::Version
127
+ version: '2.1'
128
+ - - "<"
126
129
  - !ruby/object:Gem::Version
127
- version: 2.1.0
130
+ version: '4'
128
131
  type: :development
129
132
  prerelease: false
130
133
  version_requirements: !ruby/object:Gem::Requirement
131
134
  requirements:
132
- - - "~>"
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: '2.1'
138
+ - - "<"
133
139
  - !ruby/object:Gem::Version
134
- version: 2.1.0
140
+ version: '4'
135
141
  - !ruby/object:Gem::Dependency
136
142
  name: ostruct
137
143
  requirement: !ruby/object:Gem::Requirement
@@ -188,6 +194,8 @@ files:
188
194
  - ".dockerignore"
189
195
  - ".github/FUNDING.yml"
190
196
  - ".github/config/rubocop_linter_action.yml"
197
+ - ".github/pull_request_template.md"
198
+ - ".github/workflows/changelog.yml"
191
199
  - ".github/workflows/ci-with-docker.yml"
192
200
  - ".github/workflows/ci.yml"
193
201
  - ".github/workflows/rubocop.yml"
@@ -195,6 +203,7 @@ files:
195
203
  - ".rubocop.yml"
196
204
  - ".rubocop_todo.yml"
197
205
  - CHANGES.txt
206
+ - CONTRIBUTING.md
198
207
  - DEVELOPMENT.md
199
208
  - Dockerfile
200
209
  - Dockerfile.openssl3
@@ -208,6 +217,7 @@ files:
208
217
  - SECURITY.md
209
218
  - THANKS.txt
210
219
  - appveyor.yml
220
+ - changelog.d/README.md
211
221
  - docker-compose.yml
212
222
  - lib/net/ssh.rb
213
223
  - lib/net/ssh/authentication/agent.rb
@@ -224,6 +234,7 @@ files:
224
234
  - lib/net/ssh/authentication/methods/publickey.rb
225
235
  - lib/net/ssh/authentication/pageant.rb
226
236
  - lib/net/ssh/authentication/pub_key_fingerprint.rb
237
+ - lib/net/ssh/authentication/security_key.rb
227
238
  - lib/net/ssh/authentication/session.rb
228
239
  - lib/net/ssh/buffer.rb
229
240
  - lib/net/ssh/buffered_io.rb
@@ -238,6 +249,10 @@ files:
238
249
  - lib/net/ssh/key_factory.rb
239
250
  - lib/net/ssh/known_hosts.rb
240
251
  - lib/net/ssh/loggable.rb
252
+ - lib/net/ssh/multiplex.rb
253
+ - lib/net/ssh/multiplex/client.rb
254
+ - lib/net/ssh/multiplex/control_path.rb
255
+ - lib/net/ssh/multiplex/proxy_transport.rb
241
256
  - lib/net/ssh/packet.rb
242
257
  - lib/net/ssh/prompt.rb
243
258
  - lib/net/ssh/proxy/command.rb
@@ -274,6 +289,7 @@ files:
274
289
  - lib/net/ssh/transport/hmac/ripemd160.rb
275
290
  - lib/net/ssh/transport/hmac/sha1.rb
276
291
  - lib/net/ssh/transport/hmac/sha1_96.rb
292
+ - lib/net/ssh/transport/hmac/sha1_etm.rb
277
293
  - lib/net/ssh/transport/hmac/sha2_256.rb
278
294
  - lib/net/ssh/transport/hmac/sha2_256_96.rb
279
295
  - lib/net/ssh/transport/hmac/sha2_256_etm.rb
@@ -308,6 +324,7 @@ files:
308
324
  - lib/net/ssh/version.rb
309
325
  - net-ssh-public_cert.pem
310
326
  - net-ssh.gemspec
327
+ - rakelib/changelog.rb
311
328
  - support/ssh_tunnel_bug.rb
312
329
  homepage: https://github.com/net-ssh/net-ssh
313
330
  licenses:
metadata.gz.sig CHANGED
Binary file