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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/.github/pull_request_template.md +1 -0
  4. data/.github/workflows/changelog.yml +43 -0
  5. data/.github/workflows/ci-with-docker.yml +0 -3
  6. data/.github/workflows/ci.yml +2 -17
  7. data/.github/workflows/rubocop.yml +3 -1
  8. data/.rubocop.yml +1 -1
  9. data/.rubocop_todo.yml +1 -0
  10. data/CHANGES.txt +73 -7
  11. data/CONTRIBUTING.md +35 -0
  12. data/Dockerfile +3 -3
  13. data/Dockerfile.openssl3 +1 -1
  14. data/Dockerfile.zlib_ng +1 -1
  15. data/README.md +71 -57
  16. data/Rakefile +66 -12
  17. data/appveyor.yml +4 -16
  18. data/changelog.d/README.md +1 -0
  19. data/docker-compose.yml +1 -7
  20. data/lib/net/ssh/authentication/agent.rb +35 -11
  21. data/lib/net/ssh/authentication/ed25519.rb +137 -44
  22. data/lib/net/ssh/authentication/ed25519_loader.rb +14 -9
  23. data/lib/net/ssh/authentication/key_manager.rb +22 -11
  24. data/lib/net/ssh/authentication/methods/abstract.rb +2 -8
  25. data/lib/net/ssh/authentication/methods/publickey.rb +3 -1
  26. data/lib/net/ssh/authentication/security_key.rb +71 -0
  27. data/lib/net/ssh/authentication/session.rb +29 -17
  28. data/lib/net/ssh/buffer.rb +6 -1
  29. data/lib/net/ssh/buffered_io.rb +40 -3
  30. data/lib/net/ssh/config.rb +28 -12
  31. data/lib/net/ssh/connection/channel.rb +49 -2
  32. data/lib/net/ssh/connection/session.rb +58 -8
  33. data/lib/net/ssh/errors.rb +24 -6
  34. data/lib/net/ssh/key_factory.rb +7 -1
  35. data/lib/net/ssh/known_hosts.rb +18 -8
  36. data/lib/net/ssh/multiplex/client.rb +162 -0
  37. data/lib/net/ssh/multiplex/control_path.rb +120 -0
  38. data/lib/net/ssh/multiplex/proxy_transport.rb +218 -0
  39. data/lib/net/ssh/multiplex.rb +130 -0
  40. data/lib/net/ssh/prompt.rb +3 -3
  41. data/lib/net/ssh/proxy/jump.rb +40 -2
  42. data/lib/net/ssh/service/forward.rb +29 -24
  43. data/lib/net/ssh/test.rb +2 -1
  44. data/lib/net/ssh/transport/algorithms.rb +88 -33
  45. data/lib/net/ssh/transport/chacha20_poly1305_cipher.rb +89 -16
  46. data/lib/net/ssh/transport/chacha20_poly1305_cipher_loader.rb +20 -1
  47. data/lib/net/ssh/transport/cipher_factory.rb +23 -1
  48. data/lib/net/ssh/transport/constants.rb +1 -0
  49. data/lib/net/ssh/transport/gcm_cipher.rb +27 -6
  50. data/lib/net/ssh/transport/hmac/sha1_etm.rb +12 -0
  51. data/lib/net/ssh/transport/hmac.rb +2 -0
  52. data/lib/net/ssh/transport/kex/curve25519_sha256.rb +41 -9
  53. data/lib/net/ssh/transport/kex/curve25519_sha256_loader.rb +14 -6
  54. data/lib/net/ssh/transport/kex/diffie_hellman_group1_sha1.rb +8 -0
  55. data/lib/net/ssh/transport/kex/diffie_hellman_group_exchange_sha1.rb +45 -15
  56. data/lib/net/ssh/transport/openssl.rb +2 -1
  57. data/lib/net/ssh/transport/packet_stream.rb +12 -0
  58. data/lib/net/ssh/transport/session.rb +41 -44
  59. data/lib/net/ssh/verifiers/always.rb +0 -3
  60. data/lib/net/ssh/version.rb +3 -3
  61. data/lib/net/ssh.rb +139 -57
  62. data/net-ssh.gemspec +5 -8
  63. data/rakelib/changelog.rb +229 -0
  64. data.tar.gz.sig +0 -0
  65. metadata +37 -36
  66. metadata.gz.sig +0 -0
  67. data/Gemfile.noed25519 +0 -12
  68. 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 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
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 use for
172
- # this connection. Valid values are 'rsa-sha2-256-cert-v01@openssh.com',
173
- # 'ssh-rsa-cert-v01@openssh.com', 'rsa-sha2-256', 'ssh-rsa'. Currently, this
174
- # option is only used for RSA public key authentication and ignored for other
175
- # 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.
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. 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
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+. The argument
215
- # given to +verify+ is a hash consisting of the +:key+, the +:key_blob+,
216
- # the +:fingerprint+ and the +:session+. Returning true accepts the host key,
217
- # 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.
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
- if auth.authenticate("ssh-connection", user, options[:password])
259
- connection = Connection::Session.new(transport, options)
260
- if block_given?
261
- begin
262
- yield connection
263
- ensure
264
- connection.close unless connection.closed?
265
- end
266
- else
267
- 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?
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
- def self._sanitize_options(options)
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
- def self._support_deprecated_option_paranoid(options)
317
- if options.key?(:paranoid)
318
- Kernel.warn(
319
- ":paranoid is deprecated, please use :verify_host_key. Supported " \
320
- "values are exactly the same, only the name of the option has changed."
321
- )
322
- if options.key?(:verify_host_key)
323
- Kernel.warn(
324
- "Both :paranoid and :verify_host_key were specified. " \
325
- ":verify_host_key takes precedence, :paranoid will be ignored."
326
- )
327
- else
328
- options[:verify_host_key] = options.delete(:paranoid)
329
- end
330
- 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
331
413
  end
332
- private_class_method :_support_deprecated_option_paranoid
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.6")
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
- unless ENV['NET_SSH_NO_ED25519']
34
- spec.add_development_dependency("bcrypt_pbkdf", "~> 1.0") unless RUBY_PLATFORM == "java"
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('rbnacl', '~> 7.1') unless ENV['NET_SSH_NO_RBNACL']
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", "~> 2.1.0"
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