protocol-zmtp 0.10.3 → 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: ad013d03184eae1f5298d9ebbd5bc9a1fd2657ac7c45002a298547b36ad7f63d
4
- data.tar.gz: 137204024322094d2e76ef7103444e27143c18176b8a19b06c7e978c0b1fccd1
3
+ metadata.gz: c8d53545d14bf02a264d165e08adb9ab22b1f948a614856644dea0a093dc1da5
4
+ data.tar.gz: cf71224105abc69d8cd6df5d2ab5e45cdcbed0c88ff257a6325fdd3deeb8312c
5
5
  SHA512:
6
- metadata.gz: 8ddbde5122489f94110878c0854167627a39207aa3c428aafdbec536dbcbcd39e0c9522ab23a97dc464766c0a42f7b8d6cf4689322486355880b53aa1def6385
7
- data.tar.gz: 6f069bf0af5fdff20496f376ed792bd2e3059cb64c1374674eaf7fbbbcaf2226b2f8cf69f9be9b49d994b7ab0a2e0fca26373248572b65db36653bba40846395
6
+ metadata.gz: 677c05b2057178c6c388805c53c9dd1b314c06a4c436cf448b1d0fa2f2cdc6515c7818e0f3995a398b336487bebf74f6c00f8380837f635fd9377e1c11d8b3b1
7
+ data.tar.gz: 912e32a6301493475a676952ad1ac1c9ddff7e1f68e0b5415b20fd344dfe32d3e887d51faa00a0dce1f8fe4fb219cd50f899517e21a5c0b47dfd33b1140b3cb9
data/CHANGELOG.md ADDED
@@ -0,0 +1,311 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
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
+
19
+ ## 0.10.4 - 2026-07-23
20
+
21
+ ### Changed
22
+
23
+ - Release source is now the `zeromq/omq.rb` monorepo.
24
+
25
+ ## 0.10.3 — 2026-05-30
26
+
27
+ ### Fixed
28
+
29
+ - **CURVE: COMMAND flag in encrypted inner byte must be 0x02, not 0x04.**
30
+ CurveZMQ's inner plaintext flags byte uses a compact layout where
31
+ bit 1 (0x02) means COMMAND, but encrypt/decrypt used the wire-level
32
+ COMMAND bit (0x04). This broke interop with libzmq: a CURVE-encrypted
33
+ SUBSCRIBE from a libzmq SUB was treated as data and silently dropped,
34
+ so the PUB never registered the subscription.
35
+
36
+ ## 0.10.2 — 2026-05-28
37
+
38
+ - Bump version (was missed in 0.10.1).
39
+
40
+ ## 0.10.1 — 2026-05-28
41
+
42
+ ### Added
43
+
44
+ - **`Connection#write_wire_batch`** writes multiple pre-encoded wire
45
+ byte strings under a single mutex acquisition, amortizing lock
46
+ overhead for fan-out send pumps.
47
+
48
+ ### Changed
49
+
50
+ - **Single-write short frames.** Frames with bodies <= 255 bytes now
51
+ combine the 2-byte header and body into one `@io.write` call,
52
+ halving per-frame io-stream mutex overhead (+5-7% small-message
53
+ TCP throughput).
54
+
55
+ ### Fixed
56
+
57
+ - **NULL mechanism greeting: `as-server` field is now always 0.**
58
+ RFC 23 requires `as-server = 0` for the NULL mechanism, but the
59
+ server side was forwarding the caller's flag (`0x01`). This broke
60
+ interoperability with strict ZMTP 3.x peers that validate the field.
61
+
62
+ ## 0.10.0 — 2026-04-20
63
+
64
+ ### Added
65
+
66
+ - **`PeerInfo` extended with `identity`.** `PeerInfo = Data.define(:public_key, :identity)`
67
+ now bundles both post-handshake peer anchors. CURVE authenticators
68
+ still receive a `PeerInfo` during auth (with `identity: ""`, since
69
+ identity arrives post-auth).
70
+ - **`Connection#peer_info`.** After a successful handshake, returns a
71
+ frozen `PeerInfo` combining the peer's CURVE public key (if any) and
72
+ `ZMQ_IDENTITY`. Usable directly as a Hash key. Nil before handshake.
73
+ Upper layers (e.g. omq-qos levels 2/3) use the whole value as a
74
+ stable per-peer identifier across reconnects.
75
+ - **Mechanism return hashes carry `peer_public_key`.** Both
76
+ `Mechanism::Null#handshake!` and `Mechanism::Curve` handshake paths now
77
+ return `peer_public_key:` alongside `peer_identity:` etc. (nil for
78
+ NULL; the peer's long-term `crypto::PublicKey` for CURVE).
79
+
80
+ ### Changed
81
+
82
+ - **Drop hot-path binary-encoding coercion and frame-body freezes.**
83
+ `Frame#initialize` and `Frame.encode_message` no longer copy non-binary
84
+ bodies via `String#b` — callers (OMQ's `Writable#send`, ZMTP command
85
+ builders) already hand in binary bytes, and for an outgoing message the
86
+ receiver (`String.new(capacity:) << body`) treats any encoding as raw
87
+ bytes once the buffer itself is binary. `Connection#receive_message`
88
+ also stops freezing each frame body and the returned parts array; the
89
+ caller decides what to freeze. Net effect: fewer allocations per
90
+ message and no per-part `.freeze` on the receive path.
91
+
92
+ ## 0.9.0 — 2026-04-18
93
+
94
+ ### Changed
95
+
96
+ - **`Frame.read_from` uses `io.peek` to collapse the long-frame read
97
+ path.** The previous implementation issued 2 `read_exactly` calls for
98
+ short frames (header + body) and 3 for long frames (2-byte header,
99
+ remaining 7 size bytes, body). It now peeks just enough header bytes
100
+ (2 for short, 9 for long), decodes the size from the peek buffer, and
101
+ drains header + body in a single `read_exactly(header_size + size)`.
102
+ Long frames drop to the same 2-call read path as short frames; the
103
+ `read_long_size` helper is gone. A speculative `read_exactly(9)` would
104
+ not be safe here — a <7-byte short frame at idle would hang waiting
105
+ for bytes that never arrive, or steal bytes from the next frame on a
106
+ mixed stream.
107
+
108
+ - **Transport IO must now also respond to `#peek`** in addition to
109
+ `#read_exactly`, `#write`, `#flush`, and `#close`. `io-stream`'s
110
+ `Async::IO::Stream` already provides this; custom transport wrappers
111
+ need a `#peek(&block)` that yields the current read buffer and fills
112
+ it until the block returns truthy.
113
+
114
+ ## 0.8.1 — 2026-04-16
115
+
116
+ ### Changed
117
+
118
+ - **`Frame#initialize` skips redundant `.b` copy.** Body is now kept
119
+ as-is when it is already `Encoding::BINARY`, avoiding a per-frame
120
+ String allocation on the read path (`read_exactly` and `byteslice`
121
+ already return binary strings).
122
+
123
+ - **`Frame#to_wire` uses `String.new(capacity:)` with `<<` appends**
124
+ instead of `+` concatenation, reducing from 3 intermediate String
125
+ allocations per frame to 1 pre-sized buffer.
126
+
127
+ - **`Frame.encode_message` pre-computes wire size** for the output
128
+ buffer `capacity:` hint, avoiding re-allocations during fan-out.
129
+ Single-part messages (the common case) skip the iteration and
130
+ inline the size calculation.
131
+
132
+ - **`Command.ping` / `.pong` default to `EMPTY_BINARY`** instead of
133
+ `"".b`, which allocated a mutable copy on every call despite
134
+ `frozen_string_literal: true`. `#ping_ttl_and_context` uses
135
+ `EMPTY_BINARY` for the empty-context fallback.
136
+
137
+ - **`Subscription.body` drops redundant outer `.b`** — both operands
138
+ are already binary, so the concatenation result is binary without
139
+ a second encoding conversion.
140
+
141
+ ## 0.8.0 — 2026-04-15
142
+
143
+ ### Added
144
+
145
+ - **`Codec::Subscription`** — helper module that unifies ZMTP 3.0
146
+ message-form (`\x01`/`\x00` + prefix data frame) and ZMTP 3.1
147
+ command-form (`SUBSCRIBE`/`CANCEL` command frame) subscription
148
+ encodings. `.body(prefix, cancel:)` builds the message-form body;
149
+ `.parse(frame)` returns `[:subscribe|:cancel, prefix]` for either
150
+ form, letting upper layers accept both without branching.
151
+ - **`Connection#peer_major` / `#peer_minor`** — ZMTP wire revision of
152
+ the peer, captured from the greeting. Lets upper layers pick the
153
+ subscription wire form (and other version-gated features) per peer.
154
+ Populated by `Mechanism::Null`, `Mechanism::Plain`, and
155
+ `Mechanism::Curve` via their handshake result hash.
156
+ - **`Codec::Greeting.read_from(io)`** — reads the 11-byte signature
157
+ phase first, validates the revision byte, then reads the rest of
158
+ the 64-byte greeting. A ZMTP/2.0 peer (revision `0x01`) is now
159
+ rejected loudly with `unsupported ZMTP revision 0x01 (ZMTP/2.x);
160
+ need revision >= 3`, instead of hanging forever in `read_exactly`
161
+ waiting for bytes that never arrive.
162
+
163
+ ### Changed
164
+
165
+ - **Greeting error messages** refer to the ZMTP *revision byte*
166
+ (`0x03`) rather than a "version #{major}.#{minor}" — the byte at
167
+ offset 10 is the wire revision, which only accidentally matches the
168
+ spec major in ZMTP/3.x.
169
+
170
+ ## 0.7.1 — 2026-04-14
171
+
172
+ ### Changed
173
+
174
+ - **Short-frame read fast path.** `Frame.read_from` now fetches the
175
+ 2-byte header (flags + first size byte) in a single `read_exactly`
176
+ call instead of two separate 1-byte reads. Short frames (≤255 bytes,
177
+ the vast majority of ZMTP traffic) now hit a 2-call read path
178
+ (header + body) instead of 3. Long frames read the remaining 7 size
179
+ bytes via the extracted `read_long_size` helper.
180
+
181
+ ## 0.7.0 — 2026-04-13
182
+
183
+ ### Added
184
+
185
+ - **Extension metadata hook on the handshake mechanisms.** `Mechanism::Null`,
186
+ `Mechanism::Plain`, and `Mechanism::Curve` expose a `metadata` accessor
187
+ (`Hash{String => String}`) that upper layers can populate before
188
+ `#handshake!`. Any entries are merged into the outgoing READY properties
189
+ (and INITIATE, for CURVE/PLAIN client side). `Codec::Command.ready` gained
190
+ a matching `metadata:` kwarg. Used by `omq-rfc-zstd` to advertise the
191
+ `X-Compression` property without forking the handshake code path.
192
+
193
+ - **`Connection#peer_properties`.** The full peer READY property hash is
194
+ now retained after a successful handshake (previously only Socket-Type,
195
+ Identity, and the X-QoS pair were extracted). Extensions can inspect
196
+ the peer's advertised properties to negotiate optional features.
197
+ Returned by all three mechanisms as `peer_properties:` in the
198
+ handshake result hash.
199
+
200
+ ## 0.6.0 — 2026-04-12
201
+
202
+ ### Changed
203
+
204
+ - **Consolidated empty-binary constant.** `Codec::Frame::EMPTY_BODY`
205
+ and `Codec::Command::EMPTY_DATA` are gone, replaced by a single
206
+ `Codec::EMPTY_BINARY` shared across the codec module. Anything
207
+ referencing the old constants needs to switch to
208
+ `Protocol::ZMTP::Codec::EMPTY_BINARY`.
209
+
210
+ ## 0.5.1 — 2026-04-10
211
+
212
+ ### Changed
213
+
214
+ - **Reduced allocations on hot paths.** Pre-computed `FLAG_BYTES` lookup
215
+ table eliminates `Integer#chr` + `String#b` per frame. `encode_message`
216
+ inlines wire encoding instead of creating throwaway Frame objects per
217
+ part. `write_frames` and `Command#to_body` skip redundant `.b` when
218
+ strings are already binary. `EMPTY_BODY` and `EMPTY_DATA` constants
219
+ replace per-call `"".b` allocations.
220
+
221
+ ## 0.5.0 — 2026-04-10
222
+
223
+ ### Fixed
224
+
225
+ - **Wire writes are now cancellation-safe.** `Connection#send_message`,
226
+ `#write_message`, `#write_messages`, `#write_wire`, and `#send_command`
227
+ each wrap their `@mutex.synchronize` block in
228
+ `Async::Task#defer_cancel`. A ZMTP frame is two `@io.write` calls
229
+ (header then body); under a socket-level barrier cascade (`barrier.stop`)
230
+ an `Async::Cancel` could previously land between those two writes,
231
+ leaving the peer with a header pointing at a body that never arrives
232
+ and an unrecoverable framer desync. `defer_cancel` holds the
233
+ cancellation until the write finishes, so cascading teardown only
234
+ unwinds at frame boundaries. Mutex protection is unchanged — it
235
+ guards against thread races; `defer_cancel` guards against fiber
236
+ cancellation. Non-Async callers (e.g. tests) fall through to the
237
+ unwrapped path.
238
+
239
+ ## 0.4.0 — 2026-04-09
240
+
241
+ ### Added
242
+
243
+ - **`Connection#write_messages`** — batched multipart send for work-stealing
244
+ send pumps. Dequeue a batch at once and write it under a single mutex
245
+ acquisition instead of one lock per message. Amortizes lock overhead on
246
+ high-throughput paths.
247
+
248
+ ### Changed
249
+
250
+ - **Zero-alloc frame headers on the unencrypted hot send path.**
251
+ `#write_frames` used to allocate, per part, a `Codec::Frame` object, a
252
+ `.b` body copy, a 1-or-9-byte header String, and a concatenated wire
253
+ String (a copy of the entire body just to glue the header on). The body
254
+ copy was the dominant allocation — every 64 KB send produced a 64 KB
255
+ throwaway String, feeding the GC at ~1.2 GB/s under sustained load.
256
+ Headers are now packed into a reusable `@header_buf` via
257
+ `Array#pack(buffer:)`, header and body are written separately
258
+ (io-stream buffers them into one syscall per batch), the `Frame`
259
+ object is gone, and the body is no longer copied. `@mechanism.encrypted?`
260
+ is hoisted out of the per-frame loop. The encrypted path is unchanged —
261
+ CURVE/BLAKE3ZMQ still authenticate over the full encoded frame and need
262
+ a single wire String per part.
263
+
264
+ Bench impact downstream in omq (push_pull + router_dealer, 1 and 3 peers,
265
+ all transports): 48 improvements, 0 regressions across 72 measurements.
266
+ Peaks: `router_dealer tcp 3p 64B +40.7%`, `push_pull ipc 1p 64 KB +28.8%`
267
+ (crosses 1.3 GB/s).
268
+
269
+ ## 0.3.0 — 2026-04-07
270
+
271
+ - Replace `[Async {}].each(&:wait)` with `Barrier` in tests.
272
+ - YARD documentation on all public methods and classes.
273
+ - Code style: two blank lines between methods and constants.
274
+ - Fix `#read_frame` decryption for non-CURVE encrypted mechanisms
275
+ (e.g. BLAKE3ZMQ). Previously only CURVE's `\x07MESSAGE`-wrapped command
276
+ frames were decrypted; inline-encrypted command frames (SUBSCRIBE, PING,
277
+ etc.) were silently dropped, breaking PUB/SUB over BLAKE3ZMQ.
278
+
279
+ - **Breaking:** `Mechanism::Curve` API is now kwargs-only:
280
+ `Curve.server(public_key:, secret_key:, crypto:)` and
281
+ `Curve.client(server_key:, crypto:)`. Client keys are optional — when
282
+ omitted, an ephemeral permanent keypair is auto-generated. INITIATE
283
+ always contains `C + vouch + metadata` per RFC 26.
284
+ - **Breaking:** Authenticator now receives a `Protocol::ZMTP::PeerInfo`
285
+ (with a `crypto::PublicKey`) via `#call`. The `#include?` duck-typing
286
+ is removed. Sends an ERROR command to the client on rejection.
287
+ - Add `Protocol::ZMTP::PeerInfo` shared across mechanisms.
288
+ - Add `#maintenance` to `Mechanism::Curve` for automatic cookie key rotation.
289
+ Returns `{ interval: 60, task: <Proc> }` on server-side mechanisms so the
290
+ host application can rotate the cookie key every 60 seconds, limiting the
291
+ forward secrecy exposure window.
292
+
293
+ ## 0.2.0
294
+
295
+ - Add `Mechanism::Plain` — PLAIN authentication (RFC 24). Carries username and
296
+ password in a `HELLO` command during the handshake; no frame encryption.
297
+ Accepts an optional `authenticator:` callable on the server side for
298
+ credential validation.
299
+
300
+ ## 0.1.2
301
+
302
+ - Check frame size against `max_message_size` before reading the body from the
303
+ wire. Previously, the entire frame was allocated into memory before the size
304
+ check, allowing a malicious peer to cause arbitrary memory allocation with a
305
+ single oversized frame header.
306
+ - Size limit applies to all frames including commands — an attacker cannot bypass
307
+ the check by setting the command flag.
308
+
309
+ ## 0.1.1
310
+
311
+ - Initial public release.
data/README.md CHANGED
@@ -1,27 +1,27 @@
1
1
  # Protocol::ZMTP
2
2
 
3
3
  [![Gem Version](https://img.shields.io/gem/v/protocol-zmtp)](https://rubygems.org/gems/protocol-zmtp)
4
- [![CI](https://github.com/paddor/protocol-zmtp/actions/workflows/ci.yml/badge.svg)](https://github.com/paddor/protocol-zmtp/actions/workflows/ci.yml)
4
+ [![CI](https://github.com/zeromq/omq.rb/actions/workflows/ci.yml/badge.svg)](https://github.com/zeromq/omq.rb/actions/workflows/ci.yml)
5
5
 
6
- ZMTP 3.1 wire protocol — codec, connection, NULL and CURVE mechanisms.
6
+ ZMTP 3.1 wire protocol: codec, connection, NULL and CURVE mechanisms.
7
7
  No runtime dependencies.
8
8
 
9
9
  ## What's in the box
10
10
 
11
- - **Codec::Frame** — ZMTP frame encode/decode (flags, size, body)
12
- - **Codec::Greeting** — 64-byte greeting exchange
13
- - **Codec::Command** — READY, PING/PONG, SUBSCRIBE, etc.
14
- - **Connection** — per-connection frame I/O, handshake, PING/PONG
15
- - **Mechanism::Null** — NULL security (no encryption)
16
- - **Mechanism::Curve** — CurveZMQ (RFC 26) with pluggable crypto backend
17
- - **Z85** — ZeroMQ RFC 32 encoding
11
+ - **Codec::Frame**: ZMTP frame encode/decode (flags, size, body)
12
+ - **Codec::Greeting**: 64-byte greeting exchange
13
+ - **Codec::Command**: READY, PING/PONG, SUBSCRIBE, etc.
14
+ - **Connection**: per-connection frame I/O, handshake, PING/PONG
15
+ - **Mechanism::Null**: NULL security (no encryption)
16
+ - **Mechanism::Curve**: CurveZMQ (RFC 26) with pluggable crypto backend
17
+ - **Z85**: ZeroMQ RFC 32 encoding
18
18
 
19
19
  ## Usage
20
20
 
21
21
  ```ruby
22
22
  require "protocol/zmtp"
23
23
 
24
- # NULL mechanism — no encryption
24
+ # NULL mechanism, no encryption
25
25
  conn = Protocol::ZMTP::Connection.new(
26
26
  io,
27
27
  socket_type: "REQ",
@@ -31,7 +31,7 @@ conn.handshake!
31
31
  conn.send_message(["hello"])
32
32
  msg = conn.receive_message
33
33
 
34
- # CURVE mechanism — pass any NaCl-compatible backend
34
+ # CURVE mechanism, pass any NaCl-compatible backend
35
35
  require "protocol/zmtp/mechanism/curve"
36
36
  require "nuckle" # or: require "rbnacl"
37
37
 
@@ -42,7 +42,7 @@ server_mech = Protocol::ZMTP::Mechanism::Curve.server(
42
42
 
43
43
  ## CURVE crypto backend
44
44
 
45
- `Mechanism::Curve` accepts a `crypto:` parameter — any module that
45
+ `Mechanism::Curve` accepts a `crypto:` parameter. Any module that
46
46
  provides the NaCl API:
47
47
 
48
48
  ```ruby
@@ -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.3"
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.3
4
+ version: 0.10.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrik Wenger
@@ -18,6 +18,7 @@ executables: []
18
18
  extensions: []
19
19
  extra_rdoc_files: []
20
20
  files:
21
+ - CHANGELOG.md
21
22
  - LICENSE
22
23
  - README.md
23
24
  - lib/protocol/zmtp.rb
@@ -35,7 +36,7 @@ files:
35
36
  - lib/protocol/zmtp/valid_peers.rb
36
37
  - lib/protocol/zmtp/version.rb
37
38
  - lib/protocol/zmtp/z85.rb
38
- homepage: https://github.com/paddor/protocol-zmtp
39
+ homepage: https://github.com/zeromq/omq.rb/tree/main/gems/protocol-zmtp
39
40
  licenses:
40
41
  - ISC
41
42
  metadata: {}
@@ -53,7 +54,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
53
54
  - !ruby/object:Gem::Version
54
55
  version: '0'
55
56
  requirements: []
56
- rubygems_version: 4.0.10
57
+ rubygems_version: 4.0.20
57
58
  specification_version: 4
58
59
  summary: ZMTP 3.1 wire protocol codec and connection
59
60
  test_files: []