honeymaker 0.12.4-aarch64-linux

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 (136) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/native-gems.yml +100 -0
  3. data/.github/workflows/release.yml +36 -0
  4. data/.github/workflows/test.yml +33 -0
  5. data/LICENSE +21 -0
  6. data/README.md +219 -0
  7. data/Rakefile +78 -0
  8. data/crates/honeymaker/tests/vectors/decimal.json +146 -0
  9. data/crates/honeymaker/tests/vectors/kraken_add_order_wire.json +74 -0
  10. data/crates/honeymaker/tests/vectors/kraken_signing.json +3314 -0
  11. data/crates/honeymaker/tests/vectors/kraken_trades_pager.json +1583 -0
  12. data/lib/honeymaker/3.4/honeymaker_native.so +0 -0
  13. data/lib/honeymaker/4.0/honeymaker_native.so +0 -0
  14. data/lib/honeymaker/client.rb +139 -0
  15. data/lib/honeymaker/clients/binance.rb +687 -0
  16. data/lib/honeymaker/clients/binance_us.rb +42 -0
  17. data/lib/honeymaker/clients/bingx.rb +242 -0
  18. data/lib/honeymaker/clients/bitget.rb +318 -0
  19. data/lib/honeymaker/clients/bitrue.rb +245 -0
  20. data/lib/honeymaker/clients/bitvavo.rb +244 -0
  21. data/lib/honeymaker/clients/bybit.rb +308 -0
  22. data/lib/honeymaker/clients/coinbase.rb +326 -0
  23. data/lib/honeymaker/clients/gemini.rb +189 -0
  24. data/lib/honeymaker/clients/hyperliquid.rb +232 -0
  25. data/lib/honeymaker/clients/kraken.rb +379 -0
  26. data/lib/honeymaker/clients/kraken_futures.rb +78 -0
  27. data/lib/honeymaker/clients/kucoin.rb +310 -0
  28. data/lib/honeymaker/clients/mexc.rb +249 -0
  29. data/lib/honeymaker/exchange.rb +129 -0
  30. data/lib/honeymaker/exchanges/binance.rb +55 -0
  31. data/lib/honeymaker/exchanges/binance_us.rb +9 -0
  32. data/lib/honeymaker/exchanges/bingx.rb +56 -0
  33. data/lib/honeymaker/exchanges/bitget.rb +52 -0
  34. data/lib/honeymaker/exchanges/bitrue.rb +53 -0
  35. data/lib/honeymaker/exchanges/bitvavo.rb +65 -0
  36. data/lib/honeymaker/exchanges/bybit.rb +57 -0
  37. data/lib/honeymaker/exchanges/coinbase.rb +69 -0
  38. data/lib/honeymaker/exchanges/gemini.rb +134 -0
  39. data/lib/honeymaker/exchanges/hyperliquid.rb +67 -0
  40. data/lib/honeymaker/exchanges/kraken.rb +122 -0
  41. data/lib/honeymaker/exchanges/kucoin.rb +52 -0
  42. data/lib/honeymaker/exchanges/mexc.rb +56 -0
  43. data/lib/honeymaker/native/kraken.rb +263 -0
  44. data/lib/honeymaker/native.rb +42 -0
  45. data/lib/honeymaker/result.rb +46 -0
  46. data/lib/honeymaker/utils.rb +39 -0
  47. data/lib/honeymaker/version.rb +5 -0
  48. data/lib/honeymaker.rb +97 -0
  49. data/script/parity/kraken_client_live.rb +191 -0
  50. data/script/parity/kraken_live.rb +70 -0
  51. data/script/parity/kraken_transport.rb +604 -0
  52. data/script/parity/resolve.c +49 -0
  53. data/script/release/push_gems.sh +28 -0
  54. data/script/vectors/kraken.rb +50 -0
  55. data/script/vectors/kraken_stage2.rb +146 -0
  56. data/sig/honeymaker.rbs +4 -0
  57. data/test/fixtures/binance_book_ticker.json +7 -0
  58. data/test/fixtures/binance_exchange_info.json +30 -0
  59. data/test/fixtures/bingx_symbols.json +26 -0
  60. data/test/fixtures/bingx_ticker.json +12 -0
  61. data/test/fixtures/bitget_symbols.json +28 -0
  62. data/test/fixtures/bitget_tickers.json +14 -0
  63. data/test/fixtures/bitmart_symbols.json +26 -0
  64. data/test/fixtures/bitmart_ticker.json +12 -0
  65. data/test/fixtures/bitrue_book_ticker.json +7 -0
  66. data/test/fixtures/bitrue_exchange_info.json +34 -0
  67. data/test/fixtures/bitvavo_book_ticker.json +7 -0
  68. data/test/fixtures/bitvavo_markets.json +30 -0
  69. data/test/fixtures/bybit_instruments.json +23 -0
  70. data/test/fixtures/bybit_tickers.json +19 -0
  71. data/test/fixtures/coinbase_product.json +7 -0
  72. data/test/fixtures/coinbase_products.json +24 -0
  73. data/test/fixtures/gemini_pubticker.json +10 -0
  74. data/test/fixtures/gemini_symbol_detail.json +10 -0
  75. data/test/fixtures/gemini_symbols.json +1 -0
  76. data/test/fixtures/hyperliquid_all_mids.json +5 -0
  77. data/test/fixtures/hyperliquid_spot_meta.json +12 -0
  78. data/test/fixtures/kraken_asset_pairs.json +43 -0
  79. data/test/fixtures/kraken_ticker.json +16 -0
  80. data/test/fixtures/kucoin_orderbook_level1.json +10 -0
  81. data/test/fixtures/kucoin_symbols.json +17 -0
  82. data/test/fixtures/mexc_book_ticker.json +7 -0
  83. data/test/fixtures/mexc_exchange_info.json +64 -0
  84. data/test/honeymaker/client_test.rb +90 -0
  85. data/test/honeymaker/clients/binance_client_test.rb +151 -0
  86. data/test/honeymaker/clients/binance_us_client_test.rb +25 -0
  87. data/test/honeymaker/clients/bingx_client_test.rb +106 -0
  88. data/test/honeymaker/clients/bitget_client_test.rb +146 -0
  89. data/test/honeymaker/clients/bitrue_client_test.rb +69 -0
  90. data/test/honeymaker/clients/bitvavo_client_test.rb +156 -0
  91. data/test/honeymaker/clients/bybit_client_test.rb +137 -0
  92. data/test/honeymaker/clients/coinbase_client_test.rb +153 -0
  93. data/test/honeymaker/clients/gemini_client_test.rb +100 -0
  94. data/test/honeymaker/clients/honeymaker_client_registry_test.rb +39 -0
  95. data/test/honeymaker/clients/hyperliquid_client_test.rb +292 -0
  96. data/test/honeymaker/clients/kraken_client_test.rb +255 -0
  97. data/test/honeymaker/clients/kraken_futures_client_test.rb +54 -0
  98. data/test/honeymaker/clients/kucoin_client_test.rb +149 -0
  99. data/test/honeymaker/clients/mexc_client_test.rb +98 -0
  100. data/test/honeymaker/clients/validation_test.rb +167 -0
  101. data/test/honeymaker/error_chain_test.rb +133 -0
  102. data/test/honeymaker/exchange_test.rb +138 -0
  103. data/test/honeymaker/exchanges/binance_test.rb +83 -0
  104. data/test/honeymaker/exchanges/binance_us_test.rb +53 -0
  105. data/test/honeymaker/exchanges/bingx_test.rb +65 -0
  106. data/test/honeymaker/exchanges/bitget_test.rb +65 -0
  107. data/test/honeymaker/exchanges/bitrue_test.rb +65 -0
  108. data/test/honeymaker/exchanges/bitvavo_test.rb +65 -0
  109. data/test/honeymaker/exchanges/bybit_test.rb +55 -0
  110. data/test/honeymaker/exchanges/coinbase_test.rb +106 -0
  111. data/test/honeymaker/exchanges/gemini_catalogue_test.rb +118 -0
  112. data/test/honeymaker/exchanges/gemini_test.rb +184 -0
  113. data/test/honeymaker/exchanges/hyperliquid_test.rb +64 -0
  114. data/test/honeymaker/exchanges/kraken_test.rb +191 -0
  115. data/test/honeymaker/exchanges/kucoin_test.rb +55 -0
  116. data/test/honeymaker/exchanges/mexc_test.rb +97 -0
  117. data/test/honeymaker/honeymaker_test.rb +27 -0
  118. data/test/honeymaker/result_test.rb +42 -0
  119. data/test/honeymaker/utils_test.rb +64 -0
  120. data/test/native/backend_switch_test.rb +53 -0
  121. data/test/native/convert_test.rb +137 -0
  122. data/test/native/gem_isolation_test.rb +17 -0
  123. data/test/native/loader_test.rb +29 -0
  124. data/test/native/lockstep_test.rb +23 -0
  125. data/test/native/signing_smoke_test.rb +28 -0
  126. data/test/parity/kraken_client_parity_test.rb +398 -0
  127. data/test/parity/kraken_exchange_parity_test.rb +172 -0
  128. data/test/release/branch_hardening_test.rb +34 -0
  129. data/test/release/push_gems_test.rb +79 -0
  130. data/test/smoke.rb +24 -0
  131. data/test/support/fixture_helper.rb +13 -0
  132. data/test/support/kraken_signing_smoke.rb +21 -0
  133. data/test/support/parity_helper.rb +62 -0
  134. data/test/support/venue_double.rb +100 -0
  135. data/test/test_helper.rb +9 -0
  136. metadata +254 -0
@@ -0,0 +1,604 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Ruby-vs-Rust transport parity. Each legacy transport mode (spec §4) runs against the gem's own
4
+ # Faraday transport (Honeymaker::Clients::Kraken) and against honeymaker-client (examples/probe),
5
+ # each on a fresh copy of the same local double. Compared: the outcome class deltabadger derives
6
+ # (see `oracle`), rejection/complete HTTP error strings, success values, and signed wire requests.
7
+ # One mode mismatch (both calls) is expected and asserted: a proxy that never answers CONNECT (Ruling R2). The
8
+ # plan's "Divergences from the gem" section lists every other difference; none shows up as a row.
9
+ # bundle exec ruby -Ilib script/parity/kraken_transport.rb
10
+ require "honeymaker"
11
+ require "json"
12
+ require "open3"
13
+ require "openssl"
14
+ require "rbconfig"
15
+ require "socket"
16
+ require "stringio"
17
+ require "tmpdir"
18
+ require "timeout"
19
+ require "zlib"
20
+
21
+ ROOT = File.expand_path("../..", __dir__)
22
+
23
+ def issue(dir, name)
24
+ ca_key = OpenSSL::PKey::EC.generate("prime256v1")
25
+ ca = OpenSSL::X509::Certificate.new
26
+ ca.version = 2
27
+ ca.serial = rand(1..(2**31))
28
+ ca.subject = ca.issuer = OpenSSL::X509::Name.parse("/CN=parity-#{name}-ca")
29
+ ca.public_key = ca_key
30
+ ca.not_before = Time.now - 60
31
+ ca.not_after = Time.now + 3600
32
+ ef = OpenSSL::X509::ExtensionFactory.new(ca, ca)
33
+ ca.add_extension(ef.create_extension("basicConstraints", "CA:TRUE", true))
34
+ ca.add_extension(ef.create_extension("keyUsage", "keyCertSign,cRLSign", true))
35
+ ca.sign(ca_key, OpenSSL::Digest.new("SHA256"))
36
+ key = OpenSSL::PKey::EC.generate("prime256v1")
37
+ leaf = OpenSSL::X509::Certificate.new
38
+ leaf.version = 2
39
+ leaf.serial = rand(1..(2**31))
40
+ leaf.subject = OpenSSL::X509::Name.parse("/CN=localhost")
41
+ leaf.issuer = ca.subject
42
+ leaf.public_key = key
43
+ leaf.not_before = ca.not_before
44
+ leaf.not_after = ca.not_after
45
+ ef = OpenSSL::X509::ExtensionFactory.new(ca, leaf)
46
+ leaf.add_extension(ef.create_extension("subjectAltName", "DNS:localhost,IP:127.0.0.1"))
47
+ leaf.add_extension(ef.create_extension("extendedKeyUsage", "serverAuth"))
48
+ leaf.sign(ca_key, OpenSSL::Digest.new("SHA256"))
49
+ File.write(File.join(dir, "#{name}_ca.pem"), ca.to_pem)
50
+ File.write(File.join(dir, "#{name}_leaf.pem"), leaf.to_pem)
51
+ File.write(File.join(dir, "#{name}_key.pem"), key.to_pem)
52
+ end
53
+
54
+ # Trust and the resolver double must be installed before Ruby/OpenSSL starts. The parent
55
+ # owns the temporary PKI/library and removes them even if the child fails. Clear ambient proxies.
56
+ unless ENV["HM_PARITY_DIR"]
57
+ system("cargo", "build", "--locked", "--quiet", "-p", "honeymaker-client", "--example", "probe",
58
+ chdir: ROOT, exception: true)
59
+ Dir.mktmpdir("hm-parity") do |dir|
60
+ issue(dir, "trusted")
61
+ issue(dir, "untrusted")
62
+ library = File.join(dir, "resolve.so")
63
+ darwin = RUBY_PLATFORM.include?("darwin")
64
+ system("cc", "-Wall", "-Wextra", "-Werror", darwin ? "-dynamiclib" : "-shared", "-fPIC",
65
+ File.join(__dir__, "resolve.c"), "-o", library, exception: true)
66
+ env = %w[http_proxy https_proxy all_proxy no_proxy HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY].to_h { |k| [k, nil] }
67
+ env.merge!("HM_PARITY_DIR" => dir, "SSL_CERT_FILE" => File.join(dir, "trusted_ca.pem"),
68
+ "SSL_CERT_DIR" => dir, darwin ? "DYLD_INSERT_LIBRARIES" : "LD_PRELOAD" => library)
69
+ exit(system(env, RbConfig.ruby, "-I#{ROOT}/lib", __FILE__, *ARGV) ? 0 : 1)
70
+ end
71
+ end
72
+ DIR = ENV.fetch("HM_PARITY_DIR")
73
+ $stdout.sync = true
74
+
75
+ def ctx(name)
76
+ OpenSSL::SSL::SSLContext.new.tap do |c|
77
+ c.cert = OpenSSL::X509::Certificate.new(File.read(File.join(DIR, "#{name}_leaf.pem")))
78
+ c.key = OpenSSL::PKey.read(File.read(File.join(DIR, "#{name}_key.pem")))
79
+ end
80
+ end
81
+ TRUSTED = ctx("trusted")
82
+ UNTRUSTED = ctx("untrusted")
83
+
84
+ PROBE = File.join(ROOT, "target/debug/examples/probe")
85
+
86
+ Honeymaker::Client.send(:remove_const, :OPTIONS)
87
+ Honeymaker::Client.const_set(:OPTIONS, { request: { open_timeout: 1, read_timeout: 1.5, write_timeout: 2 } }.freeze)
88
+
89
+ SECRET = Base64.strict_encode64("test_secret_key_1234567890123456")
90
+ NONCE = 1_727_000_000_000_001
91
+ ORDER = { ordertype: "market", type: "buy", volume: "0.0012", pair: "XBTEUR", oflags: ["viqc"],
92
+ cl_ord_id: "6f1c1a52-7c8e-4d0e-9a57-0b6f0f1d2e3a", deadline: "2026-09-30T12:00:10.000Z" }.freeze
93
+ RUST_ORDER = { pair: "XBTEUR", kind: "market", volume: "0.0012", quote_volume: true,
94
+ cl_ord_id: ORDER[:cl_ord_id], deadline: ORDER[:deadline] }.freeze
95
+ TICKER = '{"error":[],"result":{"XXBTZEUR":{"a":["50000.2","1","1.000"],"b":["49990.1","1","1.000"],"c":["49995.3","0.001"]}}}'
96
+ ADDED = '{"error":[],"result":{"descr":{"order":"buy"},"txid":["OTX-1"]}}'
97
+
98
+ # --- doubles (as in the 2026-09-30 capture) ---
99
+ # Every fixture is disposed before the next one. Unexpected server errors fail the row.
100
+ class Doubles
101
+ attr_reader :requests, :connects, :errors
102
+
103
+ def initialize
104
+ @sockets, @threads, @requests, @connects, @errors = [], [], [], [], []
105
+ end
106
+
107
+ def socket(s)
108
+ @sockets << s
109
+ s
110
+ end
111
+
112
+ def thread(&block)
113
+ worker = Thread.new do
114
+ block.call
115
+ rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE, OpenSSL::SSL::SSLError
116
+ # Clients intentionally abort TLS and close timed-out streams.
117
+ rescue StandardError => e
118
+ @errors << e
119
+ end
120
+ @threads << worker
121
+ worker
122
+ end
123
+
124
+ def close
125
+ @threads.each(&:kill)
126
+ @threads.each(&:join)
127
+ @sockets.reverse_each { |s| s.close rescue nil }
128
+ end
129
+ end
130
+
131
+ Req = Struct.new(:line, :accept_encoding, :body, :api_key, :api_sign)
132
+
133
+ def read_request(io)
134
+ buf = +""
135
+ buf << io.readpartial(4096) until buf.include?("\r\n\r\n")
136
+ head, body = buf.split("\r\n\r\n", 2)
137
+ body = body.to_s
138
+ len = head[/content-length: (\d+)/i, 1].to_i
139
+ body << io.readpartial(4096) while body.bytesize < len
140
+ Req.new(head.lines.first.strip, head[/^accept-encoding: ([^\r]*)/i, 1], body,
141
+ head[/^api-key: ([^\r]*)/i, 1], head[/^api-sign: ([^\r]*)/i, 1])
142
+ rescue EOFError, IOError
143
+ nil
144
+ end
145
+
146
+ def server(tls: nil, &handler)
147
+ tcp = @doubles.socket(TCPServer.new("127.0.0.1", 0))
148
+ seen = @doubles.requests
149
+ scope = @doubles
150
+ scope.thread do
151
+ loop do
152
+ sock = scope.socket(tcp.accept)
153
+ scope.thread do
154
+ io = if tls
155
+ scope.socket(OpenSSL::SSL::SSLSocket.new(sock, tls)).tap { |s| s.sync_close = true; s.accept }
156
+ else
157
+ sock
158
+ end
159
+ handler.call(io, sock, seen)
160
+ ensure
161
+ io&.close rescue nil
162
+ sock.close rescue nil
163
+ end
164
+ end
165
+ end
166
+ [tcp.addr[1], seen]
167
+ end
168
+
169
+ def respond(io, status, ctype, body, encoding: nil)
170
+ io.write "HTTP/1.1 #{status} X\r\n#{ctype ? "Content-Type: #{ctype}\r\n" : ''}" \
171
+ "#{encoding ? "Content-Encoding: #{encoding}\r\n" : ''}Content-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n"
172
+ io.write body
173
+ io.close
174
+ end
175
+
176
+ def gz(text)
177
+ z = StringIO.new
178
+ g = Zlib::GzipWriter.new(z)
179
+ g.mtime = 0
180
+ g.write(text)
181
+ g.close
182
+ z.string
183
+ end
184
+
185
+ def encoded(status, body, encoding) = after_request { |io, _| respond(io, status, "application/json", body, encoding: encoding) }
186
+
187
+ def after_request(&act) = ->(io, sock, seen) { (r = read_request(io)) and seen << r; act.call(io, sock) }
188
+ def answering(status, ctype, body) = after_request { |io, _| respond(io, status, ctype, body) }
189
+ def closed_port = TCPServer.new("127.0.0.1", 0).then { |s| s.addr[1].tap { s.close } }
190
+
191
+ def proxy(&connect)
192
+ scope = @doubles
193
+ server do |io, sock, _|
194
+ request = read_request(io)
195
+ raise "missing CONNECT" unless request&.line&.start_with?("CONNECT ")
196
+ scope.connects << request
197
+ connect.call(io, sock)
198
+ end.first
199
+ end
200
+
201
+ def tunnel_to(port)
202
+ scope = @doubles
203
+ proxy do |io, _|
204
+ up = scope.socket(TCPSocket.new("127.0.0.1", port))
205
+ io.write "HTTP/1.1 200 Connection established\r\n\r\n"
206
+ forward = scope.thread { IO.copy_stream(io, up); up.close_write rescue nil }
207
+ begin
208
+ IO.copy_stream(up, io)
209
+ ensure
210
+ forward.kill
211
+ forward.join
212
+ end
213
+ end
214
+ end
215
+
216
+ # A bound, non-listening socket black-holes SYNs on macOS. Linux instead needs a full
217
+ # accept queue. Retain all sockets until the row finishes; never rely on an unroutable host.
218
+ def stalled_tcp
219
+ sock = @doubles.socket(Socket.new(:INET, :STREAM))
220
+ sock.bind(Socket.sockaddr_in(0, "127.0.0.1"))
221
+ port = sock.local_address.ip_port
222
+ unless RUBY_PLATFORM.include?("darwin")
223
+ sock.listen(0)
224
+ loop do
225
+ filler = @doubles.socket(Socket.new(:INET, :STREAM))
226
+ result = filler.connect_nonblock(Socket.sockaddr_in(port, "127.0.0.1"), exception: false)
227
+ break if result == :wait_writable && !IO.select(nil, [filler], nil, 0.1)
228
+ end
229
+ end
230
+ ["https://127.0.0.1:#{port}", nil, []]
231
+ end
232
+
233
+ def tls(handler) = server(tls: TRUSTED, &handler).then { |port, seen| ["https://127.0.0.1:#{port}", nil, seen] }
234
+ def plain(handler) = server(&handler).then { |port, seen| ["http://127.0.0.1:#{port}", nil, seen] }
235
+ def via(proxy_port) = ["https://127.0.0.1:443", "http://127.0.0.1:#{proxy_port}", []]
236
+
237
+ # Repeated header fields are joined with ", " by Net::HTTP. Use both orders so the
238
+ # result cannot accidentally pass with a first-header-only or last-header-only parser.
239
+ def raw_answer(headers, body, framing: :length)
240
+ after_request do |io, _|
241
+ tail = case framing
242
+ when :length then "Content-Length: #{body.bytesize}\r\n"
243
+ when :chunked then "Transfer-Encoding: chunked\r\n"
244
+ when :close then ""
245
+ end
246
+ io.write "HTTP/1.1 200 OK\r\n#{headers}#{tail}Connection: close\r\n\r\n"
247
+ io.write(framing == :chunked ? "#{body.bytesize.to_s(16)}\r\n#{body}\r\n0\r\n\r\n".b : body)
248
+ io.close
249
+ end
250
+ end
251
+
252
+ def good_body(call) = call == :add_order ? ADDED : TICKER
253
+
254
+ def truncated_gzip(framing)
255
+ tls(raw_answer("Content-Type: application/json\r\nContent-Encoding: gzip\r\n",
256
+ gz('{"a":1}').byteslice(0, 12), framing: framing))
257
+ end
258
+
259
+ MODES = {
260
+ refused: ->(_) { ["https://127.0.0.1:#{closed_port}", nil, []] },
261
+ dns: ->(_) { ["https://honeymaker-nonexistent.invalid", nil, []] },
262
+ tcp_timeout: ->(_) { stalled_tcp },
263
+ tls_not_tls: ->(_) { ["https://127.0.0.1:#{server { |io, _, _| sleep 0.2; io.write("garbage\r\n\r\n"); io.close }.first}", nil, []] },
264
+ tls_untrusted: ->(_) { ["https://127.0.0.1:#{server(tls: UNTRUSTED) { |io, _, _| io.close }.first}", nil, []] },
265
+ tls_handshake_hang: ->(_) { ["https://127.0.0.1:#{server { |io, _, _| sleep 3; io.close }.first}", nil, []] },
266
+ read_timeout: ->(_) { tls(after_request { |io, _| sleep 3; io.close }) },
267
+ eof_after_request: ->(_) { plain(after_request { |io, _| io.close }) },
268
+ tls_abrupt_close: ->(_) { tls(after_request { |_, sock| sock.close }) },
269
+ rst_after_request: ->(_) { plain(after_request { |_, sock| sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_LINGER, [1, 0].pack("ii")); sock.close }) },
270
+ partial_body: ->(_) { tls(after_request { |io, _| io.write "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 50\r\n\r\n{\"a\""; io.close }) },
271
+ http_500_json: ->(_) { tls(answering(500, "application/json", '{"error":["EService:Unavailable"]}')) },
272
+ http_500_empty: ->(_) { tls(answering(500, "text/plain", "")) },
273
+ http_404_html: ->(_) { tls(answering(404, "text/html", "<h1>nf</h1> é")) },
274
+ http_429_no_ctype: ->(_) { tls(answering(429, nil, "no")) },
275
+ ok_invalid_json: ->(_) { tls(answering(200, "application/json", "{not json")) },
276
+ ok_duplicate_keys: ->(_) { tls(answering(200, "application/json", '{"error":[],"result":{"txid":["A"],"txid":["B"]}}')) },
277
+ ok_html: ->(_) { tls(answering(200, "text/html", "<p>maintenance</p>")) },
278
+ ok_blank: ->(_) { tls(answering(200, "application/json", " \n ")) },
279
+ ok_top_array: ->(_) { tls(answering(200, "application/json", '[1,2.5,"x"]')) },
280
+ venue_refusal: ->(_) { tls(answering(200, "application/json", '{"error":["EOrder:Insufficient funds"]}')) },
281
+ venue_no_txid: ->(_) { tls(answering(200, "application/json", '{"error":[],"result":{"descr":{"order":"buy"}}}')) },
282
+ venue_transient_refusal: ->(_) { tls(answering(200, "application/json", '{"error":["EService:Busy"]}')) },
283
+ answered: ->(call) { tls(answering(200, "application/json", call == :add_order ? ADDED : TICKER)) },
284
+ compressed_ok: ->(call) { tls(encoded(200, gz(call == :add_order ? ADDED : TICKER), "gzip")) },
285
+ compressed_deflate_ok: ->(call) { tls(encoded(200, Zlib::Deflate.deflate(call == :add_order ? ADDED : TICKER), "deflate")) },
286
+ compressed_refusal: ->(_) { tls(encoded(200, gz('{"error":["EOrder:Insufficient funds"]}'), "gzip")) },
287
+ compressed_malformed: ->(_) { tls(encoded(200, "not gzip at all", "gzip")) },
288
+ proxy_refused: ->(_) { via(closed_port) },
289
+ proxy_connect_403: ->(_) { via(proxy { |io, _| io.write "HTTP/1.1 403 Filtered\r\nContent-Length: 0\r\n\r\n"; sleep 0.2; io.close }) },
290
+ proxy_connect_500: ->(_) { via(proxy { |io, _| io.write "HTTP/1.1 500 Unable to connect\r\nContent-Type: text/html\r\nContent-Length: 5\r\n\r\nnope!"; sleep 0.2; io.close }) },
291
+ proxy_connect_eof: ->(_) { via(proxy { |io, _| io.close }) },
292
+ proxy_connect_hang: ->(_) { via(proxy { |io, _| sleep 3; io.close }) },
293
+ proxy_tunnel_read_timeout: ->(_) {
294
+ port, seen = server(tls: TRUSTED, &after_request { |io, _| sleep 3; io.close })
295
+ ["https://localhost:#{port}", "http://127.0.0.1:#{tunnel_to(port)}", seen]
296
+ },
297
+ proxy_tunnel_ok: ->(call) {
298
+ port, seen = server(tls: TRUSTED, &answering(200, "application/json", call == :add_order ? ADDED : TICKER))
299
+ ["https://localhost:#{port}", "http://127.0.0.1:#{tunnel_to(port)}", seen]
300
+ },
301
+ address_fallback: ->(call) {
302
+ port, seen = server(&answering(200, "application/json", good_body(call)))
303
+ ["http://fallback.parity.invalid:#{port}", nil, seen]
304
+ },
305
+ gzip_truncated_length: ->(_) { truncated_gzip(:length) },
306
+ gzip_truncated_chunked: ->(_) { truncated_gzip(:chunked) },
307
+ gzip_truncated_close: ->(_) { truncated_gzip(:close) },
308
+ repeated_type_json_first: ->(call) {
309
+ tls(raw_answer("Content-Type: application/json\r\nContent-Type: text/plain\r\n", good_body(call)))
310
+ },
311
+ repeated_type_json_last: ->(call) {
312
+ # Joined value is "text/plain, application/json", which still matches Faraday's /\bjson$/.
313
+ tls(raw_answer("Content-Type: text/plain\r\nContent-Type: application/json\r\n", good_body(call)))
314
+ },
315
+ repeated_encoding: ->(call) {
316
+ # The joined, unrecognized encoding passes plain JSON through. Taking either field
317
+ # alone would instead attempt decompression and fail with Zlib::DataError.
318
+ tls(raw_answer("Content-Type: application/json\r\nContent-Encoding: gzip\r\nContent-Encoding: deflate\r\n",
319
+ good_body(call)))
320
+ },
321
+ json_invalid_utf8_string: ->(call) {
322
+ body = good_body(call).b.sub('"error":[]', '"error":[],"ignored":"'.b + "\xff".b + '"')
323
+ tls(answering(200, "application/json", body))
324
+ },
325
+ http_407_empty: ->(_) { tls(answering(407, nil, "")) },
326
+ http_404_empty: ->(_) { tls(answering(404, nil, "")) }
327
+ }.freeze
328
+ EXPECTED_DIVERGENCE = { proxy_connect_hang: { legacy: [:ambiguous], rust: [:transient] } }.freeze
329
+
330
+ # Pin the mode as well as parity: two clients failing the same broken fixture must not pass.
331
+ EXPECTED = {
332
+ refused: :transient, dns: :transient, tcp_timeout: :transient, tls_not_tls: :ambiguous,
333
+ tls_untrusted: :ambiguous, tls_handshake_hang: :transient, read_timeout: :ambiguous,
334
+ eof_after_request: :ambiguous, tls_abrupt_close: :ambiguous, rst_after_request: :ambiguous,
335
+ partial_body: :ambiguous, http_500_json: :ambiguous, http_500_empty: :ambiguous,
336
+ http_404_html: :rejected, http_429_no_ctype: :rejected, ok_invalid_json: :ambiguous,
337
+ ok_duplicate_keys: :ambiguous, ok_html: :ambiguous, ok_blank: :ambiguous, ok_top_array: :ambiguous,
338
+ venue_refusal: :rejected, venue_no_txid: :ambiguous,
339
+ venue_transient_refusal: { add_order: :ambiguous, prices: :rejected },
340
+ answered: :ok, compressed_ok: :ok, compressed_deflate_ok: :ok, compressed_refusal: :rejected,
341
+ compressed_malformed: :ambiguous, proxy_refused: :transient, proxy_connect_403: :transient,
342
+ proxy_connect_500: :ambiguous, proxy_connect_eof: :ambiguous, proxy_connect_hang: :ambiguous,
343
+ proxy_tunnel_read_timeout: :ambiguous, proxy_tunnel_ok: :ok, address_fallback: :ok,
344
+ gzip_truncated_length: :ambiguous, gzip_truncated_chunked: :ambiguous, gzip_truncated_close: :ambiguous,
345
+ repeated_type_json_first: :ambiguous, repeated_type_json_last: :ok, repeated_encoding: :ok,
346
+ json_invalid_utf8_string: :ok, http_407_empty: :rejected, http_404_empty: :rejected
347
+ }.freeze
348
+ NO_REQUEST = %i[refused dns tcp_timeout tls_not_tls tls_untrusted tls_handshake_hang
349
+ proxy_refused proxy_connect_403 proxy_connect_500 proxy_connect_eof proxy_connect_hang].freeze
350
+ raise "unclassified modes" unless EXPECTED.keys.sort == MODES.keys.sort
351
+
352
+ # deltabadger's Exchange#ambiguous_placement_error? without its text rules (the caller applies those
353
+ # to rejection strings), using Client.most_specific_cause and Client.pre_transmission?.
354
+ PRE = %w[Net::OpenTimeout SocketError Socket::ResolutionError Resolv::ResolvError Errno::ECONNREFUSED Errno::EHOSTUNREACH
355
+ Errno::EHOSTDOWN Errno::ENETUNREACH Errno::ENETDOWN Errno::EADDRNOTAVAIL Net::HTTPClientException].freeze
356
+ PREFERRED = [/\ANet::(Open|Read)Timeout\z/, /\AErrno::/, /\A(SocketError|Socket::Resolution|Resolv::)/,
357
+ /\A(EOFError|OpenSSL::SSL::SSLError|Net::HTTPClientException)\z/].freeze
358
+
359
+ def cause(chain)
360
+ PREFERRED.each { |p| (m = chain.find { |n| n.match?(p) }) and return m }
361
+ chain.first
362
+ end
363
+
364
+ def utf8(errors) = errors.map { |e| e.to_s.dup.force_encoding(Encoding::UTF_8) }
365
+
366
+ # The text rules of ambiguous_placement_error?, which the Rust client applies to AddOrder (R19).
367
+ PLACEMENT_SAFE = ["Timestamp for this request is outside of the recvWindow", "Timestamp for this request was"].freeze
368
+ TRANSIENT_TEXT = ["Net::ReadTimeout", "Net::OpenTimeout", "Faraday::TimeoutError", "Faraday::ConnectionFailed", "execution expired",
369
+ "Connection reset", "Errno::ECONNRESET", "connection refused", "Connection refused", "Errno::ECONNREFUSED",
370
+ "end of file reached", "unexpected eof while reading", "EGeneral:Internal error", "EAPI:Invalid nonce",
371
+ "EService:Unavailable", "EService:Busy", "EService:Deadline elapsed"].freeze
372
+
373
+ def oracle(call, r)
374
+ if r.success?
375
+ return r.data[:order_id].to_s.empty? ? [:ambiguous] : [:ok] if call == :add_order
376
+ return [:ambiguous] unless r.data.is_a?(Hash) # Rails' dig_or_raise raises on anything else
377
+
378
+ errors = r.data["error"]
379
+ return errors.is_a?(Array) && errors.any? ? [:rejected, utf8(errors)] : [:ok]
380
+ end
381
+ data = r.data.is_a?(Hash) ? r.data : {}
382
+ chain = data[:error_chain]
383
+ if chain && !chain.empty?
384
+ return [PRE.include?(cause(chain)) || r.errors.join(" ").match?(/connection refused/i) ? :transient : :ambiguous]
385
+ end
386
+ if call == :add_order
387
+ text = r.errors.map(&:to_s)
388
+ return [:rejected, utf8(r.errors)] if text.any? { |m| PLACEMENT_SAFE.any? { |p| m.include?(p) } }
389
+ return [:ambiguous] if text.any? { |m| TRANSIENT_TEXT.any? { |p| m.include?(p) } }
390
+ end
391
+ return [:ambiguous] if data[:unreadable] || data[:client_error]
392
+
393
+ status = data[:status].to_i
394
+ return [:ambiguous] if status >= 500 || (200..299).cover?(status)
395
+ return [:ambiguous] if call == :add_order && r.errors.any? { |e| e.to_s.match?(/\bHTTP 5\d\d\b/) }
396
+
397
+ [:rejected, utf8(r.errors)]
398
+ end
399
+
400
+ def clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
401
+
402
+ def legacy(call, url, proxy)
403
+ c = Honeymaker::Clients::Kraken.new(api_key: "key", api_secret: SECRET, proxy: proxy)
404
+ c.define_singleton_method(:nonce) { NONCE }
405
+ c.instance_variable_set(:@connection, c.send(:build_client_connection, url))
406
+ started = clock
407
+ r = call == :add_order ? c.add_order(**ORDER) : c.get_ticker_information(pair: "XBTEUR")
408
+ [oracle(call, r), clock - started, r]
409
+ end
410
+
411
+ def probe(input)
412
+ Open3.popen3(PROBE) do |stdin, stdout, stderr, wait|
413
+ errors = Thread.new { stderr.read }
414
+ begin
415
+ out = Timeout.timeout(10) do
416
+ stdin.write(JSON.generate(input))
417
+ stdin.close
418
+ stdout.read
419
+ end
420
+ raise "probe failed: #{errors.value}" unless wait.value.success?
421
+ r = JSON.parse(out)
422
+ raise "probe omitted elapsed time: #{r.inspect}" unless r["ms"].is_a?(Numeric) && r["ms"] >= 0
423
+ r
424
+ ensure
425
+ Process.kill("KILL", wait.pid) if wait.alive?
426
+ wait.join
427
+ errors.join
428
+ end
429
+ end
430
+ end
431
+
432
+ def rust(call, url, proxy)
433
+ input = { base_url: url, proxy: proxy, timeouts: [1, 1.5, 2], ca_pem: File.join(DIR, "trusted_ca.pem"),
434
+ fixed_nonce: NONCE, api_key: "key", api_secret: SECRET,
435
+ call: call == :add_order ? "add_order" : "prices", args: call == :add_order ? RUST_ORDER : { pair: "XBTEUR" } }
436
+ r = probe(input)
437
+ [[r["class"].to_sym, *(r["class"] == "rejected" ? [r["errors"]] : [])], r.fetch("ms") / 1000.0, r]
438
+ end
439
+
440
+ # Run each stack on a fresh copy, with a watchdog independent of transport timeouts.
441
+ def exercise(stack, build, call)
442
+ @doubles = Doubles.new
443
+ url, prox, = build.call(call)
444
+ result = Timeout.timeout(12) { send(stack, call, url, prox) }
445
+ @doubles.close
446
+ raise "double errors: #{@doubles.errors.inspect}" unless @doubles.errors.empty?
447
+ [*result, @doubles.requests.map(&:to_a), @doubles.connects.map(&:to_a), url]
448
+ ensure
449
+ @doubles&.close
450
+ end
451
+
452
+ def check(condition, message)
453
+ raise message unless condition
454
+ end
455
+
456
+ def compare_row(name, call, l, r)
457
+ lo, ls, lr, lw, lc, lu = l
458
+ ro, rs, rr, rw, rc, ru = r
459
+ expected = EXPECTED.fetch(name)
460
+ expected = expected.fetch(call) if expected.is_a?(Hash)
461
+ check(lo.first == expected, "legacy class #{lo.inspect}, expected #{expected}: #{lr.inspect}")
462
+ if (want = EXPECTED_DIVERGENCE[name])
463
+ check(lo == want[:legacy] && ro == want[:rust], "CONNECT ruling: #{lo.inspect} / #{ro.inspect}")
464
+ check(rs.between?(0.8, 2.5) && ls >= 2.5, "CONNECT timing: legacy=#{ls}, rust=#{rs}")
465
+ else
466
+ normalized = ->(outcome, url) { outcome.first == :rejected ? [:rejected, outcome.last.map { |m| m.gsub(url, "<double>") }] : outcome }
467
+ check(normalized.call(lo, lu) == normalized.call(ro, ru), "outcomes: #{lo.inspect} / #{ro.inspect}; legacy=#{lr.inspect}; rust=#{rr.inspect}")
468
+ end
469
+ check(lw == rw, "wire: #{lw.inspect} / #{rw.inspect}")
470
+ count = NO_REQUEST.include?(name) ? 0 : 1
471
+ check(lw.size == count && rw.size == count, "expected #{count} requests, got #{lw.size}/#{rw.size}")
472
+ if count == 1
473
+ line = call == :add_order ? "POST /0/private/AddOrder HTTP/1.1" : "GET /0/public/Ticker?pair=XBTEUR HTTP/1.1"
474
+ check(lw.first[0] == line, "wrong request line: #{lw.first[0]}")
475
+ check(lw.first[1] == "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", "wrong Accept-Encoding")
476
+ check(call != :add_order || (lw.first[3] == "key" && !lw.first[4].to_s.empty?), "missing signing headers")
477
+ end
478
+ proxy_count = name.to_s.start_with?("proxy_") && name != :proxy_refused ? 1 : 0
479
+ check(lc.size == proxy_count && rc.size == proxy_count, "CONNECT counts: #{lc.size}/#{rc.size}")
480
+ if proxy_count == 1
481
+ # A fresh double has a fresh port; compare each CONNECT to its own exact authority.
482
+ [[lc, lu], [rc, ru]].each do |connects, url|
483
+ uri = URI(url)
484
+ check(connects.first[0] == "CONNECT #{uri.host}:#{uri.port} HTTP/1.1", "wrong CONNECT target")
485
+ check(connects.first[2].empty?, "CONNECT unexpectedly has a body")
486
+ end
487
+ end
488
+ if name == :tcp_timeout || name == :tls_handshake_hang
489
+ check(ls.between?(0.8, 2.5) && rs.between?(0.8, 2.5), "not an open timeout: #{ls}/#{rs}")
490
+ check(lr.data[:error_chain].include?("Net::OpenTimeout"), "legacy did not time out opening")
491
+ prefix = name == :tcp_timeout ? "Failed to open TCP connection to " : "Net::OpenTimeout"
492
+ check(rr["message"].start_with?(prefix), "Rust did not fail in the open phase: #{rr.inspect}")
493
+ end
494
+ if %i[read_timeout proxy_tunnel_read_timeout].include?(name)
495
+ check(ls.between?(1.3, 2.5) && rs.between?(1.3, 2.5), "not a read timeout: #{ls}/#{rs}")
496
+ check(lr.data[:error_chain].include?("Net::ReadTimeout") && rr["message"].include?("Net::ReadTimeout"), "wrong timeout phase")
497
+ end
498
+ # R14: status/rejection text is exact; low-level OS/OpenSSL diagnostics are version-specific.
499
+ # partial_body is an incomplete transport read in hyper (EOF), while Net::HTTP hands
500
+ # partial bytes to Faraday. R14 permits different transport diagnostics; the brief compares
501
+ # its ambiguous class. Complete HTTP responses below retain exact status/parser error text.
502
+ if lr.failure? && lr.data.is_a?(Hash) && lr.data[:status] && name != :partial_body
503
+ ruby_text = utf8(lr.errors).first.gsub(lu, "<double>")
504
+ rust_text = (rr["message"] || rr.fetch("errors").first).gsub(ru, "<double>")
505
+ check(ruby_text == rust_text, "HTTP text: #{ruby_text.inspect} / #{rust_text.inspect}")
506
+ end
507
+ if %i[http_500_empty http_404_empty].include?(name)
508
+ method = call == :add_order ? "POST" : "GET"
509
+ check(lr.errors.first.include?(" for #{method} "), "fallback method must be uppercase")
510
+ end
511
+ if name == :http_407_empty
512
+ check(lo == [:rejected, ['407 "Proxy Authentication Required"']], "wrong empty-407 fallback")
513
+ end
514
+ if name == :dns
515
+ check(lr.data[:error_chain].any? { |c| c.match?(/Socket.*Error|Resolv/) }, "legacy did not fail resolution")
516
+ check(rr["message"].include?("getaddrinfo"), "Rust did not fail resolution")
517
+ end
518
+ if name == :compressed_malformed
519
+ check(lr.data == { client_error: true } && lr.errors.first.start_with?("Zlib::DataError:"), "missing Ruby DataError")
520
+ check(rr["message"].start_with?("Zlib::DataError:"), "missing Rust DataError")
521
+ end
522
+ if %i[gzip_truncated_length gzip_truncated_chunked].include?(name)
523
+ check(call == :add_order ? lr.data == { unreadable: true } : lr.success? && lr.data.nil?,
524
+ "length/chunked gzip must produce an empty successful transport body")
525
+ check(rr["message"] == Honeymaker::Clients::Kraken::UNREADABLE, "unexpected gzip result")
526
+ end
527
+ if name == :gzip_truncated_close
528
+ check(lr.data == { client_error: true } && lr.errors.first.start_with?("Zlib::BufError:"), "missing Ruby BufError")
529
+ check(rr["message"] == lr.errors.first, "BufError text differs")
530
+ end
531
+ if lo.first == :ok
532
+ value = call == :add_order ? lr.data[:order_id] :
533
+ lr.data.fetch("result").values.first.then { |t| { "bid" => t.fetch("b").first, "ask" => t.fetch("a").first, "last" => t.fetch("c").first } }
534
+ check(value == rr.fetch("value"), "success values: #{value.inspect} / #{rr["value"].inspect}")
535
+ end
536
+ end
537
+
538
+ # Exercise all seven probe dispatch branches and their JSON projections, including fields
539
+ # unused by the two-call transport matrix. This is a local protocol check for Task 12's consumer.
540
+ def probe_contract!
541
+ raw_order = { "status" => "open", "vol" => "2", "vol_exec" => "1", "cost" => "3", "price" => "3",
542
+ "cl_ord_id" => ORDER[:cl_ord_id], "descr" => { "ordertype" => "limit", "type" => "sell", "price" => "3" } }
543
+ state = { "txid" => "OTX-1", "status" => "open", "price" => "3", "amount" => "2", "quote_amount" => nil,
544
+ "amount_exec" => "1", "quote_amount_exec" => "3", "limit" => true, "sell" => true }
545
+ trade = { "ordertxid" => "OTX-1", "vol" => "1", "cost" => "3", "fee" => "0", "type" => "sell", "ordertype" => "limit" }
546
+ envelope = ->(r) { JSON.generate(error: [], result: r) }
547
+ since = "2026-09-30T00:00:00Z"
548
+ cases = [
549
+ ["prices", { pair: "XBTEUR" }, TICKER, { "bid" => "49990.1", "ask" => "50000.2", "last" => "49995.3" }],
550
+ ["add_order", RUST_ORDER, ADDED, "OTX-1"],
551
+ ["add_order_validate", RUST_ORDER.merge(kind: "limit", price: "3"), envelope.call({}), true],
552
+ ["orders", { txids: ["OTX-1"] }, envelope.call("OTX-1" => raw_order), [state]],
553
+ ["order_by_client_id", { cl_ord_id: ORDER[:cl_ord_id], since: since }, envelope.call("open" => { "OTX-1" => raw_order }), state],
554
+ ["fills_from_trades", { txids: ["OTX-1"], since: since }, envelope.call("trades" => { "T1" => trade }, "count" => 1),
555
+ [state.merge("status" => "closed", "amount" => nil, "price" => "3.000000000000000000000000000000000")]],
556
+ ["balance", { asset: "EUR" }, envelope.call("ZEUR" => { "balance" => "1000", "hold_trade" => "1" }), "999"]
557
+ ]
558
+ cases.each do |call, args, body, value|
559
+ begin
560
+ @doubles = Doubles.new
561
+ url, = tls(answering(200, "application/json", body))
562
+ r = probe(base_url: url, ca_pem: File.join(DIR, "trusted_ca.pem"), fixed_nonce: NONCE,
563
+ api_key: "key", api_secret: SECRET, call: call, args: args)
564
+ check(r["class"] == "ok" && r["value"] == value, "probe #{call}: #{r.inspect}, expected #{value.inspect}")
565
+ check(@doubles.requests.size == 1, "probe #{call} must send exactly once")
566
+ if call == "add_order_validate"
567
+ form = URI.decode_www_form(@doubles.requests.first.body).to_h
568
+ check(form["validate"] == "true" && form["ordertype"] == "limit" && form["price"] == "3", "probe limit/validate order")
569
+ end
570
+ check(@doubles.errors.empty?, "probe double errors: #{@doubles.errors.inspect}")
571
+ ensure
572
+ @doubles.close
573
+ end
574
+ end
575
+ r = probe(base_url: "http://127.0.0.1", proxy: "socks5://127.0.0.1:1", call: "prices", args: { pair: "XBTEUR" })
576
+ check(r["class"] == "config" && r["message"].is_a?(String), "probe config: #{r.inspect}")
577
+ puts "8 probe contract checks passed (7 calls + config)"
578
+ end
579
+
580
+ failures = 0
581
+ rows = 0
582
+ unknown = ARGV - MODES.keys.map(&:to_s)
583
+ abort "Unknown modes: #{unknown.join(', ')}" unless unknown.empty?
584
+ puts "Ruby #{RUBY_VERSION}; #{MODES.size} modes available; local doubles only"
585
+ probe_contract!
586
+ MODES.each do |name, build|
587
+ next if ARGV.any? && !ARGV.include?(name.to_s)
588
+ %i[add_order prices].each do |call|
589
+ next if call == :prices && name == :venue_no_txid
590
+
591
+ rows += 1
592
+ begin
593
+ l = exercise(:legacy, build, call)
594
+ r = exercise(:rust, build, call)
595
+ compare_row(name, call, l, r)
596
+ printf("%-32s %-10s legacy=%-12s rust=%-12s ok (%.3fs / %.3fs)\n", name, call, l[0].first, r[0].first, l[1], r[1])
597
+ rescue StandardError => e
598
+ failures += 1
599
+ puts "#{name} #{call} FAIL #{e.full_message}"
600
+ end
601
+ end
602
+ end
603
+ abort "#{failures} of #{rows} rows differ" if failures.positive?
604
+ puts "#{rows} rows: all match (#{ARGV.empty? ? '1 expected divergence asserted: proxy_connect_hang' : 'selected modes'})"
@@ -0,0 +1,49 @@
1
+ /* Process-local resolver double for the transport harness. No DNS packets: only numeric
2
+ * loopback addresses reach libc. Both Ruby and Rust still use their real socket/TLS/HTTP stacks.
3
+ * macOS: DYLD interposition; Linux: LD_PRELOAD. Built into the harness's temporary directory. */
4
+ #define _GNU_SOURCE
5
+ #include <dlfcn.h>
6
+ #include <netdb.h>
7
+ #include <string.h>
8
+
9
+ typedef int (*resolver)(const char *, const char *, const struct addrinfo *, struct addrinfo **);
10
+
11
+ static int local_resolve(const char *node, const char *service,
12
+ const struct addrinfo *hints, struct addrinfo **result) {
13
+ #ifdef __APPLE__
14
+ resolver real_resolve = getaddrinfo;
15
+ #else
16
+ resolver real_resolve = (resolver)dlsym(RTLD_NEXT, "getaddrinfo");
17
+ #endif
18
+ *result = NULL;
19
+ if (!node) return EAI_NONAME;
20
+ struct addrinfo numeric = {0};
21
+ if (hints) numeric = *hints;
22
+ numeric.ai_flags |= AI_NUMERICHOST;
23
+ if (!strcmp(node, "fallback.parity.invalid")) {
24
+ /* First address refuses; the second has the local listener. */
25
+ int error = real_resolve("127.0.0.2", service, &numeric, result);
26
+ if (error) return error;
27
+ struct addrinfo *tail = *result;
28
+ while (tail->ai_next) tail = tail->ai_next;
29
+ error = real_resolve("127.0.0.1", service, &numeric, &tail->ai_next);
30
+ if (error) { freeaddrinfo(*result); *result = NULL; }
31
+ return error;
32
+ }
33
+ if (!strcmp(node, "localhost")) node = "127.0.0.1";
34
+ if (strcmp(node, "127.0.0.1") && strcmp(node, "127.0.0.2") && strcmp(node, "::1"))
35
+ return EAI_NONAME;
36
+ return real_resolve(node, service, &numeric, result);
37
+ }
38
+
39
+ #ifdef __APPLE__
40
+ __attribute__((used)) static struct { const void *replacement; const void *original; }
41
+ interpose __attribute__((section("__DATA,__interpose"))) = {
42
+ (const void *)local_resolve, (const void *)getaddrinfo
43
+ };
44
+ #else
45
+ int getaddrinfo(const char *node, const char *service,
46
+ const struct addrinfo *hints, struct addrinfo **result) {
47
+ return local_resolve(node, service, hints, result);
48
+ }
49
+ #endif