bevy_net_backend 0.2.0

Talk to your game's own backend from Bevy: HTTPS JSON requests, uploads and downloads to a file, (feature `ws`) named WebSocket connections, (feature `oauth`) OpenID Connect desktop sign-in and (feature `ssh`, admin tools) SSH commands and SFTP, typed answers as Bevy messages, exactly one answer per request, game-supplied credentials with redacted secrets, proxy and certificate settings, no tokio unless you enable `ssh`, and fake transports for tests.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
<p align="center">
  <img src="https://raw.githubusercontent.com/warmar94/bevy_net_backend/main/bevy_net_backend-cover.png"
       alt="bevy_net_backend: HTTP, WebSocket and SSH/SFTP for Bevy" width="100%">
</p>

<p align="center">
  <a href="#license"><img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg"></a>
  <a href="https://github.com/warmar94/bevy_net_backend/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/warmar94/bevy_net_backend/actions/workflows/ci.yml/badge.svg"></a>
  <a href="https://bevyengine.org"><img alt="Bevy 0.19.0" src="https://img.shields.io/badge/Bevy-0.19.0-informational"></a>
  <a href="https://crates.io/crates/ureq"><img alt="ureq 3.4.2" src="https://img.shields.io/badge/ureq-3.4.2-orange"></a>
  <a href="https://crates.io/crates/tungstenite"><img alt="tungstenite 0.30.0 (optional)" src="https://img.shields.io/badge/tungstenite-0.30.0%20(optional)-orange"></a>
  <a href="https://crates.io/crates/russh"><img alt="russh 0.63.3 (optional)" src="https://img.shields.io/badge/russh-0.63.3%20(optional)-orange"></a>
</p>

<p align="center"><b>HTTP, WebSocket and SSH/SFTP for Bevy</b>: fire a request, get exactly one typed answer back as a Bevy message.</p>

---

## What it is

`bevy_net_backend` connects a [Bevy](https://bevyengine.org) game to **its own backend**: the
servers behind accounts and logins, cloud saves, leaderboards, shops and inventories, friends
lists, chat, matchmaking and the other MMO-style services a game runs itself. It also lets admin
and developer tools reach those servers.

Three parts share one pattern: **a system fires a request and gets a `RequestId` back at once; a
few frames after that exactly one typed answer arrives as a Bevy message.**

- **HTTP** (default): calls to your HTTPS JSON API (Laravel, Express, Go, Django, FastAPI,
  ASP.NET, …) with your serde types, and file uploads as `multipart/form-data` (files streamed
  from disk, upload progress).
- **WebSocket** (feature `ws`): named long-lived connections for chat, lobbies, match events and
  server pushes, with reconnect and heartbeat built in.
- **SSH and SFTP** (features `ssh`, `sftp`), **for admin and developer tools only**: run commands on
  your servers and move files. Release builds refuse it unless the tool opts in.

The answer is the decoded value or an error that says what happened (network, TLS, timeout, an
HTTP status with the server's body, a decode error, cancelled, or shutdown when the app exits).
The network never blocks a frame, nothing is dropped silently, and bad input never panics.

```rust,no_run
use bevy::prelude::*;
use bevy_net_backend::prelude::*;
use serde::Deserialize;

#[derive(Deserialize, Clone, Debug)]
struct Profile {
    name: String,
    level: u32,
}

fn main() {
    App::new()
        .add_plugins((MinimalPlugins, BackendPlugin::new(HttpConfig::new("https://api.example.com"))))
        .add_json_response::<Profile>()
        .add_systems(Startup, |backend: Res<HttpClient>| {
            backend.get_json::<Profile>("/me");
        })
        .add_systems(Update, |mut answers: MessageReader<JsonResponse<Profile>>| {
            for answer in answers.read() {
                match &answer.result {
                    Ok(profile) => info!("{} is level {}", profile.name, profile.level),
                    Err(error) => warn!("could not load the profile: {error}"),
                }
            }
        })
        .run();
}
```

## Contents

- [What it is](#what-it-is)
- [Features at a glance](#features-at-a-glance)
- [What it guarantees](#what-it-guarantees)
- [Where it sits](#where-it-sits)
- [Backend compatibility](#backend-compatibility)
  - [Matching server](#matching-server)
- [Install](#install)
- [Quick start](#quick-start)
- [How to use it](#how-to-use-it)
  - [1. Configure the backend](#1-configure-the-backend)
  - [2. Typed JSON requests](#2-typed-json-requests)
  - [3. Raw requests and full control](#3-raw-requests-and-full-control)
  - [4. Reading answers and errors](#4-reading-answers-and-errors)
  - [5. Logging in: credentials](#5-logging-in-credentials)
  - [6. Cancel, in-flight tracking, app exit](#6-cancel-in-flight-tracking-app-exit)
  - [7. Plain http:// for local development, and proxies](#7-plain-http-for-local-development-and-proxies)
  - [8. Testing your game without a server](#8-testing-your-game-without-a-server)
  - [9. Your own transport](#9-your-own-transport)
  - [10. WebSocket connections (feature `ws`)](#10-websocket-connections-feature-ws)
  - [11. SSH commands and SFTP (feature `ssh`, admin / dev builds only)](#11-ssh-commands-and-sftp-feature-ssh-admin--dev-builds-only)
  - [12. File uploads (multipart)](#12-file-uploads-multipart)
  - [13. Downloads to a file](#13-downloads-to-a-file)
  - [14. Sign-in with Google or another OpenID Connect provider (feature `oauth`)](#14-sign-in-with-google-or-another-openid-connect-provider-feature-oauth)
- [How it works](#how-it-works)
- [TLS exception: the default build is not pure Rust](#tls-exception-the-default-build-is-not-pure-rust)
- [API reference](#api-reference)
- [Good to know](#good-to-know)
- [Versions](#versions)
- [Examples](#examples)
- [How it's tested](#how-its-tested)
- [FAQ](#faq)
- [License](#license)
- [Contributing](#contributing)

## Features at a glance

| Feature | Default | For | What it adds |
|---|---|---|---|
| `http` | yes | your HTTPS API, file uploads | The real transport, `UreqTransport`: ureq 3.4 on a few worker threads, rustls with ring's crypto and the Mozilla root certificates (webpki-roots). `multipart/form-data` uploads with `Multipart` (no extra dependency): bytes, files streamed from disk, typed parts (JSON), upload progress. Downloads streamed to a file (`HttpClient::download`) with progress and a SHA-256 / size check. ring compiles C and assembly (see [TLS exception](#tls-exception-the-default-build-is-not-pure-rust)). |
| `json` | yes | typed requests and answers | `get_json` / `post_json` / `send_json` / `post_multipart_json`, `JsonResponse<T>`, `OutgoingRequest::with_json`, `RawResponse::json`, `JsonBodyField` (serde + serde_json). |
| `gzip` | no | compressed answers | Accept gzip-compressed responses (ureq's decoder, flate2). |
| `ws` | no | live data: chat, lobbies, pushes | Named WebSocket connections: `WsClient`, `WsConnections`, the `Ws*` messages, `TungsteniteTransport` (tungstenite 0.30, sync, one thread per connection, rustls + ring, no permessage-deflate). With `json`: `JsonEnvelope`, `WsRequest`, `WsPushMessage`, `WsResponse<T>`, `WsPush<P>`. |
| `ssh` | no | **admin / dev tools only** | Named SSH connections that run commands: `SshClient`, `SshConnections`, the `Ssh*` messages, `RusshTransport` (russh 0.63, ring for the AEAD ciphers and RustCrypto for the rest; tokio on one private thread), strict known_hosts, key files / ssh-agent / `~/.ssh/config`. |
| `sftp` | no | admin / dev tools: files | SFTP on SSH connections (implies `ssh`): upload, download, list, create / remove directory, remove file, rename (russh-sftp). |
| `os-certificates` | no | a company CA or TLS-inspecting proxy installed on the machine | `TlsSettings::with_os_certificates(true)`: the operating system's certificate store and checks for `https://` and `wss://` instead of Mozilla's list (rustls-platform-verifier with ring; Windows, macOS / iOS, the system CA files on Linux / BSD, Android). Acts together with `http` and / or `ws`. |
| `oauth` | no | "Sign in with Google" and other OpenID Connect providers | The desktop sign-in: `OAuthClient`, `OAuthFlow`, the `OAuthSignInUrl` / `OAuthSignedIn` messages. PKCE, `state` and nonce, a one-time loopback redirect listener, the code exchange over `http` (implies `http` and `json`; no extra crate). |
| `ssh-rsa` | no | old RSA-only servers | RSA host keys and RSA key files for SSH (implies `ssh`; rsa-sha2-256/512, never SHA-1). Off by default: the `rsa` crate carries the unfixed Marvin timing advisory RUSTSEC-2023-0071. Without it, ed25519 and ECDSA keys work. |

Crates in the build (normal and build dependencies, this crate excluded, measured with
`cargo tree` on Windows; other platforms differ by a few platform crates):

| Features | Crates |
|---|---|
| `default-features = false` (types and fake transports only) | 68 |
| default (`http`, `json`) | 90 |
| default + `gzip` | 95 |
| default + `ws` | 104 |
| default + `ssh` | 209 |
| default + `ssh`, `sftp` | 219 |
| default + `ssh`, `ssh-rsa` | 212 |
| `ssh` without default features | 195 |
| default + `os-certificates` | 91 |
| all features | 229 |

Without `http` the crate still builds: every type, the `FakeHttpTransport` and your own
`HttpTransport` work, and requests without a transport are answered with `NoTransport`. The
default set is deliberately not empty: the crate exists to call an HTTPS JSON API, and it should
do that with no feature fiddling.

## What it guarantees

- **Every request gets exactly one answer:** success, or an error such as `Status` (the server's
  status, headers and body), `Network`, `Tls`, `Timeout`, `Decode`, `Cancelled`, `Shutdown` (the
  app exited), `NoTransport`, `RequestTooLarge` or `InvalidRequest`. HTTP requests, WebSocket
  requests, SSH commands and SFTP operations alike. A late result from the network after a cancel
  or a timeout is discarded, never delivered twice.
- **An answered request is never sent afterwards.** A request cancelled, timed out or answered
  `Shutdown` while it still waited (for a worker, a connection or a free channel) never goes out
  afterwards. Errors say honestly whether the request went out: `error.was_sent()` is `Some(false)`
  (never sent), `Some(true)` (the server has it) or `None` (unknown), and SSH answers carry
  `started`. Retry decisions can rely on it.
- **Real deadlines, bounded buffers, size limits.** A timeout covers the whole call, waiting
  included; a WebSocket or SSH connect is ONE deadline over TCP, TLS / key exchange and the
  handshake, so a server that trickles bytes cannot stretch it. Answers are capped (HTTP body
  10 MiB after gzip decoding, so a gzip bomb stops at the limit; WebSocket messages 1 MiB; SSH
  output 8 MiB; SFTP transfers 256 MiB) and so are requests (uploads 32 MiB and 256 parts, checked
  before anything is sent). Every default can be changed.
- **Never blocks a frame, never panics on bad input.** Requests run on the crate's own threads,
  not on Bevy's task pools; answers are written in `First`, so `PreUpdate` and `Update` read them
  in the frame they arrived. The one wait is the exit frame: at most 1 s, for running downloads
  to remove their part files. An invalid path, header, name or form is an `InvalidRequest` answer.
- **No ordering ceremony:** `HttpClient`, `WsClient` and `SshClient` are used through `Res<..>`
  (shared access), so any number of systems in any schedule can fire requests.
- **Secrets stay out of logs:** tokens and passwords are redacted from `Debug`, `Display` and the
  crate's own log lines, and the crate's `Secret` and request bodies overwrite their memory with
  zeros when they are dropped.
- **A proxy that was asked for is never gone around:** a request or connection that would have to
  use a proxy its transport cannot use (SOCKS) is answered `InvalidRequest`, never sent.
- **Safe defaults:** HTTPS only (plain `http://` only to `localhost` / `127.x.x.x` / `[::1]` unless
  you allow it), redirects not followed, a 15 s timeout, strict SSH host key checking with no
  trust-on-first-use.
- **No tokio unless you enable `ssh`**, and then only on one private thread. HTTPS uses rustls with
  ring, **no OpenSSL**, native-tls or aws-lc in any feature set. ring compiles C and assembly: see
  the [TLS exception](#tls-exception-the-default-build-is-not-pure-rust).
- **Testable offline:** `FakeHttpTransport`, `FakeWsTransport` and `FakeSshTransport` answer from
  scripts, so your game's systems can be tested headless without a server.

## Where it sits

This crate is the game talking to **your servers**, not players talking to each other.
Real-time gameplay between players (inputs, positions, replication at 30–60 Hz) belongs to UDP
netcode such as [bevy_replicon](https://crates.io/crates/bevy_replicon) with
[renet](https://crates.io/crates/renet). `bevy_net_backend` sits next to it: a typical online game
logs in, loads the save and joins matchmaking over HTTP, keeps a WebSocket open for chat and
lobby updates, plays the match over replicon, and posts the result over HTTP again. Nothing here
competes with the netcode for the frame or the socket.

## Backend compatibility

- **HTTP** works with any backend that speaks HTTPS and JSON (or raw bytes): Laravel / PHP,
  Express / Node, Go, Rust, Django, FastAPI, Spring, Rails, ASP.NET, serverless functions. Nothing
  is tailored to one framework. File uploads (`multipart/form-data`) were checked byte for byte
  against real PHP, Express + multer, FastAPI, Go and Django; frameworks differ in their limits
  and in how they read some file names, so read
  [what real backends do with an upload](#what-real-backends-do-with-an-upload).
- **WebSocket** (feature `ws`) works with any plain WebSocket server (RFC 6455), through the
  default JSON envelope or your own `WsProtocol` for another message layout.
- **Frameworks that run their own protocol on top of WebSocket** (Laravel Reverb / Pusher,
  Socket.IO, SignalR, Phoenix Channels): the crate speaks plain WebSocket frames, so such a
  protocol is implemented on top of it (`WsProtocol` or raw frames).
- **SSH** (feature `ssh`, admin / dev tools) works with any standard SSH server: OpenSSH on Linux,
  BSD, macOS or Windows, and other servers speaking SSH-2 with ed25519 or ECDSA host keys (RSA with
  feature `ssh-rsa`). Servers without strict key exchange (OpenSSH before 9.6, unless the
  distribution backported it) connect through AES-GCM, which the client prefers; only a server that
  offers nothing but ChaCha20-Poly1305 or CBC + encrypt-then-MAC is refused (see Terrapin in the
  SSH section). SFTP needs the server's `sftp` subsystem (OpenSSH's default).

### Matching server

For a ready-made server side, the [net_backend](https://github.com/warmar94/net_backend) stack (a
Rust game-server framework with its protocol and client crates) is built to pair with this crate.
Its `net_backend_protocol` crate has an optional `bevy_net_backend` feature that implements this
crate's `WsRequest`, `WsPushMessage` and `Credentials` for its messages and tokens, and turns every
HTTP route of the server into a typed request of this crate (`net_backend_protocol::bevy`).

## Install

```toml
[dependencies]
bevy_net_backend = { version = "0.2.0" }
```

Other sets:

```toml
# Also accept gzip-compressed answers.
bevy_net_backend = { version = "0.2.0", features = ["gzip"] }

# HTTP + WebSocket.
bevy_net_backend = { version = "0.2.0", features = ["ws"] }

# "Sign in with Google" (or another OpenID Connect provider) in the system browser.
bevy_net_backend = { version = "0.2.0", features = ["oauth"] }

# Trust the operating system's certificate store (opt-in at runtime, see Certificates).
bevy_net_backend = { version = "0.2.0", features = ["os-certificates"] }

# An admin / dev tool: SSH commands and SFTP (never in a build for players).
bevy_net_backend = { version = "0.2.0", features = ["ssh", "sftp"] }

# Only the types and the fake transport (e.g. a crate that brings its own transport).
bevy_net_backend = { version = "0.2.0", default-features = false }
```

The crate uses Bevy's sub-crates `bevy_app`, `bevy_ecs` and `bevy_time` 0.19.0 without default
features, so it adds no Bevy feature your game did not ask for.

## Quick start

1. Add `BackendPlugin` with your API's base URL.
2. Register each JSON answer type once: `app.add_json_response::<T>()`.
3. Fire requests from any system with `Res<HttpClient>`; keep the `RequestId` if you need to
   match the answer.
4. Read `JsonResponse<T>` (or `HttpResponse` for raw calls) with a `MessageReader`.

```rust,no_run
use bevy::prelude::*;
use bevy_net_backend::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Serialize)]
struct Score {
    level: u32,
    points: u64,
}

#[derive(Deserialize, Clone, Debug)]
struct Rank {
    rank: u32,
}

/// The request being waited for.
#[derive(Resource)]
struct Submitting(RequestId);

fn submit_score(backend: Res<HttpClient>, mut commands: Commands) {
    let id = backend.post_json::<Rank>("/scores", &Score { level: 3, points: 12_500 });
    commands.insert_resource(Submitting(id));
}

fn show_rank(mut answers: MessageReader<JsonResponse<Rank>>, submitting: Option<Res<Submitting>>) {
    let Some(submitting) = submitting else { return };
    for answer in answers.read().filter(|a| a.id == submitting.0) {
        match &answer.result {
            Ok(rank) => info!("you are #{}", rank.rank),
            Err(error) => warn!("score not submitted: {error}"),
        }
    }
}

fn main() {
    App::new()
        .add_plugins((MinimalPlugins, BackendPlugin::new(HttpConfig::new("https://api.example.com/v1"))))
        .add_json_response::<Rank>()
        .add_systems(Startup, submit_score)
        .add_systems(Update, show_rank)
        .run();
}
```

## How to use it

### 1. Configure the backend

`HttpConfig` holds the settings; `BackendPlugin::new(config)` inserts it as a resource.

```rust
use std::time::Duration;
use bevy_net_backend::{BackendPlugin, HttpConfig};

let config = HttpConfig::new("https://api.example.com/v1") // paths are appended to this
    .with_timeout(Duration::from_secs(10))                   // whole call; default 15 s
    .with_header("X-Game-Version", "1.4.2")                  // sent with every request
    .with_workers(2)                                         // worker threads; default 2, 1..=8
    .with_max_body_bytes(2 * 1024 * 1024);                   // default 10 MiB
assert!(config.validate().is_ok());
let plugin = BackendPlugin::new(config);
# let _ = plugin;
```

| Setting | Default | Read |
|---|---|---|
| base URL (`new`, `with_base_url`, `set_base_url`) | none: requests fail with `InvalidRequest` until set | per request |
| `with_timeout` | 15 s (`DEFAULT_TIMEOUT`), clamped to 1 ms ..= 1 h | per request |
| `with_header` / `without_header` | `User-Agent: bevy_net_backend/0.2.0` | per request |
| `allow_insecure_http` | `false` | per request |
| `with_max_body_bytes` | 10 MiB (`DEFAULT_MAX_BODY_BYTES`), at least 1 | per request |
| `with_workers` | 2 (`DEFAULT_WORKERS`), clamped to 1 ..= 8 (`MAX_WORKERS`) | once, at plugin build |

The base URL must be `http://` or `https://` with a host, and have no user name / password,
query or fragment. `HttpConfig::validate()` tells you what is wrong; the plugin logs it as a
warning. Change settings at runtime on the resource, for example after reading the game's own
settings file:

```rust
use bevy::prelude::*;
use bevy_net_backend::HttpConfig;

fn use_staging(mut config: ResMut<HttpConfig>) {
    config.set_base_url("https://staging.example.com/v1");
}
# let _ = use_staging;
```

#### Certificates: extra roots and the operating system's store

By default `https://` and `wss://` trust Mozilla's root certificates (webpki-roots). `TlsSettings`
changes that for both, given to the plugin with `with_tls`:

```rust
use bevy_net_backend::{BackendPlugin, HttpConfig, TlsSettings};

// A development server with a self-signed certificate (or its own CA): trust that certificate too.
let tls = TlsSettings::new().with_root_certificates_file("certs/dev-ca.pem");
// Or with PEM text the game already has: `.with_root_certificates_pem(pem)`.
assert!(tls.validate().is_err()); // the file does not exist here: `ConfigError::Tls` names it
let plugin = BackendPlugin::new(HttpConfig::new("https://localhost:8443")).with_tls(tls);
# let _ = plugin;
```

| `TlsSettings` | Trusts |
|---|---|
| `new()` (default) | Mozilla's root certificates (webpki-roots), checked by rustls |
| `with_root_certificates_pem(pem)` / `with_root_certificates_file(path)` | also every `CERTIFICATE` block of that PEM (other blocks, e.g. a key, are skipped); can be called more than once |
| `with_os_certificates(true)` (feature `os-certificates`) | the operating system's certificate store and its own checks (rustls-platform-verifier with ring) instead of Mozilla's list; extra roots are added on top (not on Android; there the verifier needs its JNI initialization first, see rustls-platform-verifier's documentation) |

- The PEM text and files are read and checked once, when the plugin builds its transports
  (`validate()` runs the same check earlier). Settings that cannot be used (a file that cannot be
  read, PEM without a certificate, a certificate that cannot be a root) are logged as a warning,
  and every `https://` request and `wss://` connection is answered with `InvalidRequest` (never
  sent). `http://` and `ws://` are not affected.
- A transport you insert yourself takes the settings in its constructor:
  `UreqTransport::with_tls(&config, &tls)`, `TungsteniteTransport::with_tls(&tls)`.
- A certificate the settings do not trust is a `Tls` error, before any request byte is sent; a
  WebSocket connection does not retry it (unless `WsReconnect::with_tls_retry(true)`).

### 2. Typed JSON requests

Register every answer type once, then use the typed calls. `T` is any
`serde::de::DeserializeOwned + Send + Sync + 'static` type; the request body is anything
`serde::Serialize`.

```rust
use bevy::prelude::*;
use bevy_net_backend::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Deserialize, Clone, Debug)]
struct Inventory {
    items: Vec<String>,
}

#[derive(Serialize)]
struct Craft<'a> {
    recipe: &'a str,
}

fn requests(backend: Res<HttpClient>) {
    // GET  /inventory               -> JsonResponse<Inventory>
    backend.get_json::<Inventory>("/inventory");
    // POST /craft  {"recipe":"axe"}  -> JsonResponse<Inventory>
    backend.post_json::<Inventory>("/craft", &Craft { recipe: "axe" });
    // Any method, with query, headers and a timeout -> JsonResponse<Inventory>
    let request = OutgoingRequest::patch("/inventory").with_query("merge", "true").with_json(&["torch"]);
    backend.send_json::<Inventory>(request);
}

let mut app = App::new();
app.add_plugins(BackendPlugin::new(HttpConfig::new("https://api.example.com")))
    .add_json_response::<Inventory>()
    .add_systems(Update, requests);
```

- The typed calls add `Accept: application/json` unless you set `Accept` yourself (Laravel
  answers validation errors as JSON only with it). `with_json` / `post_json` set
  `Content-Type: application/json`.
- A 2xx body is decoded into `T`. An empty body decodes as JSON `null`, so `()` and `Option<T>`
  accept a `204 No Content`.
- A type that was never registered is not sent: the request is answered on `HttpResponse`
  with `InvalidRequest` (naming the missing `add_json_response`) and an error is logged.
- A body that cannot be serialized is answered with `Encode` and never sent.

### 3. Raw requests and full control

Raw calls answer with `HttpResponse` (status, headers, bytes):

```rust
use bevy::prelude::*;
use bevy_net_backend::http::Method;
use bevy_net_backend::prelude::*;
use std::time::Duration;

fn raw(backend: Res<HttpClient>) {
    backend.get("/health");
    backend.request(Method::PUT, "/avatar", Some(vec![0x89, b'P', b'N', b'G']));
    backend.send(
        OutgoingRequest::post("/telemetry")
            .with_header("Content-Type", "text/csv")
            .with_body(b"frame,ms\n1,16.6\n".to_vec())
            .with_timeout(Duration::from_secs(30)),
    );
}
# let _ = raw;
```

`OutgoingRequest` has `new(method, path)` and `get` / `post` / `put` / `patch` / `delete`, plus
`with_query`, `with_header`, `with_body`, `with_json`, `with_timeout`, `without_credentials`. Paths
start with `/` and are appended to the base URL; absolute URLs and a `?` in the path are refused
(use `with_query`, which percent-encodes names and values). So is anything a server could resolve
outside the base URL's path, at every level of percent-decoding: `.` / `..` segments (split on `/`
and `\`, `..;` included), backslashes, encoded separators (`%2F`, `%5C`) and control characters
(`%00` included). A path is sent as written: percent-encode user text you put into it. The methods
sent are the standard ones except `CONNECT` (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD` without
a body, `OPTIONS`, `TRACE`). An invalid header, method or path does not panic: the request is
answered with `InvalidRequest` and never sent.

### 4. Reading answers and errors

```rust
use bevy::prelude::*;
use bevy_net_backend::http::StatusCode;
use bevy_net_backend::prelude::*;
use serde::Deserialize;

#[derive(Deserialize, Clone, Debug)]
struct Save {
    id: u64,
}

/// A typical error body (Laravel: `message` + `errors`).
#[derive(Deserialize, Debug)]
struct ApiError {
    message: String,
}

fn on_save(mut answers: MessageReader<JsonResponse<Save>>) {
    for answer in answers.read() {
        match &answer.result {
            Ok(save) => info!("saved as #{}", save.id),
            Err(BackendError::Status(response)) if response.status == StatusCode::UNPROCESSABLE_ENTITY => {
                let message = response.json::<ApiError>().map(|e| e.message).unwrap_or_default();
                warn!("the server refused the save: {message}");
            }
            Err(error) if error.status() == Some(StatusCode::UNAUTHORIZED) => warn!("log in again"),
            Err(BackendError::Timeout(_) | BackendError::Network(_)) => warn!("offline? try again"),
            Err(error) => error!("save failed: {error}"),
        }
    }
}
# let _ = on_save;
```

| Error | When | Sent? |
|---|---|---|
| `InvalidRequest(why)` | bad path, header, base URL, unregistered JSON type, credentials that cannot apply | no |
| `InsecureHttp { host }` | plain text (`http://`) to a non-loopback host without `allow_insecure_http` | no |
| `Encode(why)` | the JSON body cannot be serialized | no |
| `Network(why)` | DNS, connect, reset, protocol error, worker threads not starting (ureq's words) | no for a DNS / connect / worker-start failure, else maybe |
| `Tls(why)` | TLS failure: handshake, certificate (rustls' / ureq's words) | no for a handshake or certificate failure (before any request byte), else maybe |
| `Timeout(why)` | the timeout ran out; it counts from hand-over to the transport, waiting for a free worker included | no if `why` starts with `not sent:`, else maybe |
| `BodyTooLarge { limit }` | the ANSWER is over its limit: the response body (a download: its file; SSH: the command's output or an SFTP download) | yes |
| `RequestTooLarge { limit, size }` | the REQUEST is over its limit and was refused before anything was sent: a multipart form over `Multipart::with_max_bytes`, a WebSocket request over the message limit, an SSH command line over 64 KiB, an SFTP upload over the transfer limit | no |
| `Status(response)` | a status outside 200–299, 3xx included (redirects are not followed) | yes |
| `Decode { message, response }` | a 2xx body that is not the expected JSON | yes |
| `Cancelled` | `HttpClient::cancel` | no if it was still waiting for a worker, else maybe |
| `Shutdown` | the app exited (`AppExit`) first | no, unless it was already on the wire before the exit frame |
| `NoTransport` | no `HttpTransportRes`, or it was removed / replaced first | no if it was still waiting for a worker, else maybe |
| `Disconnected { reason, sent }` | WebSocket and SSH: the connection went away, was closed by the game, or never opened (see [WebSocket](#10-websocket-connections-feature-ws), [SSH](#11-ssh-commands-and-sftp-feature-ssh-admin--dev-builds-only)) | as `sent` says: `Some(true)` it went out before, `Some(false)` never, `None` unknown |
| `Closed { code, reason }` | WebSocket only, on `WsStateChanged` / `WsConnectionInfo`: the server closed with a close frame | – |
| `Rejected(rejection)` | WebSocket only: the server answered the request with an error; `rejection.bytes()` / `text()` / `json()` (`Debug` / `Display` do not show it) | yes |
| `HostKey { host, fingerprint, problem }` | SSH only: the server's host key is unknown, changed or revoked (`HostKeyProblem`) | no |
| `AuthFailed(why)` | SSH only: no configured key was accepted (or none could be loaded) | no |
| `Ssh(why)` | SSH only: a protocol error, a refused channel / exec / subsystem, an SFTP status (the server's words) | see `SshFinished::started` |
| `OAuth(why)` | sign-in only (feature `oauth`): the player declined, the token endpoint refused the code, or its answer had no ID token (the provider's error code, never a code or token) | – |

`error.was_sent()` sums the column up: `Some(false)` never sent, `Some(true)` the server has it
(or had it before a loss), `None` maybe.

"Waiting for a worker" is the `UreqTransport` queue: a request answered while it waits there is
never sent afterwards. One already on the wire may still reach the server; its result is
discarded.

`BackendError` is `#[non_exhaustive]`: keep a catch-all arm. `error.status()` and `error.response()`
give the server's answer for `Status` and `Decode`; `RawResponse` has `status`, `headers`, `body`,
`text()` and `json::<E>()`. `error.retry_after()` is the `Retry-After` header of a `Status` answer
(delta-seconds, e.g. a 429 or 503) as a `Duration`, else `None`. `Display` of every error is safe to
log: it never shows a body, a header value or a query. The `message` field of `Decode` is
serde_json's text and may quote part of the body (a token, say); the crate never logs it, and
neither should a release build.

### 5. Logging in: credentials

Log in with an ordinary request, then store the token. From then on every request carries it
(applied last, after the config's default headers and the request's own headers):

```rust
use bevy::prelude::*;
use bevy_net_backend::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Serialize)]
struct Login<'a> {
    email: &'a str,
    password: &'a str,
}

#[derive(Deserialize, Clone, Debug)]
struct Token {
    token: String,
}

fn log_in(backend: Res<HttpClient>) {
    // The login call must not carry an old token.
    let request = OutgoingRequest::post("/login")
        .with_json(&Login { email: "player@example.com", password: "from-the-login-form" })
        .without_credentials();
    backend.send_json::<Token>(request);
}

fn on_login(mut answers: MessageReader<JsonResponse<Token>>, mut credentials: ResMut<BackendCredentials>) {
    for answer in answers.read() {
        if let Ok(login) = &answer.result {
            credentials.set(BearerToken::new(login.token.clone()));
        }
    }
}

fn log_out(mut credentials: ResMut<BackendCredentials>) {
    credentials.clear();
}
# let _ = (log_in, on_login, log_out);
```

| Credentials | Sends | Typical backend |
|---|---|---|
| `BearerToken::new(token)` | `Authorization: Bearer <token>` | Laravel Sanctum / Passport, JWT APIs, most Node / Go APIs |
| `ApiKeyHeader::new("X-Api-Key", key)` | `X-Api-Key: <key>` (any header name) | API gateways, simple game servers |
| `ApiKeyQuery::new("api_key", key)` | `?api_key=<key>` | APIs that only take a query key |
| `JsonBodyField::new("token", token)` (feature `json`) | `"token": "<token>"` added to JSON-object bodies | APIs that read the token from the body |

Anything else is one small trait:

```rust
use bevy_net_backend::{Credentials, OutgoingRequest, Secret};
use bevy_net_backend::http::HeaderValue;

/// Two headers: a player id and a session key.
struct Session {
    player: String,
    key: Secret,
}

impl Credentials for Session {
    fn apply(&self, request: &mut OutgoingRequest) {
        match (HeaderValue::try_from(self.player.as_str()), HeaderValue::try_from(self.key.expose())) {
            (Ok(player), Ok(mut key)) => {
                key.set_sensitive(true);
                request.headers_mut().insert("x-player", player);
                request.headers_mut().insert("x-session-key", key);
            }
            // Refused requests are answered with InvalidRequest; never put the secret in the reason.
            _ => request.reject("the session is not a valid header value"),
        }
    }
}
```

- `Secret` prints as `<redacted>` in `Debug` and `Display`; read it with `expose()`. When it is
  dropped, its whole allocation is overwritten with zeros first (the `zeroize` crate, which
  rustls already uses), and so are the crate's temporary `Bearer …` header text and the SSH key
  file text it reads. Request bodies (`with_json`, `with_body`, `set_body`, an in-memory
  `with_multipart` form and the in-memory pieces of a streamed one, the body `JsonBodyField`
  writes) are held in `WipedBytes`, overwritten with zeros once the request is answered and
  dropped; JSON is written into an exactly sized buffer, so no reallocation leaves a copy.
  `TungsteniteTransport` writes the WebSocket handshake request and the first-message
  authentication frame from buffers it wipes after the write, and wipes the authentication text it
  was given. Not wiped: the copies that become part of a request (header values, the query value
  of `ApiKeyQuery` and the URL built from it, keyboard-interactive answers and passwords handed to
  russh), the value you serialized or the `Vec` you built a body from before handing it over, the
  `String` you built the secret from, and what the HTTP, TLS and SSH libraries copy while sending.
  `BackendCredentials`, `BearerToken`, `ApiKeyHeader`, `ApiKeyQuery` and `JsonBodyField` never
  show the secret in `Debug`; `OutgoingRequest` and `PreparedRequest` print header names but no
  header values, no query values and no body; `RawResponse` prints the body's length only.
- The crate's own log lines never contain a header value, a query string or a body.
- **Dependency logs at `trace` contain secrets.** At `trace` level the HTTP client logs raw request
  and response bytes (`ureq_proto`) and full paths with queries (`ureq`): `Authorization` headers,
  login bodies, tokens in answers. Keep those targets below `trace`, e.g.
  `RUST_LOG=trace,ureq=debug,ureq_proto=debug`, or in Bevy
  `LogPlugin { filter: "wgpu=error,naga=warn,ureq=debug,ureq_proto=debug".into(), ..default() }`.
  Bevy's default level (`info`) is safe.
- **WebSocket credentials never reach tungstenite's logs.** tungstenite logs the handshake request
  and the content of every frame it sends or receives at `trace` (through the `log` crate). The
  crate writes the handshake request (credential headers, an `ApiKeyQuery` key in the URL) and the
  first-message authentication frame itself, so neither passes through tungstenite. Other frames
  do: the messages your game sends and receives (requests, answers, pushes, and a credential if
  your game puts one into a message of its own) appear in tungstenite's `trace` lines; keep
  `tungstenite=debug` in the filter to leave them out.
- **`ApiKeyQuery` secrets end up in access logs.** A query key is part of the URL, so reverse
  proxies and servers log it: Caddy's access log, for example, shows `api_key=…` (and an
  `X-Api-Key` header) in plain text while it masks `Authorization`. Prefer `BearerToken` (or a
  header your proxy redacts) wherever your API allows it.
- Compatibility rule: methods are only ever added to `Credentials` with a default
  implementation.
- Refreshing a token is the game's job (its own refresh call). A WebSocket connection can wait
  for that refresh after the server refused its token (`WsSettings::with_credentials_refresh`,
  see [WebSocket](#10-websocket-connections-feature-ws)).

#### Keeping a token between runs: `SecretFile`

`SecretFile` keeps one `Secret` (for example the refresh token of the game's own login) in a file,
so the next start can log in again without the password. The crate only stores and loads it; the
login and the refresh stay the game's own calls.

```rust,no_run
use bevy_net_backend::{Secret, SecretFile};

let file = SecretFile::new("saves/refresh-token");
file.save(&Secret::new("from-the-login-answer"))?; // after the game's login
if let Some(refresh) = file.load()? {
    // the game's own refresh request with `refresh.expose()`, then `BackendCredentials::set`
#   let _ = refresh;
}
file.remove()?; // on logout
# Ok::<(), std::io::Error>(())
```

- **Atomic:** `save` writes a new file next to the old one, flushes it to disk and renames it over
  the old one, so a crash never leaves half a file. A missing folder is created.
- **Owner-only:** on Unix the file is created with mode `0600` and a missing folder with `0700`;
  loading a file other users can read logs a warning, and the next `save` writes it `0600`. On
  Windows the file gets the permissions it inherits from its folder: under the user's profile
  (`%APPDATA%\<game>\` or `%LOCALAPPDATA%\<game>\`) that is the user, `SYSTEM` and the
  Administrators group, so keep the file there.
- **Wiped, never logged:** the crate's buffers holding the secret are overwritten with zeros when
  dropped (the loaded `Secret` too, like every `Secret`). Errors and log lines name the file and
  the problem, never the content.
- **Checked:** the file starts with a line naming its format. A file without it (damaged, or not
  written by `SecretFile`), one whose secret is not UTF-8, or one over 64 KiB is refused with
  `std::io::ErrorKind::InvalidData`; a missing file is `Ok(None)`; `remove` of a missing file is
  `Ok(())`.
- It does blocking file I/O: use it at startup, on login and logout, not every frame.

### 6. Cancel, in-flight tracking, app exit

```rust
use bevy::prelude::*;
use bevy_net_backend::prelude::*;

#[derive(Resource)]
struct Search(RequestId);

fn new_search(backend: Res<HttpClient>, old: Option<Res<Search>>, mut commands: Commands) {
    if let Some(old) = old {
        backend.cancel(old.0); // answered with Cancelled; a late result is discarded
    }
    commands.insert_resource(Search(backend.get("/search")));
}

fn spinner(in_flight: Res<InFlight>) {
    if !in_flight.is_empty() {
        // show "saving…"; in_flight.contains(id), len(), ids(), describe(id) also exist
    }
}
# let _ = (new_search, spinner);
```

- **Cancel:** answered with `Cancelled` in the next frame's `First`. A request already on the
  wire keeps its worker thread until it finishes or times out (a blocking call cannot be
  interrupted); its result is discarded. Cancelling an answered id does nothing.
- **InFlight** lists every request still waiting for its answer: HTTP, WebSocket (feature `ws`), SSH
  / SFTP (feature `ssh`) and sign-ins (feature `oauth`) alike. An HTTP request enters it in
  `PostUpdate` of the frame it was made in; a WebSocket request there too, also while it waits for
  its connection. A request leaves it when it is answered; the answer message follows in `First` (of
  the same frame, or of the next one for answers decided in `PostUpdate`, such as a cancel).
  `describe(id)` returns a `RequestInfo`: `kind` (`Http`, `WebSocket`, `Ssh`, `Sftp` or `OAuth`),
  `method` (HTTP), `target` (the path without the query, the connection's name, or a sign-in's
  authorization endpoint without its query; never an SSH command line).
- **One cancel for everything:** `HttpClient::cancel(id)` cancels HTTP, WebSocket and SSH / SFTP
  requests and sign-ins alike (`WsClient::cancel`, `SshClient::cancel` and `OAuthClient::cancel`
  are the same call).
- **App exit:** in the frame an `AppExit` message is written, nothing new is sent:
  `BackendSystems::Send` hands no request to the transport, and `BackendSystems::Exit` (in `Last`)
  stops the worker threads, then answers every open request with `Shutdown` (results that already
  arrived are delivered as they are). It does not wait for busy requests, except that running
  downloads get up to 1 s to stop and remove their part files (see
  [Downloads](#13-downloads-to-a-file)). **A request answered `Shutdown` was never sent**, except
  one that was already on the wire before that frame (it may still reach the server). Systems
  ordered after `BackendSystems::Exit` in `Last` can read those answers. Write `AppExit` before
  `BackendSystems::Send` (anywhere in `Update` or earlier is fine; a `PostUpdate` writer must be
  ordered `.before(BackendSystems::Send)`): written after that, requests of that frame may still go
  out, and written after `Exit` in `Last` it is seen by nobody in this crate.
- **Save on quit:** send the save, wait for its answer (`Ok` or an error), and only then write
  `AppExit`. A save fired in the same frame as `AppExit` is answered `Shutdown` and never sent.

### 7. Plain http:// for local development, and proxies

`http://localhost`, `http://127.x.x.x` and `http://[::1]` work out of the box, for
`php artisan serve`, `npm run dev`, `go run .` and the like. Any other plain `http://` host is
refused with `InsecureHttp` (nothing is sent), because plain HTTP shows tokens to everyone on
the path. For a dev server on a trusted LAN:

```rust
use bevy_net_backend::HttpConfig;

let config = HttpConfig::new("http://192.168.1.20:8000/api").allow_insecure_http(true);
# let _ = config;
```

**Proxies.** Loopback requests never use a proxy. Which proxy the others use is a
`ProxySettings`, given to the plugin with `with_proxy` (features `http` / `ws`; it also applies to
WebSocket connections and to the code exchange of a sign-in):

| `ProxySettings` | Proxy |
|---|---|
| `from_env()` (default) | the first of `ALL_PROXY` / `HTTPS_PROXY` / `HTTP_PROXY` (lowercase names too) that holds a proxy URL, read when the transport is created; `NO_PROXY` (comma separated: `*`, `.example.com` / `*.example.com` for subdomains, `10.0.*` for a prefix, exact hosts) skips it |
| `url("http://host:port")` | this proxy instead of the environment's (`NO_PROXY` does not apply); `http://user:password@host:port` sends `Proxy-Authorization: Basic` |
| `direct()` | always direct |

```rust
use bevy_net_backend::{BackendPlugin, HttpConfig, ProxySettings};

let proxy = ProxySettings::url("http://proxy.example.com:3128");
assert!(proxy.validate().is_ok()); // `ConfigError::Proxy` for a URL that is not usable
let plugin = BackendPlugin::new(HttpConfig::new("https://api.example.com")).with_proxy(proxy);
# let _ = plugin;
```

- HTTP requests go through `http://` and `https://` proxies as a `CONNECT` tunnel (TLS for an
  `https://` server runs end to end inside it). With a SOCKS proxy set, a request that would use it
  is answered `InvalidRequest` and never made around the proxy; so is every request that would use
  a `url(…)` that is not a proxy URL.
- A proxy that refuses the tunnel (e.g. `407`) or cannot be reached is a `Network` error.
- WebSocket connections (feature `ws`) follow the same settings through an `http://` proxy (see
  [WebSocket](#10-websocket-connections-feature-ws)).
- To connect directly whatever the environment says (as WebSocket connections did in 0.1.0):
  `BackendPlugin::with_proxy(ProxySettings::direct())`.
- A transport you create yourself takes its own: `UreqTransport::with_settings(&config, &tls,
  &proxy)`, `TungsteniteTransport::with_settings(&tls, &proxy)`.

### 8. Testing your game without a server

Insert a `FakeHttpTransport` before (or after) the plugin. It answers from scripted routes and
records every request, so your game's systems can be tested headless and offline:

```rust
use bevy::prelude::*;
use bevy_net_backend::http::{Method, StatusCode};
use bevy_net_backend::prelude::*;
use bevy_net_backend::{HttpTransportRes, FakeHttpTransport, RawResponse};

let fake = FakeHttpTransport::new();
fake.route(Method::GET, "/v1/me", Ok(RawResponse::new(StatusCode::OK, r#"{"name":"Ayla"}"#)));

let mut app = App::new();
app.add_plugins(MinimalPlugins)
    .insert_resource(HttpTransportRes::new(fake.clone()))
    .add_plugins(BackendPlugin::new(HttpConfig::new("https://api.example.com/v1")));

let id = app.world().resource::<HttpClient>().get("/me");
app.update(); // PostUpdate: handed to the fake
app.update(); // First: answered, readable in Update

let (_, request) = fake.last_request().expect("a request");
assert_eq!(request.uri.to_string(), "https://api.example.com/v1/me");
let answers = app.world().resource::<Messages<HttpResponse>>();
let mut cursor = answers.get_cursor();
let answer = cursor.read(answers).find(|a| a.id == id).expect("an answer");
assert_eq!(answer.result.as_ref().map(|r| r.text()).ok(), Some(r#"{"name":"Ayla"}"#.to_string()));
```

- `route(method, path, result)`: every matching request (method + URL path, newest route first)
  is answered with a copy of `result` on the next poll. `result` can be an error, e.g.
  `Err(BackendError::Network("reset".into()))`.
- Unrouted requests wait: answer them with `reply(id, result)`, or let the plugin's deadline
  answer them with `Timeout`. `waiting()`, `requests()`, `last_request()`, `cancelled()`,
  `shutdown_count()` let tests assert what the game sent; a form with files from disk is in
  `PreparedRequest::streaming_body` (`read_all()` gives its bytes). `progress(id, sent, total)`
  scripts an `HttpProgress` message.
- The crate's own tests use `bevy_headless_test`'s strict `TestApp` (ambiguity detection on every
  main schedule); the plugin's systems are ordered, and `Res<HttpClient>` never conflicts.

### 9. Your own transport

`HttpTransport` is the seam: `submit(id, PreparedRequest)` (never block), `poll()` (every result
since the last call), and optionally `cancel(id)`, `shutdown()`, `poll_progress()` (upload
progress for requests with `upload_progress`) and `streams_bodies()` (`true` if it sends a
`PreparedRequest::streaming_body`; with the default `false` the plugin answers such a request
`InvalidRequest` and never hands it over). Wrap it in `HttpTransportRes::new(..)` and insert it;
the plugin keeps doing all the bookkeeping (deadlines, cancel, exit, status and body-limit rules).
Report each request at most once; a late or unknown result is discarded. Compatibility rule:
methods are only ever added to `HttpTransport` with a default implementation.

### 10. WebSocket connections (feature `ws`)

For live data (chat, lobbies, match events, server pushes) open a **named** WebSocket
connection. Most games open one, `"main"`; a game that needs more simply opens another name.
Everything is keyed by that name: the state in `WsConnections`, the messages, the requests.

```toml
bevy_net_backend = { version = "0.2.0", features = ["ws"] }
```

```rust,no_run
use bevy::prelude::*;
use bevy_net_backend::prelude::*;
use serde::{Deserialize, Serialize};

/// A request: `{"id":7,"type":"chat.send","data":{"text":…}}` → `{"id":7,"ok":true,"data":{…}}`.
#[derive(Serialize)]
struct ChatSend {
    text: String,
}

#[derive(Deserialize, Clone, Debug)]
struct ChatAck {
    accepted: bool,
}

impl WsRequest for ChatSend {
    type Response = ChatAck;
    const KIND: &'static str = "chat.send";
}

/// A server push: `{"type":"chat.message","data":{"from":…,"text":…}}`.
#[derive(Deserialize, Clone, Debug)]
struct ChatMessage {
    from: String,
    text: String,
}

impl WsPushMessage for ChatMessage {
    const KIND: &'static str = "chat.message";
}

fn connect(ws: Res<WsClient>) {
    ws.connect("main", WsSettings::new("wss://game.example.com/ws"));
}

fn on_state(mut changes: MessageReader<WsStateChanged>, ws: Res<WsClient>) {
    for change in changes.read() {
        if change.state == WsState::Connected {
            ws.request(&change.name, &ChatSend { text: "hello".into() });
        }
    }
}

fn on_chat(mut pushes: MessageReader<WsPush<ChatMessage>>, mut acks: MessageReader<WsResponse<ChatAck>>) {
    for push in pushes.read() {
        info!("[{}] {}: {}", push.name, push.data.from, push.data.text);
    }
    for ack in acks.read() {
        if let Err(error) = &ack.result {
            warn!("chat.send failed: {error}");
        }
    }
}

fn main() {
    App::new()
        .add_plugins((MinimalPlugins, BackendPlugin::default()))
        .add_ws_request::<ChatSend>()
        .add_ws_push::<ChatMessage>()
        .add_systems(Startup, connect)
        .add_systems(Update, (on_state, on_chat))
        .run();
}
```

- **`WsClient`** (a resource, `Res<WsClient>`, no ordering needed): `connect(name, settings)`,
  `disconnect(name)`, `send_text` / `send_binary` / `send` (fire and forget), `request::<R>`
  (typed, feature `json`), `request_raw(name, WsOutgoing)`, `cancel(id)`. Applied in
  `PostUpdate` (`BackendSystems::Send`).
- **Messages out**, all written in `First` of the frame they arrive and all carrying the
  connection's `name`: `WsStateChanged { name, state, error }`, `WsMessage { name, frame }` (every
  data frame the server sends, text or binary), `WsResponse<T>` / `WsRawResponse` (answers), and
  `WsPush<P>` (typed pushes).
- **State:** `WsConnections` (resource): `state(name)`, `is_connected(name)`, `get(name)` →
  `WsConnectionInfo` (state, failed attempts, last error, pending requests, queued frames).
  `WsState` is `Connecting`, `Connected`, `Reconnecting { attempt, retry_in }`,
  `WaitingForCredentials` (see below) or `Disconnected` (`#[non_exhaustive]`). Not Bevy
  `States`: a game maps it to its own states if it wants.
- **`WsSettings`** (builder): read timeout (default 20 ms, 5–250 ms; it is also roughly the latency
  added to every frame you send, because the thread sends between reads: measured median request
  round trips through a TLS proxy were 30 ms at 5 ms, 43 ms at the default 20 ms and 118 ms at
  100 ms, against about 20 ms for HTTP), connect timeout (10 s, ONE deadline for TCP + proxy tunnel +
  TLS + handshake, at most 1 h), heartbeat (ping every 15 s, dead after 45 s without a single byte,
  at most 1 h), request timeout (10 s), message limit (1 MiB, incoming and outgoing), reconnect
  policy, handshake headers, `allow_insecure_ws`, `without_credentials`, the protocol, outbox (64
  frames), resend (32) and waiting (64 requests) limits, `with_auth_ack`,
  `with_credentials_refresh`.
- **Reconnect:** exponential backoff with full jitter (`WsReconnect`: base 500 ms, cap 30 s,
  optional `with_max_attempts`, reset after 10 s connected, `never()`). Each attempt is a
  `WsStateChanged` with `Reconnecting { attempt, retry_in }` and the error that caused it. A
  handshake refused with `429` / `503` and a `Retry-After` header waits at least that long (above
  the cap too, at most `MAX_TIMEOUT`).
  **Permanent (not retried, the connection goes `Disconnected` with the error):** a `401` / `403`
  handshake, a TLS / certificate error (unless `WsReconnect::with_tls_retry(true)`), a close code
  4000–4099, a refused first-message auth, a missing auth acknowledgement (`with_auth_ack`),
  invalid settings or a refused plain `ws://` URL, `disconnect`, exhausted attempts. Everything
  else (connect failures, drops, timeouts, dead peers, 5xx) is retried. With
  `with_credentials_refresh`, a `401` handshake, a refused first-message auth and the close codes
  you list first wait for refreshed credentials (below).
- **Credentials** from `BackendCredentials` go on **every** handshake (headers and query; the
  request's purpose is `RequestPurpose::WebSocketHandshake`). A token changed while connected is
  used on the next reconnect (no forced reconnect). `JsonBodyField` cannot authenticate a
  handshake (it has no body): that is a clear `InvalidRequest`; use first-message auth instead:
  `Credentials::ws_auth_message()` returns a text frame sent first on every connection. Requests
  follow it at once (frames on one socket stay in order); a server that authenticates
  asynchronously can opt in to `WsSettings::with_auth_ack(timeout)`: then nothing else goes out
  until the protocol reports `WsIncoming::AuthOk` (`{"type":"auth.ok"}` with `JsonEnvelope`); without
  it in time the waiting requests are answered `Timeout` (honest about an earlier send), the link
  closes with 1008 and the connection goes `Disconnected` with that error.
- **Proxy:** `TungsteniteTransport` uses the plugin's `ProxySettings` like HTTP (see
  [Proxies](#7-plain-http-for-local-development-and-proxies); by default the `HTTPS_PROXY` /
  `HTTP_PROXY` / `ALL_PROXY` variables with `NO_PROXY`, read when the transport is created);
  loopback hosts always connect directly. Through an `http://` proxy the connection is a
  `CONNECT host:port` tunnel (TLS for `wss://` runs end to end inside it; a user
  and password in the proxy URL are sent as `Proxy-Authorization: Basic`). A proxy that refuses
  the tunnel (e.g. `407`) or cannot be reached is a `Network` error, retried like any connect
  failure. With an `https://` or SOCKS proxy set, a connection that would go through it fails with
  `InvalidRequest` (not retried) and is never made around the proxy.
- **Refreshing credentials** (off by default; `WsSettings::with_credentials_refresh`): when the
  server refuses the credentials (a `401` handshake, a refused first-message auth, or a close
  code you list with `WsCredentialsRefresh::with_close_code`, e.g. a server's 4001 for a revoked
  session), the connection goes `WaitingForCredentials` and the plugin writes ONE
  `WsCredentialsRefused { name, error }` message, however many connections were refused with
  the same credentials. Your game refreshes with its own call and sets the new credentials;
  every waiting connection then makes ONE new connection with them. A second refusal, cleared
  credentials, or no new credentials within the timeout (default 30 s) end it `Disconnected`
  with the server's refusal; a further refresh is allowed only after a connection stayed up for
  the reconnect policy's `stable_after`, so it never loops. If the credentials already changed
  since the refused handshake (your game refreshed on its own meanwhile), no message is written
  and the connection connects again at once. The crate never calls a refresh route itself, and
  never logs a token. Requests made while waiting wait (until their own timeout).

```rust,no_run
use bevy::prelude::*;
use bevy_net_backend::prelude::*;
use bevy_net_backend::WsCredentialsRefresh;
use serde::{Deserialize, Serialize};

#[derive(Serialize)]
struct Refresh {
    refresh_token: String,
}

#[derive(Deserialize, Clone, Debug)]
struct Tokens {
    access_token: String,
    refresh_token: String,
}

/// Your stored refresh token.
#[derive(Resource)]
struct RefreshToken(String);

fn connect(ws: Res<WsClient>) {
    let settings = WsSettings::new("wss://game.example.com/ws").with_credentials_refresh(WsCredentialsRefresh::new().with_close_code(4001));
    ws.connect("main", settings);
}

/// One refresh per message; your own refresh route, without the refused token.
fn refresh(mut refused: MessageReader<WsCredentialsRefused>, backend: Res<HttpClient>, token: Res<RefreshToken>) {
    if refused.read().count() > 0 {
        let request = OutgoingRequest::post("/auth/refresh").with_json(&Refresh { refresh_token: token.0.clone() }).without_credentials();
        backend.send_json::<Tokens>(request);
    }
}

/// New tokens: set them (the waiting connections connect again); refused: log out.
fn store(mut answers: MessageReader<JsonResponse<Tokens>>, mut credentials: ResMut<BackendCredentials>, mut token: ResMut<RefreshToken>) {
    for answer in answers.read() {
        match &answer.result {
            Ok(tokens) => {
                token.0 = tokens.refresh_token.clone();
                credentials.set(BearerToken::new(tokens.access_token.clone()));
            }
            Err(_) => credentials.clear(),
        }
    }
}

fn main() {
    App::new()
        .add_plugins((MinimalPlugins, BackendPlugin::new(HttpConfig::new("https://game.example.com/api"))))
        .add_json_response::<Tokens>()
        .insert_resource(RefreshToken("from-the-login".into()))
        .add_systems(Startup, connect)
        .add_systems(Update, (refresh, store))
        .run();
}
```

- **Protocol:** with feature `json` the default is `JsonEnvelope`: requests
  `{"id":…,"type":…,"data":…}`, answers `{"id":…,"ok":true,"data":…}` or
  `{"id":…,"ok":false,"error":…}` (answered `BackendError::Rejected`), pushes `{"type":…,"data":…}`
  (a push may carry its own `id` as long as it has no `ok`), auth `{"type":"auth.ok"}` /
  `{"type":"auth.failed",…}`. Implement `WsProtocol` for another layout (payloads are bytes, so
  binary formats work); `without_protocol()` for raw frames only.
- **Plain `ws://`** only to loopback hosts unless `allow_insecure_ws(true)`, as for HTTP. TLS is
  the same rustls + ring setup as HTTP. No permessage-deflate compression.

**WebSocket answers ("Sent?" as for HTTP):**

| Answer | When | Sent? |
|---|---|---|
| response / `Rejected` | the server answered | yes |
| `Timeout("not sent: …")` | the connection (or the auth acknowledgement) did not come in time | no |
| `Timeout("no answer …")` | sent, no answer within the request timeout | yes |
| `Timeout("sent before the connection was lost, …")` | a resend request that went out, then the link dropped and it timed out waiting | yes (on the earlier link) |
| `Disconnected { sent, .. }` | the connection went away or was replaced / closed by the game; not connected when asked; too many waiting | as `sent` says |
| `Cancelled` | `cancel` | maybe |
| `Shutdown` | the app exited first | no, unless it went out before the exit frame |
| `InvalidRequest` / `Encode` | unknown connection, no protocol, unregistered type, bad payload | no |
| `RequestTooLarge { limit, size }` | the request is larger than the message limit | no |

Requests made while a connection is opening or reconnecting wait for it (until their timeout, 64 at
most). A server close frame is `BackendError::Closed { code, reason }` in `WsStateChanged` /
`WsConnectionInfo::last_error` (`error.close_code()`). When a connection drops, requests already
sent are answered `Disconnected`, except those marked `resend_on_reconnect` (a `WsOutgoing` option,
or `WsRequest::resend_on_reconnect`), which are sent again after the reconnect; the default is off.
Frames sent while not connected wait in an outbox (64 by default) and go out when the connection
opens.

### 11. SSH commands and SFTP (feature `ssh`, admin / dev builds only)

> **Never ship SSH to players.** An SSH key (or access to an ssh-agent) inside a build you give
> to players is **shell access to your server for anyone who extracts it** — and extracting it is
> easy. SSH is for admin and developer tools that stay on your own machines: a deploy button in an
> editor build, a server console in an internal tool. Keys are loaded at runtime from the admin's
> machine; there is deliberately no way to pass key bytes. **In a release build
> (`debug_assertions` off) the plugin refuses every SSH request** (answered `InvalidRequest`,
> nothing connects) unless the tool opts in with
> `BackendPlugin::default().with_ssh(SshSettings::default().allow_in_release(true))`. Give the key
> a narrow account on the server (`command="…"`, `from="…"`, `no-pty`, `no-port-forwarding` in
> `authorized_keys`, a dedicated user with narrow sudo rights).

```toml
bevy_net_backend = { version = "0.2.0", features = ["ssh", "sftp"] }
```

```rust,no_run
use bevy::prelude::*;
use bevy_net_backend::prelude::*;
use bevy_net_backend::{SshAuth, SshCommand, SshTarget};
use std::time::Duration;

fn connect(ssh: Res<SshClient>) {
    ssh.connect(
        "build",
        SshTarget::new("build.example.com", "deploy")
            .with_auth(SshAuth::agent())
            .with_auth(SshAuth::key_file("/home/admin/.ssh/id_ed25519"))
            .with_known_hosts_file("/home/admin/.ssh/known_hosts"),
    );
    // Waits for the connection; runs once it is up.
    ssh.run("build", SshCommand::new("systemctl restart game-api").with_timeout(Duration::from_secs(30)));
}

fn show(mut output: MessageReader<SshOutput>, mut finished: MessageReader<SshFinished>, mut states: MessageReader<SshStateChanged>) {
    for change in states.read() {
        if let Some(error) = &change.error {
            warn!("`{}` is {:?}: {error}", change.name, change.state);
        }
    }
    for chunk in output.read() {
        info!("{} {:?}: {}", chunk.id, chunk.stream, chunk.text());
    }
    for answer in finished.read() {
        match &answer.result {
            Ok(exit) if exit.success() => info!("{} done", answer.id),
            Ok(exit) => warn!("{} exited with {:?} / signal {:?}", answer.id, exit.status, exit.signal),
            Err(error) => warn!("{} failed: {error} (started: {:?})", answer.id, answer.started),
        }
    }
}

fn main() {
    App::new()
        .add_plugins((MinimalPlugins, BackendPlugin::default()))
        .add_systems(Startup, connect)
        .add_systems(Update, show)
        .run();
}
```

- **`SshClient`** (a resource, `Res<SshClient>`, no ordering needed): `connect(name, SshTarget)`,
  `disconnect(name)`, `run(name, command)` (a `&str`, `String` or `SshCommand`), `cancel(id)` (the
  shared cancel of every protocol), and with `sftp` the file operations below. Applied in
  `PostUpdate` (`BackendSystems::Send`). A command made while its connection is connecting waits
  for it.
- **Messages out**, all written in `First`: `SshStateChanged { name, state, error }`,
  `SshOutput { id, name, stream, data }` (stdout / stderr chunks as they arrive; 0..n per command,
  all before or in the same frame as its answer; a chunk can end in the middle of a line or a UTF-8
  character, `text()` decodes lossily), and **exactly one** `SshFinished { id, name, started,
  result }` per command. A non-zero exit status is still `Ok(SshExit { status, signal, … })`: the
  command ran; `exit.success()` checks for 0.
- **State:** `SshConnections` (resource): `state(name)`, `is_connected(name)`, `get(name)` →
  `SshConnectionInfo` (state, the server's host key fingerprint, last error, open requests,
  reconnect attempts). `SshState` is `Connecting`, `Connected`, `Reconnecting { attempt, retry_in }`
  or `Disconnected`. Every name passed to `connect` gets an entry, a refused one too
  (`Disconnected` with the error). Only open connections count against `with_max_connections`;
  beyond 256 remembered names the oldest `Disconnected` ones are forgotten.
- **Reconnect is OFF by default.** `SshTarget::with_reconnect(SshReconnect::default())` turns it on:
  exponential backoff with full jitter as for WebSocket (base 1 s, cap 30 s, optional
  `with_max_attempts`, the counter resets after 10 s connected). **A reconnect never re-runs a
  command:** commands running when the connection was lost are answered `Disconnected` with their
  honest `started`; commands that were never sent wait for the new connection (until their own
  timeout). Host key, authentication, protocol (`Ssh`) and invalid-settings errors are not retried.
  Without it, a lost connection goes `Disconnected` with the error; `connect` again.
- **Host keys are always checked.** The server's key must be in a known_hosts file
  (`with_known_hosts_file`, any number; default `~/.ssh/known_hosts` when neither a file nor a
  pinned fingerprint is given) or match a fingerprint pinned in code with
  `trust_host_key_fingerprint("SHA256:…")` (only pin a fingerprint you read on the server itself,
  e.g. `ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub`). An unknown, changed or `@revoked` key
  is `BackendError::HostKey { host, fingerprint, problem }` before anything is sent; there is no
  trust-on-first-use and nothing is ever written to known_hosts. The matcher follows OpenSSH:
  patterns with `*` / `?` / `!`, hashed hosts, `[host]:port` for other ports, `@revoked` (wins
  over pins too). As OpenSSH does, the key types already listed for the host are asked for first,
  and only a different key **of the same type** is `Changed`; a host listed only with another type
  (an old RSA line, say) is `Unknown` for the new type. `@cert-authority` lines are ignored, and
  a server that presents a host certificate is refused with an `Ssh` error.
- **Authentication** (`SshAuth`, tried in order): `key_file(path)`,
  `key_file_with_passphrase(path, passphrase)` (OpenSSH, PKCS#8 or PuTTY format, at most 256 KiB;
  the passphrase is a redacted `Secret`), `agent()` (Unix: `SSH_AUTH_SOCK`; Windows: the OpenSSH
  agent's pipe, then Pageant; certificate identities are skipped). ed25519 and ECDSA keys always;
  RSA keys with feature `ssh-rsa` (SHA-2 signatures only). **Keys are the recommendation.** For
  servers that need it, opt in to `SshAuth::password(secret)` or
  `SshAuth::keyboard_interactive(responder)` (multi-prompt flows such as a password plus a 2FA
  code; `SshPromptAnswers::new().answer_containing("password", pw).answer_containing("code", otp)`
  answers prompts by word, or implement `SshPromptResponder`; it runs on the SSH thread, must not
  block, at most 8 rounds; prompt texts are server-supplied). Collect the values from the admin at
  runtime; they are held in `Secret` and never logged or shown in `Debug`. If every method fails,
  the answer is `BackendError::AuthFailed` naming the methods tried (key file names, never paths
  or secrets).
- **`~/.ssh/config`:** `SshTarget::from_ssh_config("alias")` (or `from_ssh_config_file(path,
  alias)`) takes `HostName`, `Port`, `User`, `IdentityFile` and `ConnectTimeout` from it when
  connecting; settings given in code win. `Include` is followed by the crate itself, with limits
  (nesting depth 16 like OpenSSH, 64 files, 1 MiB for all files together; `~` and relative paths
  as OpenSSH, relative to `~/.ssh`; globs): a config that includes itself is an error, not a
  crash. Other keys (`Match`, `%` tokens, `ProxyJump` / `ProxyCommand`, `UserKnownHostsFile`)
  are ignored: the connection goes straight to the host, with the known_hosts files given in code.
- **`SshTarget`** (builder, per connection): port (22), user, auth, known_hosts files, pins, connect
  timeout (15 s: ONE deadline for TCP + key exchange + host key + authentication, a server that
  trickles bytes cannot stretch it), keepalive (every 15 s of silence, lost after 3 unanswered),
  command timeout (60 s), output limit (8 MiB), SFTP timeout (5 min) and transfer limit (256 MiB),
  channels (8 commands at once; more wait, counted in their timeout). `SshCommand`: `with_timeout`,
  `with_max_output_bytes`, `with_stdin(bytes)` (then end-of-file; without it stdin is closed at
  once). `with_reconnect`, `allow_terrapin_vulnerable` (below). `SshSettings` (plugin):
  `allow_in_release`, `with_max_connections` (16 open at once), `with_max_requests_per_connection`
  (256). The release guard is also built into `RusshTransport` itself
  (`RusshTransport::new().with_release_allowed(..)`; the plugin passes `allow_in_release` on), so
  calling the transport directly does not bypass it.
- **Command lines may hold secrets:** `SshCommand`'s `Debug` shows only lengths, and the crate
  never logs a command or its output. Remember that a command line is visible in the server's
  process list: pass a secret through `with_stdin` instead.
- **Security (Terrapin, CVE-2023-48795):** strict key exchange is always offered, and AES-GCM is
  the preferred cipher (it is not affected), so servers without strict key exchange (OpenSSH before
  9.6 without a distribution backport) still connect through AES-GCM. Only the truly vulnerable
  combination is refused: no strict key exchange AND ChaCha20-Poly1305 or CBC with an
  encrypt-then-MAC MAC negotiated (a server that offers nothing else); the `Ssh` error says why.
  `SshTarget::allow_terrapin_vulnerable(true)` (off by default, logged as a warning) accepts it
  anyway, for an old server you cannot update. SHA-1 `ssh-rsa` signatures are never used.
- **Requests on one connection run at the same time** (commands and SFTP operations alike): when
  one step depends on another (upload, then move), start it after the first one's answer.

**SSH answers:**

| Answer | When | `started` |
|---|---|---|
| `Ok(SshExit)` | the command ended (any exit status or signal) | `Some(true)` |
| `Timeout("not sent: …")` | no free channel, or the connection did not open in time | `Some(false)` |
| `Timeout(…)` otherwise | ran longer than its timeout: TERM signal sent, channel closed (**the remote process may keep running**, see below) | `Some(true)` (or `None` if the exec reply never came) |
| `Cancelled` | `cancel(id)`: TERM and close as for a timeout | `Some(true)` if it was running, `Some(false)` if it was still waiting, `None` in between |
| `BodyTooLarge { limit }` | its output went over the limit; it was stopped | `Some(true)` |
| `Disconnected { sent, .. }` | the connection went away / was closed / was replaced (also with reconnect on: a running command is never re-run); or not connected when asked | as `sent` |
| `Shutdown` | the app exited first (a command of the exit frame is never sent) | as far as known |
| `Ssh(…)` | the server refused the channel or the exec request | `Some(false)` |
| `InvalidRequest` | unknown connection, bad command (empty, NUL), too many requests, SSH disabled in a release build | `Some(false)` |
| `RequestTooLarge { limit, size }` | the command line is over 64 KiB, or (SFTP) an upload is over the transfer limit | `Some(false)` |

Stopping a remote command is best effort: SSH has no reliable kill. The crate sends a `TERM` signal
and closes the channel; a server may ignore the signal, and a process without a terminal may keep
running after its channel is gone. Commands that must stop should have their own timeout on the
server (`timeout 30 ./deploy.sh`).

#### SFTP (feature `sftp`)

```rust,no_run
use bevy::prelude::*;
use bevy_net_backend::prelude::*;

fn upload(ssh: Res<SshClient>) {
    ssh.upload("build", "releases/notes.txt", b"version 1.2.3".to_vec());
    ssh.upload_file("build", "target/release/server.tar.gz", "releases/server.tar.gz");
    ssh.list_dir("build", "releases");
}

fn done(mut finished: MessageReader<SftpFinished>, mut progress: MessageReader<SftpProgress>) {
    for step in progress.read() {
        info!("{}: {} of {:?} bytes", step.id, step.done, step.total);
    }
    for answer in finished.read() {
        match &answer.result {
            Ok(SftpOutcome::Listing(entries)) => info!("{} entries", entries.len()),
            Ok(outcome) => info!("{}: {outcome:?}", answer.id),
            Err(error) => warn!("{}: {error}", answer.id),
        }
    }
}
# let _ = (upload, done);
```

`upload(name, remote, bytes)`, `upload_file(name, local, remote)` (both create or truncate the
remote file), `download(name, remote)` (into memory: `SftpOutcome::Data`), `download_file(name,
remote, local)` (written as `<local>.part`, synced to disk and renamed over `local` only when
complete; the part file's name is unique, `<local>.<process>-<id>-<n>.part`, so nothing else is
overwritten; it is removed on failure, but a transfer killed after the 1 s grace can leave it, and
the next `download_file` to that `local` removes part files other processes left; a `local` that is
a folder is refused before the transfer), `list_dir` (`Listing(Vec<SftpEntry>)`, sorted, at most
10 000 entries, names at most 4 KiB, 4 MiB of names in total), `create_dir`, `remove_file`,
`remove_dir` (empty directories), `rename`, or any `SftpOp` with `sftp(name, op)`. Each gets exactly
one `SftpFinished { id, name, started, result }`; transfers also report `SftpProgress` (about 10 per
second). Relative remote paths start in the login directory. A download over
`with_max_transfer_bytes` is `BodyTooLarge`; an upload over it (from memory or from a file) is
`RequestTooLarge`, refused before anything is sent (`started: Some(false)`); a whole operation is
bounded by `with_sftp_timeout`. A local file that cannot be opened or created is `InvalidRequest`
(nothing sent); a local file error after the transfer started (reading during `upload_file`,
writing, syncing or renaming the download) is `Ssh` naming the file. An interrupted upload can leave
a partial remote file; so can a local file that grows or shrinks during `upload_file`, which is
answered `Ssh("the local file changed size …")`. A download whose remote file ends before the size
the server reported when it was opened (the file was cut short meanwhile) is an error, `Ssh("SFTP:
the remote file was cut short during the download (expected N bytes, its size when it was opened;
received M)")`, with no final file and the part file removed; a file that reports size 0 or no size
is read to its end. Errors carry the server's SFTP status text (`Ssh("SFTP: No such file")`). A
connection (or SFTP channel) lost during an operation answers it `Disconnected` with `sent:
Some(true)` once the operation had started (`Some(false)` when it never went out). The SFTP channel
is opened on first use and shared by the connection's operations; after it ended, the next operation
opens a new one. Local files are read and written on tokio's small blocking pool, never on the SSH
thread itself.

**Downloads are pipelined:** reads of 64 KiB go out ahead of the answers, up to 1 MiB requested or
received and waiting to be written at once (16 reads in flight), and the answers are written in file
order. That window is all the memory a download to a file uses, whatever the file's size (a download
into memory holds the file, up to the transfer limit; it reserves the size the server reports once
instead of growing by doubling). A short read (fewer bytes than asked, which servers may send) asks
again for the rest; a file the server reports larger than the transfer limit is refused before any
data is read; the first error stops the download. Uploads keep 16 writes of 32 KiB in flight. Each
read or write may wait for its answer as long as the whole operation (`with_sftp_timeout`, default
5 min), so a link has to move about 1 MiB within that time (about 3.5 KB/s with the default). After
a cancel or a timeout the remote file handle is closed in the background, so a long-lived connection
collects no stale handles.

> **Listed names are untrusted input.** A hostile or broken server can list `../../.bashrc`,
> `C:\Windows\evil.dll` or `a/b`. Never join `SftpEntry::name` into a local path: use
> `entry.safe_file_name()`, which returns `None` for anything that is not one plain file name
> (`..`, separators, drive letters, control characters, Windows device names, …). The crate never
> turns a listed name into a local path itself; `download_file` writes only where you tell it to.

### 12. File uploads (multipart)

Upload files the way a browser form does: `multipart/form-data` (RFC 7578). Part of `http`: no
extra feature, no new dependency, nothing to configure app-wide. Avatars, screenshots, replays,
bug reports and cloud saves go this way (SFTP is for admin tools only).

```rust,no_run
use bevy::prelude::*;
use bevy_net_backend::prelude::*;
use serde::Deserialize;

#[derive(Deserialize, Clone, Debug)]
struct AvatarSaved {
    url: String,
}

fn upload_avatar(backend: Res<HttpClient>) {
    # let png_bytes: Vec<u8> = Vec::new();
    let form = Multipart::new()
        .text("display_name", "Ayla")
        .file("avatar", "avatar.png", "image/png", png_bytes); // bytes you already loaded
    backend.post_multipart_json::<AvatarSaved>("/me/avatar", &form);
}
# fn main() { App::new().add_json_response::<AvatarSaved>().add_systems(Update, upload_avatar); }
```

A file from disk, a JSON part and upload progress:

```rust,no_run
use bevy::prelude::*;
use bevy_net_backend::prelude::*;
use serde::Serialize;

#[derive(Serialize)]
struct SaveInfo {
    slot: u32,
    play_time_s: u64,
}

fn upload_save(backend: Res<HttpClient>) {
    let form = Multipart::new()
        .json("info", &SaveInfo { slot: 2, play_time_s: 7_380 }) // Content-Type: application/json
        .file_from_path("save", "slot2.sav", "application/octet-stream", "saves/slot2.sav") // read while it is sent
        .with_max_bytes(512 * 1024 * 1024);
    // A big upload needs a longer timeout than the default 15 s.
    backend.send(OutgoingRequest::post("/saves").with_multipart(&form).with_timeout(std::time::Duration::from_secs(600)));
}

fn show_progress(mut progress: MessageReader<HttpProgress>) {
    for step in progress.read() {
        info!("{}: {} of {:?} bytes", step.id, step.sent, step.total);
    }
}
# let _ = (upload_save, show_progress);
```

- **Builder:** `Multipart::new()`, `.text(name, value)`, `.file(name, filename, content_type,
  bytes)` (an empty content type means `application/octet-stream`; text parts have no content
  type, as in a browser), `.file_from_path(name, filename, content_type, path)` (a file read
  from disk while the request is sent), `.part(name, content_type, bytes)` (a non-file part with
  its own content type), `.json(name, &value)` (feature `json`: a part with
  `Content-Type: application/json`, as Spring's `@RequestPart` reads it; frameworks that read
  forms by name give you its value as text), repeated names allowed (`photos[]` twice),
  `.with_max_bytes(n)` (the whole encoded body, part headers included, default 32 MiB: a single
  file of exactly 32 MiB is just over it), `.with_max_parts(n)` (default 256, at most 10 000),
  `len()`, `is_empty()`, `encoded_len()` (files from disk count 0 there: they are measured when
  the request is sent).
- **Sending:** `HttpClient::post_multipart(path, &form)` (raw `HttpResponse`),
  `post_multipart_json::<T>(path, &form)` (typed, feature `json`), `send_multipart(method, path,
  &form)` (`PUT`, `PATCH`, …), or `OutgoingRequest::with_multipart(&form)` for headers, query and
  a longer timeout on a big upload (`with_timeout`). Everything else is as for any HTTP request:
  one answer, the shared `cancel`, `InFlight`, the plain-http rule, and credentials applied as
  headers or query (`BearerToken`, `ApiKeyHeader`, `ApiKeyQuery`). `JsonBodyField` cannot go into
  a form: such a request is answered `InvalidRequest` and not sent (use a header credential, or add
  the field to the form yourself). A form replaced afterwards by `with_json`, `with_body` or
  `set_body` is an ordinary request again.
- **Refused before sending** (answered, never sent, `was_sent() == Some(false)`): a form over
  `with_max_bytes` is `RequestTooLarge { limit, size }`; more parts than `with_max_parts`, an
  empty field name, an invalid content type, and a name or file name that **ends with a
  backslash** or contains a **control character** (NUL, TAB, …; CR and LF are escaped instead) are
  `InvalidRequest`. A trailing backslash would turn the closing quote into an escaped one for most
  parsers: every framework in the table below loses such a part (drops it, or turns the file into
  a text field).
- **Files from disk (`file_from_path`)** are opened, measured and read on the HTTP worker
  thread when the request goes out, 64 KiB at a time: the main thread does no file I/O and the
  file is never in memory as a whole (measured: a 256 MiB upload peaked at 12 MB for the whole
  test process). The body goes out with its exact `Content-Length`. A file that cannot be opened
  is `InvalidRequest` and a form over `with_max_bytes` `RequestTooLarge`, both before anything is
  sent; a file that changes size while it is sent cuts the request off with a `Network` error
  that says so. It needs a transport that streams bodies: `UreqTransport` and
  `FakeHttpTransport` do.
- **Bytes (`file`, `part`, `text`)** are copied once and scanned for the boundary on the calling
  thread (measured: about 4 ms per 10 MiB and 13 ms for 32 MiB in a release build on a desktop
  PC). **Memory:** while it is sent, the form and its encoded body both exist, so the peak is
  about twice the in-memory parts.
- **Upload progress:** a request with a form reports `HttpProgress { id, sent, total }` messages
  (bytes read for sending, at most about 10 per second, plus one when the whole body is out),
  written in `First` before that frame's answers and only while the request waits for its
  answer. `OutgoingRequest::with_upload_progress(bool)` turns it on for any body (or off for a
  form).
- **Encoding details:** a fresh 128-bit random boundary per request (`bnb-` + 32 hex digits, from
  the operating system through ring), checked not to occur in any in-memory part (a file read
  from disk is not scanned: 128 random bits, like a browser's boundary); CRLF line breaks; the
  header `Content-Type: multipart/form-data; boundary=…`; a fixed `Content-Length` (never
  chunked). In `Content-Disposition`, names and file names are escaped as browsers do (WHATWG
  HTML): `"` → `%22`, CR → `%0D`, LF → `%0A`, everything else (backslashes, non-ASCII as UTF-8)
  unchanged, no `filename*`. Text values are sent exactly as given (line breaks are not
  rewritten). `Debug` of a form shows names and sizes only, never file names or paths.

**How backends read the same upload** (`display_name` text + `avatar` file):

| Backend | Text field | File (name / type / size) | Repeated names |
|---|---|---|---|
| Laravel | `$request->input('display_name')` | `$request->file('avatar')`: `getClientOriginalName()`, `getClientMimeType()`, `getSize()` | name them `photos[]`: `$request->file('photos')` is an array |
| Plain PHP | `$_POST['display_name']` | `$_FILES['avatar']['name']`, `['type']`, `['size']`, `['tmp_name']`, `['error']` | `photos[]` (without `[]` only the last one is kept) |
| Express + multer | `req.body.display_name` | `upload.single('avatar')` → `req.file.originalname`, `.mimetype`, `.size`, `.buffer` | `upload.array('photos')`: the name must match exactly (`photos` or `photos[]`) |
| Go (`net/http`) | `r.FormValue("display_name")` after `r.ParseMultipartForm(max)` | `f, h, _ := r.FormFile("avatar")`: `h.Filename`, `h.Header.Get("Content-Type")`, `h.Size` | `r.MultipartForm.File["photos"]` (same name, no brackets needed) |
| Django | `request.POST['display_name']` | `request.FILES['avatar']`: `.name`, `.content_type`, `.size` | `request.FILES.getlist('photos')` |
| FastAPI | `display_name: str = Form()` | `avatar: UploadFile`: `.filename`, `.content_type`, `await avatar.read()` (needs `python-multipart`) | `photos: list[UploadFile]` |
| Spring | `@RequestParam("display_name") String` | `@RequestParam("avatar") MultipartFile`: `getOriginalFilename()`, `getContentType()`, `getSize()` | `@RequestParam("photos") List<MultipartFile>` |
| Rails | `params[:display_name]` | `params[:avatar]`: `original_filename`, `content_type`, `size` | `photos[]`: `params[:photos]` is an array |
| ASP.NET Core | `[FromForm] string display_name` | `IFormFile avatar`: `FileName`, `ContentType`, `Length` | `List<IFormFile> photos` |
| axum | the `Multipart` extractor: `field.name()`, `field.text().await` | `field.file_name()`, `field.content_type()`, `field.bytes().await` | every part is its own field; group them yourself |

Array naming: PHP, Laravel and Rails turn `photos[]` into an array (without brackets they keep
only the last value); the others read repeated names as a list and see `photos[]` literally as
the name. Use what your backend expects.

#### What real backends do with an upload

Measured by sending the same 38 upload scenarios from this crate to real servers with their
default settings: PHP 8.3 (stock `php.ini`), Express 4.21 + multer 2.0.2, FastAPI 0.115
(Starlette 0.46, python-multipart 0.0.20), Go 1.22 `net/http` and Django 5.2. **Every file that
arrived had the right size and CRC-32.** What differed was limits, file names and parts a
framework dropped:

| | PHP 8.3 | Express + multer 2.0.2 | FastAPI / Starlette | Go 1.22 | Django 5.2 |
|---|---|---|---|---|---|
| **Over a size limit** | a file over `upload_max_filesize` (2 MB): **200**, the file has `error` 1 and no data; a body over `post_max_size` (8 MB): **200 with an empty form** | a text field over 1 MB (`fieldSize`): **500** (`MulterError`, Express's default handler); `limits.fileSize` likewise 500 | a text part over 1 MiB: **400** | **no default limit** (33 MB accepted): the handler must set one (`http.MaxBytesReader`) | text over 2.5 MiB (`DATA_UPLOAD_MAX_MEMORY_SIZE`): **400** |
| **Many files** | keeps **20** (`max_file_uploads`) and **silently drops the rest** (200) | all 25 kept | all kept | all kept | more than 100 files: **400** |
| **Repeated `photos` without `[]`** | **only the last** is kept | all kept | all kept | all kept | all kept |
| **Non-ASCII names** (`mentés.json`, `😀`) | exact | **mojibake** in file names AND field names (read as latin1) | exact | exact | exact |
| **Empty file name** `""` | a file with `error` 4 (no file), no data | **a text field** | a file named `""` | **a text field** | **a text field** |
| **CSRF** | – | – | – | – | **403** without `@csrf_exempt`, whatever the body |

- **multer mojibake:** convert each name back with `Buffer.from(name, 'latin1').toString('utf8')`
  (checked exact). multer's `defParamCharset` option has no effect in multer 2.0.2.
- **`"` in a file name** arrives as a literal `%22` on every one of them (nothing decodes it).
- **Everything else was read the same everywhere:** text before and after files, `tags[]` twice,
  an empty value, CRLF inside a value, a 0-byte file, binary data containing `--` and CRLF lines,
  an empty form, 20 uploads in parallel.
- **Paths and backslashes in file names** are read three different ways, so send a plain name:

  | File name sent | PHP | multer | FastAPI | Go | Django |
  |---|---|---|---|---|---|
  | `a\b.png` | `b.png` | `b.png` | `a\b.png` | `a\b.png` | `b.png` |
  | `C:\Users\me\a.png` | `a.png` | `a.png` | `a.png` | `C:\Users\me\a.png` | `a.png` |
  | `dir/file.png` | `file.png` | `file.png` | `dir/file.png` | `file.png` | `file.png` |
  | `x\` (the crate refuses it) | `x"` | file dropped | `x\` | file dropped | file dropped |

**From the frameworks' documentation:**
- **ASP.NET Core:** Kestrel's `MaxRequestBodySize` defaults to 30,000,000 bytes, **below this
  crate's 32 MiB default**: lower `with_max_bytes` or raise the server limit. `FormOptions`
  allows 1024 form values by default.
- **Spring Boot:** `spring.servlet.multipart.max-file-size` 1 MB, `max-request-size` 10 MB.
- **Laravel:** PHP's limits above apply (Laravel answers `413` for a body over `post_max_size`).
- **Rails (Rack):** percent-decodes file names (`%22` becomes `"`) and accepts at most 128 files.
- **nginx** `client_max_body_size` 1 MB and **axum** `DefaultBodyLimit` 2 MB answer `413`.
- **CSRF protection** refuses a game's POST whatever its body: Laravel `web` routes (419), Rails
  `protect_from_forgery` (422), like Django's measured 403 above. Use API routes (Laravel
  `routes/api.php`), or exempt the endpoint, and authenticate with a token instead.

**Advice:**
- For PHP and Laravel, name repeated fields `photos[]`, and keep at most 20 files per form (or
  raise `max_file_uploads`).
- Keep file names ASCII-safe and plain: letters, digits, `-`, `_`, `.`; no path, no `\`, no `"`.
  Keep the original name in a text field if you need it.
- Set the server's limits explicitly (upload size, body size, file count, text field size) and keep
  `with_max_bytes` at or below them. **Do not rely on a `413`:** only proxies with a body limit
  (nginx's default 1 MB, Caddy's `request_body` when configured) and some frameworks send one. PHP
  answers **200 with missing data** (check `$_FILES[..]['error']` and that the fields arrived),
  multer 500, FastAPI and Django 400, Go whatever the handler decides. A `413` or any other status
  is a normal `Status` answer (`error.status()`); a server that closes the connection mid-upload is
  a `Network` error.
- Put text fields before files if the server streams files to disk: multer documents that its
  disk storage only sees the fields sent before a file.

### 13. Downloads to a file

`HttpClient::download` writes an answer body to a local file as it arrives: it is never held in
memory as a whole, so the in-memory answer limit (`with_max_body_bytes`, 10 MiB by default) does
not apply to it. Part of `http`: no extra feature. Patches, replays, level packs and cloud saves go
this way.

```rust,no_run
use bevy::prelude::*;
use bevy_net_backend::prelude::*;

fn download_replay(backend: Res<HttpClient>) {
    let download = HttpDownload::to("replays/match-17.replay")
        .with_sha256("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08") // optional
        .with_max_bytes(512 * 1024 * 1024);
    // A big file needs a longer timeout than the default 15 s: it covers the whole transfer.
    let request = OutgoingRequest::get("/replays/17/content").with_timeout(std::time::Duration::from_secs(600));
    backend.download(request, download);
    // The short form: GET into a file with the defaults.
    backend.download_to("/news/banner.png", "cache/banner.png");
}

fn on_download(mut progress: MessageReader<HttpDownloadProgress>, mut done: MessageReader<HttpDownloadResponse>) {
    for step in progress.read() {
        info!("{}: {} of {:?} bytes", step.id, step.received, step.total);
    }
    for answer in done.read() {
        match &answer.result {
            Ok(file) => info!("{} bytes in {}, SHA-256 {}", file.bytes, file.path.display(), file.sha256),
            Err(error) => warn!("download failed: {error}"),
        }
    }
}
# let _ = (download_replay, on_download);
```

- **The file:** the body (status 200–299) goes to a part file next to the target
  (`<name>.<process>-<request>-<n>.part`), is synced to disk and renamed over the target (an
  existing file is replaced; on Unix the folder is synced too) when every check passed. On any
  error, a cancel or a timeout the part file is removed and an existing target stays as it was.
  The part file is created before the request is sent: a folder that does not exist or cannot be
  written, or a target that is a folder, is answered `InvalidRequest` and never sent.
- **The answer matches the file:** the built-in transport renames the part file only under the
  lock its cancel takes. A download answered `Cancelled`, `Timeout` or `Shutdown` never replaced
  the target; a cancel that comes after the rename is too late, and the download is answered with
  its file. The one exception: an `HttpTransportRes` removed or replaced right after a rename
  answers that download `NoTransport` (as every request still waiting on it), although its file is
  in place.
- **App exit:** running downloads get up to 1 s to stop at their next piece and remove their part
  files before `BackendSystems::Exit` answers them `Shutdown`. A transfer still waiting for the
  server after that (a stalled connection) can leave its part file when the process ends; the next
  download to the same target removes part files of that target that other processes left (only
  names of exactly this pattern with another process number).
- **Checks:** `with_max_bytes(n)` (default `DEFAULT_DOWNLOAD_MAX_BYTES`, 256 MiB; a larger
  `Content-Length` is refused before anything is written, a body that grows past it is cut there:
  `BodyTooLarge`), `with_size(n)` and `with_sha256(hex)` (a file that differs is not put in place:
  `Network` naming the mismatch), and a body shorter than its `Content-Length` (`Network`).
  `DownloadedFile` carries `path`, `bytes`, `sha256` (compare it with a hash your server sent, for
  example in an `ETag`), `status` and `headers`.
- **Answers outside 200–299** write no file: they arrive as `Status` with their body in memory
  (within the in-memory answer limit), as for any request.
- **Progress:** `HttpDownloadProgress { id, received, total }` (at most about 10 per second, plus
  one when the body is complete, all before the answer); `total` is the `Content-Length`, `None`
  for a chunked or gzip-decoded body. `with_progress(false)` turns it off.
- **Everything else is as for any HTTP request:** one answer (`HttpDownloadResponse`), credentials,
  the plain-http rule, `InFlight`, the shared `cancel` (a transfer stops at its next 64 KiB piece).
  With feature `gzip` a gzip-encoded answer is written decoded.
- **Transports:** the built-in one writes on its worker thread. `FakeHttpTransport` writes a
  scripted 2xx answer's body to the file. Your own transport opts in with
  `HttpTransport::downloads_to_files()` and writes with `HttpDownload::receive` (and answers
  `HttpTransport::try_cancel` with `false` for a download it already put in place); any other
  transport gets such a request answered `InvalidRequest`, never sent.

### 14. Sign-in with Google or another OpenID Connect provider (feature `oauth`)

The desktop sign-in of RFC 8252: the player signs in at the provider in the system browser, and
the game receives the provider's tokens. Authorization code flow with PKCE (S256), `state` and a
nonce, a one-time loopback redirect listener on `127.0.0.1` (a free port). The crate never starts a
browser: the sign-in page URL arrives as a message and the game opens it.

```rust,no_run
use bevy::prelude::*;
use bevy_net_backend::prelude::*;
use bevy_net_backend::{OAuthClient, OAuthFlow, OAuthSignInUrl, OAuthSignedIn};

fn sign_in_with_google(oauth: Res<OAuthClient>) {
    let flow = OAuthFlow::google("1234-abc.apps.googleusercontent.com")
        .with_client_secret("GOCSPX-desktop-secret")
        .with_scopes(["openid", "email"]);
    oauth.sign_in(&flow);
}

fn open_sign_in_page(mut pages: MessageReader<OAuthSignInUrl>) {
    for page in pages.read() {
        // Open `page.url` in the system browser (for example with the `webbrowser` or `open`
        // crate), or show it to the player.
        info!("sign in at {}", page.url);
    }
}

fn signed_in(mut answers: MessageReader<OAuthSignedIn>, backend: Res<HttpClient>) {
    for answer in answers.read() {
        match &answer.result {
            // Your own server checks the ID token and the nonce, then answers with its session.
            Ok(tokens) => {
                let body = serde_json::json!({ "id_token": tokens.id_token.expose(), "nonce": tokens.nonce.expose() });
                backend.send(OutgoingRequest::post("/auth/oauth/google").with_json(&body).without_credentials());
            }
            Err(error) => warn!("sign-in failed: {error}"),
        }
    }
}
# let _ = (sign_in_with_google, open_sign_in_page, signed_in);
```

- **What happens:** `OAuthClient::sign_in(&flow)` starts the sign-in on its own thread
  (`net-backend-oauth`): it opens the listener (`http://127.0.0.1:{port}/callback` is the redirect
  address), makes a 256-bit PKCE verifier and a 128-bit `state` and nonce from the operating
  system's random source, and writes the sign-in page URL as `OAuthSignInUrl`. When the browser
  comes back with this sign-in's `state`, the page tells the player to return to the game, the
  listener is closed, and the code is exchanged (with the verifier) at the token endpoint through
  the crate's HTTP stack (the plugin's `TlsSettings` and `ProxySettings`, the `HttpConfig`
  timeout). `OAuthSignedIn` carries `OAuthTokens`: `id_token`, `access_token`,
  `refresh_token` (each a `Secret`), `token_type`, `expires_in`, `scope`, and the `nonce` of this
  sign-in.
- **Security rules:** a redirect with another `state` (or none) gets an error page and is ignored;
  the sign-in keeps waiting. Each sign-in has its own port, verifier, `state` and nonce, and its
  listener accepts one redirect with its `state`, then closes. The listener binds `127.0.0.1` only,
  reads at most 16 KiB per request and closes a connection that does not send a whole request
  within 10 s. Both endpoints must be `https://` (`http://` only for a loopback address). Codes,
  tokens, the verifier and the client secret are never logged; the crate's own copies of them are
  wiped after use (the HTTP client's internal buffers are not).
- **Ends:** `BackendError::OAuth` (the player declined: `access_denied`; the token endpoint
  refused the code; no `id_token` in its answer), `Timeout` (the browser did not come back within
  `with_timeout`, default 5 minutes), `Cancelled` (`OAuthClient::cancel` or the shared
  `HttpClient::cancel`), `Shutdown` (app exit), `InvalidRequest` (unusable endpoints or an empty
  client id, answered at once), and the HTTP errors of the exchange (`Network`, `Tls`, `Timeout`).
  `InFlight` lists a running sign-in with `RequestKind::OAuth`.
- **The ID token is not checked by the crate.** It is a signed JWT for your server: send it with
  the nonce to your own login route, which checks the signature (the provider's keys), the
  issuer, the audience (your client id), the expiry and the nonce before it trusts it. Logging in
  to your server is your game's request, like every other login.
- **Google:** in the Google Cloud console create an OAuth client of type "Desktop app"; its client
  id and client secret go into `OAuthFlow::google(..)` / `with_client_secret(..)` (Google gives
  desktop clients a secret that is not confidential inside a game). The endpoints are
  `GOOGLE_AUTHORIZATION_ENDPOINT` and `GOOGLE_TOKEN_ENDPOINT`; Google accepts a loopback redirect
  on any port for desktop clients.
- **Other providers:** `OAuthFlow::new(authorization_endpoint, token_endpoint, client_id)` with
  the endpoints from the provider's discovery document (`/.well-known/openid-configuration`);
  `with_param(name, value)` adds parameters such as `prompt=select_account` or `login_hint` (the
  flow's own parameters cannot be replaced).

## How it works

```text
 game system ──Res<HttpClient>──▶ queue ─┐
                                            │ PostUpdate  BackendSystems::Send
                                            ▼   defaults + credentials + URL checks
                                     InFlight map ──submit──▶ HttpTransport (UreqTransport:
                                            ▲                   N worker threads, one ureq Agent)
                                            │ First  BackendSystems::Receive
                             poll ◀─────────┘   deadlines, status + body-limit rules
                                            │
                                            ▼
                  HttpResponse / JsonResponse<T>  ──▶ PreUpdate / Update readers
```

- **One owner of every answer.** The plugin's `InFlight` map (ECS side) is the only thing that
  answers requests. The transport only reports; a result for an id that is no longer waiting is
  dropped. So every request gets exactly one answer, whatever the network does.
- **Scheduling.** `BackendSystems::Receive` runs in `First`, after Bevy's `TimeSystems` and before
  `MessageUpdateSystems`, so answers are readable in `PreUpdate` / `Update` of the frame they
  arrive. `BackendSystems::Send` runs in `PostUpdate`, so a request made in `Update` goes out the
  same frame (one made after it goes out next frame). A game system in `PostUpdate` that fires
  requests should be ordered `.before(BackendSystems::Send)` if the frame matters: both only read
  `HttpClient`, so the strict ambiguity check cannot flag the race. `BackendSystems::Exit` runs in
  `Last` only in a frame with `AppExit`. The sets are `#[non_exhaustive]` and phase-named: HTTP,
  WebSocket, SSH and sign-ins all run in the same three.
- **Threads.** ureq is blocking. `UreqTransport` starts its worker threads (named
  `net-backend-N`) on the first request and shares one `ureq::Agent` (keep-alive connection
  pool) between them. At most `workers` requests are on the wire; the rest wait in a queue, and
  that wait counts against their timeout. A request answered while it waits (cancel, timeout,
  exit, transport removed or replaced) is dropped from the queue, never sent. Worker results come
  back over a channel that `poll` drains without blocking. On exit the threads are not joined;
  only running downloads are waited for, at most 1 s, so they remove their part files. A panic
  inside the HTTP client is caught and answered as `Network` (with the usual unwinding panics; a
  game built with `panic = "abort"` aborts instead).
- **Timeouts.** A request's timeout counts from the moment it is handed to the transport. The
  worker gives ureq what is left of it (a request that waited its whole timeout in the queue is
  answered `Timeout("not sent: …")`). As a backstop the plugin answers `Timeout` itself when a
  request is still waiting after its timeout + 5 s (`DEADLINE_GRACE`, measured on `Time<Real>`,
  or a monotonic clock without `TimePlugin`). `Time<Real>` follows Bevy's
  `TimeUpdateStrategy`: with a `Manual*` strategy (replays, some headless servers) the backstop
  fires on that clock, early or late; ureq's own timeout always uses the wall clock.
- **Status codes.** The transport returns every status; the plugin turns anything outside
  200–299 into `Status`. Redirects are not followed (ureq `max_redirects(0)`), so a redirect can
  never downgrade `https://` to `http://` or carry credentials to another host.
- **Body limit.** The response body is read with a cap (`max_body_bytes`) on the bytes on the
  wire AND on the bytes after gzip decoding (feature `gzip`), so a gzip bomb stops at the limit;
  the plugin checks it again for any transport.
- **JSON decoding** runs on the main thread in `Receive`, in the frame the answer arrives. A
  multi-megabyte answer can cost that frame a few milliseconds; keep big payloads raw
  (`HttpResponse`) or small.
- **WebSocket threads (feature `ws`).** Each connection attempt runs on its own std thread
  (`net-backend-ws-link#N`): TCP (through an `http://` proxy's `CONNECT` tunnel when one is set),
  then rustls for `wss://`, then the HTTP upgrade (written and checked by the crate; tungstenite
  takes the stream after the `101` answer), then a loop: send what the game queued, ping when due,
  flush, one read whose socket reads together stop after one read timeout (Windows reports a read
  timeout as `TimedOut`, Unix as `WouldBlock`; both mean "no data"), check for a dead peer (no byte
  for `dead_after`). The handshake (TCP + proxy tunnel + TLS + upgrade) runs under ONE deadline.
  Both limits sit under rustls and tungstenite, so a peer that trickles bytes can neither stretch
  the handshake nor starve outgoing frames and pings. An idle connection wakes about 50 times a
  second, and a frame you send goes out within about one read timeout, plus the time the socket
  needs for earlier outgoing data. The heartbeat runs in the thread, so it keeps going while the
  game does not tick. Reconnects, backoff, credentials and every answer live on the ECS side; a
  reconnect is a new thread. On exit a close (1001) is queued to every link and no thread is joined
  (a process that exits right away usually wins that race).
- **SSH thread (feature `ssh`).** russh needs tokio, so `RusshTransport` owns ONE std thread
  (`net-backend-ssh`) with a tokio *current-thread* runtime, started on the first `connect` and
  stopped when the app exits (or the transport is dropped); tokio's blocking pool is capped at 2
  threads (`net-backend-ssh-io`, for DNS lookups and decrypting key files). Nothing runs on the
  game's threads or Bevy's task pools, and without feature `ssh` tokio is not even compiled.
  Every connection is a task on that thread; every command or SFTP operation is a task of its
  connection with its own deadline. The socket of each connection sits behind a kill switch tied
  to its task, so a connection that times out or is closed never lingers in the background. On
  exit the plugin answers everything `Shutdown` first; then the thread closes each connection
  (≤ 1 s to say goodbye) and ends, and the game does not wait for it.
- **TLS.** rustls with ring's crypto and the Mozilla root certificates (webpki-roots), or what a
  `TlsSettings` names (extra root certificates; with feature `os-certificates` the operating
  system's store through rustls-platform-verifier). The ring provider is always handed over
  explicitly and never installed process-wide, so a game that also links another rustls provider
  (for example aws-lc-rs through another crate) neither changes this crate's TLS nor triggers
  rustls' provider-selection panic. With the default settings ureq builds its own TLS
  connections; with other settings the crate builds one rustls configuration per transport, and
  ureq's connection chain (proxy `CONNECT`, TCP) ends in the crate's TLS step, which works like
  ureq's own.

## TLS exception: the default build is not pure Rust

`bevy_net_backend` is written in Rust, but its HTTPS stack is **not pure Rust**. TLS is done by
rustls, whose cryptography comes from **ring**, and ring compiles C code and ships assembly. A C
compiler is needed at build time (MSVC, clang or gcc, which Rust toolchains normally have at
hand). ring was picked because it is mature and widely deployed, needs no CMake or NASM, and adds
no system library. The crate never uses OpenSSL, native-tls or aws-lc, in any feature set.
With `os-certificates`, rustls-platform-verifier reads the system's certificates through the
platform's own API (Windows CryptoAPI, Apple's Security framework) or, on Linux / BSD, from the CA
files (found with the small `openssl-probe` crate, which only looks up file paths and links no
OpenSSL).

Without the `http` feature nothing of this is compiled (and no C either).

SSH (feature `ssh`) uses the same ring crate for its AEAD ciphers (ChaCha20-Poly1305, AES-GCM);
everything else in russh (key exchange, ed25519 / ECDSA, AES-CTR, HMAC) is RustCrypto. It never
uses OpenSSL, libssh2 or aws-lc either.

## API reference

Everything is re-exported at the crate root; `prelude` holds the everyday items.

| Item | Kind | What it is |
|---|---|---|
| `BackendPlugin` | plugin | `new(config)`, `with_config`, `with_tls` and `with_proxy` (features `http` / `ws`), `with_ssh` (feature `ssh`), `default()`. Inserts config, client, in-flight map, credentials, the `Http*` messages, and (feature `http`) a `UreqTransport` unless an `HttpTransportRes` exists; with `oauth` the sign-in side (its code exchange uses `with_tls` and `with_proxy`). |
| `BackendSystems` | system sets | `Receive` (`First`), `Send` (`PostUpdate`), `Exit` (`Last`, on `AppExit`). `#[non_exhaustive]`. |
| `HttpConfig` | resource | base URL, timeout, default headers, workers, `allow_insecure_http`, body limit; `validate()`, getters, `set_base_url`, `set_timeout`. |
| `ConfigError` | enum | `NoBaseUrl`, `BadBaseUrl`, `BadHeader`, `Tls` (from `TlsSettings::validate`), `Proxy` (from `ProxySettings::validate`). |
| `TlsSettings` | builder (`http` / `ws`) | `new`, `with_root_certificates_pem`, `with_root_certificates_file`, `with_os_certificates` (feature `os-certificates`), `os_certificates`, `root_certificate_sources`, `validate`. `Debug` shows counts. |
| `ProxySettings` | builder (`http` / `ws`) | `from_env` (default), `url`, `direct`, `is_from_env`, `is_direct`, `validate`. `Debug` never shows the user or password. |
| `HttpClient` | resource | `send`, `request`, `get`, `download`, `download_to`, `cancel`; `post_multipart`, `send_multipart` (feature `http`); with `json`: `send_json`, `get_json`, `post_json`, `post_multipart_json`, `is_json_registered`. |
| `Multipart` | builder (`http`) | `new`, `text`, `file`, `file_from_path`, `part`, `json` (feature `json`), `with_max_bytes`, `with_max_parts`, `len`, `is_empty`, `encoded_len`. `Debug` shows names and sizes only. |
| `StreamingBody`, `StreamingReader` | body | a body read while it is sent (a form with files from disk): `open()` (exact length + reader, on a transport's thread), `read_all()`, `max_bytes()`. |
| `WipedBytes` | body | the in-memory body of a request (`PreparedRequest::body`), overwritten with zeros when dropped: `Deref<Target = [u8]>`, `as_slice()`, `From<Vec<u8>>`; `Debug` shows the length only. |
| `HttpProgress` | message | upload progress: `id`, `sent`, `total`. |
| `HttpDownload` | builder | a download's file and checks: `to(path)`, `with_sha256`, `with_size`, `with_max_bytes`, `with_progress`, getters; `receive` (feature `http`: writes a body with every check, for your own transport). `Debug` shows the file name only. |
| `DownloadedFile` | struct | `path`, `bytes`, `sha256`, `status`, `headers`. |
| `HttpDownloadResponse`, `HttpDownloadProgress` | messages | a download's answer (`id`, `result: Result<DownloadedFile, BackendError>`); its progress (`id`, `received`, `total`). |
| `DEFAULT_DOWNLOAD_MAX_BYTES` | const | 256 MiB. |
| `OAuthClient` | resource (`oauth`) | `sign_in(&flow)` → `RequestId`, `cancel`. |
| `OAuthFlow` | builder (`oauth`) | `new(authorization_endpoint, token_endpoint, client_id)`, `google(client_id)`, `with_client_secret`, `with_scopes`, `with_timeout`, `with_param`, getters. `Debug` never shows the secret. |
| `OAuthSignInUrl`, `OAuthSignedIn`, `OAuthTokens` | messages / struct (`oauth`) | the page to open (`id`, `url`); the answer (`id`, `result: Result<OAuthTokens, BackendError>`); the tokens (`id_token`, `access_token`, `refresh_token` as `Secret`, `token_type`, `expires_in`, `scope`, `nonce`). |
| `GOOGLE_AUTHORIZATION_ENDPOINT`, `GOOGLE_TOKEN_ENDPOINT`, `DEFAULT_SIGN_IN_TIMEOUT` | consts (`oauth`) | Google's endpoints; 5 min. |
| `DEFAULT_MULTIPART_MAX_BYTES`, `DEFAULT_MULTIPART_MAX_PARTS` | consts (`http`) | 32 MiB, 256. |
| `BackendAppExt` | trait on `App` | `add_json_response::<T>()` (feature `json`); `add_ws_request::<R>()`, `add_ws_push::<P>()` (features `ws` + `json`). Sealed. |
| `RequestId` | id | opaque, unique per process, `Copy + Eq + Hash + Ord + Display`. |
| `OutgoingRequest` | request | constructors, `with_*` builders, accessors (`method`, `path`, `query`, `headers`, `body`, `timeout`, `purpose`, `uses_credentials`, `is_multipart`, `streaming_body`, `upload_progress`, `download`, `error`), `query_mut`, `headers_mut`, `set_body`, `reject`, `with_upload_progress`; `with_multipart` (feature `http`). |
| `RequestPurpose` | enum | `Http`, `WebSocketHandshake`. |
| `HttpResponse` | message | `id`, `result: Result<RawResponse, BackendError>`. |
| `JsonResponse<T>` | message | `id`, `result: Result<T, BackendError>` (feature `json`). |
| `RawResponse` | struct | `status`, `headers`, `body`, `file` (a download's `DownloadedFile`); `new`, `with_header`, `is_success`, `body()`, `text()`, `json()`. |
| `BackendError` | enum | see [Reading answers and errors](#4-reading-answers-and-errors); `status()`, `response()`, `retry_after()`, `is_invalid_request()`, `was_sent()`, `close_code()`, `host_key(..)`, `request_too_large(..)` (constructors for fakes). |
| `Credentials` | trait | `apply(&self, &mut OutgoingRequest)`; `ws_auth_message()` (default none: a first frame for WebSocket auth). |
| `BackendCredentials` | resource | `new`, `set`, `clear`, `is_set`. A WebSocket connection waiting for refreshed credentials connects again on `set` and ends on `clear`. |
| `BearerToken`, `ApiKeyHeader`, `ApiKeyQuery`, `JsonBodyField` | credentials | ready-made `Credentials` (`JsonBodyField`: feature `json`). |
| `Secret` | string | redacted in `Debug` / `Display`, overwritten with zeros when dropped; `new`, `expose`, `is_empty`. |
| `SecretFile` | file | one `Secret` in a file: `new(path)`, `path`, `load` (`io::Result<Option<Secret>>`), `save`, `remove`; atomic, owner-only on Unix. |
| `InFlight` | resource | HTTP, WebSocket, SSH and sign-ins: `contains`, `len`, `is_empty`, `ids`, `describe` (→ `RequestInfo`). |
| `RequestInfo` (struct), `RequestKind` (enum) | types | what a pending request is: `kind` (`Http`, `WebSocket`, `Ssh`, `Sftp`, `OAuth`), `method`, `target` (never an SSH command line); `#[non_exhaustive]`. |
| `Rejection` | struct | the payload of `BackendError::Rejected`: `new`, `bytes`, `text`, `json` (json). |
| `HttpTransport` | trait | `submit`, `poll`, `cancel`, `shutdown`, `streams_bodies`, `poll_progress`, `downloads_to_files`, `poll_download_progress`. |
| `HttpTransportResult` | type | `Result<RawResponse, BackendError>`. |
| `HttpTransportRes` | resource | `new(transport)`. |
| `PreparedRequest` | struct | what a transport receives: `method`, `uri`, `headers`, `body`, `streaming_body`, `upload_progress`, `timeout`, `max_body_bytes`, `purpose`, `download`; `path()`, `is_loopback()`, `is_https()`. |
| `FakeHttpTransport` | transport | `new`, `route`, `clear_routes`, `reply`, `progress`, `requests`, `last_request`, `waiting`, `cancelled`, `shutdown_count`. |
| `UreqTransport` | transport | feature `http`: `new(&config)`, `with_tls(&config, &tls)` (both read the proxy environment variables), `with_settings(&config, &tls, &proxy)`, `workers()`. |
| `http` | crate | the `http` 1.x crate, re-exported (`Method`, `StatusCode`, `HeaderMap`, …). |
| `DEFAULT_TIMEOUT`, `MAX_TIMEOUT`, `DEFAULT_WORKERS`, `MAX_WORKERS`, `DEFAULT_MAX_BODY_BYTES`, `DEADLINE_GRACE` | consts | 15 s, 1 h, 2, 8, 10 MiB, 5 s. |
| `WsClient` | resource (`ws`) | `connect`, `disconnect`, `send`, `send_text`, `send_binary`, `request_raw`, `request` (json), `cancel` (the shared one). |
| `WsConnections`, `WsConnectionInfo` | resource / struct (`ws`) | `get`, `state`, `is_connected`, `iter`; info: `state`, `attempt`, `last_error`, `pending_requests`, `queued_frames`. |
| `WsName` | name (`ws`) | a connection's name; `From<&str>` / `From<String>`, `as_str`, compares with `&str`. |
| `WsState` | enum (`ws`) | `Connecting`, `Connected`, `Reconnecting { attempt, retry_in }`, `WaitingForCredentials`, `Disconnected`; `#[non_exhaustive]`. |
| `WsSettings`, `WsReconnect` | builders (`ws`) | per connection: timeouts, heartbeat, limits, reconnect, headers, `allow_insecure_ws`, `without_credentials`, protocol, `with_auth_ack`, `with_credentials_refresh`; backoff: base, cap, max attempts, stable-after, jitter, `never()`, `delay_bound`. |
| `WsCredentialsRefresh` | builder (`ws`) | `new`, `with_timeout` (default 30 s), `with_close_code`, `timeout`, `close_codes`. |
| `WsCredentialsRefused` | message (`ws`) | `name`, `error`: refresh the credentials (one per refused credentials). |
| `WsFrame`, `WsOutgoing` | types (`ws`) | a text / binary frame (`Debug` shows the length only); a raw request (`kind`, payload, `resend_on_reconnect`, `with_timeout`). |
| `WsStateChanged`, `WsMessage`, `WsRawResponse` | messages (`ws`) | state changes (with the error), every data frame, raw answers; all with `name`. |
| `WsRequest`, `WsPushMessage`, `WsResponse<T>`, `WsPush<P>`, `JsonEnvelope` | (`ws` + `json`) | typed requests (`Response`, `KIND`, `resend_on_reconnect`), typed pushes (`KIND`), their messages, the default protocol. |
| `WsProtocol`, `WsIncoming` | trait / enum (`ws`) | `encode_request`, `decode`, `retry_after_close`; `Response { wire_id, result: Result<bytes, bytes> }`, `Push`, `AuthOk`, `AuthFailed`, `Ignore`. |
| `DEFAULT_WS_READ_TIMEOUT`, `DEFAULT_WS_MAX_MESSAGE_BYTES` | consts (`ws`) | 20 ms, 1 MiB. |
| `WsTransport`, `WsTransportRes`, `WsLinkId`, `WsLinkEvent`, `WsHandshake` | seam (`ws`) | `open`, `send`, `send_auth` (the first-message authentication; default: `send`), `close`, `poll`, `shutdown`; one link = one connection attempt. |
| `FakeWsTransport` | transport (`ws`) | `manual_accept`, `accept`, `reject_next`, `echo_envelope`, `push`, `drop_link`, `fail_link`, `opened`, `last_link`, `live_links`, `sent`, `all_sent`, `closed`, `shutdown_count`. |
| `TungsteniteTransport` | transport (`ws`) | the real one; `new()`, `with_tls(&tls)` (both read the proxy environment variables), `with_settings(&tls, &proxy)`. |
| `SshClient` | resource (`ssh`) | `connect`, `disconnect`, `run`, `cancel` (the shared one); with `sftp`: `upload`, `upload_file`, `download`, `download_file`, `list_dir`, `create_dir`, `remove_file`, `remove_dir`, `rename`, `sftp`. |
| `SshTarget` | builder (`ssh`) | `new(host, user)`, `from_ssh_config`, `from_ssh_config_file`, `with_port`, `with_user`, `with_auth`, `with_known_hosts_file`, `trust_host_key_fingerprint`, timeouts, keepalive, limits, `with_max_channels`, `with_reconnect`, `allow_terrapin_vulnerable`, `validate`, getters. |
| `SshAuth` | auth (`ssh`) | `key_file`, `key_file_with_passphrase`, `agent`; opt-in `password`, `keyboard_interactive`. `Debug` shows file names only, never secrets. |
| `SshPromptResponder`, `SshPromptAnswers`, `SshPromptRequest`, `SshPrompt` | auth (`ssh`) | keyboard-interactive: the responder trait (`respond(&request) -> Option<Vec<Secret>>`), a ready-made word-matching responder (`answer_containing`), one round of server prompts (`name`, `instructions`, `prompts`: `text`, `echo`). |
| `SshReconnect` | builder (`ssh`) | `with_base`, `with_cap`, `with_max_attempts`, `with_stable_after`, `with_jitter`, `delay_bound`; given with `SshTarget::with_reconnect`. |
| `SshCommand`, `SshExit`, `SshStream` | types (`ssh`) | a command (`new`, `with_timeout`, `with_max_output_bytes`, `with_stdin`; `From<&str>`); how it ended (`status`, `signal`, byte counts, `success()`); stdout / stderr. |
| `SshSettings` | builder (`ssh`) | plugin-wide: `allow_in_release`, `with_max_connections`, `with_max_requests_per_connection`, `is_allowed`. Given with `BackendPlugin::with_ssh`. |
| `SshConnections`, `SshConnectionInfo`, `SshState`, `SshName` | state (`ssh`) | as for WebSocket; info: `state`, `fingerprint`, `last_error`, `pending_requests`, `attempt`; `SshState::Reconnecting { attempt, retry_in }`. |
| `SshStateChanged`, `SshOutput`, `SshFinished` | messages (`ssh`) | state changes; output chunks; the one answer per command (`started`, `result`). |
| `SftpOp`, `SftpOutcome`, `SftpEntry`, `SftpEntryKind`, `SftpProgress`, `SftpFinished` | types / messages (`sftp`) | an operation, its outcome (`Uploaded`, `Downloaded`, `Data`, `Listing`, `Done`), a listing entry (`safe_file_name()`; `name` is untrusted), progress, the one answer. |
| `HostKeyProblem` | enum | `Unknown`, `Changed`, `Revoked` (in `BackendError::HostKey`). |
| `SshTransport`, `SshTransportRes`, `SshEvent`, `SshConnId` | seam (`ssh`) | `connect`, `run`, `sftp` + `supports_sftp` (default: none), `cancel`, `close`, `poll`, `shutdown`. |
| `FakeSshTransport` | transport (`ssh`) | `manual_connect`, `accept`, `reject_next`, `on_command`, `output`, `finish`, `drop_conn`, `on_next_sftp`, `sftp_progress`, `sftp_finish`, `connects`, `last_conn`, `live_conns`, `commands`, `running`, `sftp_ops`, `cancelled`, `closed`, `shutdown_count`. Never touches the network. |
| `RusshTransport` | transport (`ssh`) | the real one; `new()`, `with_release_allowed`. |
| `DEFAULT_SSH_CONNECT_TIMEOUT`, `DEFAULT_SSH_COMMAND_TIMEOUT`, `DEFAULT_SSH_MAX_OUTPUT_BYTES`, `DEFAULT_SFTP_TIMEOUT`, `DEFAULT_SFTP_MAX_BYTES`, `MAX_SSH_COMMAND_BYTES` | consts (`ssh`) | 15 s, 60 s, 8 MiB, 5 min, 256 MiB, 64 KiB. |

## Good to know

- **Platforms:** native Windows, Linux and macOS.
- **HTTP/1.1** (ureq). A response is read whole (up to the body limit) before it is delivered; a
  download (`HttpClient::download`) is written to its file as it arrives.
- **Redirects:** a 3xx arrives as `Status` with its `Location` header.
- **Each request is sent once.** Retrying, queueing while offline, caching and cookies are your
  game's decisions; the error kind and the "Sent?" column in
  [Reading answers and errors](#4-reading-answers-and-errors) tell you whether it may have been sent.
- **Credentials** are whatever the game puts into `BackendCredentials`; the game refreshes them and
  can keep one between runs with `SecretFile` (a WebSocket connection can wait for that refresh, see
  [WebSocket](#10-websocket-connections-feature-ws)).
- **One base URL** per app (`HttpConfig`, changeable at runtime with `set_base_url`). Paths are
  relative to it; absolute URLs are refused.
- **A reverse proxy in front of your API must pass responses through unchanged**: no
  decompressing or recompressing on its own (Caddy: no `encode` directive for these routes; nginx:
  `gzip off`). The body limit and gzip handling assume the client sees exactly what your
  application sent; a proxy that re-encodes can turn a body under the limit into one over it, or
  add a `Content-Encoding` the client (without feature `gzip`) cannot read.
- **Root certificates** come from webpki-roots (Mozilla's list) unless a `TlsSettings` adds extra
  roots or (feature `os-certificates`) switches to the operating system's store, see
  [Certificates](#certificates-extra-roots-and-the-operating-systems-store).
- **Proxies** (`ProxySettings`): HTTP requests through `http://` and `https://` proxies, WebSocket
  connections through `http://` proxies, both as `CONNECT` tunnels; a request or connection that
  would have to use a SOCKS proxy (or an `https://` proxy for a WebSocket connection) is answered
  `InvalidRequest` and never made around the proxy. Windows' registry proxy settings are not read.
- **Cancel does not interrupt** a request already on the wire; it holds its worker thread until
  ureq's timeout at most. A download stops at its next 64 KiB piece and removes its part file.
- **App exit waits at most 1 s,** only for running downloads to remove their part files.
- **Uploads:** in-memory parts are built into one body (at most `with_max_bytes`, about twice
  the in-memory parts at the peak while sending); files from disk (`file_from_path`) are streamed.
- **WebSocket (feature `ws`):** messages go uncompressed (permessage-deflate is not negotiated, so a
  server that requires compression refuses the connection); a subprotocol is set as a
  `Sec-WebSocket-Protocol` header with `with_header`; one thread per connection (fine for a few
  connections, not for hundreds). A large message you send occupies its connection thread until the
  socket takes it (that time does not count as the server's silence); if the server accepts no data
  for 30 s (or `dead_after`, if longer), the connection ends with a `Timeout` saying so. At `trace`
  level tungstenite prints the content of the frames it sends and receives (not the handshake
  request or the first-message authentication, which the crate writes itself): keep `tungstenite`
  below `trace` like `ureq`. A proxy is used through `CONNECT` only when it is an `http://` proxy;
  with an `https://` or SOCKS proxy set, a connection that would use it fails with `InvalidRequest`.
  Received frames the game does not take are limited to 32 times the message limit per connection
  (then it closes with 1008).
- **SSH (feature `ssh`):** connections run commands (exec channels, no terminal) and SFTP;
  reconnect only when enabled and never for a running command. Output arrives in chunks, not
  lines. A cancelled or timed-out remote process may keep running (see the SSH section). SFTP
  downloads keep 16 reads of 64 KiB in flight and uploads 16 writes of 32 KiB. russh logs agent
  sign requests at `debug` (challenge bytes, not secrets) and packet details at `trace`: keep
  `russh` at `info` or below like `ureq`. A lost connection is noticed through russh's own
  disconnect report, backed by a once-a-second check of the session; a lost network without any
  reset is noticed by the keepalive (15 s, 3 misses).

## Versions

| bevy_net_backend | Bevy | ureq | tungstenite (`ws`) | russh (`ssh`) | rustls | Rust (MSRV) |
|---|---|---|---|---|---|---|
| 0.2.0 | 0.19.0 | 3.4.2 | 0.30.0 | 0.63.3 | 0.23.45 | 1.95 |
| 0.1.0 | 0.19.0 | 3.4.2 | 0.30.0 | 0.63.3 | 0.23.45 | 1.95 |

## Examples

All examples are headless and exit on their own. Without `BACKEND_URL` they start the mock server
from `examples/mock_server.rs` on 127.0.0.1 inside the example process.

| Example | Shows |
|---|---|
| `fetch_json` | `get_json::<Character>`, matching the answer by id, error bodies. |
| `upload` | a `multipart/form-data` avatar upload (a text field + an image) with `post_multipart_json`; the mock parses it and answers what it received. |
| `post_with_token` | 401 before login, login `without_credentials`, `BearerToken`, a 422 validation error decoded from the error body, a successful authenticated `POST`. |
| `mock_server` | the mock API on its own and its JSON contract (including `POST /upload`, a real multipart parser that echoes what it received): `--seconds N` (maximum runtime, default 60; it exits by itself), `--bind ADDR` (default `127.0.0.1:0`). |
| `chat_client` (features `ws`, `json`) | a named connection, a typed request and its answer, typed pushes, state changes, disconnect. Starts `mock_ws_server` unless `BACKEND_WS_URL` is set. |
| `mock_ws_server` (features `ws`, `json`) | the mock WebSocket server and its envelope contract (echo, `chat.send` + push, `fail`, `close`, `drop`, `stall`, periodic `server.tick`, `/secure` needing a bearer token, `/busy` refusing with `503` and `Retry-After: 2`): `--seconds N`, `--bind ADDR` (default `127.0.0.1:0`), `--tick-ms N`. |
| `ssh_console` (feature `ssh`; SFTP steps with `sftp`) | connect with a known_hosts file, run commands and print their output and exit, then upload, list, download and remove a file one step after the other, disconnect. Starts `mock_ssh_server` with a throwaway key (written to `target/ssh-example/`) unless `SSH_HOST`, `SSH_USER`, `SSH_KEY` and `SSH_KNOWN_HOSTS` are set. |
| `mock_ssh_server` (feature `ssh`; SFTP with `sftp`) | the mock SSH server: canned commands (never executes anything), an in-memory SFTP file system, a throwaway host key; alone it writes a throwaway client key and a known_hosts file to `--out-dir` (default `target/mock-ssh`): `--seconds N`, `--bind ADDR` (default `127.0.0.1:0`), `--user NAME`, `--host NAME` (the name clients reach it by, for the known_hosts line; with `--bind 0.0.0.0:P` and no `--host` the line says `CHANGE-ME`, or pin the printed fingerprint), `--password PW` and `--kbd PW:CODE` (also accept a password / a keyboard-interactive `Password:` + `Verification code:` login; throwaway test values only, visible in the process list). |

```text
cargo run --example fetch_json
cargo run --example post_with_token
cargo run --example upload
cargo run --example mock_server -- --seconds 120
cargo run --example mock_server -- --seconds 1800 --bind 127.0.0.1:8080
BACKEND_URL=http://127.0.0.1:8000/api cargo run --example fetch_json
cargo run --example chat_client --features ws,json
cargo run --example mock_ws_server --features ws,json -- --seconds 1800 --bind 127.0.0.1:9001
cargo run --example ssh_console --features ssh,sftp
cargo run --example mock_ssh_server --features ssh,sftp -- --seconds 600
```

Pointed at your own backend (`BACKEND_URL`), `fetch_json` expects `GET /characters/1` →
`{"id":1,"name":"…","class":"…","level":7}`, and `post_with_token` expects the routes in its
header comment (the login reads `BACKEND_USERNAME` / `BACKEND_PASSWORD`).

## How it's tested

- **Unit tests, integration tests and every Rust block of this README**, plus live tests that are
  `#[ignore]`d by default. CI runs the tests for each feature set of its matrix on Linux, Windows
  and macOS with Rust 1.96.0, clippy with `-D warnings` for every set, rustfmt, the docs with
  `-D warnings`, a build with the minimum Rust version (1.95), dependency-tree checks (one ring,
  one rustls, no tokio without `ssh`, no OpenSSL / native-tls / aws-lc / libssh2) and a RustSec
  advisory check (cargo-deny) on every pull request, every push and weekly. RUSTSEC-2023-0071
  (rsa, Marvin) is accepted in `deny.toml`: `rsa` is compiled only with the opt-in `ssh-rsa`
  feature, but `Cargo.lock` always lists it, and no fixed release exists.
- **Hostile and slow servers are part of the regular suite:** the real transports run against mock
  servers on 127.0.0.1 inside the test process: gzip bombs, bodies over the limit, servers that
  answer too late, trickle a handshake or a TLS record byte by byte, stop reading while the client
  sends a large body, send a huge SSH banner or never say anything. Tests never contact another
  host.
- **Live-tested against a real server:** an Ubuntu machine on the internet behind Caddy with a real
  Let's Encrypt certificate.
  - **HTTP:** the real certificate chain accepted, and a wrong-name and an untrusted certificate
    rejected; every credential type; timeouts, including a request that waited for a worker;
    body limits; redirects not followed; a gzip bomb stopped at the limit; parallel requests,
    each answered exactly once; cancel and exit mid-flight.
  - **WebSocket over `wss://`:** typed requests and pushes, named connections, handshake
    credentials, close codes, message limits; the server was stopped and restarted in the middle
    of a session, and the client reconnected, authenticated again and answered the lost request
    honestly as "sent".
  - **SSH and SFTP against real OpenSSH:** strict key exchange and AES-GCM confirmed in the
    server's own log; cancelled and timed-out commands gone from the server; uploads; a reconnect
    that never re-ran a command; connection resets noticed.
  - **Uploads:** byte for byte (CRC-32) against real PHP, Express + multer, FastAPI, Go and Django
    (see [what real backends do with an upload](#what-real-backends-do-with-an-upload)); limits
    refused before sending really never reached the server.

Run it yourself:

- `cargo test` runs the unit tests, the `FakeHttpTransport` tests (every answer kind, exactly one
  answer each, strict ambiguity detection), a log-capture test proving no secret is logged (every
  `tracing` event and every `log` record at `trace`, with real WebSocket and SSH connections when
  those features are on), the loopback tests (the real `UreqTransport` against the mock server on
  127.0.0.1: statuses, redirects, timeouts, body limit, TLS handshake failure, login flow, exit
  while busy), the proxy tests (the real transport through a local `CONNECT` proxy from the
  environment and from `ProxySettings::url`, `NO_PROXY` and loopback hosts direct,
  `ProxySettings::direct`, and SOCKS proxies refused with no connection made anywhere) and the
  upload tests (the real transport against the mock's multipart parser; files streamed from disk, a
  JSON part, upload progress, refusals before sending, and a large file streamed to a local server
  that hashes it on the fly). With `--features ws` (and `--all-features`) also the WebSocket tests:
  every lifecycle path on a `FakeWsTransport` (credentials refresh included: one message for several
  refused connections, one new connection, a second refusal final), the real transport against
  `mock_ws_server` (large messages across many short read timeouts, reconnect, heartbeat, 401, a 503
  with `Retry-After`, a refused token refreshed by the game, 1009, exit), a TLS test with large
  messages cut by read timeouts mid-record, and connections through a local `CONNECT` proxy set in
  the environment or in code (`ws://` and `wss://`, proxy credentials, a refusing proxy, loopback
  direct, `ProxySettings::direct`, SOCKS and `https://` proxies refused), and `TlsSettings` against
  loopback HTTPS and WSS servers with a throwaway CA (refused by default and with another CA's root,
  trusted with the CA as PEM text or as a file, unusable settings answered without sending; with
  `os-certificates` the operating system's store with and without the CA on top). Every set runs the
  `SecretFile` tests: round trip, atomic replace, a missing folder, damaged, foreign, non-UTF-8 and
  oversized files, bad paths, and on Unix the `0600` / `0700` modes. With `--features ssh` (and
  `ssh,sftp`) also the SSH tests: every lifecycle path on a `FakeSshTransport` (including one app
  with HTTP, WebSocket and SSH cancelling each other's requests), the real `RusshTransport` against
  `mock_ssh_server` with throwaway keys generated at runtime (commands, timeouts, cancel, output
  limit, strict host keys, passphrases, ssh_config, SFTP: a large download compared by checksum,
  sizes from 0 bytes up, short reads, a server error, cancel and timeout in the middle, a lost
  connection, an SFTP channel that ends, and a file cut short on the server, with no file left
  behind; files that report size 0 or no size), the download pipeline against an in-memory file that
  answers out of order, and hostile raw TCP peers (silent, trickling, huge banner) that must not
  stretch the connect deadline. Every set with `http` runs the download tests (the real transport
  against a server the test starts on 127.0.0.1: a file over the in-memory answer limit with its
  SHA-256 and progress, a chunked body, an expected SHA-256 / size that does not match, the size
  limit, a body cut short, a 404, cancel / timeout / exit in the middle (the part file gone when the
  exit frame ends), a cancel before and after the rename, a folder that does not exist and a target
  that is a folder (never sent), a part file an earlier run left, no part file left behind; and the
  fake transport). With `--features oauth` (and `--all-features`) the sign-in tests run against a
  mock OpenID Connect provider on 127.0.0.1 with the test as the browser: PKCE checked by the token
  endpoint, a forged or missing `state` ignored, a declined sign-in, a refused or reused code, an
  answer without an ID token, a token endpoint that is down, the time limit, cancel, app exit, and
  the listener closed after use; the log-capture test runs a whole sign-in too. `cargo test
  --all-features` also compiles every Rust block of this README.
- `tests/live.rs` holds live HTTPS checks, `#[ignore]`d: they run only with
  `cargo test --test live -- --ignored` and `BNB_TEST_HTTPS_URL` set to a server that serves the
  mock's contract over HTTPS (for example `mock_server` behind a TLS-terminating reverse proxy on
  a test machine).
- `tests/live_ws.rs` does the same for WebSocket: `cargo test --features ws --test live_ws -- --ignored`
  with `BNB_TEST_WSS_URL` set to `mock_ws_server` behind a TLS proxy (for example `wss://…/ws`).
- `tests/live_multipart.rs` uploads to the mock (`BNB_TEST_HTTPS_URL` + `/upload`) and to any echo
  servers listed in `BNB_TEST_MULTIPART_URLS` (comma-separated upload URLs, e.g. small PHP /
  Express / FastAPI / Go / Django servers that answer the mock's JSON echo shape, documented in
  `examples/mock_server.rs`), and checks the names, values, content types, sizes and CRC-32 they
  report, for a simple form and for the cases every tested framework reads the same way:
  `cargo test --test live_multipart -- --ignored --test-threads 1`.
- `tests/live_ssh.rs` checks a real OpenSSH server: `cargo test --features ssh,sftp --test live_ssh
  -- --ignored --test-threads 1` with `BNB_TEST_HOST`, `BNB_TEST_SSH_USER`, `BNB_TEST_SSH_KEY` (a key
  file) and `BNB_TEST_SSH_KNOWN_HOSTS` (a known_hosts file) set, optionally `BNB_TEST_SSH_PORT` and
  `BNB_TEST_SSH_PASSPHRASE`. It runs harmless commands only (`echo`, `uname`, `whoami`, `sleep`)
  and SFTP inside a new temporary directory in the user's home that it removes again.
- Against your real API, run the examples with `BACKEND_URL` (see [Examples](#examples)).

## FAQ

**Why not reqwest?** reqwest needs tokio (an async runtime next to Bevy's) and is a much larger
tree. A game API client does a handful of requests; blocking ureq on two threads is plenty.

**Why not Bevy's `IoTaskPool`?** It has 1–4 threads shared with asset loading. A 15-second
request would stall asset IO; a dedicated pool cannot.

**How many APIs can one app call?** One base URL per app (`HttpConfig`); `set_base_url` changes
it at runtime.

**Is the answer delivered if my reader runs in `PostUpdate`?** Yes. Answers are written in
`First` before Bevy's message update, so they are readable in every schedule of that frame (and
in the next frame's `First` before the update), then dropped. A reader that runs only every
other frame can miss them.

**Can I build a `JsonResponse<T>` myself for a unit test?** No (it is `#[non_exhaustive]` and
`RequestId` has no public constructor). Drive your systems through the plugin with a
`FakeHttpTransport` instead (see [Testing your game](#8-testing-your-game-without-a-server)).

**Where does the token live between sessions?** Wherever your game keeps it. `SecretFile` stores
one `Secret` in a file (atomic, owner-only on Unix, see
[Keeping a token between runs](#keeping-a-token-between-runs-secretfile)); the crate sends what is
in `BackendCredentials`, and its `Secret` wipes its memory when it is dropped.

**My development server has a self-signed certificate.** Trust that certificate (or its CA) with
`TlsSettings::with_root_certificates_file` or `with_root_certificates_pem`, given to
`BackendPlugin::with_tls`; Mozilla's roots stay trusted next to it. For a CA installed on the
machine: feature `os-certificates` and `with_os_certificates(true)`.

**Can my game use SSH to talk to its servers?** Not a game you give to players: an SSH key in a
player build is shell access for anyone who extracts it. Use HTTP or WebSocket with per-player
tokens for that. SSH is for your own admin and developer tools, and release builds refuse it
unless the tool explicitly opts in (`SshSettings::allow_in_release`).

**How do I upload a screenshot or a save file?** `HttpClient::post_multipart` with a
`Multipart` form (see [File uploads](#12-file-uploads-multipart)): `file` for bytes you have,
`file_from_path` for a file on disk (read on the worker thread while it is sent, with
`HttpProgress` messages).

**Can SSH log in with a password or a 2FA code?** Yes, opt-in: `SshAuth::password` and
`SshAuth::keyboard_interactive` (see the SSH section). Keys stay the recommendation; the values are
typed by the admin at runtime and never logged.

**Why does `ssh` bring tokio when nothing else does?** Every maintained, complete SSH client in
Rust that needs neither C nor OpenSSL is built on tokio (russh). The crate keeps it contained: one
current-thread runtime on one thread of its own, started on the first connect, and nothing of it
without the feature.

**Does it follow the system proxy?** The `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` / `NO_PROXY`
environment variables, yes, for HTTP requests and WebSocket connections (not for loopback hosts),
unless `BackendPlugin::with_proxy` sets one proxy (`ProxySettings::url`) or none
(`ProxySettings::direct`). HTTP requests go through `http://` and `https://` proxies, WebSocket
connections through `http://` proxies; with a SOCKS proxy set, a request that would use it is
answered `InvalidRequest` and never made around the proxy. Windows' registry proxy settings, no.

## License

Licensed under either of

- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or <http://www.apache.org/licenses/LICENSE-2.0>)
- MIT license ([LICENSE-MIT](LICENSE-MIT) or <http://opensource.org/licenses/MIT>)

at your option.

## Contributing

Issues and pull requests are welcome. Please run `cargo fmt`, `cargo clippy --all-targets
--all-features -- -D warnings` and `cargo test` (with and without default features) before
opening a pull request. Unless you explicitly state otherwise, any contribution intentionally
submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual
licensed as above, without any additional terms or conditions.