honeymaker 0.12.2 → 0.12.4

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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/native-gems.yml +100 -0
  3. data/.github/workflows/release.yml +36 -0
  4. data/.github/workflows/test.yml +19 -4
  5. data/Cargo.lock +1078 -0
  6. data/Cargo.toml +14 -0
  7. data/README.md +39 -0
  8. data/Rakefile +28 -16
  9. data/crates/honeymaker/Cargo.toml +18 -0
  10. data/crates/honeymaker/src/encode.rs +106 -0
  11. data/crates/honeymaker/src/kraken/lookup.rs +140 -0
  12. data/crates/honeymaker/src/kraken/mod.rs +5 -0
  13. data/crates/honeymaker/src/kraken/normalize.rs +536 -0
  14. data/crates/honeymaker/src/kraken/requests.rs +286 -0
  15. data/crates/honeymaker/src/kraken/sign.rs +166 -0
  16. data/crates/honeymaker/src/kraken/trades.rs +201 -0
  17. data/crates/honeymaker/src/lib.rs +10 -0
  18. data/crates/honeymaker/src/num.rs +115 -0
  19. data/crates/honeymaker/src/semantics.rs +100 -0
  20. data/crates/honeymaker/src/types.rs +85 -0
  21. data/crates/honeymaker/tests/kraken_stage2.rs +592 -0
  22. data/crates/honeymaker/tests/kraken_trades_pager.rs +307 -0
  23. data/crates/honeymaker/tests/kraken_vectors.rs +78 -0
  24. data/crates/honeymaker/tests/vectors/decimal.json +146 -0
  25. data/crates/honeymaker/tests/vectors/kraken_add_order_wire.json +74 -0
  26. data/crates/honeymaker/tests/vectors/kraken_signing.json +3314 -0
  27. data/crates/honeymaker/tests/vectors/kraken_trades_pager.json +1583 -0
  28. data/crates/honeymaker-client/Cargo.toml +30 -0
  29. data/crates/honeymaker-client/examples/probe.rs +147 -0
  30. data/crates/honeymaker-client/src/kraken.rs +514 -0
  31. data/crates/honeymaker-client/src/lib.rs +4 -0
  32. data/crates/honeymaker-client/src/transport/connect.rs +583 -0
  33. data/crates/honeymaker-client/src/transport/io_timeout.rs +352 -0
  34. data/crates/honeymaker-client/src/transport/response.rs +169 -0
  35. data/crates/honeymaker-client/src/transport.rs +467 -0
  36. data/crates/honeymaker-client/tests/classification.rs +203 -0
  37. data/crates/honeymaker-client/tests/kraken_client.rs +949 -0
  38. data/crates/honeymaker-client/tests/response.rs +522 -0
  39. data/crates/honeymaker-client/tests/support/mod.rs +550 -0
  40. data/crates/honeymaker-client/tests/transport.rs +828 -0
  41. data/ext/honeymaker_native/Cargo.toml +16 -0
  42. data/ext/honeymaker_native/extconf.rb +6 -0
  43. data/ext/honeymaker_native/src/convert.rs +150 -0
  44. data/ext/honeymaker_native/src/kraken.rs +362 -0
  45. data/ext/honeymaker_native/src/lib.rs +90 -0
  46. data/ext/honeymaker_native/src/ruby_decimal.rs +90 -0
  47. data/lib/honeymaker/clients/bybit.rb +19 -2
  48. data/lib/honeymaker/clients/coinbase.rb +3 -2
  49. data/lib/honeymaker/clients/kraken.rb +17 -1
  50. data/lib/honeymaker/exchanges/coinbase.rb +5 -4
  51. data/lib/honeymaker/native/kraken.rb +263 -0
  52. data/lib/honeymaker/native.rb +42 -0
  53. data/lib/honeymaker/version.rb +1 -1
  54. data/lib/honeymaker.rb +12 -2
  55. data/script/parity/kraken_client_live.rb +191 -0
  56. data/script/parity/kraken_live.rb +70 -0
  57. data/script/parity/kraken_transport.rb +604 -0
  58. data/script/parity/resolve.c +49 -0
  59. data/script/release/push_gems.sh +28 -0
  60. data/script/vectors/kraken.rb +50 -0
  61. data/script/vectors/kraken_stage2.rb +146 -0
  62. data/test/fixtures/coinbase_products.json +1 -1
  63. data/test/honeymaker/clients/bybit_client_test.rb +25 -0
  64. data/test/honeymaker/clients/coinbase_client_test.rb +14 -0
  65. data/test/honeymaker/clients/kraken_client_test.rb +27 -0
  66. data/test/honeymaker/exchanges/coinbase_test.rb +22 -2
  67. data/test/native/backend_switch_test.rb +53 -0
  68. data/test/native/convert_test.rb +137 -0
  69. data/test/native/gem_isolation_test.rb +17 -0
  70. data/test/native/loader_test.rb +29 -0
  71. data/test/native/lockstep_test.rb +23 -0
  72. data/test/native/signing_smoke_test.rb +28 -0
  73. data/test/parity/kraken_client_parity_test.rb +398 -0
  74. data/test/parity/kraken_exchange_parity_test.rb +172 -0
  75. data/test/release/branch_hardening_test.rb +34 -0
  76. data/test/release/push_gems_test.rb +79 -0
  77. data/test/smoke.rb +24 -0
  78. data/test/support/kraken_signing_smoke.rb +21 -0
  79. data/test/support/parity_helper.rb +62 -0
  80. data/test/support/venue_double.rb +100 -0
  81. metadata +84 -4
@@ -0,0 +1,828 @@
1
+ mod support;
2
+ use honeymaker_client::transport::{Cause, Failure, Raw, Request, Timeouts, Transport};
3
+ use std::time::{Duration, Instant};
4
+ use support::*;
5
+
6
+ /// Distinct limits, so no test can race two of them.
7
+ fn quick() -> Timeouts {
8
+ Timeouts {
9
+ open: Duration::from_millis(300),
10
+ read: Duration::from_millis(400),
11
+ write: Duration::from_millis(500),
12
+ }
13
+ }
14
+ fn post() -> Request {
15
+ Request {
16
+ method: "POST",
17
+ path_and_query: "/0/private/AddOrder".into(),
18
+ headers: vec![(
19
+ "Content-Type".into(),
20
+ "application/x-www-form-urlencoded".into(),
21
+ )],
22
+ body: Some("nonce=1&ordertype=market".into()),
23
+ }
24
+ }
25
+ async fn send(base: &str, proxy: Option<&str>, pki: &Pki) -> Result<Raw, Failure> {
26
+ Transport::new(base, proxy, quick(), Some(pki.roots.clone()))
27
+ .unwrap()
28
+ .send(&post())
29
+ .await
30
+ }
31
+ fn err(r: &Result<Raw, Failure>) -> &Failure {
32
+ r.as_ref().expect_err("expected a failure")
33
+ }
34
+
35
+ // ---- before the request: must be pre-transmission, and the target must have seen nothing ----
36
+
37
+ #[tokio::test(flavor = "current_thread")]
38
+ async fn a_refused_connection_is_pre_transmission() {
39
+ let pki = pki();
40
+ let port = closed_port().await;
41
+ let r = send(&format!("https://127.0.0.1:{port}"), None, &pki).await;
42
+ assert_eq!(
43
+ err(&r),
44
+ &Failure::new(
45
+ Cause::Refused,
46
+ format!("connection refused: 127.0.0.1:{port}")
47
+ )
48
+ );
49
+ assert!(err(&r).pre_transmission());
50
+ }
51
+
52
+ #[tokio::test(flavor = "current_thread")]
53
+ async fn a_tls_handshake_that_stalls_is_an_open_timeout() {
54
+ let pki = pki();
55
+ let t = target(Tls::NeverHandshake, Act::Hang, &pki).await;
56
+ let r = send(&t.https(), None, &pki).await;
57
+ assert_eq!(
58
+ err(&r),
59
+ &Failure::new(Cause::OpenTimeout, "Net::OpenTimeout")
60
+ );
61
+ assert!(err(&r).pre_transmission());
62
+ assert_eq!(t.seen.request_bytes(), 0);
63
+ }
64
+
65
+ #[tokio::test(flavor = "current_thread")]
66
+ async fn a_peer_that_does_not_speak_tls_is_a_tls_failure_and_rails_calls_it_ambiguous() {
67
+ let pki = pki();
68
+ let t = target(Tls::NotTls, Act::Hang, &pki).await;
69
+ let r = send(&t.https(), None, &pki).await;
70
+ assert_eq!(err(&r).cause, Cause::Tls);
71
+ assert!(
72
+ !err(&r).pre_transmission(),
73
+ "Ruling R1: OpenSSL::SSL::SSLError is not pre-transmission"
74
+ );
75
+ }
76
+
77
+ #[tokio::test(flavor = "current_thread")]
78
+ async fn an_untrusted_certificate_is_a_tls_failure_that_sent_nothing() {
79
+ let pki = pki();
80
+ let t = target(Tls::Untrusted, Act::Hang, &pki).await;
81
+ let r = send(&t.https(), None, &pki).await;
82
+ assert_eq!(err(&r).cause, Cause::Tls);
83
+ assert!(!err(&r).pre_transmission());
84
+ assert_eq!(
85
+ t.seen.request_bytes(),
86
+ 0,
87
+ "a handshake failure never writes the request"
88
+ );
89
+ }
90
+
91
+ #[tokio::test(flavor = "current_thread")]
92
+ async fn a_localhost_url_reaches_an_ipv4_only_listener() {
93
+ let pki = pki();
94
+ let t = target(
95
+ Tls::Trusted,
96
+ Act::Reply(http("200 OK", "application/json", OK_JSON)),
97
+ &pki,
98
+ )
99
+ .await;
100
+ assert_eq!(t.addr.ip(), std::net::Ipv4Addr::LOCALHOST);
101
+ let raw = send(&t.https_localhost(), None, &pki).await.unwrap();
102
+ assert_eq!(raw.status, 200);
103
+ assert_eq!(raw.body.as_ref(), OK_JSON.as_bytes());
104
+ assert_eq!(t.seen.conns(), 1);
105
+ assert_eq!(t.seen.heads().len(), 1);
106
+ }
107
+
108
+ // ---- the exchange ----
109
+
110
+ #[tokio::test(flavor = "current_thread")]
111
+ async fn an_answer_over_tls_comes_back_raw_with_one_connection_and_connection_close() {
112
+ let pki = pki();
113
+ let t = target(
114
+ Tls::Trusted,
115
+ Act::Reply(http("200 OK", "application/json", OK_JSON)),
116
+ &pki,
117
+ )
118
+ .await;
119
+ let raw = send(&t.https(), None, &pki).await.unwrap();
120
+ assert_eq!(
121
+ (raw.status, raw.content_type.as_deref(), &raw.body[..]),
122
+ (200, Some("application/json"), OK_JSON.as_bytes())
123
+ );
124
+ let head = &t.seen.heads()[0];
125
+ assert!(
126
+ head.starts_with("post /0/private/addorder http/1.1\r\n"),
127
+ "{head}"
128
+ );
129
+ assert!(head.contains(&format!("host: 127.0.0.1:{}\r\n", t.addr.port())));
130
+ assert!(head.contains("connection: close\r\n"));
131
+ assert!(
132
+ head.contains("accept-encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3\r\n"),
133
+ "the gem's header"
134
+ );
135
+ assert!(head.ends_with("nonce=1&ordertype=market"));
136
+ assert_eq!(t.seen.conns(), 1);
137
+ }
138
+
139
+ #[tokio::test(flavor = "current_thread")]
140
+ async fn a_silent_venue_after_the_request_is_a_read_timeout_and_ambiguous() {
141
+ let pki = pki();
142
+ let t = target(Tls::Trusted, Act::Hang, &pki).await;
143
+ let r = send(&t.https(), None, &pki).await;
144
+ assert_eq!(
145
+ err(&r),
146
+ &Failure::new(Cause::ReadTimeout, "Net::ReadTimeout")
147
+ );
148
+ assert!(!err(&r).pre_transmission());
149
+ assert!(t.seen.request_bytes() > 0);
150
+ }
151
+
152
+ #[tokio::test(flavor = "current_thread")]
153
+ async fn a_slow_but_steady_answer_is_not_a_timeout() {
154
+ let pki = pki();
155
+ // 4 pieces, 130 ms apart: each wait is a third of the 400 ms read limit, the total is not.
156
+ let t = target(
157
+ Tls::Trusted,
158
+ Act::Trickle(http("200 OK", "application/json", OK_JSON), 130),
159
+ &pki,
160
+ )
161
+ .await;
162
+ assert_eq!(send(&t.https(), None, &pki).await.unwrap().status, 200);
163
+ }
164
+
165
+ #[tokio::test(flavor = "current_thread")]
166
+ async fn a_close_after_the_request_is_end_of_file_ambiguous_and_never_retried() {
167
+ let pki = pki();
168
+ let t = target(Tls::Plain, Act::Close, &pki).await;
169
+ let r = send(&t.http(), None, &pki).await;
170
+ assert_eq!(err(&r), &Failure::new(Cause::Eof, "end of file reached"));
171
+ tokio::time::sleep(Duration::from_millis(100)).await;
172
+ assert_eq!(t.seen.conns(), 1, "no second attempt");
173
+ }
174
+
175
+ #[tokio::test(flavor = "current_thread")]
176
+ async fn post_send_failures_are_never_pre_transmission() {
177
+ let pki = pki();
178
+ for (tls, act) in [
179
+ (Tls::Trusted, Act::Close),
180
+ (Tls::Plain, Act::Reset),
181
+ (Tls::Trusted, Act::Reset),
182
+ (Tls::Trusted, Act::Partial),
183
+ ] {
184
+ let t = target(tls, act, &pki).await;
185
+ let base = if matches!(tls, Tls::Plain) {
186
+ t.http()
187
+ } else {
188
+ t.https()
189
+ };
190
+ let r = send(&base, None, &pki).await;
191
+ assert!(!err(&r).pre_transmission(), "{:?}", err(&r));
192
+ assert!(t.seen.request_bytes() > 0);
193
+ assert_eq!(t.seen.conns(), 1);
194
+ }
195
+ }
196
+
197
+ // ---- the proxy ----
198
+
199
+ #[tokio::test(flavor = "current_thread")]
200
+ async fn a_refused_proxy_is_pre_transmission_and_names_the_proxy() {
201
+ let port = closed_port().await;
202
+ let r = send(
203
+ "https://api.kraken.com",
204
+ Some(&format!("http://127.0.0.1:{port}")),
205
+ &pki(),
206
+ )
207
+ .await;
208
+ assert_eq!(
209
+ err(&r),
210
+ &Failure::new(
211
+ Cause::Refused,
212
+ format!("connection refused: 127.0.0.1:{port}")
213
+ )
214
+ );
215
+ }
216
+
217
+ #[tokio::test(flavor = "current_thread")]
218
+ async fn a_proxy_refusing_connect_with_4xx_is_pre_transmission() {
219
+ let p = proxy(ProxyAct::Answer(
220
+ "HTTP/1.1 403 Filtered\r\nContent-Length: 0\r\n\r\n",
221
+ ))
222
+ .await;
223
+ let r = send(
224
+ "https://api.kraken.com",
225
+ Some(&format!("http://127.0.0.1:{}", p.addr.port())),
226
+ &pki(),
227
+ )
228
+ .await;
229
+ assert_eq!(
230
+ err(&r),
231
+ &Failure::new(Cause::ProxyRefused(403), "403 \"Filtered\"")
232
+ );
233
+ assert!(err(&r).pre_transmission());
234
+ assert!(p.seen.heads()[0].starts_with("connect api.kraken.com:443 http/1.1\r\n"));
235
+ }
236
+
237
+ #[tokio::test(flavor = "current_thread")]
238
+ async fn proxy_credentials_go_as_basic_auth_on_connect() {
239
+ let p = proxy(ProxyAct::Answer(
240
+ "HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n",
241
+ ))
242
+ .await;
243
+ let r = send(
244
+ "https://api.kraken.com",
245
+ Some(&format!("http://user:p%40ss@127.0.0.1:{}/", p.addr.port())),
246
+ &pki(),
247
+ )
248
+ .await;
249
+ assert_eq!(err(&r).cause, Cause::ProxyRefused(407));
250
+ assert!(
251
+ p.seen.heads()[0].contains("proxy-authorization: basic dxnlcjpwqhnz\r\n"),
252
+ "{}",
253
+ p.seen.heads()[0]
254
+ ); // user:p@ss, lowercased
255
+ }
256
+
257
+ #[tokio::test(flavor = "current_thread")]
258
+ async fn a_proxy_failing_connect_with_5xx_or_closing_is_ambiguous_as_rails_says() {
259
+ for (act, want) in [
260
+ (
261
+ ProxyAct::Answer(
262
+ "HTTP/1.1 500 Unable to connect\r\nContent-Type: text/html\r\nContent-Length: 5\r\n\r\nnope!",
263
+ ),
264
+ Failure::new(Cause::ProxyFailed, "500 \"Unable to connect\""),
265
+ ),
266
+ (
267
+ ProxyAct::Close,
268
+ Failure::new(Cause::Eof, "end of file reached"),
269
+ ),
270
+ ] {
271
+ let p = proxy(act).await;
272
+ let r = send(
273
+ "https://api.kraken.com",
274
+ Some(&format!("http://127.0.0.1:{}", p.addr.port())),
275
+ &pki(),
276
+ )
277
+ .await;
278
+ assert_eq!(err(&r), &want);
279
+ assert!(!err(&r).pre_transmission());
280
+ }
281
+ }
282
+
283
+ #[tokio::test(flavor = "current_thread")]
284
+ async fn a_proxy_that_never_answers_connect_is_an_open_timeout_not_a_hang() {
285
+ let p = proxy(ProxyAct::Hang).await;
286
+ let started = Instant::now();
287
+ let r = send(
288
+ "https://api.kraken.com",
289
+ Some(&format!("http://127.0.0.1:{}", p.addr.port())),
290
+ &pki(),
291
+ )
292
+ .await;
293
+ assert_eq!(
294
+ err(&r),
295
+ &Failure::new(Cause::OpenTimeout, "Net::OpenTimeout (proxy CONNECT)")
296
+ );
297
+ assert!(err(&r).pre_transmission(), "Ruling R2");
298
+ assert!(started.elapsed() < Duration::from_secs(2));
299
+ }
300
+
301
+ #[tokio::test(flavor = "current_thread")]
302
+ async fn a_tunnel_carries_tls_end_to_end() {
303
+ let pki = pki();
304
+ let t = target(
305
+ Tls::Trusted,
306
+ Act::Reply(http("200 OK", "application/json", OK_JSON)),
307
+ &pki,
308
+ )
309
+ .await;
310
+ let p = proxy(ProxyAct::Tunnel(t.addr)).await;
311
+ let raw = send(
312
+ &t.https_localhost(),
313
+ Some(&format!("http://127.0.0.1:{}", p.addr.port())),
314
+ &pki,
315
+ )
316
+ .await
317
+ .unwrap();
318
+ assert_eq!(raw.status, 200);
319
+ assert!(
320
+ p.seen.heads()[0].starts_with(&format!("connect localhost:{} http/1.1\r\n", t.addr.port()))
321
+ );
322
+ assert_eq!(t.seen.heads().len(), 1);
323
+ }
324
+
325
+ #[tokio::test(flavor = "current_thread")]
326
+ async fn a_silent_venue_behind_a_tunnel_is_a_read_timeout() {
327
+ let pki = pki();
328
+ let t = target(Tls::Trusted, Act::Hang, &pki).await;
329
+ let p = proxy(ProxyAct::Tunnel(t.addr)).await;
330
+ let r = send(
331
+ &t.https_localhost(),
332
+ Some(&format!("http://127.0.0.1:{}", p.addr.port())),
333
+ &pki,
334
+ )
335
+ .await;
336
+ assert_eq!(err(&r).cause, Cause::ReadTimeout);
337
+ }
338
+
339
+ // ---- timeouts measure socket waits under TLS (Ruling R21) ----
340
+
341
+ fn with(open: u64, read: u64, write: u64) -> Timeouts {
342
+ Timeouts {
343
+ open: Duration::from_millis(open),
344
+ read: Duration::from_millis(read),
345
+ write: Duration::from_millis(write),
346
+ }
347
+ }
348
+
349
+ #[tokio::test(flavor = "current_thread")]
350
+ async fn fragmented_tls_records_that_keep_arriving_are_not_a_timeout() {
351
+ let pki = pki();
352
+ let body = format!(
353
+ r#"{{"error":[],"result":{{"pad":"{}"}}}}"#,
354
+ "x".repeat(4000)
355
+ );
356
+ // One ~4 KB record in 512-byte pieces, 100 ms apart: each socket wait is a quarter of the
357
+ // 400 ms read limit, while the record takes about 800 ms to complete.
358
+ let t = target(
359
+ Tls::Relayed(Relay::Trickle {
360
+ chunk: 512,
361
+ ms: 100,
362
+ }),
363
+ Act::Reply(http("200 OK", "application/json", &body)),
364
+ &pki,
365
+ )
366
+ .await;
367
+ let tr = Transport::new(
368
+ &t.https(),
369
+ None,
370
+ with(5000, 400, 1000),
371
+ Some(pki.roots.clone()),
372
+ )
373
+ .unwrap();
374
+ let started = Instant::now();
375
+ let raw = tr.send(&post()).await.unwrap();
376
+ assert_eq!((raw.status, raw.body.len()), (200, body.len()));
377
+ assert!(
378
+ started.elapsed() > Duration::from_millis(400),
379
+ "the reply really outlasted the read limit"
380
+ );
381
+ }
382
+
383
+ #[tokio::test(flavor = "current_thread")]
384
+ async fn a_stall_inside_a_tls_record_is_a_read_timeout() {
385
+ let pki = pki();
386
+ let t = target(
387
+ Tls::Relayed(Relay::StallAfter(20)),
388
+ Act::Reply(http("200 OK", "application/json", OK_JSON)),
389
+ &pki,
390
+ )
391
+ .await;
392
+ let r = Transport::new(
393
+ &t.https(),
394
+ None,
395
+ with(5000, 400, 1000),
396
+ Some(pki.roots.clone()),
397
+ )
398
+ .unwrap()
399
+ .send(&post())
400
+ .await;
401
+ assert_eq!(
402
+ err(&r),
403
+ &Failure::new(Cause::ReadTimeout, "Net::ReadTimeout")
404
+ );
405
+ assert!(!err(&r).pre_transmission());
406
+ }
407
+
408
+ #[tokio::test(flavor = "current_thread")]
409
+ async fn a_progressing_upload_longer_than_the_read_limit_is_not_a_timeout() {
410
+ let pki = pki();
411
+ // The double's raw socket takes the first 1 MiB 64 KiB per 100 ms (about 1.6 s of steady
412
+ // progress, four times the 400 ms read limit), then drains the rest at full speed; the server
413
+ // answers as soon as the request is complete.
414
+ let relay = Relay::ThrottleUpload {
415
+ chunk: 64 << 10,
416
+ ms: 100,
417
+ budget: 1 << 20,
418
+ };
419
+ let t = target(
420
+ Tls::Relayed(relay),
421
+ Act::Reply(http("200 OK", "application/json", OK_JSON)),
422
+ &pki,
423
+ )
424
+ .await;
425
+ let big = Request {
426
+ body: Some("x".repeat(16 << 20)),
427
+ ..post()
428
+ };
429
+ let raw = Transport::new(
430
+ &t.https(),
431
+ None,
432
+ with(5000, 400, 2000),
433
+ Some(pki.roots.clone()),
434
+ )
435
+ .unwrap()
436
+ .send(&big)
437
+ .await
438
+ .unwrap();
439
+ assert_eq!(raw.status, 200);
440
+ let times = t.seen.times();
441
+ let transmission = times.request_done.unwrap() - times.first_byte.unwrap();
442
+ let latency = times.replied.unwrap() - times.request_done.unwrap();
443
+ assert!(
444
+ transmission > Duration::from_millis(400),
445
+ "{transmission:?}: the upload outlasted the read limit"
446
+ );
447
+ assert!(
448
+ latency < Duration::from_millis(100),
449
+ "{latency:?}: the reply was prompt"
450
+ );
451
+ }
452
+
453
+ #[tokio::test(flavor = "current_thread")]
454
+ async fn a_blocked_tls_write_is_a_write_timeout_sent_once() {
455
+ let pki = pki();
456
+ let t = target(Tls::Trusted, Act::ReadSomeThenStop(1024), &pki).await;
457
+ let big = Request {
458
+ body: Some("x".repeat(32 << 20)),
459
+ ..post()
460
+ };
461
+ // Distinct limits: a 5 s read limit cannot race the 300 ms write limit.
462
+ let r = Transport::new(
463
+ &t.https(),
464
+ None,
465
+ with(5000, 5000, 300),
466
+ Some(pki.roots.clone()),
467
+ )
468
+ .unwrap()
469
+ .send(&big)
470
+ .await;
471
+ assert_eq!(
472
+ err(&r),
473
+ &Failure::new(Cause::WriteTimeout, "Net::WriteTimeout")
474
+ );
475
+ assert!(!err(&r).pre_transmission());
476
+ assert!(
477
+ t.seen.request_bytes() >= 1024,
478
+ "part of the request reached the venue"
479
+ );
480
+ tokio::time::sleep(Duration::from_millis(100)).await;
481
+ assert_eq!(t.seen.conns(), 1, "never retried");
482
+ }
483
+
484
+ // ---- configuration ----
485
+
486
+ #[test]
487
+ fn a_bad_proxy_or_base_url_is_a_configuration_error_that_never_echoes_credentials() {
488
+ for proxy in [
489
+ "",
490
+ "socks5://h:1",
491
+ "https://h:1",
492
+ "http://user:secret@h:notaport",
493
+ ] {
494
+ let e = Transport::new(
495
+ "https://api.kraken.com",
496
+ Some(proxy),
497
+ Timeouts::default(),
498
+ None,
499
+ )
500
+ .err()
501
+ .unwrap();
502
+ assert!(!e.to_string().contains("secret"), "{e}");
503
+ }
504
+ assert!(Transport::new("ftp://api.kraken.com", None, Timeouts::default(), None).is_err());
505
+ assert!(
506
+ Transport::new(
507
+ "https://api.kraken.com/0/public",
508
+ None,
509
+ Timeouts::default(),
510
+ None
511
+ )
512
+ .is_err()
513
+ );
514
+ }
515
+
516
+ #[test]
517
+ fn the_defaults_are_the_gems() {
518
+ let t = Timeouts::default();
519
+ assert_eq!(
520
+ (t.open, t.read, t.write),
521
+ (
522
+ Duration::from_secs(5),
523
+ Duration::from_secs(30),
524
+ Duration::from_secs(10)
525
+ )
526
+ );
527
+ }
528
+
529
+ #[tokio::test(flavor = "current_thread")]
530
+ async fn malformed_connect_statuses_never_establish_a_tunnel() {
531
+ for response in [
532
+ "garbage 200 OK\r\n\r\n",
533
+ "HTTP/1.1 0200 OK\r\n\r\n",
534
+ "HTTP/1.1 200\r\nbroken\r\n\r\n",
535
+ ] {
536
+ let p = proxy(ProxyAct::Answer(response)).await;
537
+ let result = send("https://localhost", Some(&p.http()), &pki()).await;
538
+ assert_eq!(err(&result).cause, Cause::ProxyFailed);
539
+ assert!(!err(&result).pre_transmission());
540
+ assert_eq!(p.seen.conns(), 1);
541
+ }
542
+ }
543
+
544
+ #[test]
545
+ fn malformed_authorities_are_rejected_without_echoing_credentials() {
546
+ for base in ["https://[::1]suffix", "https://::1", "https://[localhost]"] {
547
+ assert!(Transport::new(base, None, quick(), None).is_err(), "{base}");
548
+ }
549
+ for proxy in [
550
+ "http://user:secret@host/path",
551
+ "http://host?query",
552
+ "http://host#fragment",
553
+ "http://[::1]suffix",
554
+ ] {
555
+ let failure = Transport::new("https://localhost", Some(proxy), quick(), None)
556
+ .err()
557
+ .expect("invalid proxy");
558
+ assert!(!failure.to_string().contains("secret"));
559
+ }
560
+ }
561
+
562
+ #[tokio::test(flavor = "current_thread")]
563
+ async fn raw_metadata_and_encoded_body_are_preserved() {
564
+ let pki = pki();
565
+ let t = target(Tls::Trusted, Act::Reply("HTTP/1.1 206 Partial Content\r\nContent-Encoding: gzip\r\nContent-Range: bytes 0-3/4\r\nContent-Length: 4\r\n\r\nraw!"), &pki).await;
566
+ let raw = send(&t.https(), None, &pki).await.unwrap();
567
+ assert_eq!(raw.status, 206);
568
+ assert_eq!(raw.content_type, None);
569
+ assert_eq!(raw.content_encoding.as_deref(), Some("gzip"));
570
+ assert!(raw.content_range);
571
+ assert_eq!(&raw.body[..], b"raw!");
572
+ }
573
+
574
+ #[tokio::test(flavor = "current_thread")]
575
+ async fn an_empty_request_still_enables_the_response_timeout() {
576
+ let pki = pki();
577
+ let t = target(Tls::Trusted, Act::Hang, &pki).await;
578
+ let req = Request {
579
+ method: "GET",
580
+ body: None,
581
+ ..post()
582
+ };
583
+ let result = Transport::new(&t.https(), None, quick(), Some(pki.roots.clone()))
584
+ .unwrap()
585
+ .send(&req)
586
+ .await;
587
+ assert_eq!(err(&result).cause, Cause::ReadTimeout);
588
+ assert!(!err(&result).pre_transmission());
589
+ assert_eq!(t.seen.conns(), 1);
590
+ }
591
+
592
+ #[tokio::test(flavor = "current_thread")]
593
+ async fn an_inconsistent_content_length_is_rejected_before_connecting() {
594
+ let pki = pki();
595
+ let t = target(Tls::Trusted, Act::Hang, &pki).await;
596
+ let mut req = post();
597
+ req.headers.push(("Content-Length".into(), "0".into()));
598
+ let tr = Transport::new(&t.https(), None, quick(), Some(pki.roots.clone())).unwrap();
599
+ let result = tokio::time::timeout(Duration::from_secs(1), tr.send(&req))
600
+ .await
601
+ .expect("must not hang with the response timer disabled");
602
+ assert_eq!(err(&result).cause, Cause::Other);
603
+ assert_eq!(t.seen.conns(), 0);
604
+ }
605
+
606
+ #[tokio::test(flavor = "current_thread")]
607
+ async fn request_shapes_what_send_received() {
608
+ use honeymaker_client::transport::Reply;
609
+ let pki = pki();
610
+ let t = target(
611
+ Tls::Trusted,
612
+ Act::Reply(http("200 OK", "application/json", OK_JSON)),
613
+ &pki,
614
+ )
615
+ .await;
616
+ let tr = Transport::new(&t.https(), None, quick(), Some(pki.roots.clone())).unwrap();
617
+ assert_eq!(
618
+ tr.request(&post()).await.unwrap(),
619
+ Reply::Parsed(serde_json::json!({"error": [], "result": {"a": 1}}))
620
+ );
621
+ }
622
+
623
+ // Binary replies exercise framing and inflation through the real send/request pipeline.
624
+ async fn encoded_response(
625
+ framing: honeymaker_client::transport::BodyFraming,
626
+ truncated: bool,
627
+ ) -> Transport {
628
+ use flate2::{Compression, write::GzEncoder};
629
+ use honeymaker_client::transport::BodyFraming;
630
+ use std::io::Write;
631
+ let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
632
+ encoder.write_all(OK_JSON.as_bytes()).unwrap();
633
+ let mut body = encoder.finish().unwrap();
634
+ if truncated {
635
+ body.truncate(12);
636
+ }
637
+ let mut wire = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Encoding: gzip\r\nConnection: close\r\n".to_vec();
638
+ match framing {
639
+ BodyFraming::ContentLength => {
640
+ wire.extend(format!("Content-Length: {}\r\n\r\n", body.len()).as_bytes())
641
+ }
642
+ BodyFraming::Chunked => wire
643
+ .extend(format!("Transfer-Encoding: chunked\r\n\r\n{:x}\r\n", body.len()).as_bytes()),
644
+ BodyFraming::CloseDelimited => wire.extend(b"\r\n"),
645
+ }
646
+ wire.extend(body);
647
+ if framing == BodyFraming::Chunked {
648
+ wire.extend(b"\r\n0\r\n\r\n");
649
+ }
650
+ wire_response(wire).await
651
+ }
652
+
653
+ async fn wire_response(wire: Vec<u8>) -> Transport {
654
+ use tokio::io::{AsyncReadExt, AsyncWriteExt};
655
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
656
+ let base = format!("http://{}", listener.local_addr().unwrap());
657
+ tokio::spawn(async move {
658
+ let (mut socket, _) = listener.accept().await.unwrap();
659
+ let mut request = vec![0; post().body.unwrap().len()];
660
+ let mut head = Vec::new();
661
+ while !head.ends_with(b"\r\n\r\n") {
662
+ head.push(socket.read_u8().await.unwrap());
663
+ }
664
+ socket.read_exact(&mut request).await.unwrap();
665
+ socket.write_all(&wire).await.unwrap();
666
+ socket.shutdown().await.unwrap();
667
+ });
668
+ Transport::new(&base, None, quick(), None).unwrap()
669
+ }
670
+
671
+ #[tokio::test(flavor = "current_thread")]
672
+ async fn send_preserves_body_framing() {
673
+ use honeymaker_client::transport::BodyFraming;
674
+ for framing in [
675
+ BodyFraming::ContentLength,
676
+ BodyFraming::Chunked,
677
+ BodyFraming::CloseDelimited,
678
+ ] {
679
+ let tr = encoded_response(framing, true).await;
680
+ let raw = tr.send(&post()).await.unwrap();
681
+ assert_eq!(raw.framing, framing);
682
+ assert_eq!(raw.body.len(), 12);
683
+ assert_eq!(raw.content_encoding.as_deref(), Some("gzip"));
684
+ }
685
+ }
686
+
687
+ #[tokio::test(flavor = "current_thread")]
688
+ async fn request_inflates_gzip_under_every_framing() {
689
+ use honeymaker_client::transport::{BodyFraming, Reply};
690
+ for framing in [
691
+ BodyFraming::ContentLength,
692
+ BodyFraming::Chunked,
693
+ BodyFraming::CloseDelimited,
694
+ ] {
695
+ let tr = encoded_response(framing, false).await;
696
+ assert_eq!(
697
+ tr.request(&post()).await.unwrap(),
698
+ Reply::Parsed(serde_json::json!({"error": [], "result": {"a": 1}}))
699
+ );
700
+ }
701
+ }
702
+
703
+ #[tokio::test(flavor = "current_thread")]
704
+ async fn request_with_truncated_content_length_gzip_matches_the_gem() {
705
+ use honeymaker_client::transport::{BodyFraming, Reply};
706
+ let tr = encoded_response(BodyFraming::ContentLength, true).await;
707
+ assert_eq!(tr.request(&post()).await.unwrap(), Reply::NotJson);
708
+ }
709
+
710
+ #[tokio::test(flavor = "current_thread")]
711
+ async fn request_with_truncated_chunked_gzip_matches_the_gem() {
712
+ use honeymaker_client::transport::{BodyFraming, Reply};
713
+ let tr = encoded_response(BodyFraming::Chunked, true).await;
714
+ assert_eq!(tr.request(&post()).await.unwrap(), Reply::NotJson);
715
+ }
716
+
717
+ #[tokio::test(flavor = "current_thread")]
718
+ async fn request_with_truncated_close_delimited_gzip_matches_the_gem() {
719
+ use honeymaker_client::transport::BodyFraming;
720
+ let tr = encoded_response(BodyFraming::CloseDelimited, true).await;
721
+ let f = tr.request(&post()).await.unwrap_err();
722
+ assert_eq!(f.cause, Cause::Other);
723
+ assert!(f.message.starts_with("Zlib::BufError:"));
724
+ assert!(!f.pre_transmission());
725
+ }
726
+
727
+ #[tokio::test(flavor = "current_thread")]
728
+ async fn a_non_chunked_transfer_encoding_is_close_delimited() {
729
+ use honeymaker_client::transport::BodyFraming;
730
+ let pki = pki();
731
+ let t = target(
732
+ Tls::Plain,
733
+ Act::Reply(
734
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: identity\r\nConnection: close\r\n\r\nraw!",
735
+ ),
736
+ &pki,
737
+ )
738
+ .await;
739
+ let raw = send(&t.http(), None, &pki).await.unwrap();
740
+ assert_eq!(raw.framing, BodyFraming::CloseDelimited);
741
+ assert_eq!(&raw.body[..], b"raw!");
742
+ }
743
+
744
+ async fn header_response(headers: &[u8], body: &[u8]) -> Transport {
745
+ let mut wire = format!(
746
+ "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: {}\r\n",
747
+ body.len()
748
+ )
749
+ .into_bytes();
750
+ wire.extend(headers);
751
+ wire.extend(b"\r\n");
752
+ wire.extend(body);
753
+ wire_response(wire).await
754
+ }
755
+
756
+ #[tokio::test(flavor = "current_thread")]
757
+ async fn repeated_gzip_headers_leave_the_body_encoded_and_fail_json_parsing() {
758
+ use flate2::{Compression, write::GzEncoder};
759
+ use honeymaker_client::transport::{Reply, decode};
760
+ use std::io::Write;
761
+ let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
762
+ encoder.write_all(OK_JSON.as_bytes()).unwrap();
763
+ let body = encoder.finish().unwrap();
764
+ let headers =
765
+ b"Content-Type: application/json\r\nContent-Encoding: gzip\r\nContent-Encoding: gzip\r\n";
766
+ let raw = header_response(headers, &body)
767
+ .await
768
+ .send(&post())
769
+ .await
770
+ .unwrap();
771
+ assert_eq!(raw.content_encoding.as_deref(), Some("gzip, gzip"));
772
+ assert_eq!(&decode(raw).unwrap().body[..], body);
773
+ assert_eq!(
774
+ header_response(headers, &body)
775
+ .await
776
+ .request(&post())
777
+ .await
778
+ .unwrap(),
779
+ Reply::Status {
780
+ status: 200,
781
+ message: String::from_utf8_lossy(&body).into_owned()
782
+ }
783
+ );
784
+ }
785
+
786
+ #[tokio::test(flavor = "current_thread")]
787
+ async fn repeated_content_types_are_joined_before_matching_json() {
788
+ use honeymaker_client::transport::Reply;
789
+ let headers = b"Content-Type: text/plain\r\nContent-Type: application/json\r\n";
790
+ let raw = header_response(headers, OK_JSON.as_bytes())
791
+ .await
792
+ .send(&post())
793
+ .await
794
+ .unwrap();
795
+ assert_eq!(
796
+ raw.content_type.as_deref(),
797
+ Some("text/plain, application/json")
798
+ );
799
+ assert_eq!(
800
+ header_response(headers, OK_JSON.as_bytes())
801
+ .await
802
+ .request(&post())
803
+ .await
804
+ .unwrap(),
805
+ Reply::Parsed(serde_json::json!({"error": [], "result": {"a": 1}}))
806
+ );
807
+ }
808
+
809
+ #[tokio::test(flavor = "current_thread")]
810
+ async fn non_ascii_header_values_are_retained_with_lossy_decoding() {
811
+ use honeymaker_client::transport::Reply;
812
+ let headers = b"Content-Type: application/\xff+json\r\nContent-Encoding: \xfe\r\n";
813
+ let raw = header_response(headers, OK_JSON.as_bytes())
814
+ .await
815
+ .send(&post())
816
+ .await
817
+ .unwrap();
818
+ assert_eq!(raw.content_type.as_deref(), Some("application/�+json"));
819
+ assert_eq!(raw.content_encoding.as_deref(), Some("�"));
820
+ assert_eq!(
821
+ header_response(headers, OK_JSON.as_bytes())
822
+ .await
823
+ .request(&post())
824
+ .await
825
+ .unwrap(),
826
+ Reply::Parsed(serde_json::json!({"error": [], "result": {"a": 1}}))
827
+ );
828
+ }