protocol-zmtp 0.10.4 → 0.10.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ea36d2dec54002414471143e5dcffcbc6fcedb10a53a52f9c22684e5657c4a0d
4
- data.tar.gz: 17e2377d7534476344dd47164d8b6fde121526b4d2715af4dfb5c4682bd98bf8
3
+ metadata.gz: c8d53545d14bf02a264d165e08adb9ab22b1f948a614856644dea0a093dc1da5
4
+ data.tar.gz: cf71224105abc69d8cd6df5d2ab5e45cdcbed0c88ff257a6325fdd3deeb8312c
5
5
  SHA512:
6
- metadata.gz: 8ecaa0325141c8e1e41f8389aa1b431328b2c9b0c83c912f301119bd49503a001889aec9db285485fe786022033927588e3843dd8af60dfb1597619ca57aff40
7
- data.tar.gz: 67af1e45d9834d9ae7ed47352758eca7b92817691bf80518addfb66de28979d4a0bbaf2ec53ff802c8fbefd788a88bfeb6d794b9669cda1fdc57be20af00f669
6
+ metadata.gz: 677c05b2057178c6c388805c53c9dd1b314c06a4c436cf448b1d0fa2f2cdc6515c7818e0f3995a398b336487bebf74f6c00f8380837f635fd9377e1c11d8b3b1
7
+ data.tar.gz: 912e32a6301493475a676952ad1ac1c9ddff7e1f68e0b5415b20fd344dfe32d3e887d51faa00a0dce1f8fe4fb219cd50f899517e21a5c0b47dfd33b1140b3cb9
data/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## [0.10.5] - 2026-10-15
6
+
7
+ ### Added
8
+
9
+ - Allow updating `Connection#max_message_size` after the handshake so
10
+ compressed transports can bound wire envelopes separately from messages.
11
+
12
+ ### Fixed
13
+
14
+ - Reject invalid frame flags, lengths above 2^63-1, and malformed PING commands.
15
+ - Match handshake metadata names case-insensitively for NULL, PLAIN, and CURVE.
16
+ - Raise `FatalHandshakeError` on handshake ERROR responses and send ERROR when
17
+ PLAIN authentication rejects credentials.
18
+
5
19
  ## 0.10.4 - 2026-07-23
6
20
 
7
21
  ### Changed
@@ -72,7 +72,7 @@ module Protocol
72
72
 
73
73
  # Builds a READY command with Socket-Type, Identity, and any
74
74
  # extra properties supplied by upper layers (e.g. an extension
75
- # that injects +X-QoS+ or +X-Compression+).
75
+ # that injects +X-Compression+).
76
76
  #
77
77
  # @param metadata [Hash{String => String}, nil] additional READY properties
78
78
  # @return [Command]
@@ -83,6 +83,27 @@ module Protocol
83
83
  end
84
84
 
85
85
 
86
+ # Builds a fatal handshake refusal.
87
+ def self.error(reason)
88
+ raise Error, "ERROR reason exceeds 255 bytes" if reason.bytesize > 255
89
+
90
+ new("ERROR", reason.bytesize.chr.b + reason.b)
91
+ end
92
+
93
+
94
+ # ERROR is fatal even if its reason is malformed.
95
+ def raise_if_error!
96
+ return self unless @name == "ERROR"
97
+
98
+ length = @data.getbyte(0)
99
+ unless length && @data.bytesize == length + 1
100
+ raise FatalHandshakeError, "malformed ERROR command"
101
+ end
102
+
103
+ raise FatalHandshakeError, "peer rejected handshake: #{@data.byteslice(1, length).inspect}"
104
+ end
105
+
106
+
86
107
  # Builds a SUBSCRIBE command.
87
108
  #
88
109
  # @param prefix [String] subscription prefix to match
@@ -125,6 +146,9 @@ module Protocol
125
146
  # @param context [String] optional context bytes (up to 16 bytes)
126
147
  # @return [Command]
127
148
  def self.ping(ttl: 0, context: EMPTY_BINARY)
149
+ raise Error, "PING TTL must be between 0 and 6553.5 seconds" unless (0..6553.5).cover?(ttl)
150
+ raise Error, "PING context exceeds 16 bytes" if context.bytesize > 16
151
+
128
152
  ttl_ds = (ttl * 10).to_i
129
153
  new("PING", [ttl_ds].pack("n") + (context.encoding == Encoding::BINARY ? context : context.b))
130
154
  end
@@ -135,6 +159,8 @@ module Protocol
135
159
  # @param context [String] context bytes echoed from the PING
136
160
  # @return [Command]
137
161
  def self.pong(context: EMPTY_BINARY)
162
+ raise Error, "PONG context exceeds 16 bytes" if context.bytesize > 16
163
+
138
164
  new("PONG", context.encoding == Encoding::BINARY ? context : context.b)
139
165
  end
140
166
 
@@ -143,6 +169,8 @@ module Protocol
143
169
  #
144
170
  # @return [Array(Numeric, String)] [ttl_seconds, context_bytes]
145
171
  def ping_ttl_and_context
172
+ raise Error, "PING data must contain a TTL and at most 16 context bytes" unless (2..18).cover?(@data.bytesize)
173
+
146
174
  ttl_ds = @data.unpack1("n")
147
175
  context = @data.bytesize > 2 ? @data.byteslice(2..) : EMPTY_BINARY
148
176
  [ttl_ds / 10.0, context]
@@ -157,6 +185,16 @@ module Protocol
157
185
  end
158
186
 
159
187
 
188
+ # Property names are case-insensitive; retain their original spelling
189
+ # in the public metadata hash for callers that enumerate it.
190
+ def self.property(props, name)
191
+ props.each_pair do |key, value|
192
+ return value if key.casecmp?(name)
193
+ end
194
+ nil
195
+ end
196
+
197
+
160
198
  # Encodes a hash of properties into ZMTP property list format.
161
199
  #
162
200
  # @param props [Hash{String => String}] property name-value pairs
@@ -23,6 +23,7 @@ module Protocol
23
23
 
24
24
  # Short frame: 1-byte size, max body 255 bytes.
25
25
  SHORT_MAX = 255
26
+ LONG_MAX = (1 << 63) - 1
26
27
 
27
28
  # Pre-computed single-byte flag strings (avoids Integer#chr + String#b per frame).
28
29
  FLAG_BYTES = Array.new(256) { |i| i.chr.b.freeze }.freeze
@@ -102,6 +103,9 @@ module Protocol
102
103
  long = (flags & FLAGS_LONG) != 0
103
104
  command = (flags & FLAGS_COMMAND) != 0
104
105
 
106
+ raise Error, "reserved frame flags must be zero" unless (flags & 0xF8).zero?
107
+ raise Error, "command frames cannot have MORE set" if command && more
108
+
105
109
  if long
106
110
  raise EOFError, "Stream finished before reading long frame size" if buf.bytesize < 9
107
111
 
@@ -119,6 +123,8 @@ module Protocol
119
123
  header_size = 2
120
124
  end
121
125
 
126
+ raise Error, "frame size exceeds ZMTP's 63-bit limit" if size > LONG_MAX
127
+
122
128
  if max_message_size && size > max_message_size
123
129
  raise Error, "frame size #{size} exceeds max_message_size #{max_message_size}"
124
130
  end
@@ -141,6 +147,8 @@ module Protocol
141
147
  # @param more [Boolean] more frames follow
142
148
  # @param command [Boolean] this is a command frame
143
149
  def initialize(body, more: false, command: false)
150
+ raise Error, "command frames cannot have MORE set" if command && more
151
+
144
152
  @body = body
145
153
  @more = more
146
154
  @command = command
@@ -47,6 +47,10 @@ module Protocol
47
47
  # @return [Float, nil] monotonic timestamp of last received frame
48
48
  attr_reader :last_received_at
49
49
 
50
+ # Transport wrappers may need room for bounded wire envelopes in
51
+ # addition to the application message limit they enforce after decoding.
52
+ attr_writer :max_message_size
53
+
50
54
 
51
55
  # @param io [#peek, #read_exactly, #write, #flush, #close] transport IO
52
56
  # @param socket_type [String] our socket type name (e.g. "REQ")
@@ -55,7 +59,7 @@ module Protocol
55
59
  # @param mechanism [Mechanism::Null, Mechanism::Curve] security mechanism
56
60
  # @param max_message_size [Integer, nil] max frame size in bytes, nil = unlimited
57
61
  # @param opts [Hash{String => String}] extra READY properties to
58
- # advertise (e.g. +"X-QoS" => "1"+). Upper-layer extensions use
62
+ # advertise (e.g. +"X-Compression" => "lz4"+). Upper-layer extensions use
59
63
  # this to inject their own negotiated properties without the
60
64
  # codec needing to know about them.
61
65
  def initialize(io, socket_type:, identity: "", as_server: false,
@@ -278,12 +282,7 @@ module Protocol
278
282
  # @raise [EOFError] if connection is closed
279
283
  def read_frame
280
284
  loop do
281
- begin
282
- frame = Codec::Frame.read_from(@io, max_message_size: @max_message_size)
283
- rescue Error
284
- close
285
- raise
286
- end
285
+ frame = Codec::Frame.read_from(@io, max_message_size: @max_message_size)
287
286
 
288
287
  touch_heartbeat
289
288
 
@@ -297,12 +296,16 @@ module Protocol
297
296
  send_command(Codec::Command.pong(context: context))
298
297
  next
299
298
  when "PONG"
299
+ raise Error, "PONG context exceeds 16 bytes" if cmd.data.bytesize > 16
300
300
  next
301
301
  end
302
302
  end
303
303
 
304
304
  return frame
305
305
  end
306
+ rescue Error
307
+ close
308
+ raise
306
309
  end
307
310
 
308
311
 
@@ -5,5 +5,10 @@ module Protocol
5
5
  # Raised on ZMTP protocol violations.
6
6
  class Error < RuntimeError
7
7
  end
8
+
9
+
10
+ # An explicit handshake refusal. Do not retry with the same credentials.
11
+ class FatalHandshakeError < Error
12
+ end
8
13
  end
9
14
  end
@@ -153,6 +153,8 @@ module Protocol
153
153
  # @param command [Boolean] whether this is a command frame
154
154
  # @return [String] binary wire bytes ready for writing
155
155
  def encrypt(body, more: false, command: false)
156
+ raise Error, "command frames cannot have MORE set" if command && more
157
+
156
158
  flags = 0
157
159
  flags |= 0x01 if more
158
160
  flags |= 0x02 if command
@@ -211,6 +213,8 @@ module Protocol
211
213
  end
212
214
 
213
215
  flags = plaintext.getbyte(0)
216
+ raise Error, "invalid MESSAGE flags" unless flags && (flags & 0xFC).zero?
217
+
214
218
  body = plaintext.byteslice(1..) || "".b
215
219
  Codec::Frame.new(body, more: (flags & 0x01) != 0, command: (flags & 0x02) != 0)
216
220
  end
@@ -255,7 +259,7 @@ module Protocol
255
259
  # --- Read WELCOME ---
256
260
  welcome_frame = Codec::Frame.read_from(io)
257
261
  raise Error, "expected command frame" unless welcome_frame.command?
258
- welcome_cmd = Codec::Command.from_body(welcome_frame.body)
262
+ welcome_cmd = Codec::Command.from_body(welcome_frame.body).raise_if_error!
259
263
  raise Error, "expected WELCOME, got #{welcome_cmd.name}" unless welcome_cmd.name == "WELCOME"
260
264
 
261
265
  wdata = welcome_cmd.data
@@ -307,7 +311,7 @@ module Protocol
307
311
  # --- Read READY ---
308
312
  ready_frame = Codec::Frame.read_from(io)
309
313
  raise Error, "expected command frame" unless ready_frame.command?
310
- ready_cmd = Codec::Command.from_body(ready_frame.body)
314
+ ready_cmd = Codec::Command.from_body(ready_frame.body).raise_if_error!
311
315
  raise Error, "expected READY, got #{ready_cmd.name}" unless ready_cmd.name == "READY"
312
316
 
313
317
  rdata = ready_cmd.data
@@ -324,8 +328,8 @@ module Protocol
324
328
  end
325
329
 
326
330
  props = Codec::Command.decode_properties(r_plaintext)
327
- peer_socket_type = props["Socket-Type"]
328
- peer_identity = props["Identity"] || ""
331
+ peer_socket_type = Codec::Command.property(props, "Socket-Type")
332
+ peer_identity = Codec::Command.property(props, "Identity") || ""
329
333
 
330
334
  @session_box = session
331
335
  @send_nonce = 1
@@ -361,7 +365,7 @@ module Protocol
361
365
  # --- Read HELLO ---
362
366
  hello_frame = Codec::Frame.read_from(io)
363
367
  raise Error, "expected command frame" unless hello_frame.command?
364
- hello_cmd = Codec::Command.from_body(hello_frame.body)
368
+ hello_cmd = Codec::Command.from_body(hello_frame.body).raise_if_error!
365
369
  raise Error, "expected HELLO, got #{hello_cmd.name}" unless hello_cmd.name == "HELLO"
366
370
 
367
371
  hdata = hello_cmd.data
@@ -407,7 +411,7 @@ module Protocol
407
411
  # --- Read INITIATE ---
408
412
  init_frame = Codec::Frame.read_from(io)
409
413
  raise Error, "expected command frame" unless init_frame.command?
410
- init_cmd = Codec::Command.from_body(init_frame.body)
414
+ init_cmd = Codec::Command.from_body(init_frame.body).raise_if_error!
411
415
  raise Error, "expected INITIATE, got #{init_cmd.name}" unless init_cmd.name == "INITIATE"
412
416
 
413
417
  idata = init_cmd.data
@@ -468,7 +472,7 @@ module Protocol
468
472
  peer = PeerInfo.new(public_key: client_permanent, identity: "")
469
473
  unless @authenticator.call(peer)
470
474
  send_error(io, "client key not authorized")
471
- raise Error, "client key not authorized"
475
+ raise FatalHandshakeError, "client key not authorized"
472
476
  end
473
477
  end
474
478
 
@@ -498,8 +502,8 @@ module Protocol
498
502
  init_nonce_buffers!
499
503
 
500
504
  {
501
- peer_socket_type: props["Socket-Type"],
502
- peer_identity: props["Identity"] || "",
505
+ peer_socket_type: Codec::Command.property(props, "Socket-Type"),
506
+ peer_identity: Codec::Command.property(props, "Identity") || "",
503
507
  peer_public_key: client_permanent,
504
508
  peer_properties: props,
505
509
  peer_major: @peer_major,
@@ -48,14 +48,14 @@ module Protocol
48
48
  raise Error, "expected command frame, got data frame"
49
49
  end
50
50
 
51
- peer_cmd = Codec::Command.from_body(frame.body)
51
+ peer_cmd = Codec::Command.from_body(frame.body).raise_if_error!
52
52
  unless peer_cmd.name == "READY"
53
53
  raise Error, "expected READY command, got #{peer_cmd.name}"
54
54
  end
55
55
 
56
56
  props = peer_cmd.properties
57
- peer_socket_type = props["Socket-Type"]
58
- peer_identity = props["Identity"] || ""
57
+ peer_socket_type = Codec::Command.property(props, "Socket-Type")
58
+ peer_identity = Codec::Command.property(props, "Identity") || ""
59
59
 
60
60
  unless peer_socket_type
61
61
  raise Error, "peer READY missing Socket-Type"
@@ -92,7 +92,8 @@ module Protocol
92
92
  username, password = decode_credentials(cmd.data)
93
93
 
94
94
  if @authenticator && !@authenticator.call(username, password)
95
- raise Error, "authentication failed"
95
+ send_command(io, Codec::Command.error("authentication failed"))
96
+ raise FatalHandshakeError, "authentication failed"
96
97
  end
97
98
 
98
99
  send_command(io, Codec::Command.new("WELCOME"))
@@ -143,13 +144,13 @@ module Protocol
143
144
 
144
145
  def extract_peer_info(cmd)
145
146
  props = cmd.properties
146
- peer_socket_type = props["Socket-Type"]
147
+ peer_socket_type = Codec::Command.property(props, "Socket-Type")
147
148
 
148
149
  raise Error, "peer command missing Socket-Type" unless peer_socket_type
149
150
 
150
151
  {
151
152
  peer_socket_type: peer_socket_type,
152
- peer_identity: props["Identity"] || "",
153
+ peer_identity: Codec::Command.property(props, "Identity") || "",
153
154
  peer_properties: props,
154
155
  }
155
156
  end
@@ -165,7 +166,7 @@ module Protocol
165
166
  frame = Codec::Frame.read_from(io)
166
167
  raise Error, "expected command frame, got data frame" unless frame.command?
167
168
 
168
- Codec::Command.from_body(frame.body)
169
+ Codec::Command.from_body(frame.body).raise_if_error!
169
170
  end
170
171
  end
171
172
  end
@@ -9,7 +9,7 @@ module Protocol
9
9
  # - +identity+ is the peer's +ZMQ_IDENTITY+ string, or +""+ when no
10
10
  # identity was advertised in the READY metadata.
11
11
  #
12
- # Used as a peer key by upper layers (e.g. omq-qos levels 2/3): the
12
+ # Used as a peer key by upper layers: the
13
13
  # whole value object is frozen and equality-comparable, so it can be
14
14
  # stored directly in a Hash without picking one anchor over the other.
15
15
  #
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Protocol
4
4
  module ZMTP
5
- VERSION = "0.10.4"
5
+ VERSION = "0.10.5"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: protocol-zmtp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.4
4
+ version: 0.10.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrik Wenger
@@ -54,7 +54,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
54
54
  - !ruby/object:Gem::Version
55
55
  version: '0'
56
56
  requirements: []
57
- rubygems_version: 4.0.16
57
+ rubygems_version: 4.0.20
58
58
  specification_version: 4
59
59
  summary: ZMTP 3.1 wire protocol codec and connection
60
60
  test_files: []