mirror of
https://github.com/leanprover/lean4.git
synced 2026-04-13 15:44:15 +00:00
Compare commits
681 Commits
sg/fix-get
...
sofia/asyn
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8099bffcc | ||
|
|
ea16a1de33 | ||
|
|
7b8b15a136 | ||
|
|
53a343cad4 | ||
|
|
b2cd0501c5 | ||
|
|
d4a080dbf2 | ||
|
|
7bd5e107fb | ||
|
|
d8847cdc4a | ||
|
|
c351ba5385 | ||
|
|
09a7174d24 | ||
|
|
f502c4e2e1 | ||
|
|
a7527d5139 | ||
|
|
fe9fb63454 | ||
|
|
c3a2783d71 | ||
|
|
808f3a7753 | ||
|
|
dda9e3c6d5 | ||
|
|
5198a449f9 | ||
|
|
7e628ada8b | ||
|
|
6ee95db055 | ||
|
|
89e52c3359 | ||
|
|
77bbbc3b16 | ||
|
|
125ac55801 | ||
|
|
74d425f584 | ||
|
|
e89331b9e9 | ||
|
|
43a2dd5809 | ||
|
|
d6b2e0b890 | ||
|
|
83df67ff34 | ||
|
|
0ac6746e3a | ||
|
|
85348b91a5 | ||
|
|
cb55e2f921 | ||
|
|
b2791f1564 | ||
|
|
c69f5d63dc | ||
|
|
41470c1c0a | ||
|
|
a5551e3291 | ||
|
|
96253d357f | ||
|
|
db1d553245 | ||
|
|
286182df24 | ||
|
|
3eee136224 | ||
|
|
38f189dab2 | ||
|
|
55ce4dc2b0 | ||
|
|
bb90f72a40 | ||
|
|
c485824d11 | ||
|
|
afe1676e4a | ||
|
|
64889857b2 | ||
|
|
0ac5d75bac | ||
|
|
e4f2f5717c | ||
|
|
abbe36c0d2 | ||
|
|
7ef652911e | ||
|
|
9ef386d7c3 | ||
|
|
b9b2e08181 | ||
|
|
33caa4e82f | ||
|
|
8c292c70ee | ||
|
|
4f4ee7c789 | ||
|
|
d7ea3a5984 | ||
|
|
33c36c7466 | ||
|
|
af40af987c | ||
|
|
65da1ee047 | ||
|
|
d4884cde14 | ||
|
|
49da0f2d9c | ||
|
|
7fbecca6f0 | ||
|
|
ae5a3d2c8b | ||
|
|
1a270555ae | ||
|
|
72702c3538 | ||
|
|
e86dbf3992 | ||
|
|
d71f0bdae7 | ||
|
|
6ae49d7639 | ||
|
|
232d173af3 | ||
|
|
3a4a309aed | ||
|
|
033b7b537a | ||
|
|
9c87a9f044 | ||
|
|
34c9cafc12 | ||
|
|
014dd1d263 | ||
|
|
2a7a407875 | ||
|
|
e359001026 | ||
|
|
72244398dc | ||
|
|
c0e60b797c | ||
|
|
400908a2f4 | ||
|
|
394c999c2a | ||
|
|
b7e88dadeb | ||
|
|
a39a0575a0 | ||
|
|
5815f33342 | ||
|
|
4fdf94ed3d | ||
|
|
66743e80a6 | ||
|
|
2d0d63f5d3 | ||
|
|
10951fdb57 | ||
|
|
71d3967338 | ||
|
|
34dbcb2ca5 | ||
|
|
abb60e47c8 | ||
|
|
7a852aedb6 | ||
|
|
1554f57525 | ||
|
|
1fa01cdadb | ||
|
|
758e5afb07 | ||
|
|
11516bbf09 | ||
|
|
f76dca5bba | ||
|
|
fe6ac812af | ||
|
|
51a00843ea | ||
|
|
c8c702af8d | ||
|
|
5b5b0fad70 | ||
|
|
eab144bbb2 | ||
|
|
cfe282f024 | ||
|
|
e7f06c8fa2 | ||
|
|
beb85dd6b0 | ||
|
|
debafcf0ef | ||
|
|
2668f07808 | ||
|
|
e3928b7b1a | ||
|
|
2f3a97ed8a | ||
|
|
0315d56389 | ||
|
|
b9e489cc8f | ||
|
|
135b049080 | ||
|
|
4005bd027b | ||
|
|
fbf03e31f9 | ||
|
|
39ab2b289c | ||
|
|
6c6f9a5d83 | ||
|
|
a7aea9a12d | ||
|
|
9517b5bc2d | ||
|
|
71debba5a2 | ||
|
|
a2c5f3c79e | ||
|
|
fd9117fc12 | ||
|
|
1b6357dc03 | ||
|
|
38cb50d629 | ||
|
|
74af777707 | ||
|
|
3dfb5e002a | ||
|
|
3075e5091b | ||
|
|
af12f7e9be | ||
|
|
a2f9f74740 | ||
|
|
13fb8a5980 | ||
|
|
41d2984f25 | ||
|
|
f63639d42b | ||
|
|
6df74943e0 | ||
|
|
865b147a91 | ||
|
|
c2f2b3cf32 | ||
|
|
4173713f94 | ||
|
|
53c9277209 | ||
|
|
f14977f495 | ||
|
|
cfa5cf76fc | ||
|
|
238925a681 | ||
|
|
8cb236e9eb | ||
|
|
3d039f8dba | ||
|
|
203d5362d4 | ||
|
|
6189d4c130 | ||
|
|
58f14d34d7 | ||
|
|
710eee2b49 | ||
|
|
bd4af50d04 | ||
|
|
8cb30347b6 | ||
|
|
d8e6b09b90 | ||
|
|
df8abc2b3f | ||
|
|
5a852bdffd | ||
|
|
11d3860c69 | ||
|
|
5a253001b3 | ||
|
|
083fec29c8 | ||
|
|
d41753a5f9 | ||
|
|
a086a817e0 | ||
|
|
e434a4d44b | ||
|
|
7295389284 | ||
|
|
f8e1bc685a | ||
|
|
5e1204e70d | ||
|
|
a00ec10261 | ||
|
|
cb9b182824 | ||
|
|
61d7c151da | ||
|
|
f9f1bdc77b | ||
|
|
f3452c09a9 | ||
|
|
2bed27681a | ||
|
|
5bb3b08698 | ||
|
|
82645d0953 | ||
|
|
2ab52fb864 | ||
|
|
1bba3082f0 | ||
|
|
7ed7a1b69d | ||
|
|
bd10d0193e | ||
|
|
67822f4c42 | ||
|
|
e7f6fbb473 | ||
|
|
1cb3d56618 | ||
|
|
d99485dd79 | ||
|
|
f85b9b8d09 | ||
|
|
5fb254b7ef | ||
|
|
6e202e34a4 | ||
|
|
843c814778 | ||
|
|
c7d4d8d799 | ||
|
|
91c60f801c | ||
|
|
ae30f55728 | ||
|
|
63b0cc17c4 | ||
|
|
c9a5111dcc | ||
|
|
8e12a4181c | ||
|
|
33393a7c00 | ||
|
|
7434b97511 | ||
|
|
29c8f8cfa1 | ||
|
|
36b2d99e3d | ||
|
|
4b8a48c817 | ||
|
|
e0862a0220 | ||
|
|
10fc7da3fa | ||
|
|
a1f535d9d8 | ||
|
|
993c87dd80 | ||
|
|
742e3080c9 | ||
|
|
3de1d21c86 | ||
|
|
83a0756b05 | ||
|
|
b8f2cd94aa | ||
|
|
64ff045559 | ||
|
|
109ab8eb68 | ||
|
|
bf09ea8ff5 | ||
|
|
7ce9fe9f97 | ||
|
|
aff9e0c459 | ||
|
|
a74df33feb | ||
|
|
dd63b614eb | ||
|
|
515e6e20c0 | ||
|
|
cc45fc9cc2 | ||
|
|
bc9c18f0b0 | ||
|
|
8ee21a7176 | ||
|
|
92aa9f2b8a | ||
|
|
c2243a0ea5 | ||
|
|
efbd23a6d9 | ||
|
|
26440fcf6a | ||
|
|
ac4c5451e4 | ||
|
|
c94c5cb7e4 | ||
|
|
78ca6edc99 | ||
|
|
d92dc22df3 | ||
|
|
48ab74f044 | ||
|
|
da68a63902 | ||
|
|
db99fd2d7d | ||
|
|
a61712c962 | ||
|
|
ea36555588 | ||
|
|
b02bc4d6d2 | ||
|
|
c836fe8723 | ||
|
|
8068ed317c | ||
|
|
0bd44ab745 | ||
|
|
172d12c75c | ||
|
|
6b6b9fffff | ||
|
|
f3fa5c8242 | ||
|
|
b0c5667f06 | ||
|
|
2d262c9755 | ||
|
|
571898bf63 | ||
|
|
0570277a2e | ||
|
|
557709d9bb | ||
|
|
0229508ca7 | ||
|
|
ace10ee42b | ||
|
|
4e36dcc98f | ||
|
|
a93ea184fe | ||
|
|
c309a3c07e | ||
|
|
30641c617f | ||
|
|
37fcb2ce55 | ||
|
|
97cd66afde | ||
|
|
6dbb6b8d0e | ||
|
|
4306782b93 | ||
|
|
6935306439 | ||
|
|
1aa23cd92b | ||
|
|
0bb4ba72d4 | ||
|
|
57a4d9ad4b | ||
|
|
bfc6617c12 | ||
|
|
c1b5b64797 | ||
|
|
9b563220b2 | ||
|
|
0eb4a6e8c6 | ||
|
|
4614def4cd | ||
|
|
c97dfe585a | ||
|
|
74ecbca430 | ||
|
|
6fa6d2e3f7 | ||
|
|
05c4d9202a | ||
|
|
3a4e9f6eca | ||
|
|
aa09ab0cd9 | ||
|
|
8affe05767 | ||
|
|
3aa02eede3 | ||
|
|
c86f926d1b | ||
|
|
ff4419357c | ||
|
|
3c131da050 | ||
|
|
5fd94a1e1d | ||
|
|
fcc4185bb2 | ||
|
|
bae251d15a | ||
|
|
6edc0c7427 | ||
|
|
563189fec9 | ||
|
|
25d7db2e62 | ||
|
|
e569c9ef64 | ||
|
|
c467175336 | ||
|
|
7562c103dd | ||
|
|
1be8c11cee | ||
|
|
ea6c1e65f6 | ||
|
|
67300c640c | ||
|
|
625e1c9a32 | ||
|
|
b09946684b | ||
|
|
beedfa1e4e | ||
|
|
f68c2420e7 | ||
|
|
cdfd24171a | ||
|
|
718e549de3 | ||
|
|
81f76a24d8 | ||
|
|
292f297006 | ||
|
|
b7be57272a | ||
|
|
a0dc1dbbc0 | ||
|
|
2e604884dd | ||
|
|
2049542833 | ||
|
|
caf19b8458 | ||
|
|
c5180b2dfc | ||
|
|
91c5b717f0 | ||
|
|
cb6f540efb | ||
|
|
ec833b52ee | ||
|
|
ba36c1dee2 | ||
|
|
5cb510cdf7 | ||
|
|
a72de461cd | ||
|
|
228f0d24a7 | ||
|
|
73cf41d7e5 | ||
|
|
819d4c6c1f | ||
|
|
4de3e40349 | ||
|
|
03f1d47462 | ||
|
|
a88908572c | ||
|
|
55d357dbb4 | ||
|
|
49d00ae056 | ||
|
|
e9eed5cbe4 | ||
|
|
2652ae0fb8 | ||
|
|
3f48ef4af9 | ||
|
|
a9de308aea | ||
|
|
405d03aac9 | ||
|
|
d5a819f30f | ||
|
|
81c3e5034a | ||
|
|
c971d3f490 | ||
|
|
26bcd2d065 | ||
|
|
9c1054adca | ||
|
|
cba7bfbbe7 | ||
|
|
2990b41d44 | ||
|
|
f543206d4a | ||
|
|
1cd2cba130 | ||
|
|
a009ad2a68 | ||
|
|
6a19fc5a21 | ||
|
|
91275b3747 | ||
|
|
df80ac720a | ||
|
|
6797ca9345 | ||
|
|
c266649454 | ||
|
|
7160b92bfb | ||
|
|
6d1a0ecc8a | ||
|
|
fd96be3870 | ||
|
|
3a3620e8aa | ||
|
|
11fd4c8244 | ||
|
|
2731e1d942 | ||
|
|
0ef3c83ed8 | ||
|
|
edad8a090b | ||
|
|
74dc55152f | ||
|
|
bf2471b8f1 | ||
|
|
21821ef062 | ||
|
|
5ba3a6d4fc | ||
|
|
8492e58a82 | ||
|
|
e65e20e1cb | ||
|
|
de7c029c9f | ||
|
|
89c992a3c9 | ||
|
|
0b76c3de69 | ||
|
|
ff99979855 | ||
|
|
9ddbb59fe1 | ||
|
|
36f87f98f8 | ||
|
|
5914fe3a4a | ||
|
|
29f651a89c | ||
|
|
2e1bdd922e | ||
|
|
ab5d50cbc3 | ||
|
|
7902db17c2 | ||
|
|
5626ee369c | ||
|
|
682e2b99f3 | ||
|
|
6ed32edec0 | ||
|
|
662bed5a28 | ||
|
|
d0e884dc54 | ||
|
|
abf3305397 | ||
|
|
a6f42abe62 | ||
|
|
7a50344af4 | ||
|
|
c7bcd4fbed | ||
|
|
d367a9fe80 | ||
|
|
0e0578eacb | ||
|
|
663eec9dc3 | ||
|
|
e62f8d608d | ||
|
|
0fb57a405f | ||
|
|
ce009e2dca | ||
|
|
c9cf60f173 | ||
|
|
5263c32ea4 | ||
|
|
89191367b7 | ||
|
|
999ce40ca6 | ||
|
|
bfa18ef30c | ||
|
|
a850879adf | ||
|
|
34c5c70ec6 | ||
|
|
81492aa5b2 | ||
|
|
e0efb8aec9 | ||
|
|
530f6865f9 | ||
|
|
f97d86cf4b | ||
|
|
781b9f561e | ||
|
|
a9ac33d994 | ||
|
|
c457a98d6a | ||
|
|
8d8439bf0b | ||
|
|
7cf419491a | ||
|
|
4cbdb39211 | ||
|
|
54ac93fb32 | ||
|
|
eddb5e139d | ||
|
|
5a53207723 | ||
|
|
0d3f6e5481 | ||
|
|
96a017262c | ||
|
|
04c73b64a5 | ||
|
|
02adf1fae0 | ||
|
|
9291e925ff | ||
|
|
1d0e26e494 | ||
|
|
5528f97c8f | ||
|
|
32d42b52e9 | ||
|
|
f1ed971f26 | ||
|
|
b5610a43db | ||
|
|
a182a6652e | ||
|
|
cf51a32ffb | ||
|
|
11cc11bc2f | ||
|
|
8cef903224 | ||
|
|
f5492db7fa | ||
|
|
cf603cdc7c | ||
|
|
d07e1a6341 | ||
|
|
549e16f069 | ||
|
|
2e1406b683 | ||
|
|
bfdfabd4a5 | ||
|
|
004c076236 | ||
|
|
93a6ecbbbc | ||
|
|
3c877f9604 | ||
|
|
d317c0208b | ||
|
|
4716725e81 | ||
|
|
4f15fe36e0 | ||
|
|
8bcc838f47 | ||
|
|
462e3d02dd | ||
|
|
541f9b2dc9 | ||
|
|
86107e2b5a | ||
|
|
5cc0026f3d | ||
|
|
c5db47444e | ||
|
|
fffc2b5633 | ||
|
|
637f260529 | ||
|
|
469f466832 | ||
|
|
ecb7480b37 | ||
|
|
42800e4037 | ||
|
|
b52bbc9ae4 | ||
|
|
eaa1390a36 | ||
|
|
b38f01ef51 | ||
|
|
73bf2b5e04 | ||
|
|
c8c92fcf92 | ||
|
|
cf6b159da5 | ||
|
|
330e1c5340 | ||
|
|
b40bc2e89c | ||
|
|
e8347e9e9b | ||
|
|
d051b967ed | ||
|
|
cf4776ef92 | ||
|
|
b1ff312ef5 | ||
|
|
319214cfb3 | ||
|
|
e75049b604 | ||
|
|
836cdf47a5 | ||
|
|
01f9c257e8 | ||
|
|
3d07f4fd56 | ||
|
|
7dc97a02fd | ||
|
|
afd2f12242 | ||
|
|
5faf0572f6 | ||
|
|
8d349ccbaa | ||
|
|
9c35a91e0f | ||
|
|
2da4e1b572 | ||
|
|
5368b134bb | ||
|
|
d1f090ee98 | ||
|
|
f311c9594f | ||
|
|
c6a3ab0a77 | ||
|
|
ba25ab3490 | ||
|
|
1095ebbeed | ||
|
|
299b15c8e9 | ||
|
|
091cb00ab9 | ||
|
|
2b408d2699 | ||
|
|
702efcacca | ||
|
|
98ba01dc49 | ||
|
|
e1225efa03 | ||
|
|
37c7b1e22c | ||
|
|
eea8e06d6b | ||
|
|
c4234961bc | ||
|
|
42cfda23f3 | ||
|
|
78316b9ade | ||
|
|
dd09289d2b | ||
|
|
10a66e9f9a | ||
|
|
ad4719399d | ||
|
|
892ab921b7 | ||
|
|
6551c32f6b | ||
|
|
b8eac648ab | ||
|
|
53fb1a25b3 | ||
|
|
3fdaf2df0c | ||
|
|
4ba722f51c | ||
|
|
42b726c376 | ||
|
|
8bec5f4b98 | ||
|
|
9a8bc523c5 | ||
|
|
59253973ce | ||
|
|
205149a884 | ||
|
|
a89a69e7da | ||
|
|
9bb429d4e7 | ||
|
|
542a3a4e71 | ||
|
|
3646590506 | ||
|
|
cf87c9594c | ||
|
|
71420f6c81 | ||
|
|
b6fdd8adc3 | ||
|
|
45747bd2ef | ||
|
|
69c75c1b56 | ||
|
|
bed5d8567c | ||
|
|
0c5d25a763 | ||
|
|
c324ee8347 | ||
|
|
193bbddb4e | ||
|
|
6821bb82db | ||
|
|
1cbd0569eb | ||
|
|
14dbb661f8 | ||
|
|
ea5a986693 | ||
|
|
37ec94e2f0 | ||
|
|
157e3b032d | ||
|
|
910c71954e | ||
|
|
27107066e3 | ||
|
|
fd1843e120 | ||
|
|
dd2ab67d2b | ||
|
|
9dd5634759 | ||
|
|
a521ba3abd | ||
|
|
6b0f05d075 | ||
|
|
61d6c02ecd | ||
|
|
b7d4e12fbf | ||
|
|
dc6d015870 | ||
|
|
07a05a3995 | ||
|
|
182625774d | ||
|
|
b4684a2406 | ||
|
|
ecc0ec05bd | ||
|
|
5193b739ca | ||
|
|
70c0a902f4 | ||
|
|
7f29fd0fcd | ||
|
|
239536f1d8 | ||
|
|
71be391dd3 | ||
|
|
df738acaa4 | ||
|
|
8ed56677e5 | ||
|
|
60d0b7c97a | ||
|
|
17a2c9e0c2 | ||
|
|
7ee37564d3 | ||
|
|
2ee7513f80 | ||
|
|
7d6505d296 | ||
|
|
8722e50897 | ||
|
|
fa8d76fa37 | ||
|
|
c50fca363a | ||
|
|
e8ff308154 | ||
|
|
cdcb9db4ba | ||
|
|
a8e405ac5d | ||
|
|
b6705cceb2 | ||
|
|
af58b4f286 | ||
|
|
02dc048ad2 | ||
|
|
a981d91552 | ||
|
|
96ffa3e354 | ||
|
|
1c564ed5f7 | ||
|
|
9dd5f62e0e | ||
|
|
c4737fb66a | ||
|
|
43d3b2df91 | ||
|
|
87c5488c20 | ||
|
|
e0d5596e63 | ||
|
|
1f2671db3d | ||
|
|
940ab9bdb5 | ||
|
|
8017d39c4e | ||
|
|
25bb4ee812 | ||
|
|
7c1aff34e2 | ||
|
|
28670d4420 | ||
|
|
30f3a3520e | ||
|
|
9acca40aaf | ||
|
|
bf2ed2c87a | ||
|
|
3561d58203 | ||
|
|
1d80616068 | ||
|
|
61c93a7f57 | ||
|
|
b042b8efbd | ||
|
|
8c00ba48ae | ||
|
|
991a27b7f2 | ||
|
|
69e38e9495 | ||
|
|
16d0162ef0 | ||
|
|
d07f5c502f | ||
|
|
5b1493507d | ||
|
|
1180572926 | ||
|
|
6dc19ef871 | ||
|
|
4a641fc498 | ||
|
|
2a04014fa7 | ||
|
|
4f20a815ec | ||
|
|
4906e14e51 | ||
|
|
c9296c7371 | ||
|
|
4db36b214b | ||
|
|
a6d94c7504 | ||
|
|
045abb48bb | ||
|
|
10337c620b | ||
|
|
698f557aa3 | ||
|
|
692c7c1a09 | ||
|
|
1bdfdcdb38 | ||
|
|
cacfe00c1d | ||
|
|
0fd0fa9c73 | ||
|
|
52fdc0f734 | ||
|
|
451c11d5a1 | ||
|
|
e92fcf6d46 | ||
|
|
07140aceb8 | ||
|
|
2cc32928a4 | ||
|
|
153513d5e2 | ||
|
|
94308408a9 | ||
|
|
1ae6970b77 | ||
|
|
0704f877f5 | ||
|
|
7ff0e6f9c0 | ||
|
|
5b4498ac9d | ||
|
|
976cc79b0c | ||
|
|
8d6ff0d727 | ||
|
|
26c0e4dac4 | ||
|
|
9ce1821be0 | ||
|
|
eeff4847fe | ||
|
|
2956f88050 | ||
|
|
26d9c1c07b | ||
|
|
73af014cbd | ||
|
|
d206f437ef | ||
|
|
d099586632 | ||
|
|
058d95e441 | ||
|
|
b40ac55755 | ||
|
|
43aa88e5a6 | ||
|
|
8fe2d519d2 | ||
|
|
07ed645f45 | ||
|
|
9485e8f5eb | ||
|
|
dc96616781 | ||
|
|
0c44b4ae05 | ||
|
|
3568464ca7 | ||
|
|
8e5296c71a | ||
|
|
eee971e3ef | ||
|
|
7a1f8b2d30 | ||
|
|
157e122891 | ||
|
|
b12ab7eae4 | ||
|
|
10c8a923e6 | ||
|
|
2b91589750 | ||
|
|
3e9674eaa9 | ||
|
|
d902c6a9f4 | ||
|
|
04a17e8c55 | ||
|
|
1b6cd457d3 | ||
|
|
2bc2080fbe | ||
|
|
6b6425e8d7 | ||
|
|
fb0e95d8ce | ||
|
|
4e4702a31f | ||
|
|
5a2ad22f97 | ||
|
|
f02139f7ce | ||
|
|
d004e175e2 | ||
|
|
7928a95c34 | ||
|
|
202e6c5228 | ||
|
|
0aeaa5e71d | ||
|
|
9ad4ee304b | ||
|
|
5bd280553d | ||
|
|
7e215c8220 | ||
|
|
2c23680163 | ||
|
|
c4f179daa0 | ||
|
|
c2f657a15a | ||
|
|
9332081875 | ||
|
|
1cec97568b | ||
|
|
b567713641 | ||
|
|
de776c1f32 | ||
|
|
c498ea74ec | ||
|
|
f4aad3a494 | ||
|
|
1cebf576c3 | ||
|
|
25dac2e239 | ||
|
|
4a9de7094c | ||
|
|
c4eab3b677 | ||
|
|
dd125c7999 | ||
|
|
5e3dce8088 | ||
|
|
4c64f2c2e8 | ||
|
|
aa6e11dfc0 | ||
|
|
e7d1e7dd54 | ||
|
|
03843fd3f0 | ||
|
|
294e9900ea | ||
|
|
f13651979e | ||
|
|
3d8ba4d09b | ||
|
|
63984c8dda | ||
|
|
e2fd8a5835 | ||
|
|
a0263870b9 | ||
|
|
3c4ae58aff | ||
|
|
5965707575 | ||
|
|
dbe0140578 | ||
|
|
bc21289793 | ||
|
|
f11bd0928d | ||
|
|
6ffd5ad2a4 | ||
|
|
7ce8cbc01c | ||
|
|
12a7603c77 | ||
|
|
53a6355074 | ||
|
|
f8ad249e42 | ||
|
|
3c41d3961e | ||
|
|
18bc715bad | ||
|
|
3349d20663 | ||
|
|
bad70e3eab | ||
|
|
21286eb163 | ||
|
|
0e5f07558c | ||
|
|
6e26b901e4 | ||
|
|
81c67c8f12 | ||
|
|
990e21eefc | ||
|
|
7141144a2f | ||
|
|
8c343501c1 | ||
|
|
44f08686cd | ||
|
|
65883f8c2a | ||
|
|
bd28a8fad5 | ||
|
|
8ba86c2c67 | ||
|
|
d3cddf9e44 | ||
|
|
5f3babee5c | ||
|
|
26dfc9a872 | ||
|
|
e47439e8be | ||
|
|
1ef53758be | ||
|
|
8544042789 | ||
|
|
f564d43d98 | ||
|
|
32fa0666c9 |
@@ -10,6 +10,8 @@ public import Init.Data.Random
|
||||
public import Std.Internal.Async.Basic
|
||||
import Init.Data.ByteArray.Extra
|
||||
import Init.Data.Array.Lemmas
|
||||
public import Std.Sync.Mutex
|
||||
public import Std.Sync.Barrier
|
||||
import Init.Omega
|
||||
|
||||
public section
|
||||
@@ -132,6 +134,8 @@ partial def Selectable.one (selectables : Array (Selectable α)) : Async α := d
|
||||
let gen := mkStdGen seed
|
||||
let selectables := shuffleIt selectables gen
|
||||
|
||||
let gate ← IO.Promise.new
|
||||
|
||||
for selectable in selectables do
|
||||
if let some val ← selectable.selector.tryFn then
|
||||
let result ← selectable.cont val
|
||||
@@ -141,6 +145,9 @@ partial def Selectable.one (selectables : Array (Selectable α)) : Async α := d
|
||||
let promise ← IO.Promise.new
|
||||
|
||||
for selectable in selectables do
|
||||
if ← finished.get then
|
||||
break
|
||||
|
||||
let waiterPromise ← IO.Promise.new
|
||||
let waiter := Waiter.mk finished waiterPromise
|
||||
selectable.selector.registerFn waiter
|
||||
@@ -157,18 +164,20 @@ partial def Selectable.one (selectables : Array (Selectable α)) : Async α := d
|
||||
let async : Async _ :=
|
||||
try
|
||||
let res ← IO.ofExcept res
|
||||
discard <| await gate.result?
|
||||
|
||||
for selectable in selectables do
|
||||
selectable.selector.unregisterFn
|
||||
|
||||
let contRes ← selectable.cont res
|
||||
promise.resolve (.ok contRes)
|
||||
promise.resolve (.ok (← selectable.cont res))
|
||||
catch e =>
|
||||
promise.resolve (.error e)
|
||||
|
||||
async.toBaseIO
|
||||
|
||||
Async.ofPromise (pure promise)
|
||||
gate.resolve ()
|
||||
let result ← Async.ofPromise (pure promise)
|
||||
return result
|
||||
|
||||
/--
|
||||
Performs fair and data-loss free non-blocking multiplexing on the `Selectable`s in `selectables`.
|
||||
@@ -224,6 +233,8 @@ def Selectable.combine (selectables : Array (Selectable α)) : IO (Selector α)
|
||||
let derivedWaiter := Waiter.mk waiter.finished waiterPromise
|
||||
selectable.selector.registerFn derivedWaiter
|
||||
|
||||
let barrier ← IO.Promise.new
|
||||
|
||||
discard <| IO.bindTask (t := waiterPromise.result?) fun res? => do
|
||||
match res? with
|
||||
| none => return (Task.pure (.ok ()))
|
||||
@@ -231,6 +242,7 @@ def Selectable.combine (selectables : Array (Selectable α)) : IO (Selector α)
|
||||
let async : Async _ := do
|
||||
let mainPromise := waiter.promise
|
||||
|
||||
await barrier
|
||||
for selectable in selectables do
|
||||
selectable.selector.unregisterFn
|
||||
|
||||
|
||||
@@ -6,4 +6,190 @@ Authors: Sofia Rodrigues
|
||||
module
|
||||
|
||||
prelude
|
||||
public import Std.Internal.Http.Data
|
||||
public import Std.Internal.Http.Server
|
||||
public import Std.Internal.Http.Client
|
||||
public import Std.Internal.Http.Test.Helpers
|
||||
|
||||
public section
|
||||
|
||||
/-!
|
||||
# HTTP Library
|
||||
|
||||
A low-level HTTP/1.1 server implementation for Lean. This library provides a pure,
|
||||
sans-I/O protocol implementation that can be used with the `Async` library or with
|
||||
custom connection handlers.
|
||||
|
||||
## Overview
|
||||
|
||||
This module provides a complete HTTP/1.1 server implementation with support for:
|
||||
|
||||
- Request/response handling with directional streaming bodies
|
||||
- Keep-alive connections
|
||||
- Chunked transfer encoding
|
||||
- Header validation and management
|
||||
- Configurable timeouts and limits
|
||||
|
||||
**Sans I/O Architecture**: The core protocol logic doesn't perform any actual I/O itself -
|
||||
it just defines how data should be processed. This separation allows the protocol implementation
|
||||
to remain pure and testable, while different transports (TCP sockets, mock clients) handle
|
||||
the actual reading and writing of bytes.
|
||||
|
||||
## Quick Start
|
||||
|
||||
The main entry point is `Server.serve`, which starts an HTTP/1.1 server. Implement the
|
||||
`Server.Handler` type class to define how the server handles requests, errors, and
|
||||
`Expect: 100-continue` headers:
|
||||
|
||||
```lean
|
||||
import Std.Internal.Http
|
||||
|
||||
open Std Internal IO Async
|
||||
open Std Http Server
|
||||
|
||||
structure MyHandler
|
||||
|
||||
instance : Handler MyHandler where
|
||||
onRequest _ req := do
|
||||
Response.ok |>.text "Hello, World!"
|
||||
|
||||
def main : IO Unit := Async.block do
|
||||
let addr : Net.SocketAddress := .v4 ⟨.ofParts 127 0 0 1, 8080⟩
|
||||
let server ← Server.serve addr MyHandler.mk
|
||||
server.waitShutdown
|
||||
```
|
||||
|
||||
## Working with Requests
|
||||
|
||||
Incoming requests are represented by `Request Body.Stream`, which bundles the request
|
||||
line, parsed headers, and a lazily-consumed body. Headers are available immediately,
|
||||
while the body can be streamed or collected on demand, allowing handlers to process both
|
||||
small and large payloads efficiently.
|
||||
|
||||
### Reading Headers
|
||||
|
||||
```lean
|
||||
def handler (req : Request Body.Stream) : ContextAsync (Response Body.Stream) := do
|
||||
-- Access request method and URI
|
||||
let method := req.head.method -- Method.get, Method.post, etc.
|
||||
let uri := req.head.uri -- RequestTarget
|
||||
|
||||
-- Read a specific header
|
||||
if let some contentType := req.head.headers.get? (.mk "content-type") then
|
||||
IO.println s!"Content-Type: {contentType}"
|
||||
|
||||
Response.ok |>.text "OK"
|
||||
```
|
||||
|
||||
### URI Query Semantics
|
||||
|
||||
`RequestTarget.query` is parsed using form-style key/value conventions (`k=v&...`), and `+` is decoded as a
|
||||
space in query components. If you need RFC 3986 opaque query handling, use the raw request target string
|
||||
(`toString req.head.uri`) and parse it with custom logic.
|
||||
|
||||
### Reading the Request Body
|
||||
|
||||
The request body is exposed as `Body.Stream`, which can be consumed incrementally or
|
||||
collected into memory. The `readAll` method reads the entire body, with an optional size
|
||||
limit to protect against unbounded payloads.
|
||||
|
||||
```lean
|
||||
def handler (req : Request Body.Stream) : ContextAsync (Response Body.Stream) := do
|
||||
-- Collect entire body as a String
|
||||
let bodyStr : String ← req.body.readAll
|
||||
|
||||
-- Or with a maximum size limit
|
||||
let bodyStr : String ← req.body.readAll (maximumSize := some 1024)
|
||||
|
||||
Response.ok |>.text s!"Received: {bodyStr}"
|
||||
```
|
||||
|
||||
## Building Responses
|
||||
|
||||
Responses are constructed using a builder API that starts from a status code and adds
|
||||
headers and a body. Common helpers exist for text, HTML, JSON, and binary responses, while
|
||||
still allowing full control over status codes and header values.
|
||||
|
||||
Response builders produce `Async (Response Body.Stream)`.
|
||||
|
||||
```lean
|
||||
-- Text response
|
||||
Response.ok |>.text "Hello!"
|
||||
|
||||
-- HTML response
|
||||
Response.ok |>.html "<h1>Hello!</h1>"
|
||||
|
||||
-- JSON response
|
||||
Response.ok |>.json "{\"key\": \"value\"}"
|
||||
|
||||
-- Binary response
|
||||
Response.ok |>.bytes someByteArray
|
||||
|
||||
-- Custom status
|
||||
Response.new |>.status .created |>.text "Resource created"
|
||||
|
||||
-- With custom headers
|
||||
Response.ok
|
||||
|>.header! "X-Custom-Header" "value"
|
||||
|>.header! "Cache-Control" "no-cache"
|
||||
|>.text "Response with headers"
|
||||
```
|
||||
|
||||
### Streaming Responses
|
||||
|
||||
For large responses or server-sent events, use streaming:
|
||||
|
||||
```lean
|
||||
def handler (req : Request Body.Stream) : ContextAsync (Response Body.Stream) := do
|
||||
Response.ok
|
||||
|>.header! "Content-Type" "text/plain"
|
||||
|>.stream fun stream => do
|
||||
for i in [0:10] do
|
||||
stream.send { data := s!"chunk {i}\n".toUTF8 }
|
||||
Async.sleep 1000
|
||||
stream.close
|
||||
```
|
||||
|
||||
## Server Configuration
|
||||
|
||||
Configure server behavior with `Config`:
|
||||
|
||||
```lean
|
||||
def config : Config := {
|
||||
maxRequests := 10000000,
|
||||
lingeringTimeout := 5000,
|
||||
}
|
||||
|
||||
let server ← Server.serve addr MyHandler.mk config
|
||||
```
|
||||
|
||||
## Handler Type Class
|
||||
|
||||
Implement `Server.Handler` to define how the server processes events. The class has three
|
||||
methods, all with default implementations:
|
||||
|
||||
- `onRequest` — called for each incoming request; returns a response inside `ContextAsync`
|
||||
- `onFailure` — called when an error occurs while processing a request
|
||||
- `onContinue` — called when a request includes an `Expect: 100-continue` header; return
|
||||
`true` to accept the body or `false` to reject it
|
||||
|
||||
```lean
|
||||
structure MyHandler where
|
||||
greeting : String
|
||||
|
||||
instance : Handler MyHandler where
|
||||
onRequest self req := do
|
||||
Response.ok |>.text self.greeting
|
||||
|
||||
onFailure self err := do
|
||||
IO.eprintln s!"Error: {err}"
|
||||
```
|
||||
|
||||
The handler methods operate in the following monads:
|
||||
|
||||
- `onRequest` uses `ContextAsync` — an asynchronous monad (`ReaderT CancellationContext Async`) that provides:
|
||||
- Full access to `Async` operations (spawning tasks, sleeping, concurrent I/O)
|
||||
- A `CancellationContext` tied to the client connection — when the client disconnects, the
|
||||
context is cancelled, allowing your handler to detect this and stop work early
|
||||
- `onFailure` uses `Async`
|
||||
- `onContinue` uses `Async`
|
||||
-/
|
||||
|
||||
313
src/Std/Internal/Http/Client.lean
Normal file
313
src/Std/Internal/Http/Client.lean
Normal file
@@ -0,0 +1,313 @@
|
||||
/-
|
||||
Copyright (c) 2025 Lean FRO, LLC. All rights reserved.
|
||||
Released under Apache 2.0 license as described in the file LICENSE.
|
||||
Authors: Sofia Rodrigues
|
||||
-/
|
||||
module
|
||||
|
||||
prelude
|
||||
public import Std.Internal.Http.Client.Pool
|
||||
|
||||
public section
|
||||
|
||||
namespace Std.Http
|
||||
|
||||
set_option linter.all true
|
||||
|
||||
open Std Internal IO Async TCP Protocol
|
||||
open Time
|
||||
|
||||
/-!
|
||||
# Client
|
||||
|
||||
A top-level HTTP client backed by a connection pool, similar to `reqwest::Client`.
|
||||
Use `Client.builder` to construct, then `client.get "https://..."` etc.
|
||||
|
||||
```lean
|
||||
let client ← Client.builder
|
||||
|>.proxy! "http://proxy.example.com:8080"
|
||||
|>.build
|
||||
|
||||
let res ← client.get (URI.parse! "https://api.example.com/data")
|
||||
|>.header! "Accept" "application/json"
|
||||
|>.send
|
||||
```
|
||||
-/
|
||||
|
||||
/--
|
||||
A top-level HTTP client backed by a connection pool.
|
||||
-/
|
||||
abbrev Client := Client.Agent.Pool
|
||||
|
||||
/--
|
||||
Builder for `Client`. Chain configuration setters then call `.build`.
|
||||
-/
|
||||
public structure Client.Builder where
|
||||
|
||||
/--
|
||||
Configuration applied to all sessions created by this client.
|
||||
-/
|
||||
config : Config := {}
|
||||
|
||||
/--
|
||||
Maximum number of pooled connections per host.
|
||||
-/
|
||||
maxPerHost : Nat := 4
|
||||
|
||||
namespace Client.Builder
|
||||
|
||||
/--
|
||||
Routes all connections through a proxy.
|
||||
`host` is the proxy hostname, `port` is the proxy port.
|
||||
Only HTTP proxies are supported.
|
||||
-/
|
||||
def proxy (b : Client.Builder) (host : String) (port : UInt16) : Client.Builder :=
|
||||
{ b with config := { b.config with proxy := some (host, port) } }
|
||||
|
||||
/--
|
||||
Routes all connections through a proxy specified as a URL string.
|
||||
Returns `none` if the URL is invalid or has no authority component.
|
||||
Only HTTP proxies are supported. The scheme determines the default port
|
||||
when no explicit port is specified (`http` → 80, `https` → 443). TLS
|
||||
(HTTPS proxy CONNECT tunnels) is not supported.
|
||||
-/
|
||||
def proxy? (b : Client.Builder) (url : String) : Option Client.Builder := do
|
||||
let uri ← URI.parse? url
|
||||
let auth ← uri.authority
|
||||
let host := toString auth.host
|
||||
let port : UInt16 := match auth.port with
|
||||
| .value p => p
|
||||
| _ => URI.Scheme.defaultPort uri.scheme
|
||||
pure { b with config := { b.config with proxy := some (host, port) } }
|
||||
|
||||
/--
|
||||
Sets the request timeout (send + receive).
|
||||
DNS resolution and TCP connect are not covered by this timeout;
|
||||
use the OS-level socket timeout for those.
|
||||
-/
|
||||
def timeout (b : Client.Builder) (ms : Time.Millisecond.Offset) : Client.Builder :=
|
||||
if h : 0 < ms then
|
||||
{ b with config := { b.config with requestTimeout := ⟨ms, h⟩ } }
|
||||
else b
|
||||
|
||||
/--
|
||||
Sets the `User-Agent` header sent with every request.
|
||||
-/
|
||||
def userAgent (b : Client.Builder) (ua : String) : Client.Builder :=
|
||||
{ b with config := { b.config with userAgent := Header.Value.ofString? ua } }
|
||||
|
||||
/--
|
||||
Sets the maximum number of pooled connections per host.
|
||||
-/
|
||||
def maxConnectionsPerHost (b : Client.Builder) (n : Nat) : Client.Builder :=
|
||||
{ b with maxPerHost := n }
|
||||
|
||||
/--
|
||||
Sets the maximum number of redirects to follow automatically.
|
||||
-/
|
||||
def maxRedirects (b : Client.Builder) (n : Nat) : Client.Builder :=
|
||||
{ b with config := { b.config with maxRedirects := n } }
|
||||
|
||||
/--
|
||||
Sets the predicate that decides whether a response status is acceptable.
|
||||
When set, the final response status is passed to `f`; if `f` returns `false`
|
||||
an `IO.Error` is thrown with the numeric status code.
|
||||
-/
|
||||
def validateStatus (b : Client.Builder) (f : Status → Bool) : Client.Builder :=
|
||||
{ b with config := { b.config with validateStatus := some f } }
|
||||
|
||||
/--
|
||||
Builds the `Client`.
|
||||
-/
|
||||
def build (b : Client.Builder) : Async Client := do
|
||||
Agent.Pool.new b.config b.maxPerHost
|
||||
|
||||
end Builder
|
||||
|
||||
/--
|
||||
A request builder bound to a `Client`. Build up headers, query parameters, and body,
|
||||
then dispatch with one of the `send*` methods.
|
||||
-/
|
||||
public structure RequestBuilder where
|
||||
|
||||
/--
|
||||
The client that will dispatch this request.
|
||||
-/
|
||||
client : Client
|
||||
|
||||
/--
|
||||
URI scheme for this request (`"http"` or `"https"`).
|
||||
Used as part of the pool key and for the `Host` header.
|
||||
-/
|
||||
scheme : URI.Scheme
|
||||
|
||||
/--
|
||||
Resolved hostname for this request.
|
||||
-/
|
||||
host : URI.Host
|
||||
|
||||
/--
|
||||
Target port.
|
||||
-/
|
||||
port : UInt16
|
||||
|
||||
/--
|
||||
The underlying request builder.
|
||||
-/
|
||||
builder : Request.Builder
|
||||
|
||||
namespace RequestBuilder
|
||||
|
||||
/--
|
||||
Injects a `Host` header if not already present.
|
||||
-/
|
||||
private def withHostHeader (rb : RequestBuilder) : RequestBuilder :=
|
||||
if rb.builder.line.headers.contains Header.Name.host then rb
|
||||
else
|
||||
let defaultPort := URI.Scheme.defaultPort rb.scheme
|
||||
let hostValue :=
|
||||
if rb.port == defaultPort then toString rb.host
|
||||
else s!"{rb.host}:{rb.port}"
|
||||
{ rb with builder := rb.builder.header! "Host" hostValue }
|
||||
|
||||
/--
|
||||
Adds a typed header to the request.
|
||||
-/
|
||||
def header (rb : RequestBuilder) (key : Header.Name) (value : Header.Value) : RequestBuilder :=
|
||||
{ rb with builder := rb.builder.header key value }
|
||||
|
||||
/--
|
||||
Adds a header to the request. Panics if the name or value is invalid.
|
||||
-/
|
||||
def header! (rb : RequestBuilder) (key : String) (value : String) : RequestBuilder :=
|
||||
{ rb with builder := rb.builder.header! key value }
|
||||
|
||||
/--
|
||||
Adds a header to the request. Returns `none` if the name or value is invalid.
|
||||
-/
|
||||
def header? (rb : RequestBuilder) (key : String) (value : String) : Option RequestBuilder := do
|
||||
let builder ← rb.builder.header? key value
|
||||
pure { rb with builder }
|
||||
|
||||
/--
|
||||
Sets the request URI from a string. Panics if the string is not a valid request target.
|
||||
-/
|
||||
def uri! (rb : RequestBuilder) (u : String) : RequestBuilder :=
|
||||
{ rb with builder := rb.builder.uri! u }
|
||||
|
||||
/--
|
||||
Adds a query parameter to the request URI.
|
||||
-/
|
||||
def queryParam (rb : RequestBuilder) (key : String) (value : String) : RequestBuilder :=
|
||||
let newTarget := match rb.builder.line.uri with
|
||||
| .originForm path query =>
|
||||
.originForm path (some ((query.getD URI.Query.empty).insert key value))
|
||||
| .absoluteForm af =>
|
||||
.absoluteForm { af with query := af.query.insert key value }
|
||||
| other => other
|
||||
{ rb with builder := { rb.builder with line := { rb.builder.line with uri := newTarget } } }
|
||||
|
||||
/--
|
||||
Sends the request with an empty body.
|
||||
-/
|
||||
def send (rb : RequestBuilder) : Async (Response Body.Stream) := do
|
||||
let rb := rb.withHostHeader
|
||||
rb.client.send rb.host rb.port rb.scheme (← rb.builder.empty)
|
||||
|
||||
/--
|
||||
Sends the request with a plain-text body. Sets `Content-Type: text/plain; charset=utf-8`.
|
||||
-/
|
||||
def text (rb : RequestBuilder) (content : String) : Async (Response Body.Stream) := do
|
||||
let rb := rb.withHostHeader
|
||||
rb.client.send rb.host rb.port rb.scheme (← rb.builder.text content)
|
||||
|
||||
/--
|
||||
Sends the request with a JSON body. Sets `Content-Type: application/json`.
|
||||
-/
|
||||
def json (rb : RequestBuilder) (content : String) : Async (Response Body.Stream) := do
|
||||
let rb := rb.withHostHeader
|
||||
rb.client.send rb.host rb.port rb.scheme (← rb.builder.json content)
|
||||
|
||||
/--
|
||||
Sends the request with a raw binary body. Sets `Content-Type: application/octet-stream`.
|
||||
-/
|
||||
def bytes (rb : RequestBuilder) (content : ByteArray) : Async (Response Body.Stream) := do
|
||||
let rb := rb.withHostHeader
|
||||
rb.client.send rb.host rb.port rb.scheme (← rb.builder.bytes content)
|
||||
|
||||
/--
|
||||
Sends the request with a streaming body produced by `gen`.
|
||||
-/
|
||||
def stream (rb : RequestBuilder) (gen : Body.Stream → Async Unit) : Async (Response Body.Stream) := do
|
||||
let rb := rb.withHostHeader
|
||||
rb.client.send rb.host rb.port rb.scheme (← rb.builder.stream gen)
|
||||
|
||||
end RequestBuilder
|
||||
|
||||
/--
|
||||
Returns a `Client.Builder` with default configuration.
|
||||
-/
|
||||
def new : Client.Builder := {}
|
||||
|
||||
/--
|
||||
Builds a `RequestBuilder` from a parsed `URI`, extracting host, port, and origin-form target.
|
||||
-/
|
||||
private def mkRequest
|
||||
(method : Request.Builder → Request.Builder)
|
||||
(client : Client) (url : URI) : Client.RequestBuilder :=
|
||||
let target : RequestTarget :=
|
||||
.originForm url.path (if url.query.isEmpty then none else some url.query)
|
||||
let host := (url.authority.map (·.host)).getD default
|
||||
let scheme := url.scheme
|
||||
let port : UInt16 := match url.authority with
|
||||
| some auth => match auth.port with
|
||||
| .value p => p
|
||||
| _ => URI.Scheme.defaultPort scheme
|
||||
| none => URI.Scheme.defaultPort scheme
|
||||
{ client, scheme, host, port, builder := method (Request.new |>.uri target) }
|
||||
|
||||
/--
|
||||
Creates a GET request builder for `url`.
|
||||
-/
|
||||
def get (client : Client) (url : URI) : Client.RequestBuilder :=
|
||||
mkRequest (·.method .get) client url
|
||||
|
||||
/--
|
||||
Creates a POST request builder for `url`.
|
||||
-/
|
||||
def post (client : Client) (url : URI) : Client.RequestBuilder :=
|
||||
mkRequest (·.method .post) client url
|
||||
|
||||
/--
|
||||
Creates a PUT request builder for `url`.
|
||||
-/
|
||||
def put (client : Client) (url : URI) : Client.RequestBuilder :=
|
||||
mkRequest (·.method .put) client url
|
||||
|
||||
/--
|
||||
Creates a DELETE request builder for `url`.
|
||||
-/
|
||||
def delete (client : Client) (url : URI) : Client.RequestBuilder :=
|
||||
mkRequest (·.method .delete) client url
|
||||
|
||||
/--
|
||||
Creates a PATCH request builder for `url`.
|
||||
-/
|
||||
def patch (client : Client) (url : URI) : Client.RequestBuilder :=
|
||||
mkRequest (·.method .patch) client url
|
||||
|
||||
/--
|
||||
Creates a HEAD request builder for `url`.
|
||||
-/
|
||||
def head (client : Client) (url : URI) : Client.RequestBuilder :=
|
||||
mkRequest (·.method .head) client url
|
||||
|
||||
/--
|
||||
Creates an OPTIONS request builder for `url`.
|
||||
-/
|
||||
def options (client : Client) (url : URI) : Client.RequestBuilder :=
|
||||
mkRequest (·.method .options) client url
|
||||
|
||||
end Client
|
||||
end Http
|
||||
end Std
|
||||
556
src/Std/Internal/Http/Client/Agent.lean
Normal file
556
src/Std/Internal/Http/Client/Agent.lean
Normal file
@@ -0,0 +1,556 @@
|
||||
/-
|
||||
Copyright (c) 2026 Lean FRO, LLC. All rights reserved.
|
||||
Released under Apache 2.0 license as described in the file LICENSE.
|
||||
Authors: Sofia Rodrigues
|
||||
-/
|
||||
module
|
||||
|
||||
prelude
|
||||
public import Std.Internal.Http.Client.Session
|
||||
public import Std.Internal.Http.Data.Cookie
|
||||
import Init.Data.Array
|
||||
|
||||
public section
|
||||
|
||||
namespace Std
|
||||
namespace Http
|
||||
namespace Client
|
||||
|
||||
set_option linter.all true
|
||||
|
||||
open Std Internal IO Async TCP Protocol
|
||||
open Time
|
||||
|
||||
/-!
|
||||
# Agent
|
||||
|
||||
This module defines `Client.Agent`, a transport-agnostic HTTP user-agent that wraps a `Session`
|
||||
and adds automatic redirect following, cookie jar support, response interceptors, and configurable
|
||||
retries.
|
||||
|
||||
`Agent` is parameterized by the transport type `α` and contains no TCP-specific code.
|
||||
Use `Agent.ofTransport` to create an `Agent` from any connected transport. Pass a `connectTo`
|
||||
factory to enable cross-host redirect following and automatic reconnection on error.
|
||||
|
||||
On each redirect the `Location` header is parsed as a URI. If the redirect targets a different
|
||||
host or port the agent closes the current session and opens a new one using `connectTo` (when
|
||||
available). A `Array URI` tracks every URI visited in the current redirect chain so that cycles
|
||||
are detected and broken immediately.
|
||||
|
||||
When crossing to a different host the `Authorization` header is stripped from the redirected
|
||||
request to prevent credential leakage.
|
||||
-/
|
||||
|
||||
/--
|
||||
An HTTP user-agent that manages a connection to a host. It follows redirects, maintains a cookie
|
||||
jar for automatic cookie handling, applies response interceptors, and retries on connection errors.
|
||||
-/
|
||||
public structure Agent (α : Type) where
|
||||
/--
|
||||
The underlying HTTP session over the transport.
|
||||
-/
|
||||
session : Session α
|
||||
|
||||
/--
|
||||
URI scheme for this connection (e.g., `"http"` or `"https"`).
|
||||
Used when constructing absolute-form request URIs for proxy requests and some redirects.
|
||||
-/
|
||||
scheme : URI.Scheme
|
||||
|
||||
/--
|
||||
The hostname this agent is currently connected to.
|
||||
-/
|
||||
host : URI.Host
|
||||
|
||||
/--
|
||||
The port this agent is currently connected to.
|
||||
-/
|
||||
port : UInt16
|
||||
|
||||
/--
|
||||
Cookie jar shared across all requests and redirects through this agent.
|
||||
-/
|
||||
cookieJar : Cookie.Jar
|
||||
|
||||
/--
|
||||
Response interceptors applied (in order) after every response, including intermediate
|
||||
redirect responses. Each interceptor receives the response and returns a (possibly
|
||||
modified) response. Interceptors run before cookie processing and redirect evaluation
|
||||
so they can, e.g., unwrap envelopes or transparently decompress bodies.
|
||||
-/
|
||||
interceptors : Array (Response Body.Stream → Async (Response Body.Stream)) := #[]
|
||||
|
||||
/--
|
||||
Optional factory for opening a new session to `(scheme, host, port)`. Used for:
|
||||
* Automatic retry after connection errors (`maxRetries`): reconnects to the same origin.
|
||||
* Cross-host redirects: connects to the new origin.
|
||||
The scheme is included so that http→https redirects open the correct pool entry.
|
||||
`none` for agents created via `Agent.ofTransport` without a factory; cross-host redirects
|
||||
are not followed and connection errors are not retried automatically for such agents.
|
||||
-/
|
||||
connectTo : Option (URI.Scheme → URI.Host → UInt16 → Async (Session α)) := none
|
||||
|
||||
/--
|
||||
Called when a connection error is confirmed (i.e., `session.send` threw and all retries
|
||||
are committed to using a fresh session). Receives the broken session together with the
|
||||
scheme, host, and port so the caller can:
|
||||
* For pool agents: evict the session from the pool so the next retry gets a fresh one.
|
||||
* For standalone agents: close the session's request channel so the background loop exits.
|
||||
The default closes the session channel; pool agents set this to an eviction function.
|
||||
-/
|
||||
onBrokenSession : Session α → URI.Scheme → URI.Host → UInt16 → Async Unit :=
|
||||
fun s _ _ _ => discard <| s.close
|
||||
|
||||
namespace Agent
|
||||
|
||||
/--
|
||||
Returns `true` for HTTP methods that are safe to retry on connection failure.
|
||||
Non-idempotent methods (POST, PATCH) must not be retried automatically because
|
||||
the server may have already processed the request before the connection dropped.
|
||||
-/
|
||||
private def isIdempotentMethod (m : Method) : Bool :=
|
||||
m == .get || m == .head || m == .put || m == .delete || m == .options || m == .trace
|
||||
|
||||
/--
|
||||
Rewrites an origin-form request target to absolute-form for proxy forwarding.
|
||||
`GET /path?q=1 HTTP/1.1` becomes `GET http://host:port/path?q=1 HTTP/1.1`.
|
||||
No-op for targets that are already in absolute-form or do not carry a path.
|
||||
-/
|
||||
def toAbsoluteFormRequest
|
||||
(request : Request Body.Any)
|
||||
(scheme : URI.Scheme) (host : URI.Host) (port : UInt16) : Request Body.Any :=
|
||||
match request.line.uri with
|
||||
| .originForm path query =>
|
||||
{ request with
|
||||
line := { request.line with uri := .absoluteForm {
|
||||
scheme,
|
||||
path,
|
||||
query := query.getD URI.Query.empty,
|
||||
authority := some { host, port := .value port }
|
||||
fragment := none
|
||||
}
|
||||
}
|
||||
}
|
||||
| _ => request
|
||||
|
||||
/--
|
||||
Creates an `Agent` from an already-connected transport `socket`.
|
||||
Pass a `connectTo` factory to enable automatic reconnection on error and cross-host redirect
|
||||
following; omit it (or pass `none`) to disable both.
|
||||
-/
|
||||
def ofTransport [Transport α] (socket : α) (scheme : URI.Scheme)
|
||||
(host : URI.Host) (port : UInt16)
|
||||
(connectTo : Option (URI.Scheme → URI.Host → UInt16 → Async (Session α)) := none)
|
||||
(config : Config := {}) : Async (Agent α) := do
|
||||
|
||||
let session ← Session.new socket config
|
||||
let cookieJar ← Cookie.Jar.new
|
||||
pure { session, scheme, host, port, cookieJar, connectTo }
|
||||
|
||||
/--
|
||||
Injects matching cookies from `cookieJar` into the request headers for `host`.
|
||||
Does nothing if the jar contains no matching cookies.
|
||||
-/
|
||||
def injectCookies (cookieJar : Cookie.Jar) (host : URI.Host) (scheme : URI.Scheme)
|
||||
(request : Request Body.Any) : Async (Request Body.Any) := do
|
||||
|
||||
-- Respect an explicit Cookie header set by the caller.
|
||||
if request.line.headers.contains .cookie then return request
|
||||
|
||||
let path := match request.line.uri with
|
||||
| .originForm path _ => path
|
||||
| .absoluteForm af => af.path
|
||||
| _ => URI.Path.parseOrRoot "/"
|
||||
|
||||
match ← cookieJar.cookiesFor host path (secure := scheme.val == "https") with
|
||||
| none => return request
|
||||
| some cookieValue =>
|
||||
let newHeaders := request.line.headers.insert .cookie cookieValue
|
||||
return { request with line := { request.line with headers := newHeaders } }
|
||||
|
||||
/--
|
||||
Reads all `Set-Cookie` headers from `responseHeaders` and stores the cookies in `cookieJar`.
|
||||
-/
|
||||
def processCookies (cookieJar : Cookie.Jar) (host : URI.Host)
|
||||
(responseHeaders : Headers) : BaseIO Unit := do
|
||||
if let some values := responseHeaders.getAll? Header.Name.setCookie then
|
||||
for v in values do
|
||||
cookieJar.processSetCookie host v.value
|
||||
|
||||
/--
|
||||
Applies all response interceptors to `response` in order, returning the final result.
|
||||
-/
|
||||
def applyInterceptors
|
||||
(interceptors : Array (Response Body.Stream → Async (Response Body.Stream)))
|
||||
(response : Response Body.Stream) : Async (Response Body.Stream) :=
|
||||
interceptors.foldlM (init := response) (fun r f => f r)
|
||||
|
||||
/--
|
||||
Outcome of evaluating whether a response should trigger an automatic redirect.
|
||||
-/
|
||||
inductive RedirectDecision where
|
||||
/--
|
||||
Response is final, should validate status and return it.
|
||||
-/
|
||||
| done
|
||||
|
||||
/--
|
||||
Follow a redirect to `(host, port, scheme)` with `request`, updating `history`.
|
||||
-/
|
||||
| follow (host : URI.Host) (port : UInt16) (scheme : URI.Scheme) (request : Request Body.Any)
|
||||
|
||||
/--
|
||||
Inspects `response` and decides whether to follow a redirect.
|
||||
|
||||
Returns `.done` when:
|
||||
- `remaining` is 0 or the response is not a redirection,
|
||||
- the `Location` header is absent, or
|
||||
- the `Location` value cannot be parsed.
|
||||
|
||||
Returns `.follow` with the rewritten request (method, body, and headers adjusted per
|
||||
RFC 9110 §15.4, including `Authorization` stripped on cross-origin hops) when a valid
|
||||
redirect target is found. The response body is drained (up to `drainLimit` bytes) before
|
||||
returning `.follow`; if the body exceeds `drainLimit` the incoming channel is closed and
|
||||
the connection is left to recover or time out.
|
||||
-/
|
||||
def decideRedirect
|
||||
(remaining : Nat)
|
||||
(currentHost : URI.Host) (currentPort : UInt16) (currentScheme : URI.Scheme)
|
||||
(request : Request Body.Any) (response : Response Body.Stream)
|
||||
(drainLimit : Nat)
|
||||
: Async RedirectDecision := do
|
||||
|
||||
if remaining == 0 ∨ !response.line.status.isRedirection then
|
||||
return .done
|
||||
|
||||
let some locationValue := response.line.headers.get? .location
|
||||
| return .done
|
||||
|
||||
let locationStr := locationValue.value
|
||||
|
||||
let some target := RequestTarget.parse? locationStr
|
||||
| return .done
|
||||
|
||||
-- Drain
|
||||
discard <| ContextAsync.run do
|
||||
try
|
||||
discard <| response.body.readAll (α := ByteArray) (maximumSize := some drainLimit.toUInt64)
|
||||
catch _ =>
|
||||
response.body.close
|
||||
|
||||
let newMethod := match response.line.status with
|
||||
| .seeOther => .get
|
||||
| .movedPermanently | .found =>
|
||||
if request.line.method == .post then .get else request.line.method
|
||||
| _ => request.line.method
|
||||
|
||||
let (newHost, newPort, newScheme) := match target with
|
||||
| .absoluteForm af =>
|
||||
let h := af.authority.map URI.Authority.host |>.getD currentHost
|
||||
let p : UInt16 :=
|
||||
match af.authority with
|
||||
| some auth => match auth.port with
|
||||
| URI.Port.value v => v
|
||||
| _ => URI.Scheme.defaultPort af.scheme
|
||||
| none => URI.Scheme.defaultPort af.scheme
|
||||
(h, p, af.scheme)
|
||||
| _ => (currentHost, currentPort, currentScheme)
|
||||
|
||||
-- Avoid SSRF.
|
||||
if newScheme.val != "http" && newScheme.val != "https" then
|
||||
return .done
|
||||
|
||||
-- Strip Authorization
|
||||
let isCrossOrigin := newHost != currentHost || newPort != currentPort || newScheme != currentScheme
|
||||
|
||||
let newHeaders :=
|
||||
if isCrossOrigin then
|
||||
request.line.headers
|
||||
|>.erase Header.Name.authorization
|
||||
|>.erase Header.Name.proxyAuthorization
|
||||
|>.erase Header.Name.cookie
|
||||
else request.line.headers
|
||||
|
||||
-- For method-changing redirects (301/302 POST→GET, 303) drop the body.
|
||||
-- For method-preserving redirects (307/308) reuse the body if re-readable (Body.Full).
|
||||
-- A Body.Stream is a live producer whose bytes have already been sent and cannot be replayed;
|
||||
-- follow the redirect with an empty body rather than silently sending a stale/empty stream.
|
||||
let newBody : Body.Any ←
|
||||
if newMethod == .get || newMethod == .head || newMethod != request.line.method then
|
||||
pure (Body.Any.ofBody Body.Empty.mk)
|
||||
else if request.body.isReplayable then do
|
||||
request.body.resetInPlace
|
||||
pure request.body
|
||||
else
|
||||
-- Body.Stream: already consumed, send empty body on redirect
|
||||
pure (Body.Any.ofBody Body.Empty.mk)
|
||||
|
||||
return .follow newHost newPort newScheme
|
||||
{ line := { request.line with uri := target, method := newMethod, headers := newHeaders }
|
||||
body := newBody
|
||||
extensions := request.extensions }
|
||||
|
||||
private partial def sendWithRedirects [Transport α]
|
||||
(agent : Agent α) (request : Request Body.Any)
|
||||
(remaining : Nat) (retriesLeft : Nat)
|
||||
(history : Array (URI.Host × UInt16 × String) := #[]) : Async (Response Body.Stream) := do
|
||||
|
||||
-- Record the current URL in the history and detect redirect cycles.
|
||||
let currentKey := (agent.host, agent.port, toString request.line.uri)
|
||||
let history := history.push currentKey
|
||||
|
||||
-- Rewrite to absolute-form when a proxy is configured.
|
||||
let request :=
|
||||
if agent.session.config.proxy.isSome then
|
||||
toAbsoluteFormRequest request agent.scheme agent.host agent.port
|
||||
else
|
||||
request
|
||||
|
||||
let request ← injectCookies agent.cookieJar agent.host agent.scheme request
|
||||
|
||||
let response ← try agent.session.send request
|
||||
catch err => do
|
||||
agent.onBrokenSession agent.session agent.scheme agent.host agent.port
|
||||
|
||||
if retriesLeft > 0 && isIdempotentMethod request.line.method then
|
||||
if let some factory := agent.connectTo then
|
||||
let attempt := agent.session.config.maxRetries - retriesLeft
|
||||
let delay : Time.Millisecond.Offset := ⟨min (agent.session.config.retryDelay.val * (2 : Int) ^ attempt) 32000⟩
|
||||
sleep delay
|
||||
let newSession ← factory agent.scheme agent.host agent.port
|
||||
return ← sendWithRedirects { agent with session := newSession } request remaining (retriesLeft - 1) history
|
||||
|
||||
throw err
|
||||
|
||||
let response ← applyInterceptors agent.interceptors response
|
||||
processCookies agent.cookieJar agent.host response.line.headers
|
||||
|
||||
match ← decideRedirect remaining agent.host agent.port agent.scheme request response
|
||||
agent.session.config.redirectBodyDrainLimit with
|
||||
| .done =>
|
||||
if let some validate := agent.session.config.validateStatus then
|
||||
if !validate response.line.status then
|
||||
throw (.userError s!"unexpected HTTP status: {response.line.status.toCode}")
|
||||
return response
|
||||
| .follow newHost newPort newScheme newRequest =>
|
||||
if let some policy := agent.session.config.redirectPolicy then
|
||||
if !policy newHost newPort then
|
||||
return response
|
||||
|
||||
let nextKey := (newHost, newPort, toString newRequest.line.uri)
|
||||
if history.contains nextKey then
|
||||
return response
|
||||
|
||||
if newHost != agent.host || newPort != agent.port then
|
||||
|
||||
-- For custom transports without a connectTo factory we cannot open a new
|
||||
-- connection to a different host; return the redirect response as-is.
|
||||
let some factory := agent.connectTo
|
||||
| return response
|
||||
|
||||
let newSession ← factory newScheme newHost newPort
|
||||
|
||||
sendWithRedirects
|
||||
{ session := newSession
|
||||
scheme := newScheme
|
||||
host := newHost
|
||||
port := newPort
|
||||
cookieJar := agent.cookieJar
|
||||
interceptors := agent.interceptors
|
||||
connectTo := some factory
|
||||
onBrokenSession := agent.onBrokenSession }
|
||||
newRequest (remaining - 1) retriesLeft history
|
||||
else
|
||||
sendWithRedirects agent newRequest (remaining - 1) retriesLeft history
|
||||
|
||||
/--
|
||||
Send a request, automatically following redirects up to `config.maxRedirects` hops and
|
||||
retrying on connection errors up to `config.maxRetries` times.
|
||||
For cross-host redirects the agent reconnects using its `connectTo` factory (if set).
|
||||
Cookies are automatically injected from the jar and `Set-Cookie` responses are stored.
|
||||
Response interceptors are applied after every response.
|
||||
-/
|
||||
def send {β : Type} [Coe β Body.Any] [Transport α] (agent : Agent α) (request : Request β) : Async (Response Body.Stream) :=
|
||||
sendWithRedirects
|
||||
agent
|
||||
{ line := request.line, body := (request.body : Body.Any), extensions := request.extensions }
|
||||
agent.session.config.maxRedirects
|
||||
agent.session.config.maxRetries
|
||||
|
||||
end Agent
|
||||
|
||||
/-!
|
||||
# Agent.RequestBuilder
|
||||
|
||||
A fluent builder that attaches an `Agent` to a `Request.Builder`, letting callers chain header
|
||||
and query-parameter setters before dispatching with a typed `send*` terminal.
|
||||
|
||||
```lean
|
||||
let response ←
|
||||
agent.get "/api/items"
|
||||
|>.header! "Accept" "application/json"
|
||||
|>.queryParam "page" "2"
|
||||
|>.send
|
||||
```
|
||||
-/
|
||||
|
||||
/--
|
||||
A `Request.Builder` bound to a specific `Agent`. Build up headers, query parameters, and body,
|
||||
then call one of the `send*` methods to dispatch the request.
|
||||
-/
|
||||
public structure Agent.RequestBuilder (α : Type) where
|
||||
/--
|
||||
The agent that will send this request.
|
||||
-/
|
||||
agent : Agent α
|
||||
|
||||
/--
|
||||
The underlying request builder.
|
||||
-/
|
||||
builder : Request.Builder
|
||||
|
||||
|
||||
namespace Agent.RequestBuilder
|
||||
|
||||
/--
|
||||
Injects a `Host` header derived from the agent's `host` and `port` if no `Host` header
|
||||
is already present.
|
||||
-/
|
||||
private def withHostHeader [Transport α] (rb : Agent.RequestBuilder α) : Agent.RequestBuilder α :=
|
||||
if rb.builder.line.headers.contains Header.Name.host then
|
||||
rb
|
||||
else
|
||||
let defaultPort := URI.Scheme.defaultPort rb.agent.scheme
|
||||
let hostValue :=
|
||||
if rb.agent.port == defaultPort then toString rb.agent.host
|
||||
else s!"{rb.agent.host}:{rb.agent.port}"
|
||||
{ rb with builder := rb.builder.header! "Host" hostValue }
|
||||
|
||||
/--
|
||||
Prepares the builder by injecting the `Host` header, then calls `f` to build and send the
|
||||
request. Cookie injection is handled by `Agent.injectCookies` inside `sendWithRedirects`.
|
||||
-/
|
||||
private def prepare [Transport α] (rb : Agent.RequestBuilder α)
|
||||
(f : Agent.RequestBuilder α → Async (Response Body.Stream)) : Async (Response Body.Stream) :=
|
||||
f rb.withHostHeader
|
||||
|
||||
/--
|
||||
Adds a typed header to the request.
|
||||
-/
|
||||
def header [Transport α] (rb : Agent.RequestBuilder α) (key : Header.Name) (value : Header.Value) : Agent.RequestBuilder α :=
|
||||
{ rb with builder := rb.builder.header key value }
|
||||
|
||||
/--
|
||||
Adds a header to the request. Panics if the name or value is invalid.
|
||||
-/
|
||||
def header! [Transport α] (rb : Agent.RequestBuilder α) (key : String) (value : String) : Agent.RequestBuilder α :=
|
||||
{ rb with builder := rb.builder.header! key value }
|
||||
|
||||
/--
|
||||
Adds a header to the request. Returns `none` if the name or value is invalid.
|
||||
-/
|
||||
def header? [Transport α] (rb : Agent.RequestBuilder α) (key : String) (value : String) : Option (Agent.RequestBuilder α) := do
|
||||
let builder ← rb.builder.header? key value
|
||||
pure { rb with builder }
|
||||
|
||||
/--
|
||||
Sets the request URI from a string. Panics if the string is not a valid request target.
|
||||
-/
|
||||
def uri! [Transport α] (rb : Agent.RequestBuilder α) (u : String) : Agent.RequestBuilder α :=
|
||||
{ rb with builder := rb.builder.uri! u }
|
||||
|
||||
/--
|
||||
Adds a query parameter to the request URI.
|
||||
Works for both origin-form (e.g. set by `agent.get "/path"`) and absolute-form targets.
|
||||
-/
|
||||
def queryParam [Transport α] (rb : Agent.RequestBuilder α) (key : String) (value : String) : Agent.RequestBuilder α :=
|
||||
let newTarget := match rb.builder.line.uri with
|
||||
| .originForm path query =>
|
||||
.originForm path (some ((query.getD URI.Query.empty).insert key value))
|
||||
| .absoluteForm af =>
|
||||
.absoluteForm { af with query := af.query.insert key value }
|
||||
| other => other
|
||||
{ rb with builder := { rb.builder with line := { rb.builder.line with uri := newTarget } } }
|
||||
|
||||
/--
|
||||
Sends the request with an empty body.
|
||||
-/
|
||||
def send [Transport α] (rb : Agent.RequestBuilder α) : Async (Response Body.Stream) :=
|
||||
rb.prepare fun rb => do rb.agent.send (← rb.builder.empty)
|
||||
|
||||
/--
|
||||
Sends the request with a plain-text body.
|
||||
Sets `Content-Type: text/plain; charset=utf-8`.
|
||||
-/
|
||||
def text [Transport α] (rb : Agent.RequestBuilder α) (content : String) : Async (Response Body.Stream) :=
|
||||
rb.prepare fun rb => do rb.agent.send (← rb.builder.text content)
|
||||
|
||||
/--
|
||||
Sends the request with a JSON body.
|
||||
Sets `Content-Type: application/json`.
|
||||
-/
|
||||
def json [Transport α] (rb : Agent.RequestBuilder α) (content : String) : Async (Response Body.Stream) :=
|
||||
rb.prepare fun rb => do rb.agent.send (← rb.builder.json content)
|
||||
|
||||
/--
|
||||
Sends the request with a raw binary body.
|
||||
Sets `Content-Type: application/octet-stream`.
|
||||
-/
|
||||
def bytes [Transport α] (rb : Agent.RequestBuilder α) (content : ByteArray) : Async (Response Body.Stream) :=
|
||||
rb.prepare fun rb => do rb.agent.send (← rb.builder.bytes content)
|
||||
|
||||
/--
|
||||
Sends the request with a streaming body produced by `gen`.
|
||||
-/
|
||||
def sendStream [Transport α]
|
||||
(rb : Agent.RequestBuilder α)
|
||||
(gen : Body.Stream → Async Unit) : Async (Response Body.Stream) :=
|
||||
rb.prepare fun rb => do rb.agent.send (← rb.builder.stream gen)
|
||||
|
||||
end Agent.RequestBuilder
|
||||
|
||||
namespace Agent
|
||||
|
||||
/--
|
||||
Creates a GET request builder for the given path or URL
|
||||
-/
|
||||
def get [Transport α] (agent : Agent α) (path : String) : Agent.RequestBuilder α :=
|
||||
{ agent, builder := Request.get (RequestTarget.parse! path) }
|
||||
|
||||
/--
|
||||
Creates a POST request builder for the given path or URL
|
||||
-/
|
||||
def post [Transport α] (agent : Agent α) (path : String) : Agent.RequestBuilder α :=
|
||||
{ agent, builder := Request.post (RequestTarget.parse! path) }
|
||||
|
||||
/--
|
||||
Creates a PUT request builder for the given path or URL
|
||||
-/
|
||||
def put [Transport α] (agent : Agent α) (path : String) : Agent.RequestBuilder α :=
|
||||
{ agent, builder := Request.put (RequestTarget.parse! path) }
|
||||
|
||||
/--
|
||||
Creates a DELETE request builder for the given path or URL
|
||||
-/
|
||||
def delete [Transport α] (agent : Agent α) (path : String) : Agent.RequestBuilder α :=
|
||||
{ agent, builder := Request.delete (RequestTarget.parse! path) }
|
||||
|
||||
/--
|
||||
Creates a PATCH request builder for the given path or URL
|
||||
-/
|
||||
def patch [Transport α] (agent : Agent α) (path : String) : Agent.RequestBuilder α :=
|
||||
{ agent, builder := Request.patch (RequestTarget.parse! path) }
|
||||
|
||||
/--
|
||||
Creates a HEAD request builder for the given path or URL
|
||||
-/
|
||||
def headReq [Transport α] (agent : Agent α) (path : String) : Agent.RequestBuilder α :=
|
||||
{ agent, builder := Request.head (RequestTarget.parse! path) }
|
||||
|
||||
/--
|
||||
Creates an OPTIONS request builder for the given path or URL.
|
||||
-/
|
||||
def options [Transport α] (agent : Agent α) (path : String) : Agent.RequestBuilder α :=
|
||||
{ agent, builder := Request.options (RequestTarget.parse! path) }
|
||||
|
||||
end Std.Http.Client.Agent
|
||||
157
src/Std/Internal/Http/Client/Config.lean
Normal file
157
src/Std/Internal/Http/Client/Config.lean
Normal file
@@ -0,0 +1,157 @@
|
||||
/-
|
||||
Copyright (c) 2025 Lean FRO, LLC. All rights reserved.
|
||||
Released under Apache 2.0 license as described in the file LICENSE.
|
||||
Authors: Sofia Rodrigues
|
||||
-/
|
||||
module
|
||||
|
||||
prelude
|
||||
public import Std.Time
|
||||
public import Std.Internal.Http.Protocol.H1
|
||||
|
||||
public section
|
||||
|
||||
/-!
|
||||
# Config
|
||||
|
||||
This module exposes the `Config` structure describing timeouts, connection,
|
||||
and header configurations for an HTTP client.
|
||||
-/
|
||||
|
||||
namespace Std.Http.Client
|
||||
|
||||
set_option linter.all true
|
||||
|
||||
/--
|
||||
Client connection configuration with validation.
|
||||
-/
|
||||
structure Config where
|
||||
/--
|
||||
Maximum number of requests per connection (for keep-alive).
|
||||
-/
|
||||
maxRequestsPerConnection : Nat := 1000
|
||||
|
||||
/--
|
||||
Maximum number of headers allowed per response.
|
||||
-/
|
||||
maxResponseHeaders : Nat := 200
|
||||
|
||||
/--
|
||||
Maximum size of a single header name in bytes.
|
||||
-/
|
||||
maxHeaderNameSize : Nat := 256
|
||||
|
||||
/--
|
||||
Maximum size of a single header value in bytes.
|
||||
-/
|
||||
maxHeaderValueSize : Nat := 16384
|
||||
|
||||
/--
|
||||
Maximum waiting time for additional data before timing out.
|
||||
-/
|
||||
readTimeout : Time.Millisecond.Offset := 30000
|
||||
|
||||
/--
|
||||
Timeout duration for keep-alive connections.
|
||||
-/
|
||||
keepAliveTimeout : { x : Time.Millisecond.Offset // 0 < x } := ⟨4000, by decide⟩
|
||||
|
||||
/--
|
||||
Timeout for the request lifecycle (send + receive) per connection.
|
||||
DNS resolution and TCP connect are not covered by this timeout.
|
||||
-/
|
||||
requestTimeout : { x : Time.Millisecond.Offset // 0 < x } := ⟨120000, by decide⟩
|
||||
|
||||
/--
|
||||
Whether to enable keep-alive connections.
|
||||
-/
|
||||
enableKeepAlive : Bool := true
|
||||
|
||||
/--
|
||||
Maximum number of bytes to receive in a single read call.
|
||||
-/
|
||||
maxRecvChunkSize : Nat := 16384
|
||||
|
||||
/--
|
||||
Default buffer size for request payloads.
|
||||
-/
|
||||
defaultRequestBufferSize : Nat := 16384
|
||||
|
||||
/--
|
||||
The user-agent string to send by default.
|
||||
-/
|
||||
userAgent : Option Header.Value := some (.mk "lean-http/1.1")
|
||||
|
||||
/--
|
||||
Maximum number of redirects to follow automatically.
|
||||
Set to 0 to disable automatic redirect following.
|
||||
-/
|
||||
maxRedirects : Nat := 10
|
||||
|
||||
/--
|
||||
Maximum number of times to retry a request after a connection error.
|
||||
Set to 0 to disable automatic retries.
|
||||
-/
|
||||
maxRetries : Nat := 3
|
||||
|
||||
/--
|
||||
Base delay in milliseconds for exponential backoff between retry attempts.
|
||||
The actual delay for attempt `n` (0-indexed) is `min(retryDelay * 2^n, 32000)`.
|
||||
-/
|
||||
retryDelay : Time.Millisecond.Offset := 1000
|
||||
|
||||
/--
|
||||
Optional HTTP proxy address as `(host, port)`.
|
||||
When set, all TCP connections are routed through this proxy and
|
||||
request URIs are rewritten to absolute-form (`GET http://host/path HTTP/1.1`).
|
||||
-/
|
||||
proxy : Option (String × UInt16) := none
|
||||
|
||||
/--
|
||||
Maximum number of bytes allowed in a single response body.
|
||||
When `some n`, reading more than `n` bytes from the body resolves the current
|
||||
request with an error and closes the connection.
|
||||
`none` (default) imposes no limit.
|
||||
-/
|
||||
maxResponseBodySize : Option Nat := none
|
||||
|
||||
/--
|
||||
Optional predicate that decides whether a response status is acceptable.
|
||||
When `none`, all status codes are accepted (no error is thrown).
|
||||
When `some f`, the final response status is passed to `f`; if `f` returns `false`
|
||||
an `IO.Error` is thrown with the numeric status code.
|
||||
Only applied to the final (non-redirect) response, not intermediate `3xx` responses.
|
||||
|
||||
Example — reject anything outside 2xx:
|
||||
```lean
|
||||
validateStatus := some (fun s => s.toCode / 100 == 2)
|
||||
```
|
||||
-/
|
||||
validateStatus : Option (Status → Bool) := none
|
||||
|
||||
/--
|
||||
Maximum number of bytes drained from an intermediate redirect response body before
|
||||
-/
|
||||
redirectBodyDrainLimit : Nat := 1024 * 1024
|
||||
|
||||
/--
|
||||
Optional predicate called before following each redirect.
|
||||
-/
|
||||
redirectPolicy : Option (URI.Host → UInt16 → Bool) := none
|
||||
|
||||
namespace Config
|
||||
|
||||
/--
|
||||
Convert this client config into an HTTP/1.1 protocol configuration.
|
||||
-/
|
||||
def toH1Config (config : Config) : Std.Http.Protocol.H1.Config :=
|
||||
{ maxMessages := config.maxRequestsPerConnection
|
||||
maxHeaders := config.maxResponseHeaders
|
||||
maxHeaderNameLength := config.maxHeaderNameSize
|
||||
maxHeaderValueLength := config.maxHeaderValueSize
|
||||
enableKeepAlive := config.enableKeepAlive
|
||||
agentName := config.userAgent
|
||||
}
|
||||
|
||||
end Config
|
||||
end Std.Http.Client
|
||||
604
src/Std/Internal/Http/Client/Connection.lean
Normal file
604
src/Std/Internal/Http/Client/Connection.lean
Normal file
@@ -0,0 +1,604 @@
|
||||
/-
|
||||
Copyright (c) 2025 Lean FRO, LLC. All rights reserved.
|
||||
Released under Apache 2.0 license as described in the file LICENSE.
|
||||
Authors: Sofia Rodrigues
|
||||
-/
|
||||
module
|
||||
|
||||
prelude
|
||||
public import Std.Internal.Async.TCP
|
||||
public import Std.Internal.Async.ContextAsync
|
||||
public import Std.Internal.Http.Transport
|
||||
public import Std.Internal.Http.Protocol.H1
|
||||
public import Std.Internal.Http.Client.Config
|
||||
public import Std.Sync.Watch
|
||||
|
||||
public section
|
||||
|
||||
namespace Std.Http.Client
|
||||
|
||||
open Std Internal IO Async TCP Protocol
|
||||
open Time
|
||||
|
||||
/--
|
||||
Type-erased body operations for use in the request pipeline.
|
||||
Captures `Reader` and `Writer` methods as closures so the connection state
|
||||
is not parameterized by the body type.
|
||||
-/
|
||||
structure Body.Operations where
|
||||
/--
|
||||
Selector that resolves when a chunk is available or the body reaches EOF.
|
||||
-/
|
||||
recvSelector : Selector (Option Chunk)
|
||||
|
||||
/--
|
||||
Returns `true` when the body is closed for reading.
|
||||
-/
|
||||
isClosed : Async Bool
|
||||
|
||||
/--
|
||||
Closes the body for reading.
|
||||
-/
|
||||
close : Async Unit
|
||||
|
||||
/--
|
||||
Returns the known content length if available.
|
||||
-/
|
||||
getKnownSize : Async (Option Body.Length)
|
||||
|
||||
namespace Body.Operations
|
||||
|
||||
/--
|
||||
Creates a `Body.Operations` from any type with a `Body` instance.
|
||||
-/
|
||||
def of [Body β] (body : β) : Body.Operations where
|
||||
recvSelector := Body.recvSelector body
|
||||
isClosed := Body.isClosed body
|
||||
close := Body.close body
|
||||
getKnownSize := Body.getKnownSize body
|
||||
|
||||
end Body.Operations
|
||||
|
||||
/-!
|
||||
# Connection
|
||||
|
||||
This module defines the `Connection.handle` loop, used to manage one persistent HTTP/1.1 client
|
||||
connection and handle sequential request/response exchanges over it.
|
||||
-/
|
||||
|
||||
set_option linter.all true
|
||||
|
||||
/--
|
||||
A request packet queued to the background connection loop.
|
||||
-/
|
||||
structure RequestPacket where
|
||||
/--
|
||||
The request to send.
|
||||
-/
|
||||
request : Request Body.Operations
|
||||
|
||||
/--
|
||||
Promise resolved with the eventual response.
|
||||
-/
|
||||
responsePromise : IO.Promise (Except Error (Response Body.Stream))
|
||||
|
||||
/--
|
||||
Watch channel updated with the cumulative number of request-body bytes sent.
|
||||
`none` when the caller does not need upload-progress tracking.
|
||||
-/
|
||||
uploadProgress : Option (Watch UInt64) := none
|
||||
|
||||
/--
|
||||
Watch channel updated with the cumulative number of response bytes received.
|
||||
`none` when the caller does not need download-progress tracking.
|
||||
-/
|
||||
downloadProgress : Option (Watch UInt64) := none
|
||||
|
||||
namespace RequestPacket
|
||||
|
||||
/--
|
||||
Resolve the packet with an error.
|
||||
-/
|
||||
def onError (packet : RequestPacket) (error : Error) : BaseIO Unit :=
|
||||
discard <| packet.responsePromise.resolve (.error error)
|
||||
|
||||
/--
|
||||
Resolve the packet with a response.
|
||||
-/
|
||||
def onResponse (packet : RequestPacket) (response : Response Body.Stream) : BaseIO Unit :=
|
||||
discard <| packet.responsePromise.resolve (.ok response)
|
||||
|
||||
end RequestPacket
|
||||
|
||||
namespace Connection
|
||||
|
||||
/--
|
||||
Events produced by the async select loop in `pollNextEvent`.
|
||||
Each variant corresponds to one possible outcome of waiting for I/O.
|
||||
-/
|
||||
private inductive Recv
|
||||
| bytes (x : Option ByteArray)
|
||||
| requestBody (x : Option Chunk)
|
||||
| bodyInterest (x : Bool)
|
||||
| packet (x : Option RequestPacket)
|
||||
| timeout
|
||||
| shutdown
|
||||
| close
|
||||
|
||||
/--
|
||||
The set of I/O sources to wait on during a single poll iteration.
|
||||
Each `Option` field is `none` when that source is not currently active.
|
||||
-/
|
||||
private structure PollSources (α : Type) where
|
||||
socket : Option α
|
||||
expect : Option Nat
|
||||
requestBody : Option Body.Operations
|
||||
requestChannel : Option (Std.CloseableChannel RequestPacket)
|
||||
responseBody : Option Body.Stream
|
||||
timeout : Millisecond.Offset
|
||||
keepAliveTimeout : Option Millisecond.Offset
|
||||
connectionContext : CancellationContext
|
||||
|
||||
/--
|
||||
All mutable state carried through the connection processing loop.
|
||||
Bundled into a struct so it can be passed to and returned from helper functions.
|
||||
-/
|
||||
private structure ConnectionState where
|
||||
machine : H1.Machine .sending
|
||||
currentTimeout : Millisecond.Offset
|
||||
keepAliveTimeout : Option Millisecond.Offset
|
||||
currentRequest : Option RequestPacket
|
||||
requestBody : Option Body.Operations
|
||||
responseStream : Option Body.Stream
|
||||
requiresData : Bool
|
||||
expectData : Option Nat
|
||||
waitingForRequest : Bool
|
||||
isInformationalResponse : Bool
|
||||
waitingForContinue : Bool
|
||||
pendingRequestBody : Option Body.Operations
|
||||
uploadProgress : Option (Watch UInt64) := none
|
||||
uploadBytes : UInt64 := 0
|
||||
downloadProgress : Option (Watch UInt64) := none
|
||||
downloadBytes : UInt64 := 0
|
||||
downloadBodyBytes : UInt64 := 0
|
||||
|
||||
@[inline]
|
||||
private def requestHasExpectContinue (request : Request Body.Operations) : Bool :=
|
||||
match request.line.headers.getAll? Header.Name.expect with
|
||||
| some #[value] =>
|
||||
match Header.Expect.parse value with
|
||||
| some res => res.expect
|
||||
| none => false
|
||||
| _ => false
|
||||
|
||||
/--
|
||||
Waits for the next I/O event across all active sources described by `sources`.
|
||||
Computes the socket recv size from `config`, then races all active selectables.
|
||||
Returns `.close` on transport errors.
|
||||
-/
|
||||
private def pollNextEvent
|
||||
[Transport α]
|
||||
(config : Config) (sources : PollSources α) : Async Recv := do
|
||||
|
||||
let expectedBytes := sources.expect
|
||||
|>.getD config.defaultRequestBufferSize
|
||||
|>.min config.maxRecvChunkSize
|
||||
|>.toUInt64
|
||||
|
||||
let mut selectables : Array (Selectable Recv) := #[
|
||||
.case sources.connectionContext.doneSelector (fun _ => do
|
||||
let reason ← sources.connectionContext.getCancellationReason
|
||||
match reason with
|
||||
| some .deadline => pure .timeout
|
||||
| _ => pure .shutdown)
|
||||
]
|
||||
|
||||
if let some socket := sources.socket then
|
||||
selectables := selectables.push (.case (Transport.recvSelector socket expectedBytes) (Recv.bytes · |> pure))
|
||||
|
||||
if let some keepAliveTimeout := sources.keepAliveTimeout then
|
||||
selectables := selectables.push (.case (← Selector.sleep keepAliveTimeout) (fun _ => pure .timeout))
|
||||
else
|
||||
selectables := selectables.push (.case (← Selector.sleep sources.timeout) (fun _ => pure .timeout))
|
||||
|
||||
if let some requestBody := sources.requestBody then
|
||||
selectables := selectables.push (.case requestBody.recvSelector (Recv.requestBody · |> pure))
|
||||
|
||||
if let some requestChannel := sources.requestChannel then
|
||||
selectables := selectables.push (.case requestChannel.recvSelector (Recv.packet · |> pure))
|
||||
|
||||
if let some responseBody := sources.responseBody then
|
||||
selectables := selectables.push (.case (responseBody.interestSelector) (Recv.bodyInterest · |> pure))
|
||||
|
||||
try Selectable.one selectables catch _ => pure .close
|
||||
|
||||
/--
|
||||
Processes all H1 events from a single machine step, updating the connection state.
|
||||
Handles keep-alive resets, body-size tracking, `Expect: 100-continue`, and parse errors.
|
||||
Returns the updated state and `true` if a parse failure was encountered.
|
||||
-/
|
||||
private def processH1Events
|
||||
(config : Config)
|
||||
(events : Array (H1.Event .sending))
|
||||
(state : ConnectionState) : Async (ConnectionState × Bool) := do
|
||||
|
||||
let mut st := state
|
||||
let mut sawFailure := false
|
||||
|
||||
for event in events do
|
||||
match event with
|
||||
| .needMoreData expect =>
|
||||
st := { st with requiresData := true, expectData := expect }
|
||||
|
||||
-- `.needAnswer` is emitted by processWrite when the writer is in `waitingHeaders`
|
||||
-- state in `.sending` mode, signalling that the client machine needs the next request.
|
||||
-- The client loop tracks this through `waitingForRequest` instead, so this event
|
||||
-- is intentionally a no-op here.
|
||||
| .needAnswer => pure ()
|
||||
|
||||
| .endHeaders head =>
|
||||
if head.status.isInformational then
|
||||
-- Informational (1xx) responses are interim; do not resolve the caller's
|
||||
-- promise. The machine loops back to read the real response.
|
||||
st := { st with isInformationalResponse := true }
|
||||
|
||||
-- A `100 Continue` response authorises the body: move it from the
|
||||
-- pending slot into `requestBody` so the pump loop starts sending.
|
||||
if head.status == .continue && st.waitingForContinue then
|
||||
st := { st with
|
||||
requestBody := st.pendingRequestBody
|
||||
pendingRequestBody := none
|
||||
waitingForContinue := false
|
||||
}
|
||||
else
|
||||
st := { st with
|
||||
isInformationalResponse := false
|
||||
currentTimeout := config.readTimeout
|
||||
keepAliveTimeout := none
|
||||
}
|
||||
|
||||
-- A non-informational response while we were still waiting for
|
||||
-- `100 Continue`: the server rejected (or bypassed) the expectation.
|
||||
-- Discard the pending body — it must not be sent.
|
||||
if st.waitingForContinue then
|
||||
if let some body := st.pendingRequestBody then
|
||||
if !(← body.isClosed) then body.close
|
||||
st := { st with pendingRequestBody := none, waitingForContinue := false }
|
||||
|
||||
if let some body := st.responseStream then
|
||||
if let some length := head.getSize true then
|
||||
Body.setKnownSize body (some length)
|
||||
|
||||
if let some packet := st.currentRequest then
|
||||
if let some incoming := st.responseStream then
|
||||
packet.onResponse { line := head, body := incoming }
|
||||
|
||||
| .closeBody =>
|
||||
-- Skip closing for informational (1xx) responses; the channel stays
|
||||
-- open for the real response body that follows.
|
||||
if !st.isInformationalResponse then
|
||||
if let some body := st.responseStream then
|
||||
if ¬(← Body.isClosed body) then Body.close body
|
||||
|
||||
| .next =>
|
||||
-- Reset all per-request state for the next pipelined request.
|
||||
if let some body := st.requestBody then
|
||||
if ¬(← body.isClosed) then body.close
|
||||
|
||||
if let some body := st.pendingRequestBody then
|
||||
if ¬(← body.isClosed) then body.close
|
||||
|
||||
if let some body := st.responseStream then
|
||||
if ¬(← Body.isClosed body) then Body.close body
|
||||
|
||||
if let some w := st.uploadProgress then Watch.close w
|
||||
if let some w := st.downloadProgress then Watch.close w
|
||||
|
||||
st := { st with
|
||||
requestBody := none
|
||||
pendingRequestBody := none
|
||||
waitingForContinue := false
|
||||
responseStream := none
|
||||
currentRequest := none
|
||||
isInformationalResponse := false
|
||||
waitingForRequest := true
|
||||
keepAliveTimeout := some config.keepAliveTimeout.val
|
||||
currentTimeout := config.keepAliveTimeout.val
|
||||
uploadProgress := none
|
||||
uploadBytes := 0
|
||||
downloadProgress := none
|
||||
downloadBytes := 0
|
||||
downloadBodyBytes := 0
|
||||
}
|
||||
|
||||
| .failed err =>
|
||||
if let some packet := st.currentRequest then
|
||||
packet.onError (.userError (toString err))
|
||||
sawFailure := true
|
||||
|
||||
| .«continue» => pure ()
|
||||
|
||||
| .close => pure ()
|
||||
|
||||
return (st, sawFailure)
|
||||
|
||||
/--
|
||||
Computes the active `PollSources` for the current connection state.
|
||||
Determines which I/O sources need attention and whether to include the socket.
|
||||
-/
|
||||
private def buildPollSources
|
||||
[Transport α]
|
||||
(socket : α) (requestChannel : Std.CloseableChannel RequestPacket)
|
||||
(connectionContext : CancellationContext) (state : ConnectionState)
|
||||
: Async (PollSources α) := do
|
||||
-- Always include an active request body, even if already closed.
|
||||
-- A closed body's recvSelector resolves immediately with `none`, which
|
||||
-- triggers `userClosedBody` so the H1 machine can finalize chunked encoding.
|
||||
let requestBodySource :=
|
||||
state.requestBody
|
||||
|
||||
let responseBodySource ←
|
||||
if state.machine.canPullBodyNow then
|
||||
if let some body := state.responseStream then
|
||||
if ¬(← Body.isClosed body) then pure (some body) else pure none
|
||||
else
|
||||
pure none
|
||||
else
|
||||
pure none
|
||||
|
||||
let pollSocket :=
|
||||
state.requiresData ∨
|
||||
state.machine.writer.sentMessage ∨
|
||||
!state.waitingForRequest ∨
|
||||
requestBodySource.isSome ∨
|
||||
state.machine.canPullBody
|
||||
|
||||
return {
|
||||
socket := if pollSocket then some socket else none
|
||||
expect := state.expectData
|
||||
requestBody := requestBodySource
|
||||
requestChannel := if state.waitingForRequest then some requestChannel else none
|
||||
responseBody := responseBodySource
|
||||
timeout := state.currentTimeout
|
||||
keepAliveTimeout := state.keepAliveTimeout
|
||||
connectionContext := connectionContext
|
||||
}
|
||||
|
||||
/--
|
||||
Processes a single async I/O event and updates the connection state.
|
||||
Returns the updated state and `true` if the connection should be closed immediately.
|
||||
-/
|
||||
private def handleRecvEvent
|
||||
(config : Config)
|
||||
(event : Recv) (state : ConnectionState) : Async (ConnectionState × Bool) := do
|
||||
|
||||
match event with
|
||||
| .bytes (some bytes) =>
|
||||
let newDownloadBytes := state.downloadBytes + bytes.size.toUInt64
|
||||
if let some w := state.downloadProgress then
|
||||
Watch.send w newDownloadBytes
|
||||
return ({ state with machine := state.machine.feed bytes, downloadBytes := newDownloadBytes }, false)
|
||||
|
||||
| .bytes none =>
|
||||
return ({ state with machine := state.machine.noMoreInput }, false)
|
||||
|
||||
| .requestBody (some chunk) =>
|
||||
let newUploadBytes := state.uploadBytes + chunk.data.size.toUInt64
|
||||
if let some w := state.uploadProgress then
|
||||
Watch.send w newUploadBytes
|
||||
return ({ state with machine := state.machine.sendData #[chunk], uploadBytes := newUploadBytes }, false)
|
||||
|
||||
| .requestBody none =>
|
||||
if let some body := state.requestBody then
|
||||
if ¬(← body.isClosed) then body.close
|
||||
return ({ state with machine := state.machine.userClosedBody, requestBody := none }, false)
|
||||
|
||||
| .bodyInterest interested =>
|
||||
if interested then
|
||||
let (newMachine, pulledChunk) := state.machine.pullBody
|
||||
let mut st := { state with machine := newMachine }
|
||||
|
||||
if let some pulled := pulledChunk then
|
||||
let newBodyBytes := st.downloadBodyBytes + pulled.chunk.data.size.toUInt64
|
||||
st := { st with downloadBodyBytes := newBodyBytes }
|
||||
|
||||
-- Enforce the response body size limit before writing data to the caller.
|
||||
if let some maxSize := config.maxResponseBodySize then
|
||||
if newBodyBytes > maxSize.toUInt64 then
|
||||
if let some packet := st.currentRequest then
|
||||
packet.onError (.userError "response body exceeds maximum allowed size")
|
||||
if let some body := st.responseStream then
|
||||
if ¬(← Body.isClosed body) then Body.close body
|
||||
if let some w := st.downloadProgress then Watch.close w
|
||||
return ({ st with
|
||||
machine := st.machine.closeWriter.closeReader.noMoreInput
|
||||
currentRequest := none
|
||||
responseStream := none
|
||||
downloadProgress := none
|
||||
}, false)
|
||||
|
||||
if let some body := st.responseStream then
|
||||
-- If the caller has dropped/closed the incoming side, the write fails.
|
||||
-- Silently swallowing the error is correct: the loop must continue pulling
|
||||
-- wire bytes to keep the connection in a valid state for reuse.
|
||||
try body.send pulled.chunk pulled.incomplete
|
||||
catch _ => pure ()
|
||||
|
||||
if pulled.final then
|
||||
if ¬(← Body.isClosed body) then Body.close body
|
||||
st := { st with responseStream := none }
|
||||
|
||||
return (st, false)
|
||||
else
|
||||
return (state, false)
|
||||
|
||||
| .packet (some packet) =>
|
||||
let mut machine := state.machine.send packet.request.line
|
||||
let mut requestBody : Option Body.Operations := none
|
||||
let mut pendingRequestBody : Option Body.Operations := none
|
||||
let mut waitingForContinue := false
|
||||
|
||||
if requestHasExpectContinue packet.request then
|
||||
-- Defer body pumping until the server sends `100 Continue`, but still
|
||||
-- set the known size so that `Content-Length` is included in the request
|
||||
-- headers (required by RFC 9112; servers need it to fire checkContinue).
|
||||
if let some size ← packet.request.body.getKnownSize then
|
||||
machine := machine.setKnownSize size
|
||||
waitingForContinue := true
|
||||
pendingRequestBody := some packet.request.body
|
||||
else
|
||||
if let some size ← packet.request.body.getKnownSize then
|
||||
machine := machine.setKnownSize size
|
||||
requestBody := some packet.request.body
|
||||
|
||||
let responseStream ← Body.mkStream
|
||||
|
||||
return ({ state with
|
||||
machine := machine
|
||||
currentRequest := some packet
|
||||
waitingForRequest := false
|
||||
currentTimeout := config.requestTimeout.val
|
||||
keepAliveTimeout := none
|
||||
requestBody := requestBody
|
||||
pendingRequestBody := pendingRequestBody
|
||||
waitingForContinue := waitingForContinue
|
||||
responseStream := some responseStream
|
||||
uploadProgress := packet.uploadProgress
|
||||
uploadBytes := 0
|
||||
downloadProgress := packet.downloadProgress
|
||||
downloadBytes := 0
|
||||
}, false)
|
||||
|
||||
| .packet none => return (state, true)
|
||||
|
||||
| .close => return (state, true)
|
||||
|
||||
| .timeout =>
|
||||
if let some packet := state.currentRequest then
|
||||
packet.onError (.userError "request timeout")
|
||||
if let some body := state.responseStream then
|
||||
if ¬(← Body.isClosed body) then Body.close body
|
||||
if let some w := state.uploadProgress then Watch.close w
|
||||
if let some w := state.downloadProgress then Watch.close w
|
||||
return ({ state with
|
||||
machine := state.machine.closeWriter.closeReader.noMoreInput
|
||||
currentRequest := none
|
||||
responseStream := none
|
||||
uploadProgress := none
|
||||
downloadProgress := none
|
||||
}, false)
|
||||
|
||||
| .shutdown =>
|
||||
if let some packet := state.currentRequest then
|
||||
packet.onError (.userError "connection shutdown")
|
||||
if let some body := state.responseStream then
|
||||
if ¬(← Body.isClosed body) then Body.close body
|
||||
if let some w := state.uploadProgress then Watch.close w
|
||||
if let some w := state.downloadProgress then Watch.close w
|
||||
return ({ state with
|
||||
machine := state.machine.closeWriter.closeReader.noMoreInput
|
||||
currentRequest := none
|
||||
responseStream := none
|
||||
uploadProgress := none
|
||||
downloadProgress := none
|
||||
}, false)
|
||||
|
||||
/--
|
||||
Runs the main request/response processing loop for a single connection.
|
||||
Drives the HTTP/1.1 state machine through four phases each iteration:
|
||||
close finished readers, send buffered output, process H1 events, poll for I/O.
|
||||
-/
|
||||
protected def handle
|
||||
[Transport α]
|
||||
(socket : α)
|
||||
(machine : H1.Machine .sending)
|
||||
(config : Config)
|
||||
(connectionContext : CancellationContext)
|
||||
(requestChannel : Std.CloseableChannel RequestPacket) : Async Unit := do
|
||||
|
||||
let mut state : ConnectionState := {
|
||||
machine := machine
|
||||
currentTimeout := config.keepAliveTimeout.val
|
||||
keepAliveTimeout := some config.keepAliveTimeout.val
|
||||
currentRequest := none
|
||||
requestBody := none
|
||||
responseStream := none
|
||||
requiresData := false
|
||||
expectData := none
|
||||
waitingForRequest := true
|
||||
isInformationalResponse := false
|
||||
waitingForContinue := false
|
||||
pendingRequestBody := none
|
||||
}
|
||||
|
||||
while ¬state.machine.halted do
|
||||
|
||||
-- Phase 1: close any reader that the user has signalled is done.
|
||||
if let some body := state.requestBody then
|
||||
if ← body.isClosed then
|
||||
state := { state with machine := state.machine.userClosedBody, requestBody := none }
|
||||
|
||||
-- Phase 2: advance the state machine and flush any output.
|
||||
let (newMachine, step) := state.machine.step
|
||||
state := { state with machine := newMachine }
|
||||
|
||||
if step.output.size > 0 then
|
||||
try Transport.sendAll socket #[step.output.toByteArray]
|
||||
catch _ =>
|
||||
if let some packet := state.currentRequest then
|
||||
packet.onError (.userError "connection write failed")
|
||||
if let some body := state.responseStream then
|
||||
if ¬(← Body.isClosed body) then Body.close body
|
||||
state := { state with
|
||||
machine := state.machine.closeWriter.closeReader.noMoreInput
|
||||
currentRequest := none
|
||||
responseStream := none
|
||||
}
|
||||
break
|
||||
|
||||
-- Phase 3: process all events emitted by this step.
|
||||
let (newState, sawFailure) ← processH1Events config step.events state
|
||||
state := newState
|
||||
if sawFailure then break
|
||||
|
||||
-- Phase 4: wait for the next IO event when any source needs attention.
|
||||
if state.requiresData ∨ state.waitingForRequest ∨ state.currentRequest.isSome ∨ state.requestBody.isSome ∨ state.machine.canPullBody then
|
||||
let sources ← buildPollSources socket requestChannel connectionContext state
|
||||
state := { state with requiresData := false }
|
||||
let event ← pollNextEvent config sources
|
||||
let (newState, shouldClose) ← handleRecvEvent config event state
|
||||
state := newState
|
||||
if shouldClose then break
|
||||
|
||||
-- Clean up: notify any in-flight request and close all open streams.
|
||||
if let some packet := state.currentRequest then
|
||||
packet.onError (.userError "connection closed")
|
||||
|
||||
if let some w := state.uploadProgress then
|
||||
Watch.close w
|
||||
|
||||
if let some w := state.downloadProgress then
|
||||
Watch.close w
|
||||
|
||||
if let some body := state.responseStream then
|
||||
if ¬(← Body.isClosed body) then Body.close body
|
||||
|
||||
if let some body := state.requestBody then
|
||||
if ¬(← body.isClosed) then body.close
|
||||
|
||||
if let some body := state.pendingRequestBody then
|
||||
if ¬(← body.isClosed) then body.close
|
||||
|
||||
discard <| EIO.toBaseIO requestChannel.close
|
||||
|
||||
-- Drain any remaining queued packets.
|
||||
repeat do
|
||||
match ← requestChannel.tryRecv with
|
||||
| some packet => packet.onError (.userError "connection closed")
|
||||
| none => break
|
||||
|
||||
Transport.close socket
|
||||
|
||||
end Connection
|
||||
|
||||
end Std.Http.Client
|
||||
210
src/Std/Internal/Http/Client/Pool.lean
Normal file
210
src/Std/Internal/Http/Client/Pool.lean
Normal file
@@ -0,0 +1,210 @@
|
||||
/-
|
||||
Copyright (c) 2026 Lean FRO, LLC. All rights reserved.
|
||||
Released under Apache 2.0 license as described in the file LICENSE.
|
||||
Authors: Sofia Rodrigues
|
||||
-/
|
||||
module
|
||||
|
||||
prelude
|
||||
public import Std.Internal.Http.Client.Agent
|
||||
import Std.Internal.Async.DNS
|
||||
import Std.Data.HashMap
|
||||
import Init.Data.Array
|
||||
|
||||
public section
|
||||
|
||||
namespace Std
|
||||
namespace Http
|
||||
namespace Client
|
||||
|
||||
set_option linter.all true
|
||||
|
||||
open Std Internal IO Async TCP Protocol
|
||||
open Time
|
||||
|
||||
/-!
|
||||
# Agent.Pool
|
||||
|
||||
A connection pool that maintains multiple reusable sessions per `(host, port)` pair,
|
||||
enabling parallel request pipelines to the same host.
|
||||
|
||||
Use `Pool.new` to create a pool with a shared configuration and cookie jar, then call
|
||||
`pool.send` to dispatch requests through managed sessions.
|
||||
|
||||
```lean
|
||||
let pool ← Agent.Pool.new (maxPerHost := 4)
|
||||
|
||||
-- requests are distributed across up to 4 connections per host
|
||||
let r1 ← pool.send "api.example.com" 80
|
||||
(Request.get (.originForm! "/a") |>.header! "Host" "api.example.com" |>.empty)
|
||||
```
|
||||
-/
|
||||
|
||||
/--
|
||||
Resolves `host` via DNS, opens a TCP socket to `port`, and creates an HTTP session.
|
||||
When `config.proxy` is set the TCP connection is made to the proxy address instead.
|
||||
-/
|
||||
private def createTcpSession (host : URI.Host) (port : UInt16) (config : Config) : Async (Session Socket.Client) := do
|
||||
let (connectHost, connectPort) := config.proxy.getD (toString host, port)
|
||||
let addrs ← DNS.getAddrInfo connectHost (toString connectPort)
|
||||
|
||||
if addrs.isEmpty then
|
||||
throw (IO.userError s!"could not resolve host: {connectHost.quote}")
|
||||
|
||||
-- Try each resolved address in order; return on first successful connect.
|
||||
-- This handles hosts that resolve to both IPv6 (::1) and IPv4 (127.0.0.1).
|
||||
let mut lastErr : IO.Error := IO.userError s!"could not connect to {connectHost.quote}:{connectPort}"
|
||||
|
||||
for ipAddr in addrs do
|
||||
let socketAddr : Std.Net.SocketAddress := match ipAddr with
|
||||
| .v4 ip => .v4 ⟨ip, connectPort⟩
|
||||
| .v6 ip => .v6 ⟨ip, connectPort⟩
|
||||
try
|
||||
let socket ← Socket.Client.mk
|
||||
let _ ← socket.connect socketAddr
|
||||
return ← Session.new socket config
|
||||
catch err =>
|
||||
lastErr := err
|
||||
|
||||
throw lastErr
|
||||
|
||||
/--
|
||||
A connection pool that manages multiple sessions per `(scheme, host, port)` triple.
|
||||
Each value in the map is an array of live sessions paired with a round-robin counter.
|
||||
-/
|
||||
public structure Agent.Pool where
|
||||
/--
|
||||
Per-origin session lists and round-robin counters, guarded by a mutex.
|
||||
The key is `(scheme, host, port)` so that `http://example.com:443` and
|
||||
`https://example.com:443` are never mixed in the same pool.
|
||||
-/
|
||||
state : Mutex (Std.HashMap (String × String × UInt16) (Array (Session Socket.Client) × Nat))
|
||||
|
||||
/--
|
||||
Maximum number of sessions (connections) per host.
|
||||
-/
|
||||
maxPerHost : Nat
|
||||
|
||||
/--
|
||||
Configuration used when creating new sessions.
|
||||
-/
|
||||
config : Config
|
||||
|
||||
/--
|
||||
Cookie jar shared across all sessions in the pool.
|
||||
-/
|
||||
cookieJar : Cookie.Jar
|
||||
|
||||
/--
|
||||
Monotonically increasing counter used to assign unique IDs to pooled sessions.
|
||||
-/
|
||||
nextId : Mutex UInt64
|
||||
|
||||
/--
|
||||
Response interceptors applied (in order) after every response from any session in the pool.
|
||||
-/
|
||||
interceptors : Array (Response Body.Stream → Async (Response Body.Stream)) := #[]
|
||||
|
||||
namespace Agent.Pool
|
||||
|
||||
/--
|
||||
Creates a new, empty connection pool.
|
||||
-/
|
||||
def new (config : Config := {}) (maxPerHost : Nat := 4) : Async Agent.Pool := do
|
||||
let state ← Mutex.new (∅ : Std.HashMap (String × String × UInt16) (Array (Session Socket.Client) × Nat))
|
||||
let cookieJar ← Cookie.Jar.new
|
||||
let nextId ← Mutex.new (1 : UInt64)
|
||||
pure { state, maxPerHost, config, cookieJar, nextId }
|
||||
|
||||
/--
|
||||
Returns a session for `(scheme, host, port)`, reusing an existing one when available or
|
||||
creating a new one when the pool has room. Uses round-robin scheduling.
|
||||
The scheme is part of the key so that `http://example.com:443` and `https://example.com:443`
|
||||
never share a pool entry.
|
||||
-/
|
||||
def getOrCreateSession (pool : Agent.Pool) (scheme : URI.Scheme) (host : URI.Host) (port : UInt16) : Async (Session Socket.Client) := do
|
||||
let key := (scheme.val, toString host, port)
|
||||
-- Fast path: pick an existing session round-robin.
|
||||
let maybeSession ← pool.state.atomically do
|
||||
let st ← MonadState.get
|
||||
let (sessions, idx) := (st.get? key).getD (#[], 0)
|
||||
match sessions[idx % sessions.size]? with
|
||||
| none => return none
|
||||
| some selected =>
|
||||
MonadState.set (st.insert key (sessions, idx + 1))
|
||||
return some selected
|
||||
|
||||
if let some session := maybeSession then
|
||||
return session
|
||||
|
||||
-- Slow path: create a new session and register it.
|
||||
let session ← createTcpSession host port pool.config
|
||||
let newId ← pool.nextId.atomically do
|
||||
let id ← MonadState.get
|
||||
MonadState.set (id + 1)
|
||||
return id
|
||||
let session := { session with id := newId }
|
||||
pool.state.atomically do
|
||||
let st ← MonadState.get
|
||||
let (sessions, idx) := (st.get? key).getD (#[], 0)
|
||||
-- Respect maxPerHost: only register if we are still under the limit.
|
||||
if sessions.size < pool.maxPerHost then
|
||||
MonadState.set (st.insert key (sessions.push session, idx))
|
||||
-- If over the limit (concurrent creation race), this session is still
|
||||
-- returned for the current request but not stored for future reuse.
|
||||
return session
|
||||
|
||||
/--
|
||||
Removes a single broken session from the pool by its unique ID.
|
||||
Healthy sibling sessions to the same origin are preserved.
|
||||
-/
|
||||
private def evictSession (pool : Agent.Pool) (scheme : URI.Scheme) (host : URI.Host) (port : UInt16) (sessionId : UInt64) : Async Unit := do
|
||||
let key := (scheme.val, toString host, port)
|
||||
pool.state.atomically do
|
||||
let st ← MonadState.get
|
||||
match st.get? key with
|
||||
| none => pure ()
|
||||
| some (sessions, idx) =>
|
||||
let sessions' := sessions.filter (fun s => s.id != sessionId)
|
||||
MonadState.set (st.insert key (sessions', idx))
|
||||
|
||||
/--
|
||||
Sends a request through a pooled session for `(scheme, host, port)`, injecting cookies from the
|
||||
shared jar, applying response interceptors, storing any `Set-Cookie` responses, following
|
||||
redirects up to `config.maxRedirects` hops, and evicting dead sessions on connection
|
||||
failure (retrying up to `config.maxRetries` times).
|
||||
-/
|
||||
def send {β : Type} [Coe β Body.Any]
|
||||
(pool : Agent.Pool) (host : URI.Host) (port : UInt16) (scheme : URI.Scheme)
|
||||
(request : Request β) : Async (Response Body.Stream) := do
|
||||
let session ← pool.getOrCreateSession scheme host port
|
||||
|
||||
Agent.send {
|
||||
session
|
||||
scheme := scheme
|
||||
host := host
|
||||
port := port
|
||||
cookieJar := pool.cookieJar
|
||||
interceptors := pool.interceptors
|
||||
connectTo := some (pool.getOrCreateSession · · ·)
|
||||
onBrokenSession := fun brokenSession s h p => pool.evictSession s h p brokenSession.id
|
||||
} request
|
||||
|
||||
end Agent.Pool
|
||||
|
||||
namespace Agent
|
||||
|
||||
/--
|
||||
Resolves `host` via DNS and establishes a TCP connection on `port`, returning a new
|
||||
`Agent Socket.Client`. Throws if DNS resolution returns no addresses.
|
||||
|
||||
When `config.proxy` is set every connection (including cross-host redirects) is routed
|
||||
through the proxy.
|
||||
-/
|
||||
def connect (host : URI.Host) (port : UInt16) (config : Config := {}) : Async (Agent Socket.Client) := do
|
||||
let session ← createTcpSession host port config
|
||||
let cookieJar ← Cookie.Jar.new
|
||||
let scheme := URI.Scheme.ofPort port
|
||||
pure { session, scheme, host, port, cookieJar, connectTo := some (fun _scheme h p => createTcpSession h p config) }
|
||||
|
||||
end Std.Http.Client.Agent
|
||||
107
src/Std/Internal/Http/Client/Session.lean
Normal file
107
src/Std/Internal/Http/Client/Session.lean
Normal file
@@ -0,0 +1,107 @@
|
||||
/-
|
||||
Copyright (c) 2026 Lean FRO, LLC. All rights reserved.
|
||||
Released under Apache 2.0 license as described in the file LICENSE.
|
||||
Authors: Sofia Rodrigues
|
||||
-/
|
||||
module
|
||||
|
||||
prelude
|
||||
public import Std.Internal.Http.Client.Connection
|
||||
|
||||
public section
|
||||
|
||||
namespace Std.Http.Client
|
||||
|
||||
open Std Internal IO Async TCP Protocol
|
||||
open Time
|
||||
|
||||
set_option linter.all true
|
||||
|
||||
/-!
|
||||
# Session
|
||||
|
||||
This module defines `Client.Session`, an HTTP/1.1 client session that manages a single
|
||||
persistent connection and dispatches sequential request/response exchanges over it.
|
||||
A background task drives the `Connection` loop; callers interact through a channel.
|
||||
-/
|
||||
|
||||
/--
|
||||
An HTTP client session that sends sequential requests over a persistent connection.
|
||||
-/
|
||||
public structure Session (α : Type) where
|
||||
/--
|
||||
Queue of requests sent by users.
|
||||
-/
|
||||
requestChannel : Std.CloseableChannel RequestPacket
|
||||
|
||||
/--
|
||||
Resolves when the background loop exits.
|
||||
-/
|
||||
shutdown : IO.Promise Unit
|
||||
|
||||
/--
|
||||
Configuration for this session.
|
||||
-/
|
||||
config : Config
|
||||
|
||||
/--
|
||||
Unique identifier assigned by the pool when this session is registered.
|
||||
Zero for sessions created outside a pool.
|
||||
-/
|
||||
id : UInt64 := 0
|
||||
|
||||
namespace Session
|
||||
|
||||
/--
|
||||
Queue a request and await its response.
|
||||
-/
|
||||
def send [Transport α] {β : Type} [Body β]
|
||||
(session : Session α) (request : Request β) : Async (Response Body.Stream) := do
|
||||
let responsePromise ← IO.Promise.new
|
||||
|
||||
let task ← session.requestChannel.send {
|
||||
request := { line := request.line, body := Body.Operations.of request.body, extensions := request.extensions }
|
||||
responsePromise
|
||||
}
|
||||
|
||||
let .ok _ ← await task
|
||||
| throw (.userError "connection closed, cannot send more requests")
|
||||
|
||||
match ← await responsePromise.result! with
|
||||
| .ok response => pure response
|
||||
| .error error => throw error
|
||||
|
||||
/--
|
||||
Wait for background loop shutdown.
|
||||
-/
|
||||
def waitShutdown (session : Session α) : Async Unit := do
|
||||
await session.shutdown.result!
|
||||
|
||||
/--
|
||||
Close the session's request channel.
|
||||
-/
|
||||
def close (session : Session α) : Async Unit := do
|
||||
discard <| EIO.toBaseIO session.requestChannel.close
|
||||
|
||||
/--
|
||||
Creates an HTTP client session over the given transport and starts its background loop.
|
||||
-/
|
||||
def new [Transport t] (client : t) (config : Config := {}) : Async (Session t) := do
|
||||
let requestChannel ← Std.CloseableChannel.new
|
||||
let shutdown ← IO.Promise.new
|
||||
|
||||
let context ← CancellationContext.new
|
||||
|
||||
background do
|
||||
try
|
||||
Std.Http.Client.Connection.handle client
|
||||
({ config := config.toH1Config } : H1.Machine .sending)
|
||||
config context requestChannel
|
||||
finally
|
||||
discard <| shutdown.resolve ()
|
||||
|
||||
pure { requestChannel, shutdown, config }
|
||||
|
||||
end Session
|
||||
|
||||
end Std.Http.Client
|
||||
@@ -15,6 +15,7 @@ public import Std.Internal.Http.Data.Chunk
|
||||
public import Std.Internal.Http.Data.Headers
|
||||
public import Std.Internal.Http.Data.URI
|
||||
public import Std.Internal.Http.Data.Body
|
||||
public import Std.Internal.Http.Data.Cookie
|
||||
|
||||
/-!
|
||||
# HTTP Data Types
|
||||
|
||||
@@ -8,10 +8,12 @@ module
|
||||
prelude
|
||||
public import Std.Internal.Http.Data.Body.Basic
|
||||
public import Std.Internal.Http.Data.Body.Length
|
||||
public import Std.Internal.Http.Data.Body.Replayable
|
||||
public import Std.Internal.Http.Data.Body.Any
|
||||
public import Std.Internal.Http.Data.Body.Stream
|
||||
public import Std.Internal.Http.Data.Body.Empty
|
||||
public import Std.Internal.Http.Data.Body.Full
|
||||
public import Std.Internal.Http.Data.Body.Buffered
|
||||
|
||||
public section
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ module
|
||||
|
||||
prelude
|
||||
public import Std.Internal.Http.Data.Body.Basic
|
||||
public import Std.Internal.Http.Data.Body.Replayable
|
||||
|
||||
public section
|
||||
|
||||
@@ -57,19 +58,44 @@ structure Any where
|
||||
Sets the size of the body.
|
||||
-/
|
||||
setKnownSize : Option Body.Length → Async Unit
|
||||
|
||||
/--
|
||||
`true` when this body can be re-read after being consumed.
|
||||
Set by `Any.ofReplayableBody`; `false` for non-replayable bodies (e.g. `Body.Stream`).
|
||||
The HTTP client uses this to decide whether to follow 307/308 redirects.
|
||||
-/
|
||||
isReplayable : Bool := false
|
||||
|
||||
/--
|
||||
Resets this body's read state so it can be re-read from the start.
|
||||
Only meaningful when `isReplayable = true`. No-op for `Body.Full` (always re-readable);
|
||||
resets the internal cursor for `Body.Buffered`.
|
||||
-/
|
||||
resetInPlace : Async Unit := pure ()
|
||||
|
||||
namespace Any
|
||||
|
||||
/--
|
||||
Erases a body of any `Http.Body` instance into a `Body.Any`.
|
||||
The resulting body has `isReplayable = false`.
|
||||
-/
|
||||
def ofBody [Http.Body α] (body : α) : Any where
|
||||
recv := Http.Body.recv body
|
||||
close := Http.Body.close body
|
||||
isClosed := Http.Body.isClosed body
|
||||
recv := Http.Body.recv body
|
||||
close := Http.Body.close body
|
||||
isClosed := Http.Body.isClosed body
|
||||
recvSelector := Http.Body.recvSelector body
|
||||
getKnownSize := Http.Body.getKnownSize body
|
||||
setKnownSize := Http.Body.setKnownSize body
|
||||
|
||||
/--
|
||||
Erases a replayable body into a `Body.Any`, preserving replay capability.
|
||||
Sets `isReplayable = true` and `resetInPlace` from `Replayable.resetInPlace`.
|
||||
-/
|
||||
def ofReplayableBody [Http.Body α] [Replayable α] (body : α) : Any :=
|
||||
{ ofBody body with
|
||||
isReplayable := true
|
||||
resetInPlace := Replayable.resetInPlace body }
|
||||
|
||||
end Any
|
||||
|
||||
instance : Http.Body Any where
|
||||
|
||||
155
src/Std/Internal/Http/Data/Body/Buffered.lean
Normal file
155
src/Std/Internal/Http/Data/Body/Buffered.lean
Normal file
@@ -0,0 +1,155 @@
|
||||
/-
|
||||
Copyright (c) 2026 Lean FRO, LLC. All rights reserved.
|
||||
Released under Apache 2.0 license as described in the file LICENSE.
|
||||
Authors: Sofia Rodrigues
|
||||
-/
|
||||
module
|
||||
|
||||
prelude
|
||||
public import Std.Sync
|
||||
public import Std.Internal.Http.Data.Body.Any
|
||||
public import Init.Data.ByteArray
|
||||
|
||||
public section
|
||||
|
||||
/-!
|
||||
# Body.Buffered
|
||||
|
||||
`Buffered α` eagerly reads all chunks from a body of type `α` into a `ByteArray` and
|
||||
implements both `Http.Body` (sequential reads) and `Replayable` (reset to the start).
|
||||
|
||||
Use `Buffered.ofBody` to create one from any `Http.Body`. Typical use cases:
|
||||
- Wrapping a `Body.Stream` before a request that may be redirected with 307/308.
|
||||
- Buffering a response body for multiple reads.
|
||||
|
||||
```lean
|
||||
let buffered ← Buffered.ofBody stream
|
||||
let req ← client.post url |>.sendStream (fun _ => pure ())
|
||||
-- or pass buffered directly
|
||||
```
|
||||
-/
|
||||
|
||||
namespace Std.Http.Body
|
||||
open Std Internal IO Async
|
||||
|
||||
set_option linter.all true
|
||||
|
||||
/--
|
||||
A body that has been fully read into memory and can be replayed from the start.
|
||||
The type parameter `α` records what kind of body was buffered.
|
||||
The internal cursor is reset to zero on each `replay`.
|
||||
-/
|
||||
structure Buffered (α : Type) where
|
||||
private mk ::
|
||||
/-- The buffered content. -/
|
||||
private data : ByteArray
|
||||
/-- Read state: `some pos` = cursor at `pos`; `none` = closed. -/
|
||||
private state : Mutex (Option Nat)
|
||||
|
||||
namespace Buffered
|
||||
|
||||
/--
|
||||
Reads all chunks from `body` into memory and returns a `Buffered α`.
|
||||
The original body is fully consumed by this call.
|
||||
-/
|
||||
partial def ofBody [Http.Body α] (body : α) : Async (Buffered α) := do
|
||||
let rec loop (acc : ByteArray) : Async ByteArray := do
|
||||
match ← Http.Body.recv body with
|
||||
| none => pure acc
|
||||
| some chunk => loop (acc ++ chunk.data)
|
||||
let data ← loop ByteArray.empty
|
||||
let state ← Mutex.new (some 0)
|
||||
return { data, state }
|
||||
|
||||
/--
|
||||
Returns the remaining bytes as a single chunk, or `none` at EOF or when closed.
|
||||
-/
|
||||
def recv (buf : Buffered α) : Async (Option Chunk) :=
|
||||
buf.state.atomically do
|
||||
match ← get with
|
||||
| none => pure none
|
||||
| some pos =>
|
||||
if pos >= buf.data.size then pure none
|
||||
else
|
||||
set (some buf.data.size)
|
||||
pure (some (Chunk.ofByteArray (buf.data.extract pos buf.data.size)))
|
||||
|
||||
/--
|
||||
Closes the body, discarding any unread data.
|
||||
-/
|
||||
def close (buf : Buffered α) : Async Unit :=
|
||||
buf.state.atomically do
|
||||
set (none : Option Nat)
|
||||
|
||||
/--
|
||||
Returns `true` when the body has been closed via `close`.
|
||||
-/
|
||||
def isClosed (buf : Buffered α) : Async Bool :=
|
||||
buf.state.atomically do
|
||||
return (← get).isNone
|
||||
|
||||
/--
|
||||
Returns the number of remaining bytes as a fixed-size length.
|
||||
-/
|
||||
def getKnownSize (buf : Buffered α) : Async (Option Body.Length) :=
|
||||
buf.state.atomically do
|
||||
match ← get with
|
||||
| none => pure (some (.fixed 0))
|
||||
| some pos => pure (some (.fixed (buf.data.size - pos)))
|
||||
|
||||
/--
|
||||
Selector that resolves immediately since buffered data is always in memory.
|
||||
-/
|
||||
def recvSelector (buf : Buffered α) : Selector (Option Chunk) where
|
||||
tryFn := do
|
||||
buf.state.atomically do
|
||||
match ← get with
|
||||
| none => return some none
|
||||
| some pos =>
|
||||
if pos >= buf.data.size then return some none
|
||||
else
|
||||
set (some buf.data.size)
|
||||
return some (some (Chunk.ofByteArray (buf.data.extract pos buf.data.size)))
|
||||
|
||||
registerFn waiter := do
|
||||
let chunk ← recv buf
|
||||
let lose := pure ()
|
||||
let win promise := promise.resolve (.ok chunk)
|
||||
waiter.race lose win
|
||||
|
||||
unregisterFn := pure ()
|
||||
|
||||
/--
|
||||
Returns a new `Buffered` sharing the same data with a fresh cursor at position zero.
|
||||
-/
|
||||
def replay (buf : Buffered α) : Async (Buffered α) := do
|
||||
let state ← Mutex.new (some 0)
|
||||
return { buf with state }
|
||||
|
||||
/--
|
||||
Resets the cursor to position zero so the body can be re-read from the start.
|
||||
-/
|
||||
def resetInPlace (buf : Buffered α) : Async Unit :=
|
||||
buf.state.atomically do set (some 0)
|
||||
|
||||
end Buffered
|
||||
|
||||
instance : Http.Body (Buffered α) where
|
||||
recv := Buffered.recv
|
||||
close := Buffered.close
|
||||
isClosed := Buffered.isClosed
|
||||
recvSelector := Buffered.recvSelector
|
||||
getKnownSize := Buffered.getKnownSize
|
||||
setKnownSize _ _ := pure ()
|
||||
|
||||
/--
|
||||
`Buffered` is replayable.
|
||||
- `replay`: returns a new `Buffered` sharing the same data with a fresh cursor.
|
||||
- `resetInPlace`: resets the cursor to zero in the existing body (used by `Body.Any`).
|
||||
-/
|
||||
instance : Replayable (Buffered α) where
|
||||
replay := Buffered.replay
|
||||
resetInPlace := Buffered.resetInPlace
|
||||
|
||||
|
||||
end Std.Http.Body
|
||||
@@ -17,11 +17,10 @@ public section
|
||||
/-!
|
||||
# Body.Full
|
||||
|
||||
A body backed by a fixed `ByteArray` held in a `Mutex`.
|
||||
|
||||
The byte array is consumed at most once: the first call to `recv` atomically takes the data
|
||||
and returns it as a single chunk; subsequent calls return `none` (end-of-stream).
|
||||
Closing the body discards any unconsumed data.
|
||||
A body backed by a fixed `ByteArray`. The body uses a cursor: the first call to `recv`
|
||||
returns the full byte array as a single chunk; subsequent calls return `none` (end-of-stream).
|
||||
Closing the body discards any unconsumed data. `resetInPlace` resets the cursor to zero so
|
||||
the body can be re-sent (e.g. on 307/308 redirects).
|
||||
-/
|
||||
|
||||
namespace Std.Http.Body
|
||||
@@ -30,60 +29,66 @@ open Std Internal IO Async
|
||||
set_option linter.all true
|
||||
|
||||
/--
|
||||
A body backed by a fixed, mutex-protected `ByteArray`.
|
||||
A body backed by a fixed `ByteArray` with a cursor.
|
||||
|
||||
The data is consumed on the first read. Once consumed (or explicitly closed), the body
|
||||
behaves as a closed, empty channel.
|
||||
The cursor tracks whether the data has been sent:
|
||||
- `some 0` = ready to send
|
||||
- `some n` (n > 0) = data sent, at EOF
|
||||
- `none` = closed
|
||||
|
||||
Unlike `Body.Stream`, a `Full` body is replayable: `resetInPlace` resets the cursor
|
||||
to zero so the same body can be re-sent on redirects (307/308), matching the behavior
|
||||
of reqwest, axios, and curl.
|
||||
-/
|
||||
structure Full where
|
||||
private mk ::
|
||||
private state : Mutex (Option ByteArray)
|
||||
private data : ByteArray
|
||||
private state : Mutex (Option Nat)
|
||||
deriving Nonempty
|
||||
|
||||
namespace Full
|
||||
|
||||
private def takeChunk : AtomicT (Option ByteArray) Async (Option Chunk) := do
|
||||
private def takeChunk (full : Full) : AtomicT (Option Nat) Async (Option Chunk) := do
|
||||
match ← get with
|
||||
| none =>
|
||||
pure none
|
||||
| some data =>
|
||||
set (none : Option ByteArray)
|
||||
if data.isEmpty then
|
||||
pure none
|
||||
| none => pure none
|
||||
| some 0 =>
|
||||
if full.data.isEmpty then pure none
|
||||
else
|
||||
pure (some (Chunk.ofByteArray data))
|
||||
set (some full.data.size)
|
||||
pure (some (Chunk.ofByteArray full.data))
|
||||
| some _ => pure none
|
||||
|
||||
/--
|
||||
Creates a `Full` body from a `ByteArray`.
|
||||
-/
|
||||
def ofByteArray (data : ByteArray) : Async Full := do
|
||||
let state ← Mutex.new (some data)
|
||||
return { state }
|
||||
let state ← Mutex.new (some 0)
|
||||
return { data, state }
|
||||
|
||||
/--
|
||||
Creates a `Full` body from a `String`.
|
||||
-/
|
||||
def ofString (data : String) : Async Full := do
|
||||
let state ← Mutex.new (some data.toUTF8)
|
||||
return { state }
|
||||
let state ← Mutex.new (some 0)
|
||||
return { data := data.toUTF8, state }
|
||||
|
||||
/--
|
||||
Receives the body data. Returns the full byte array on the first call as a single chunk,
|
||||
then `none` on all subsequent calls.
|
||||
then `none` on all subsequent calls until the cursor is reset.
|
||||
-/
|
||||
def recv (full : Full) : Async (Option Chunk) :=
|
||||
full.state.atomically do
|
||||
takeChunk
|
||||
takeChunk full
|
||||
|
||||
/--
|
||||
Closes the body, discarding any unconsumed data.
|
||||
-/
|
||||
def close (full : Full) : Async Unit :=
|
||||
full.state.atomically do
|
||||
set (none : Option ByteArray)
|
||||
set (none : Option Nat)
|
||||
|
||||
/--
|
||||
Returns `true` when the data has been consumed or the body has been closed.
|
||||
Returns `true` when the body has been closed via `close`.
|
||||
-/
|
||||
def isClosed (full : Full) : Async Bool :=
|
||||
full.state.atomically do
|
||||
@@ -91,14 +96,14 @@ def isClosed (full : Full) : Async Bool :=
|
||||
|
||||
/--
|
||||
Returns the known size of the remaining data.
|
||||
Returns `some (.fixed n)` with the current byte count, or `some (.fixed 0)` if the body has
|
||||
already been consumed or closed.
|
||||
Returns `some (.fixed n)` with the byte count if not yet consumed, `some (.fixed 0)` otherwise.
|
||||
-/
|
||||
def getKnownSize (full : Full) : Async (Option Body.Length) :=
|
||||
full.state.atomically do
|
||||
match ← get with
|
||||
| none => pure (some (.fixed 0))
|
||||
| some data => pure (some (.fixed data.size))
|
||||
| some 0 => pure (some (.fixed full.data.size))
|
||||
| some _ => pure (some (.fixed 0))
|
||||
|
||||
/--
|
||||
Selector that immediately resolves to the remaining chunk (or EOF).
|
||||
@@ -106,21 +111,37 @@ Selector that immediately resolves to the remaining chunk (or EOF).
|
||||
def recvSelector (full : Full) : Selector (Option Chunk) where
|
||||
tryFn := do
|
||||
let chunk ← full.state.atomically do
|
||||
takeChunk
|
||||
takeChunk full
|
||||
pure (some chunk)
|
||||
|
||||
registerFn waiter := do
|
||||
full.state.atomically do
|
||||
let lose := pure ()
|
||||
let lose := pure (())
|
||||
|
||||
let win promise := do
|
||||
let chunk ← takeChunk
|
||||
let chunk ← takeChunk full
|
||||
promise.resolve (.ok chunk)
|
||||
|
||||
waiter.race lose win
|
||||
|
||||
unregisterFn := pure ()
|
||||
|
||||
/--
|
||||
Returns a new `Full` sharing the same data with a fresh cursor at position zero.
|
||||
-/
|
||||
def replay (full : Full) : Async Full := do
|
||||
let state ← Mutex.new (some 0)
|
||||
return { full with state }
|
||||
|
||||
/--
|
||||
Resets the cursor to position zero so the body can be re-read from the start.
|
||||
Since `Full.data` is always preserved in the struct, this always works regardless of
|
||||
whether `close` was previously called (e.g. by the connection loop after EOF).
|
||||
-/
|
||||
def resetInPlace (full : Full) : Async Unit :=
|
||||
full.state.atomically do
|
||||
set (some 0)
|
||||
|
||||
end Full
|
||||
|
||||
instance : Http.Body Full where
|
||||
@@ -131,7 +152,15 @@ instance : Http.Body Full where
|
||||
getKnownSize := Full.getKnownSize
|
||||
setKnownSize _ _ := pure ()
|
||||
|
||||
instance : Coe Full Any := ⟨Any.ofBody⟩
|
||||
/--
|
||||
`Full` is replayable: `resetInPlace` resets the cursor to zero so the body can be re-read
|
||||
from the start. `replay` creates a new `Full` sharing the same data with a fresh cursor.
|
||||
-/
|
||||
instance : Replayable Full where
|
||||
replay := Full.replay
|
||||
resetInPlace := Full.resetInPlace
|
||||
|
||||
instance : Coe Full Any := ⟨Any.ofReplayableBody⟩
|
||||
|
||||
instance : Coe (Response Full) (Response Any) where
|
||||
coe f := { f with }
|
||||
|
||||
52
src/Std/Internal/Http/Data/Body/Replayable.lean
Normal file
52
src/Std/Internal/Http/Data/Body/Replayable.lean
Normal file
@@ -0,0 +1,52 @@
|
||||
/-
|
||||
Copyright (c) 2026 Lean FRO, LLC. All rights reserved.
|
||||
Released under Apache 2.0 license as described in the file LICENSE.
|
||||
Authors: Sofia Rodrigues
|
||||
-/
|
||||
module
|
||||
|
||||
prelude
|
||||
public import Std.Internal.Http.Data.Body.Basic
|
||||
|
||||
public section
|
||||
|
||||
/-!
|
||||
# Body.Replayable
|
||||
|
||||
A typeclass for HTTP body types whose content can be re-read from the start.
|
||||
|
||||
`Body.Full` and `Body.Buffered` implement `Replayable`; `Body.Stream` does not because its
|
||||
bytes come from a live producer that has already been consumed and cannot be rewound.
|
||||
|
||||
The HTTP client uses this to decide whether to follow method-preserving redirects (307/308):
|
||||
if the body is not replayable the redirect is not followed and the 307/308 response is returned
|
||||
to the caller, matching the behavior of reqwest.
|
||||
-/
|
||||
|
||||
namespace Std.Http.Body
|
||||
open Std Internal IO Async
|
||||
|
||||
set_option linter.all true
|
||||
|
||||
/--
|
||||
A body that can be re-read from the start.
|
||||
|
||||
- `replay`: returns a fresh body (or `self` for re-readable bodies like `Full`).
|
||||
- `resetInPlace`: resets the body's read position without allocating. Used by `Body.Any` on
|
||||
method-preserving (307/308) redirects. Defaults to a no-op.
|
||||
-/
|
||||
class Replayable (α : Type) where
|
||||
/--
|
||||
Returns a fresh body positioned at the start.
|
||||
For `Body.Full` this is `pure self`; for `Body.Buffered` it creates a new cursor at zero.
|
||||
-/
|
||||
replay : α → Async α
|
||||
|
||||
/--
|
||||
Resets this body's read state in place so it can be read from the start again.
|
||||
Defaults to a no-op (suitable for `Body.Full`, which is always re-readable).
|
||||
`Body.Buffered` overrides this to reset its internal cursor.
|
||||
-/
|
||||
resetInPlace : α → Async Unit := fun _ => pure ()
|
||||
|
||||
end Std.Http.Body
|
||||
@@ -630,6 +630,8 @@ def stream
|
||||
(gen : Body.Stream → Async Unit) :
|
||||
Async (Request Body.Stream) := do
|
||||
let s ← Body.stream gen
|
||||
s.setKnownSize (some .chunked)
|
||||
|
||||
return Request.Builder.body builder s
|
||||
|
||||
end Request.Builder
|
||||
|
||||
349
src/Std/Internal/Http/Data/Cookie.lean
Normal file
349
src/Std/Internal/Http/Data/Cookie.lean
Normal file
@@ -0,0 +1,349 @@
|
||||
/-
|
||||
Copyright (c) 2026 Lean FRO, LLC. All rights reserved.
|
||||
Released under Apache 2.0 license as described in the file LICENSE.
|
||||
Authors: Sofia Rodrigues
|
||||
-/
|
||||
module
|
||||
|
||||
prelude
|
||||
public import Std.Sync.Mutex
|
||||
public import Std.Internal.Http.Data.URI
|
||||
public import Std.Internal.Http.Data.Cookie.Parser
|
||||
public import Std.Internal.Http.Data.Headers
|
||||
public import Init.Data.String
|
||||
public import Init.Data.Array.Basic
|
||||
public import Init.Data.List.Basic
|
||||
|
||||
public section
|
||||
|
||||
/-!
|
||||
# Cookie
|
||||
|
||||
This module defines the `Cookie` and `Jar` types, a minimal RFC 6265-compliant
|
||||
implementation for managing HTTP cookies.
|
||||
|
||||
Cookies are parsed from `Set-Cookie` response headers, stored in a thread-safe jar, and
|
||||
injected as a `Cookie` request header on outgoing requests.
|
||||
|
||||
Supported `Set-Cookie` attributes: `Domain`, `Path`, `Secure`.
|
||||
|
||||
Unsupported: `Expires`, `Max-Age`, `HttpOnly`, `SameSite`. All cookies persist for the
|
||||
lifetime of the jar regardless of any expiry directives.
|
||||
|
||||
Reference: https://www.rfc-editor.org/rfc/rfc6265
|
||||
-/
|
||||
|
||||
namespace Std.Http
|
||||
|
||||
set_option linter.all true
|
||||
|
||||
open Internal Char
|
||||
|
||||
namespace Cookie
|
||||
|
||||
/--
|
||||
Proposition asserting that a string is a valid cookie name: a non-empty HTTP token.
|
||||
Cookie names are case-sensitive.
|
||||
|
||||
Reference: https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1
|
||||
-/
|
||||
abbrev IsValidCookieName (s : String) : Prop :=
|
||||
isToken s
|
||||
|
||||
/--
|
||||
A validated HTTP cookie name. Cookie names are case-sensitive HTTP tokens.
|
||||
|
||||
Reference: https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1
|
||||
-/
|
||||
@[ext]
|
||||
structure Name where
|
||||
/--
|
||||
The cookie name string.
|
||||
-/
|
||||
value : String
|
||||
|
||||
/--
|
||||
Proof that the name is a valid HTTP token.
|
||||
-/
|
||||
isValidCookieName : IsValidCookieName value := by decide
|
||||
deriving BEq, DecidableEq, Repr
|
||||
|
||||
namespace Name
|
||||
|
||||
instance : Inhabited Name where
|
||||
default := ⟨"_", by decide⟩
|
||||
|
||||
/--
|
||||
Attempts to create a `Cookie.Name` from a `String`, returning `none` if the string is
|
||||
not a valid HTTP token or is empty.
|
||||
-/
|
||||
def ofString? (s : String) : Option Name :=
|
||||
let val := s.trimAscii.toString
|
||||
if h : IsValidCookieName val then
|
||||
some ⟨val, h⟩
|
||||
else
|
||||
none
|
||||
|
||||
/--
|
||||
Creates a `Cookie.Name` from a string, panicking if the string is not a valid HTTP token.
|
||||
-/
|
||||
def ofString! (s : String) : Name :=
|
||||
match ofString? s with
|
||||
| some res => res
|
||||
| none => panic! s!"invalid cookie name: {s.quote}"
|
||||
|
||||
instance : ToString Name where
|
||||
toString n := n.value
|
||||
|
||||
end Name
|
||||
|
||||
/--
|
||||
`cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E`
|
||||
|
||||
US-ASCII visible characters excluding SP, DQUOTE, comma, semicolon, and backslash.
|
||||
|
||||
Reference: https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1
|
||||
-/
|
||||
def isCookieOctet (c : Char) : Bool :=
|
||||
c = '!' ∨
|
||||
('#' ≤ c ∧ c ≤ '+') ∨
|
||||
('-' ≤ c ∧ c ≤ ':') ∨
|
||||
('<' ≤ c ∧ c ≤ '[') ∨
|
||||
(']' ≤ c ∧ c ≤ '~')
|
||||
|
||||
/--
|
||||
Proposition asserting that a string is a valid cookie value: all characters are
|
||||
`cookie-octet` characters. Empty values are permitted.
|
||||
|
||||
Reference: https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1
|
||||
-/
|
||||
abbrev IsValidCookieValue (s : String) : Prop :=
|
||||
s.toList.all isCookieOctet
|
||||
|
||||
/--
|
||||
A validated HTTP cookie value. Empty values are permitted.
|
||||
|
||||
Reference: https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1
|
||||
-/
|
||||
@[ext]
|
||||
structure Value where
|
||||
/--
|
||||
The cookie value string.
|
||||
-/
|
||||
value : String
|
||||
|
||||
/--
|
||||
Proof that the value contains only valid cookie-octet characters.
|
||||
-/
|
||||
isValidCookieValue : IsValidCookieValue value := by decide
|
||||
deriving BEq, DecidableEq, Repr
|
||||
|
||||
namespace Value
|
||||
|
||||
instance : Inhabited Value where
|
||||
default := ⟨"", by decide⟩
|
||||
|
||||
/--
|
||||
Attempts to create a `Cookie.Value` from a `String`, returning `none` if the string
|
||||
contains characters not permitted in cookie values.
|
||||
-/
|
||||
def ofString? (s : String) : Option Value :=
|
||||
let val := s.trimAscii.toString
|
||||
if h : IsValidCookieValue val then
|
||||
some ⟨val, h⟩
|
||||
else
|
||||
none
|
||||
|
||||
/--
|
||||
Creates a `Cookie.Value` from a string, panicking if the string contains characters not
|
||||
permitted in cookie values.
|
||||
-/
|
||||
def ofString! (s : String) : Value :=
|
||||
match ofString? s with
|
||||
| some res => res
|
||||
| none => panic! s!"invalid cookie value: {s.quote}"
|
||||
|
||||
instance : ToString Value where
|
||||
toString v := v.value
|
||||
|
||||
end Value
|
||||
|
||||
end Cookie
|
||||
|
||||
/--
|
||||
An HTTP cookie with its matching attributes.
|
||||
|
||||
Reference: https://www.rfc-editor.org/rfc/rfc6265#section-4.1
|
||||
-/
|
||||
structure Cookie where
|
||||
/--
|
||||
The cookie name.
|
||||
-/
|
||||
name : Cookie.Name
|
||||
|
||||
/--
|
||||
The cookie value.
|
||||
-/
|
||||
value : Cookie.Value
|
||||
|
||||
/--
|
||||
The effective domain for this cookie. When `Set-Cookie` carries no `Domain` attribute this
|
||||
equals the origin host and `hostOnly` is `true` — only that exact host will receive the
|
||||
cookie. When `Domain` is set, `hostOnly` is `false` and subdomains also match.
|
||||
-/
|
||||
domain : URI.Host
|
||||
|
||||
/--
|
||||
`true` when the cookie must only be sent to the exact origin host (no subdomain matching).
|
||||
-/
|
||||
hostOnly : Bool
|
||||
|
||||
/--
|
||||
Path prefix for which the cookie is valid. Defaults to `"/"`.
|
||||
-/
|
||||
path : URI.Path
|
||||
|
||||
/--
|
||||
When `true` the cookie must only be sent over a secure (HTTPS) channel.
|
||||
-/
|
||||
secure : Bool
|
||||
|
||||
/--
|
||||
When `true` the cookie must not be exposed to non-HTTP APIs.
|
||||
Stored for completeness; no client-side script enforcement applies here.
|
||||
-/
|
||||
httpOnly : Bool
|
||||
|
||||
/--
|
||||
A HTTP cookie jar.
|
||||
|
||||
Reference: https://www.rfc-editor.org/rfc/rfc6265#section-5
|
||||
-/
|
||||
structure Cookie.Jar where
|
||||
private mk ::
|
||||
private cookies : Mutex (Array Cookie)
|
||||
|
||||
namespace Cookie.Jar
|
||||
|
||||
/--
|
||||
Creates an empty cookie jar.
|
||||
-/
|
||||
def new : BaseIO Jar := do
|
||||
let cookies ← Mutex.new #[]
|
||||
return .mk cookies
|
||||
|
||||
/--
|
||||
Domain matching per RFC 6265 §5.1.3.
|
||||
-/
|
||||
private def domainMatches (cookieDomain : URI.Host) (hostOnly : Bool) (host : URI.Host) : Bool :=
|
||||
if hostOnly then
|
||||
host == cookieDomain
|
||||
else
|
||||
let d := cookieDomain
|
||||
host == d || (toString host).endsWith ("." ++ toString d)
|
||||
|
||||
/--
|
||||
Path matching per RFC 6265 §5.1.4.
|
||||
|
||||
A request path matches a cookie path when they are identical, or when the cookie path is a
|
||||
strict segment-wise prefix of the request path. Segment boundaries correspond to `/`, so
|
||||
`/foo` never prefix-matches `/foobar` (different segments).
|
||||
|
||||
A trailing `/` in the cookie path is normalised away before the prefix test; this covers
|
||||
both RFC conditions:
|
||||
- cookie-path ends with `/` → its meaningful segments are a strict prefix of request-path.
|
||||
- first char after prefix is `/` → satisfied automatically at segment boundaries.
|
||||
-/
|
||||
private def pathMatches (cookiePath : URI.Path) (requestPath : URI.Path) : Bool :=
|
||||
requestPath == cookiePath ||
|
||||
let cp :=
|
||||
if cookiePath.hasTrailingSlash && !cookiePath.isEmpty
|
||||
then cookiePath.segments.pop
|
||||
else cookiePath.segments
|
||||
requestPath.segments.size > cp.size &&
|
||||
requestPath.startsWith { cookiePath with segments := cp }
|
||||
|
||||
/--
|
||||
Parses a single `Set-Cookie` header value and stores the resulting cookie.
|
||||
`host` is the origin host of the response (used as the effective domain when no
|
||||
`Domain` attribute is present).
|
||||
|
||||
Reference: https://www.rfc-editor.org/rfc/rfc6265#section-5.2
|
||||
-/
|
||||
def processSetCookie (jar : Jar) (host : URI.Host) (headerValue : String) : BaseIO Unit := do
|
||||
let .ok parsed := Cookie.Parser.parseSetCookie.run headerValue.toUTF8
|
||||
| return ()
|
||||
|
||||
let some cookieName := Cookie.Name.ofString? parsed.name
|
||||
| return ()
|
||||
|
||||
let some cookieValue := Cookie.Value.ofString? parsed.value
|
||||
| return ()
|
||||
|
||||
let cookiePath : URI.Path :=
|
||||
if let some p := parsed.path then URI.Path.parseOrRoot p
|
||||
else URI.Path.parseOrRoot "/"
|
||||
|
||||
-- RFC 6265 §5.2.3: resolve domain; missing or invalid Domain → host-only
|
||||
let (domain, hostOnly) :=
|
||||
match parsed.domain with
|
||||
| some d =>
|
||||
match URI.DomainName.ofString? d with
|
||||
| some name => (URI.Host.name name, false)
|
||||
| none => (host, true)
|
||||
| none => (host, true)
|
||||
|
||||
-- RFC 6265 §5.3 step 6: if domain attribute is set, the origin host must domain-match it.
|
||||
-- This prevents a server at api.example.com from setting Domain=evil.com or Domain=com.
|
||||
if !hostOnly && !domainMatches domain false host then
|
||||
return ()
|
||||
|
||||
-- RFC 6265 §5.2.2: Max-Age ≤ 0 signals deletion — remove any matching cookie and stop.
|
||||
if let some maxAgeVal := parsed.maxAge then
|
||||
if maxAgeVal ≤ 0 then
|
||||
jar.cookies.atomically do
|
||||
let cs ← get
|
||||
set (cs.filter fun c => !(c.name == cookieName && c.domain == domain && c.path == cookiePath))
|
||||
return ()
|
||||
|
||||
let cookie : Cookie := {
|
||||
name := cookieName
|
||||
value := cookieValue
|
||||
domain
|
||||
hostOnly
|
||||
path := cookiePath
|
||||
secure := parsed.secure
|
||||
httpOnly := parsed.httpOnly
|
||||
}
|
||||
|
||||
-- Limit the total cookie count to prevent unbounded memory growth.
|
||||
-- RFC 6265 §6.1 recommends supporting at least 3000 cookies total.
|
||||
let maxCookies := 3000
|
||||
jar.cookies.atomically do
|
||||
let cs ← get
|
||||
let cs := cs.filter fun c => !(c.name == cookie.name && c.domain == cookie.domain && c.path == cookie.path)
|
||||
if cs.size < maxCookies then
|
||||
set (cs.push cookie)
|
||||
|
||||
/--
|
||||
Returns the `Cookie` header value for all cookies that should be sent for a request to `host`
|
||||
at `path`. Pass `secure := true` when the request channel is HTTPS. Returns `none` when no
|
||||
cookies match.
|
||||
|
||||
Reference: https://www.rfc-editor.org/rfc/rfc6265#section-5.4
|
||||
-/
|
||||
def cookiesFor
|
||||
(jar : Jar) (host : URI.Host) (path : URI.Path)
|
||||
(secure : Bool := false) : BaseIO (Option Header.Value) :=
|
||||
jar.cookies.atomically do
|
||||
let cs ← get
|
||||
let matching := cs.filter fun c =>
|
||||
domainMatches c.domain c.hostOnly host &&
|
||||
pathMatches c.path path &&
|
||||
(!c.secure || secure)
|
||||
if matching.isEmpty then
|
||||
return none
|
||||
else
|
||||
return Header.Value.ofString? (String.intercalate "; " (matching.map (fun c => c.name.value ++ "=" ++ c.value.value)).toList)
|
||||
|
||||
end Std.Http.Cookie.Jar
|
||||
225
src/Std/Internal/Http/Data/Cookie/Parser.lean
Normal file
225
src/Std/Internal/Http/Data/Cookie/Parser.lean
Normal file
@@ -0,0 +1,225 @@
|
||||
/-
|
||||
Copyright (c) 2026 Lean FRO, LLC. All rights reserved.
|
||||
Released under Apache 2.0 license as described in the file LICENSE.
|
||||
Authors: Sofia Rodrigues
|
||||
-/
|
||||
module
|
||||
|
||||
prelude
|
||||
import Init.Data.String
|
||||
public import Std.Internal.Parsec
|
||||
public import Std.Internal.Parsec.ByteArray
|
||||
public import Std.Internal.Http.Internal.Char
|
||||
|
||||
public section
|
||||
|
||||
/-!
|
||||
# Cookie Parser
|
||||
|
||||
This module provides a `Set-Cookie` response-header parser following RFC 6265 §4.1. The
|
||||
`parseSetCookie` combinator returns a `Parsed` structure with raw `String` fields; callers are
|
||||
responsible for validating cookie-name and cookie-value semantics (e.g. via `Cookie.Name.ofString?`
|
||||
and `Cookie.Value.ofString?`).
|
||||
|
||||
On parse failure the cookie is silently discarded per RFC 6265 §5.2.
|
||||
|
||||
Reference: https://www.rfc-editor.org/rfc/rfc6265#section-4.1
|
||||
-/
|
||||
|
||||
namespace Std.Http.Cookie.Parser
|
||||
|
||||
set_option linter.all true
|
||||
|
||||
open Std Internal Parsec ByteArray Internal.Char
|
||||
|
||||
/-
|
||||
cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E
|
||||
; US-ASCII visible characters excluding SP, DQUOTE,
|
||||
; comma, semicolon, and backslash.
|
||||
; Reference: https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1
|
||||
-/
|
||||
@[inline]
|
||||
private def isCookieOctetByte (c : UInt8) : Bool :=
|
||||
c == 0x21 ||
|
||||
(0x23 ≤ c && c ≤ 0x2B) ||
|
||||
(0x2D ≤ c && c ≤ 0x3A) ||
|
||||
(0x3C ≤ c && c ≤ 0x5B) ||
|
||||
(0x5D ≤ c && c ≤ 0x7E)
|
||||
|
||||
/-
|
||||
av-octet = %x20-3A / %x3C-7E
|
||||
; any CHAR except CTLs or ";"
|
||||
; Reference: https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1
|
||||
-/
|
||||
@[inline]
|
||||
private def isAvOctetByte (c : UInt8) : Bool :=
|
||||
(0x20 ≤ c && c ≤ 0x3A) || (0x3C ≤ c && c ≤ 0x7E)
|
||||
|
||||
/-
|
||||
token = 1*tchar
|
||||
tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." /
|
||||
"^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
|
||||
; Reference: https://www.rfc-editor.org/rfc/rfc9110#section-5.6.2
|
||||
-/
|
||||
@[inline]
|
||||
private def parseToken (limit : Nat) : Parser ByteSlice :=
|
||||
takeWhile1AtMost (fun c => tchar (Char.ofUInt8 c)) limit
|
||||
|
||||
/--
|
||||
Parsed result of a `Set-Cookie` header value, prior to semantic validation.
|
||||
|
||||
Cookie-name and cookie-value are raw strings that callers must validate
|
||||
(e.g. via `Cookie.Name.ofString?` and `Cookie.Value.ofString?`).
|
||||
|
||||
- `domain`: the `Domain` attribute value with any leading `.` already stripped;
|
||||
`none` if the attribute is absent.
|
||||
- `path`: the `Path` attribute value (guaranteed to start with `/`);
|
||||
`none` if the attribute is absent or does not start with `/`.
|
||||
- `secure`: `true` when the `Secure` attribute is present.
|
||||
- `httpOnly`: `true` when the `HttpOnly` attribute is present.
|
||||
-/
|
||||
structure Parsed where
|
||||
|
||||
/--
|
||||
Raw cookie name (an HTTP token).
|
||||
-/
|
||||
name : String
|
||||
|
||||
/--
|
||||
Raw cookie value (`*cookie-octet` or double-quoted).
|
||||
-/
|
||||
value : String
|
||||
|
||||
/--
|
||||
`Domain` attribute value with any leading `.` stripped, or `none` if absent.
|
||||
-/
|
||||
domain : Option String
|
||||
|
||||
/--
|
||||
`Path` attribute value starting with `/`, or `none` if absent or invalid.
|
||||
-/
|
||||
path : Option String
|
||||
|
||||
/--
|
||||
`true` when the `Secure` attribute is present.
|
||||
-/
|
||||
secure : Bool
|
||||
|
||||
/-- `true` when the `HttpOnly` attribute is present. -/
|
||||
httpOnly : Bool
|
||||
|
||||
/-- `Max-Age` attribute value in seconds, or `none` if absent or unparseable.
|
||||
Values ≤ 0 signal cookie deletion per RFC 6265 §5.2.2. -/
|
||||
maxAge : Option Int := none
|
||||
|
||||
-- cookie-name = token
|
||||
private def parseCookieName : Parser String := do
|
||||
let bytes ← parseToken 4096
|
||||
|
||||
let some str := String.fromUTF8? bytes.toByteArray
|
||||
| fail "invalid cookie name encoding"
|
||||
|
||||
return str
|
||||
|
||||
/-
|
||||
cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE )
|
||||
-/
|
||||
private def parseCookieValue : Parser String := do
|
||||
let bytes ←
|
||||
if (← peekWhen? (· == '"'.toUInt8)).isSome then
|
||||
skipByte '"'.toUInt8
|
||||
let inner ← takeWhileAtMost isCookieOctetByte 4096
|
||||
skipByte '"'.toUInt8
|
||||
pure inner
|
||||
else
|
||||
takeWhileAtMost isCookieOctetByte 4096
|
||||
let some str := String.fromUTF8? bytes.toByteArray
|
||||
| fail "invalid cookie value encoding"
|
||||
return str
|
||||
|
||||
-- av-name = token (parsed case-insensitively)
|
||||
private def parseAttrName : Parser String := do
|
||||
let bytes ← takeWhileAtMost (fun c => tchar (Char.ofUInt8 c)) 256
|
||||
return (String.fromUTF8! bytes.toByteArray).toLower
|
||||
|
||||
-- av-value = *av-octet
|
||||
private def parseAttrValue : Parser String := do
|
||||
let bytes ← takeWhileAtMost isAvOctetByte 4096
|
||||
let some str := String.fromUTF8? bytes.toByteArray
|
||||
| fail "invalid attribute value encoding"
|
||||
return str
|
||||
|
||||
/-
|
||||
cookie-av = expires-av / max-age-av / domain-av / path-av / secure-av /
|
||||
httponly-av / extension-av
|
||||
domain-av = "Domain=" domain-value
|
||||
domain-value = <subdomain> ; as per RFC 1034, Section 3.5
|
||||
path-av = "Path=" path-value
|
||||
path-value = *av-octet
|
||||
secure-av = "Secure"
|
||||
httponly-av = "HttpOnly"
|
||||
extension-av = *av-octet
|
||||
-/
|
||||
private def parseCookieAv : Parser (String × Option String) := do
|
||||
let name ← parseAttrName
|
||||
let value ← optional (attempt (skipByte '='.toUInt8 *> parseAttrValue))
|
||||
return (name, value)
|
||||
|
||||
/-
|
||||
set-cookie-string = cookie-pair *( ";" SP cookie-av )
|
||||
cookie-pair = cookie-name "=" cookie-value
|
||||
-/
|
||||
|
||||
/--
|
||||
Parses a `Set-Cookie` header value and returns a `Parsed` result.
|
||||
|
||||
Attribute processing follows RFC 6265 §5.2:
|
||||
- `Domain`: leading `.` is stripped; invalid domain strings set `domain` to `none`.
|
||||
- `Path`: values not starting with `/` set `path` to `none` (caller uses the default `/`).
|
||||
- `Secure`: sets `secure` to `true` regardless of whether a value follows the attribute name.
|
||||
- `HttpOnly`: sets `httpOnly` to `true`.
|
||||
- All other attributes (including `Expires`, `Max-Age`, `SameSite`) are ignored.
|
||||
-/
|
||||
public def parseSetCookie : Parser Parsed := do
|
||||
let name ← parseCookieName
|
||||
skipByte '='.toUInt8
|
||||
let value ← parseCookieValue
|
||||
|
||||
-- *( ";" SP cookie-av )
|
||||
let attrs ← many (attempt do
|
||||
skipByte ';'.toUInt8
|
||||
let _ ← optional (skipByte ' '.toUInt8)
|
||||
parseCookieAv)
|
||||
|
||||
let mut domain : Option String := none
|
||||
let mut path : Option String := none
|
||||
let mut secure := false
|
||||
let mut httpOnly := false
|
||||
let mut maxAge : Option Int := none
|
||||
|
||||
for (attrName, attrVal) in attrs do
|
||||
match attrName with
|
||||
| "domain" =>
|
||||
let v := (attrVal.getD "").trimAscii.toString
|
||||
-- RFC 6265 §5.2.3: ignore a leading U+002E FULL STOP character
|
||||
let v := if v.startsWith "." then (v.drop 1).toString else v
|
||||
if !v.isEmpty then domain := some v
|
||||
| "path" =>
|
||||
let v := (attrVal.getD "").trimAscii.toString
|
||||
-- RFC 6265 §5.2.4: if av-value is empty or does not start with "/", use default
|
||||
if !v.isEmpty && v.startsWith "/" then path := some v
|
||||
| "secure" => secure := true
|
||||
| "httponly" => httpOnly := true
|
||||
| "max-age" =>
|
||||
-- RFC 6265 §5.2.2: parse an optional leading '-' followed by one or more digits.
|
||||
if let some v := attrVal then
|
||||
let s := v.trimAscii.toString
|
||||
let (neg, digits) := if s.startsWith "-" then (true, s.drop 1) else (false, s)
|
||||
if !digits.isEmpty && digits.all Char.isDigit then
|
||||
let absVal : Nat := digits.foldl (fun acc c => acc * 10 + (c.toNat - '0'.toNat)) 0
|
||||
maxAge := some (if neg then -(absVal : Int) else (absVal : Int))
|
||||
| _ => pure ()
|
||||
|
||||
return { name, value, domain, path, secure, httpOnly, maxAge }
|
||||
|
||||
end Std.Http.Cookie.Parser
|
||||
@@ -6,6 +6,7 @@ Authors: Sofia Rodrigues
|
||||
module
|
||||
|
||||
prelude
|
||||
public import Std.Internal.Http.Data.URI
|
||||
public import Std.Internal.Http.Data.Headers.Name
|
||||
public import Std.Internal.Http.Data.Headers.Value
|
||||
public import Std.Internal.Parsec.Basic
|
||||
@@ -215,4 +216,97 @@ def serialize (connection : Connection) : Header.Name × Header.Value :=
|
||||
|
||||
instance : Header Connection := ⟨parse, serialize⟩
|
||||
|
||||
end Std.Http.Header.Connection
|
||||
end Connection
|
||||
|
||||
/--
|
||||
The `Host` header.
|
||||
|
||||
Represents the authority component of a URI:
|
||||
host [ ":" port ]
|
||||
|
||||
Reference: https://www.rfc-editor.org/rfc/rfc9110.html#name-host-and-authority
|
||||
-/
|
||||
structure Host where
|
||||
/--
|
||||
Host name (reg-name, IPv4, or IPv6 literal).
|
||||
-/
|
||||
host : URI.Host
|
||||
|
||||
/--
|
||||
Optional port.
|
||||
-/
|
||||
port : URI.Port
|
||||
deriving Repr, BEq
|
||||
|
||||
namespace Host
|
||||
|
||||
/--
|
||||
Parses a `Host` header value.
|
||||
-/
|
||||
def parse (v : Value) : Option Host :=
|
||||
let parsed := (Std.Http.URI.Parser.parseHostHeader <* Std.Internal.Parsec.eof).run v.value.toUTF8
|
||||
match parsed with
|
||||
| .ok ⟨host, port⟩ => some ⟨host, port⟩
|
||||
| .error _ => none
|
||||
|
||||
/--
|
||||
Serializes a `Host` header back to a name and a value.
|
||||
-/
|
||||
def serialize (host : Host) : Header.Name × Header.Value :=
|
||||
let value := match host.port with
|
||||
| .value port => Header.Value.ofString! s!"{host.host}:{port}"
|
||||
| .empty => Header.Value.ofString! s!"{host.host}:"
|
||||
| .omitted => Header.Value.ofString! <| toString host.host
|
||||
|
||||
(.mk "host", value)
|
||||
|
||||
instance : Header Host := ⟨parse, serialize⟩
|
||||
|
||||
end Host
|
||||
|
||||
/--
|
||||
The `Expect` header.
|
||||
|
||||
Represents an expectation token.
|
||||
The only standardized expectation is `100-continue`.
|
||||
|
||||
Reference: https://www.rfc-editor.org/rfc/rfc9110.html#name-expect
|
||||
-/
|
||||
structure Expect where
|
||||
|
||||
/--
|
||||
True if the client expects `100-continue`.
|
||||
-/
|
||||
expect : Bool
|
||||
deriving Repr, BEq
|
||||
|
||||
namespace Expect
|
||||
|
||||
/--
|
||||
Parses an `Expect` header.
|
||||
|
||||
Succeeds only if the value is exactly `100-continue`
|
||||
(case-insensitive, trimmed).
|
||||
-/
|
||||
def parse (v : Value) : Option Expect :=
|
||||
let normalized := v.value.trimAscii.toString.toLower
|
||||
|
||||
if normalized == "100-continue" then
|
||||
some ⟨true⟩
|
||||
else
|
||||
none
|
||||
|
||||
/--
|
||||
Serializes an `Expect` header.
|
||||
-/
|
||||
def serialize (e : Expect) : Header.Name × Header.Value :=
|
||||
if e.expect then
|
||||
(Header.Name.expect, Value.ofString! "100-continue")
|
||||
else
|
||||
(Header.Name.expect, Value.ofString! "")
|
||||
|
||||
instance : Header Expect := ⟨parse, serialize⟩
|
||||
|
||||
end Expect
|
||||
|
||||
end Std.Http.Header
|
||||
|
||||
@@ -179,4 +179,24 @@ Standard Expect header name
|
||||
-/
|
||||
def expect : Header.Name := .mk "expect"
|
||||
|
||||
/--
|
||||
Standard Cookie header name (client → server)
|
||||
-/
|
||||
def cookie : Header.Name := .mk "cookie"
|
||||
|
||||
/--
|
||||
Standard Set-Cookie header name (server → client)
|
||||
-/
|
||||
def setCookie : Header.Name := .mk "set-cookie"
|
||||
|
||||
/--
|
||||
Standard Location header name
|
||||
-/
|
||||
def location : Header.Name := .mk "location"
|
||||
|
||||
/--
|
||||
Standard Proxy-Authorization header name
|
||||
-/
|
||||
def proxyAuthorization : Header.Name := .mk "proxy-authorization"
|
||||
|
||||
end Std.Http.Header.Name
|
||||
|
||||
@@ -382,6 +382,32 @@ def toDecodedSegments (p : Path) : Array String :=
|
||||
p.segments.map fun seg =>
|
||||
seg.decode.getD (toString seg)
|
||||
|
||||
/--
|
||||
Returns `true` if `pre` is a segment-wise prefix of `p`. Each segment in `pre` must equal
|
||||
the corresponding segment in `p` by encoded value. An absolute `pre` additionally requires
|
||||
`p` to be absolute.
|
||||
-/
|
||||
def startsWith (p pre : Path) : Bool :=
|
||||
(!pre.absolute || p.absolute) &&
|
||||
pre.segments.size ≤ p.segments.size &&
|
||||
(Array.range pre.segments.size).all fun i => p.segments[i]! == pre.segments[i]!
|
||||
|
||||
/--
|
||||
Returns `true` if the path ends with a trailing slash. The root path (`/`) is considered to
|
||||
have a trailing slash.
|
||||
-/
|
||||
def hasTrailingSlash (p : Path) : Bool :=
|
||||
(p.absolute && p.segments.isEmpty) ||
|
||||
(p.segments.back?.map (toString · == "") |>.getD false)
|
||||
|
||||
/--
|
||||
Ensures the path ends with a trailing slash by appending an empty segment if needed. Idempotent:
|
||||
the root path (`/`) and any path already ending with `/` are returned unchanged.
|
||||
-/
|
||||
def ensureTrailingSlash (p : Path) : Path :=
|
||||
if p.hasTrailingSlash then p
|
||||
else { p with segments := p.segments.push (EncodedSegment.encode "") }
|
||||
|
||||
end Path
|
||||
|
||||
/--
|
||||
|
||||
@@ -52,13 +52,13 @@ private def parseScheme (config : URI.Config) : Parser URI.Scheme := do
|
||||
if config.maxSchemeLength = 0 then
|
||||
fail "scheme length limit is 0 (no scheme allowed)"
|
||||
|
||||
let first ← takeWhileUpTo1 isAlphaByte 1
|
||||
let rest ← takeWhileUpTo
|
||||
let first : UInt8 ← satisfy isAlphaByte
|
||||
let rest ← takeWhileAtMost
|
||||
(fun c =>
|
||||
isAlphaNum c ∨
|
||||
c = '+'.toUInt8 ∨ c = '-'.toUInt8 ∨ c = '.'.toUInt8)
|
||||
(config.maxSchemeLength - 1)
|
||||
let schemeBytes := first.toByteArray ++ rest.toByteArray
|
||||
let schemeBytes := ByteArray.empty.push first ++ rest.toByteArray
|
||||
let str := String.fromUTF8! schemeBytes |>.toLower
|
||||
|
||||
if h : URI.IsValidScheme str then
|
||||
@@ -68,7 +68,7 @@ private def parseScheme (config : URI.Config) : Parser URI.Scheme := do
|
||||
|
||||
-- port = 1*DIGIT
|
||||
private def parsePortNumber : Parser UInt16 := do
|
||||
let portBytes ← takeWhileUpTo1 isDigitByte 5
|
||||
let portBytes ← takeWhileAtMost isDigitByte 5
|
||||
|
||||
let portStr := String.fromUTF8! portBytes.toByteArray
|
||||
|
||||
@@ -82,7 +82,7 @@ private def parsePortNumber : Parser UInt16 := do
|
||||
|
||||
-- userinfo = *( unreserved / pct-encoded / sub-delims / ":" )
|
||||
private def parseUserInfo (config : URI.Config) : Parser URI.UserInfo := do
|
||||
let userBytesName ← takeWhileUpTo
|
||||
let userBytesName ← takeWhileAtMost
|
||||
(fun x =>
|
||||
x ≠ ':'.toUInt8 ∧
|
||||
(isUserInfoChar x ∨ x = '%'.toUInt8))
|
||||
@@ -94,7 +94,7 @@ private def parseUserInfo (config : URI.Config) : Parser URI.UserInfo := do
|
||||
let userPassEncoded ← if ← peekIs (· == ':'.toUInt8) then
|
||||
skip
|
||||
|
||||
let userBytesPass ← takeWhileUpTo
|
||||
let userBytesPass ← takeWhileAtMost
|
||||
(fun x => isUserInfoChar x ∨ x = '%'.toUInt8)
|
||||
config.maxUserInfoLength
|
||||
|
||||
@@ -113,7 +113,7 @@ private def parseUserInfo (config : URI.Config) : Parser URI.UserInfo := do
|
||||
private def parseIPv6 : Parser Net.IPv6Addr := do
|
||||
skipByte '['.toUInt8
|
||||
|
||||
let result ← takeWhileUpTo1
|
||||
let result ← takeWhile1AtMost
|
||||
(fun x => x = ':'.toUInt8 ∨ x = '.'.toUInt8 ∨ isHexDigitByte x)
|
||||
256
|
||||
|
||||
@@ -127,7 +127,7 @@ private def parseIPv6 : Parser Net.IPv6Addr := do
|
||||
|
||||
-- IPv4address = dec-octet "." dec-octet "." dec-octet "." dec-octet
|
||||
private def parseIPv4 : Parser Net.IPv4Addr := do
|
||||
let result ← takeWhileUpTo1
|
||||
let result ← takeWhile1AtMost
|
||||
(fun x => x = '.'.toUInt8 ∨ isDigitByte x)
|
||||
256
|
||||
|
||||
@@ -148,8 +148,8 @@ private def parseHost (config : URI.Config) : Parser URI.Host := do
|
||||
if let some ipv4 ← tryOpt parseIPv4 then
|
||||
return .ipv4 ipv4
|
||||
|
||||
-- We intentionally parse DNS names here (not full RFC 3986 reg-name).
|
||||
let some str := String.fromUTF8? (← takeWhileUpTo1
|
||||
-- It needs to be a legal DNS label, so it differs from reg-name.
|
||||
let some str := String.fromUTF8? (← takeWhile1AtMost
|
||||
(fun x => isAlphaNum x ∨ x = '-'.toUInt8 ∨ x = '.'.toUInt8)
|
||||
config.maxHostLength).toByteArray
|
||||
| fail s!"invalid host"
|
||||
@@ -187,7 +187,7 @@ private def parseAuthority (config : URI.Config) : Parser URI.Authority := do
|
||||
|
||||
-- segment = *pchar
|
||||
private def parseSegment (config : URI.Config) : Parser ByteSlice := do
|
||||
takeWhileUpTo (fun c => isPChar c ∨ c = '%'.toUInt8) config.maxSegmentLength
|
||||
takeWhileAtMost (fun c => isPChar c ∨ c = '%'.toUInt8) config.maxSegmentLength
|
||||
|
||||
/-
|
||||
path = path-abempty ; begins with "/" or is empty
|
||||
@@ -272,7 +272,7 @@ def parsePath (config : URI.Config) (forceAbsolute : Bool) (allowEmpty : Bool) :
|
||||
-- query = *( pchar / "/" / "?" )
|
||||
private def parseQuery (config : URI.Config) : Parser URI.Query := do
|
||||
let queryBytes ←
|
||||
takeWhileUpTo (fun c => isQueryChar c ∨ c = '%'.toUInt8) config.maxQueryLength
|
||||
takeWhileAtMost (fun c => isQueryChar c ∨ c = '%'.toUInt8) config.maxQueryLength
|
||||
|
||||
let some queryStr := String.fromUTF8? queryBytes.toByteArray
|
||||
| fail "invalid query string"
|
||||
@@ -304,7 +304,7 @@ private def parseQuery (config : URI.Config) : Parser URI.Query := do
|
||||
-- fragment = *( pchar / "/" / "?" )
|
||||
private def parseFragment (config : URI.Config) : Parser URI.EncodedFragment := do
|
||||
let fragmentBytes ←
|
||||
takeWhileUpTo (fun c => isFragmentChar c ∨ c = '%'.toUInt8) config.maxFragmentLength
|
||||
takeWhileAtMost (fun c => isFragmentChar c ∨ c = '%'.toUInt8) config.maxFragmentLength
|
||||
|
||||
let some fragmentStr := URI.EncodedFragment.ofByteArray? fragmentBytes.toByteArray
|
||||
| fail "invalid percent encoding in fragment"
|
||||
|
||||
1625
src/Std/Internal/Http/Protocol/H1.lean
Normal file
1625
src/Std/Internal/Http/Protocol/H1.lean
Normal file
File diff suppressed because it is too large
Load Diff
134
src/Std/Internal/Http/Protocol/H1/Config.lean
Normal file
134
src/Std/Internal/Http/Protocol/H1/Config.lean
Normal file
@@ -0,0 +1,134 @@
|
||||
/-
|
||||
Copyright (c) 2025 Lean FRO, LLC. All rights reserved.
|
||||
Released under Apache 2.0 license as described in the file LICENSE.
|
||||
Authors: Sofia Rodrigues
|
||||
-/
|
||||
module
|
||||
|
||||
prelude
|
||||
public import Std.Internal.Http.Data
|
||||
public import Std.Internal.Http.Internal
|
||||
|
||||
public section
|
||||
|
||||
/-!
|
||||
# HTTP/1.1 Configuration
|
||||
|
||||
This module defines the configuration options for HTTP/1.1 protocol processing,
|
||||
including connection limits, header constraints, and various size limits.
|
||||
-/
|
||||
|
||||
namespace Std.Http.Protocol.H1
|
||||
|
||||
set_option linter.all true
|
||||
|
||||
open Std Internal Parsec ByteArray
|
||||
open Internal
|
||||
|
||||
/--
|
||||
Connection limits and parser bounds configuration.
|
||||
-/
|
||||
structure Config where
|
||||
/--
|
||||
Maximum number of requests (server) or responses (client) per connection.
|
||||
-/
|
||||
maxMessages : Nat := 100
|
||||
|
||||
/--
|
||||
Maximum number of headers allowed per message.
|
||||
-/
|
||||
maxHeaders : Nat := 100
|
||||
|
||||
/--
|
||||
Maximum aggregate byte size of all header field lines in a single message
|
||||
(name + value bytes plus 4 bytes per line for `: ` and `\r\n`). Default: 64 KiB.
|
||||
-/
|
||||
maxHeaderBytes : Nat := 65536
|
||||
|
||||
/--
|
||||
Whether to enable keep-alive connections by default.
|
||||
-/
|
||||
enableKeepAlive : Bool := true
|
||||
|
||||
/--
|
||||
The `Server` header value injected into outgoing responses (receiving mode) or the
|
||||
`User-Agent` header value injected into outgoing requests (sending mode).
|
||||
`none` suppresses the header entirely.
|
||||
-/
|
||||
agentName : Option Header.Value := none
|
||||
|
||||
/--
|
||||
Maximum length of request URI (default: 8192 bytes).
|
||||
-/
|
||||
maxUriLength : Nat := 8192
|
||||
|
||||
/--
|
||||
Maximum number of bytes consumed while parsing request/status start-lines (default: 8192 bytes).
|
||||
-/
|
||||
maxStartLineLength : Nat := 8192
|
||||
|
||||
/--
|
||||
Maximum length of header field name (default: 256 bytes).
|
||||
-/
|
||||
maxHeaderNameLength : Nat := 256
|
||||
|
||||
/--
|
||||
Maximum length of header field value (default: 8192 bytes).
|
||||
-/
|
||||
maxHeaderValueLength : Nat := 8192
|
||||
|
||||
/--
|
||||
Maximum number of spaces in delimiter sequences (default: 16).
|
||||
-/
|
||||
maxSpaceSequence : Nat := 16
|
||||
|
||||
/--
|
||||
Maximum number of leading empty lines (bare CRLF) to skip before a request-line
|
||||
(RFC 9112 §2.2 robustness). Default: 8.
|
||||
-/
|
||||
maxLeadingEmptyLines : Nat := 8
|
||||
|
||||
/--
|
||||
Maximum number of extensions on a single chunk-size line (default: 16).
|
||||
-/
|
||||
maxChunkExtensions : Nat := 16
|
||||
|
||||
/--
|
||||
Maximum length of chunk extension name (default: 256 bytes).
|
||||
-/
|
||||
maxChunkExtNameLength : Nat := 256
|
||||
|
||||
/--
|
||||
Maximum length of chunk extension value (default: 256 bytes).
|
||||
-/
|
||||
maxChunkExtValueLength : Nat := 256
|
||||
|
||||
/--
|
||||
Maximum number of bytes consumed while parsing one chunk-size line with extensions (default: 8192 bytes).
|
||||
-/
|
||||
maxChunkLineLength : Nat := 8192
|
||||
|
||||
/--
|
||||
Maximum allowed chunk payload size in bytes (default: 8 MiB).
|
||||
-/
|
||||
maxChunkSize : Nat := 8 * 1024 * 1024
|
||||
|
||||
/--
|
||||
Maximum allowed total body size per message in bytes (default: 64 MiB).
|
||||
This limit applies across all body framing modes. For chunked transfer encoding,
|
||||
chunk-size lines (including extensions) and the trailer section also count toward
|
||||
this limit, so the total wire bytes consumed by the body cannot exceed this value.
|
||||
-/
|
||||
maxBodySize : Nat := 64 * 1024 * 1024
|
||||
|
||||
/--
|
||||
Maximum length of reason phrase (default: 512 bytes).
|
||||
-/
|
||||
maxReasonPhraseLength : Nat := 512
|
||||
|
||||
/--
|
||||
Maximum number of trailer headers (default: 20).
|
||||
-/
|
||||
maxTrailerHeaders : Nat := 20
|
||||
|
||||
end Std.Http.Protocol.H1
|
||||
110
src/Std/Internal/Http/Protocol/H1/Error.lean
Normal file
110
src/Std/Internal/Http/Protocol/H1/Error.lean
Normal file
@@ -0,0 +1,110 @@
|
||||
/-
|
||||
Copyright (c) 2025 Lean FRO, LLC. All rights reserved.
|
||||
Released under Apache 2.0 license as described in the file LICENSE.
|
||||
Authors: Sofia Rodrigues
|
||||
-/
|
||||
module
|
||||
|
||||
prelude
|
||||
public import Std.Time
|
||||
public import Std.Internal.Http.Data
|
||||
public import Std.Internal.Http.Internal
|
||||
public import Std.Internal.Http.Protocol.H1.Parser
|
||||
public import Std.Internal.Http.Protocol.H1.Config
|
||||
public import Std.Internal.Http.Protocol.H1.Message
|
||||
|
||||
public section
|
||||
|
||||
/-!
|
||||
# HTTP/1.1 Errors
|
||||
|
||||
This module defines the error types for HTTP/1.1 protocol processing,
|
||||
including parsing errors, timeout errors, and connection errors.
|
||||
-/
|
||||
|
||||
namespace Std.Http.Protocol.H1
|
||||
|
||||
set_option linter.all true
|
||||
|
||||
/--
|
||||
Specific HTTP processing errors with detailed information.
|
||||
-/
|
||||
inductive Error
|
||||
/--
|
||||
Malformed start line (request-line or status-line).
|
||||
-/
|
||||
| invalidStatusLine
|
||||
|
||||
/--
|
||||
Invalid or malformed header.
|
||||
-/
|
||||
| invalidHeader
|
||||
|
||||
/--
|
||||
Request timeout occurred.
|
||||
-/
|
||||
| timeout
|
||||
|
||||
/--
|
||||
Request entity too large.
|
||||
-/
|
||||
| entityTooLarge
|
||||
|
||||
/--
|
||||
Request URI is too long.
|
||||
-/
|
||||
| uriTooLong
|
||||
|
||||
/--
|
||||
Unsupported HTTP version.
|
||||
-/
|
||||
| unsupportedVersion
|
||||
|
||||
/--
|
||||
Invalid chunk encoding.
|
||||
-/
|
||||
| invalidChunk
|
||||
|
||||
/--
|
||||
Connection closed.
|
||||
-/
|
||||
| connectionClosed
|
||||
|
||||
/--
|
||||
Bad request or response message.
|
||||
-/
|
||||
| badMessage
|
||||
|
||||
/--
|
||||
The number of header fields in the message exceeds the configured limit.
|
||||
Maps to HTTP 431 Request Header Fields Too Large.
|
||||
-/
|
||||
| tooManyHeaders
|
||||
|
||||
/--
|
||||
The aggregate byte size of all header fields exceeds the configured limit.
|
||||
Maps to HTTP 431 Request Header Fields Too Large.
|
||||
-/
|
||||
| headersTooLarge
|
||||
|
||||
/--
|
||||
Generic error with message.
|
||||
-/
|
||||
| other (message : String)
|
||||
deriving Repr, BEq
|
||||
|
||||
instance : ToString Error where
|
||||
toString
|
||||
| .invalidStatusLine => "Invalid status line"
|
||||
| .invalidHeader => "Invalid header"
|
||||
| .timeout => "Timeout"
|
||||
| .entityTooLarge => "Entity too large"
|
||||
| .uriTooLong => "URI too long"
|
||||
| .unsupportedVersion => "Unsupported version"
|
||||
| .invalidChunk => "Invalid chunk"
|
||||
| .connectionClosed => "Connection closed"
|
||||
| .badMessage => "Bad message"
|
||||
| .tooManyHeaders => "Too many headers"
|
||||
| .headersTooLarge => "Headers too large"
|
||||
| .other msg => s!"Other error: {msg}"
|
||||
|
||||
73
src/Std/Internal/Http/Protocol/H1/Event.lean
Normal file
73
src/Std/Internal/Http/Protocol/H1/Event.lean
Normal file
@@ -0,0 +1,73 @@
|
||||
/-
|
||||
Copyright (c) 2025 Lean FRO, LLC. All rights reserved.
|
||||
Released under Apache 2.0 license as described in the file LICENSE.
|
||||
Authors: Sofia Rodrigues
|
||||
-/
|
||||
module
|
||||
|
||||
prelude
|
||||
public import Std.Time
|
||||
public import Std.Internal.Http.Data
|
||||
public import Std.Internal.Http.Internal
|
||||
public import Std.Internal.Http.Protocol.H1.Parser
|
||||
public import Std.Internal.Http.Protocol.H1.Config
|
||||
public import Std.Internal.Http.Protocol.H1.Message
|
||||
public import Std.Internal.Http.Protocol.H1.Error
|
||||
|
||||
public section
|
||||
|
||||
/-!
|
||||
# HTTP/1.1 Events
|
||||
|
||||
This module defines the events that can occur during HTTP/1.1 message processing,
|
||||
including header completion and control/error signals.
|
||||
-/
|
||||
|
||||
namespace Std.Http.Protocol.H1
|
||||
|
||||
set_option linter.all true
|
||||
|
||||
/--
|
||||
Events emitted during HTTP message processing.
|
||||
-/
|
||||
inductive Event (dir : Direction)
|
||||
/--
|
||||
Indicates that all headers have been successfully parsed.
|
||||
-/
|
||||
| endHeaders (head : Message.Head dir)
|
||||
|
||||
/--
|
||||
Signals that additional input data is required to continue processing.
|
||||
-/
|
||||
| needMoreData (size : Option Nat)
|
||||
|
||||
/--
|
||||
Indicates a failure during parsing or processing.
|
||||
-/
|
||||
| failed (err : Error)
|
||||
|
||||
/--
|
||||
Requests that the connection be closed.
|
||||
-/
|
||||
| close
|
||||
|
||||
/--
|
||||
The body should be closed.
|
||||
-/
|
||||
| closeBody
|
||||
|
||||
/--
|
||||
Indicates that a response is required.
|
||||
-/
|
||||
| needAnswer
|
||||
|
||||
/--
|
||||
Indicates readiness to process the next message.
|
||||
-/
|
||||
| next
|
||||
|
||||
/--
|
||||
Signals that an `Expect: 100-continue` decision is pending.
|
||||
-/
|
||||
| «continue»
|
||||
deriving Inhabited, Repr
|
||||
139
src/Std/Internal/Http/Protocol/H1/Message.lean
Normal file
139
src/Std/Internal/Http/Protocol/H1/Message.lean
Normal file
@@ -0,0 +1,139 @@
|
||||
/-
|
||||
Copyright (c) 2025 Lean FRO, LLC. All rights reserved.
|
||||
Released under Apache 2.0 license as described in the file LICENSE.
|
||||
Authors: Sofia Rodrigues
|
||||
-/
|
||||
module
|
||||
|
||||
prelude
|
||||
import Init.Data.Array
|
||||
public import Std.Internal.Http.Data
|
||||
|
||||
public section
|
||||
|
||||
/-!
|
||||
# Message
|
||||
|
||||
This module provides types and operations for HTTP/1.1 messages, centered around the `Direction`
|
||||
type which models the server's role in message exchange: `Direction.receiving` for parsing incoming
|
||||
requests from clients, and `Direction.sending` for generating outgoing responses to clients.
|
||||
The `Message.Head` type is parameterized by `Direction` and resolves to `Request.Head` or
|
||||
`Response.Head` accordingly, enabling generic code that works uniformly across both phases
|
||||
while exposing common operations such as headers, version, and `shouldKeepAlive`
|
||||
-/
|
||||
|
||||
namespace Std.Http.Protocol.H1
|
||||
|
||||
set_option linter.all true
|
||||
|
||||
/--
|
||||
Direction of message flow from the server's perspective.
|
||||
-/
|
||||
inductive Direction
|
||||
/--
|
||||
Receiving and parsing incoming requests from clients.
|
||||
-/
|
||||
| receiving
|
||||
|
||||
/--
|
||||
Client perspective: writing outgoing requests and reading incoming responses.
|
||||
-/
|
||||
| sending
|
||||
deriving BEq
|
||||
|
||||
/--
|
||||
Inverts the message direction.
|
||||
-/
|
||||
@[expose]
|
||||
abbrev Direction.swap : Direction → Direction
|
||||
| .receiving => .sending
|
||||
| .sending => .receiving
|
||||
|
||||
/--
|
||||
Gets the message head type based on direction.
|
||||
-/
|
||||
@[expose]
|
||||
def Message.Head : Direction → Type
|
||||
| .receiving => Request.Head
|
||||
| .sending => Response.Head
|
||||
|
||||
/--
|
||||
Gets the headers of a `Message`.
|
||||
-/
|
||||
def Message.Head.headers (m : Message.Head dir) : Headers :=
|
||||
match dir with
|
||||
| .receiving => Request.Head.headers m
|
||||
| .sending => Response.Head.headers m
|
||||
|
||||
/--
|
||||
Gets the version of a `Message`.
|
||||
-/
|
||||
def Message.Head.version (m : Message.Head dir) : Version :=
|
||||
match dir with
|
||||
| .receiving => Request.Head.version m
|
||||
| .sending => Response.Head.version m
|
||||
|
||||
/--
|
||||
Determines the message body size based on the `Content-Length` header and the `Transfer-Encoding` (chunked) flag.
|
||||
-/
|
||||
def Message.Head.getSize (message : Message.Head dir) (allowEOFBody : Bool) : Option Body.Length :=
|
||||
let contentLength := message.headers.getAll? .contentLength
|
||||
|
||||
match message.headers.getAll? .transferEncoding with
|
||||
| none =>
|
||||
match contentLength with
|
||||
| some #[cl] => .fixed <$> cl.value.toNat?
|
||||
| some _ => none -- To avoid request smuggling with malformed/multiple content-length headers.
|
||||
| none => if allowEOFBody then some (.fixed 0) else none
|
||||
|
||||
-- Single transfer-encoding header.
|
||||
| some #[header] =>
|
||||
let te := Header.TransferEncoding.parse header
|
||||
|
||||
match Header.TransferEncoding.isChunked <$> te, contentLength with
|
||||
| some true, none =>
|
||||
-- HTTP/1.0 does not define chunked transfer encoding (RFC 2068 §19.4.6).
|
||||
-- A server MUST NOT use chunked with an HTTP/1.0 peer; likewise, an
|
||||
-- HTTP/1.0 request carrying Transfer-Encoding: chunked is malformed.
|
||||
if message.version == .v10 then none else some .chunked
|
||||
| _, _ => none -- To avoid request smuggling when TE and CL are mixed.
|
||||
|
||||
-- We disallow multiple transfer-encoding headers.
|
||||
| some _ => none
|
||||
/--
|
||||
Checks whether the message indicates that the connection should be kept alive.
|
||||
-/
|
||||
def Message.Head.shouldKeepAlive (message : Message.Head dir) : Bool :=
|
||||
let tokens? : Option (Array String) :=
|
||||
match message.headers.getAll? .connection with
|
||||
| none => some #[]
|
||||
| some values =>
|
||||
values.foldl (fun acc raw => do
|
||||
let acc ← acc
|
||||
let parsed ← Header.Connection.parse raw
|
||||
pure (acc ++ parsed.tokens)
|
||||
) (some #[])
|
||||
|
||||
match tokens? with
|
||||
| none =>false
|
||||
| some tokens =>
|
||||
if message.version == .v11 then
|
||||
!tokens.any (· == "close")
|
||||
else
|
||||
tokens.any (· == "keep-alive")
|
||||
|
||||
instance : Repr (Message.Head dir) :=
|
||||
match dir with
|
||||
| .receiving => inferInstanceAs (Repr Request.Head)
|
||||
| .sending => inferInstanceAs (Repr Response.Head)
|
||||
|
||||
instance : Internal.Encode .v11 (Message.Head dir) :=
|
||||
match dir with
|
||||
| .receiving => inferInstanceAs (Internal.Encode .v11 Request.Head)
|
||||
| .sending => inferInstanceAs (Internal.Encode .v11 Response.Head)
|
||||
|
||||
instance : EmptyCollection (Message.Head dir) where
|
||||
emptyCollection :=
|
||||
match dir with
|
||||
| .receiving => { method := .get, version := .v11 }
|
||||
| .sending => {}
|
||||
548
src/Std/Internal/Http/Protocol/H1/Parser.lean
Normal file
548
src/Std/Internal/Http/Protocol/H1/Parser.lean
Normal file
@@ -0,0 +1,548 @@
|
||||
/-
|
||||
Copyright (c) 2025 Lean FRO, LLC. All rights reserved.
|
||||
Released under Apache 2.0 license as described in the file LICENSE.
|
||||
Authors: Sofia Rodrigues
|
||||
-/
|
||||
module
|
||||
|
||||
prelude
|
||||
public import Std.Internal.Parsec
|
||||
public import Std.Internal.Http.Data
|
||||
public import Std.Internal.Parsec.ByteArray
|
||||
public import Std.Internal.Http.Protocol.H1.Config
|
||||
|
||||
/-!
|
||||
This module defines parsers for HTTP/1.1 request and response lines, headers, and body framing. The
|
||||
reference used is https://httpwg.org/specs/rfc9112.html.
|
||||
-/
|
||||
|
||||
namespace Std.Http.Protocol.H1
|
||||
|
||||
open Std Internal Parsec ByteArray Internal Internal.Char
|
||||
|
||||
set_option linter.all true
|
||||
|
||||
/--
|
||||
Checks if a byte may appear inside a field value.
|
||||
|
||||
This parser enforces strict ASCII-only field values and allows only `field-content`
|
||||
(`HTAB / SP / VCHAR`).
|
||||
-/
|
||||
@[inline]
|
||||
def isFieldVChar (c : UInt8) : Bool :=
|
||||
fieldContent (Char.ofUInt8 c)
|
||||
|
||||
/--
|
||||
Checks if a byte may appear unescaped inside a quoted-string value.
|
||||
|
||||
Allows `HTAB / SP / %x21 / %x23-5B / %x5D-7E` (strict ASCII-only; no obs-text).
|
||||
-/
|
||||
@[inline]
|
||||
def isQdText (c : UInt8) : Bool :=
|
||||
qdtext (Char.ofUInt8 c)
|
||||
|
||||
/--
|
||||
Checks if a byte is optional whitespace (`OWS = SP / HTAB`, RFC 9110 §5.6.3).
|
||||
-/
|
||||
@[inline]
|
||||
def isOwsByte (c : UInt8) : Bool :=
|
||||
ows (Char.ofUInt8 c)
|
||||
|
||||
-- Parser blocks
|
||||
|
||||
/--
|
||||
Repeatedly applies `parser` until it returns `none` or the `maxCount` limit is
|
||||
exceeded. Returns the collected results as an array.
|
||||
-/
|
||||
partial def manyItems {α : Type} (parser : Parser (Option α)) (maxCount : Nat) : Parser (Array α) := do
|
||||
let rec go (acc : Array α) : Parser (Array α) := do
|
||||
let step ← optional <| attempt do
|
||||
match ← parser with
|
||||
| none => fail "end of items"
|
||||
| some x => return x
|
||||
|
||||
match step with
|
||||
| none =>
|
||||
return acc
|
||||
| some x =>
|
||||
let acc := acc.push x
|
||||
|
||||
if acc.size > maxCount then
|
||||
fail s!"too many items: {acc.size} > {maxCount}"
|
||||
|
||||
go acc
|
||||
go #[]
|
||||
|
||||
|
||||
/--
|
||||
Lifts an `Option` into the parser monad, failing with a generic message if the value is `none`.
|
||||
-/
|
||||
def liftOption (x : Option α) : Parser α :=
|
||||
if let some res := x then
|
||||
return res
|
||||
else
|
||||
fail "expected value but got none"
|
||||
|
||||
/--
|
||||
Parses an HTTP token (RFC 9110 §5.6.2): one or more token characters, up to `limit` bytes.
|
||||
Fails if the input starts with a non-token character or is empty.
|
||||
-/
|
||||
@[inline]
|
||||
def parseToken (limit : Nat) : Parser ByteSlice :=
|
||||
takeWhileUpTo1 (fun c => tchar (Char.ofUInt8 c)) limit
|
||||
|
||||
/--
|
||||
Parses a line terminator.
|
||||
-/
|
||||
@[inline]
|
||||
def crlf : Parser Unit := do
|
||||
skipBytes "\r\n".toUTF8
|
||||
|
||||
/--
|
||||
Consumes and ignores empty lines (`CRLF`) that appear before a request-line.
|
||||
|
||||
https://httpwg.org/specs/rfc9112.html#rfc.section.2.2:
|
||||
|
||||
"In the interest of robustness, a server that is expecting to receive and parse a request-line SHOULD
|
||||
ignore at least one empty line (CRLF) received prior to the request-line."
|
||||
-/
|
||||
def skipLeadingRequestEmptyLines (limits : H1.Config) : Parser Unit := do
|
||||
let mut count := 0
|
||||
while (← peekWhen? (· == '\r'.toUInt8)).isSome do
|
||||
if count >= limits.maxLeadingEmptyLines then
|
||||
fail "too many leading empty lines"
|
||||
crlf
|
||||
count := count + 1
|
||||
|
||||
/--
|
||||
Parses a single space (SP, 0x20).
|
||||
-/
|
||||
@[inline]
|
||||
def sp : Parser Unit :=
|
||||
skipByte ' '.toUInt8
|
||||
|
||||
/--
|
||||
Parses optional whitespace (OWS = *(SP / HTAB), RFC 9110 §5.6.3), bounded by
|
||||
`limits.maxSpaceSequence`. Fails if more whitespace follows the limit, so oversized
|
||||
padding is rejected rather than silently truncated.
|
||||
-/
|
||||
@[inline]
|
||||
def ows (limits : H1.Config) : Parser Unit := do
|
||||
discard <| takeWhileUpTo isOwsByte limits.maxSpaceSequence
|
||||
|
||||
if (← peekWhen? isOwsByte) |>.isSome then
|
||||
fail "invalid space sequence"
|
||||
else
|
||||
pure ()
|
||||
|
||||
/--
|
||||
Parses a single ASCII hex digit and returns its numeric value (`0`–`15`).
|
||||
-/
|
||||
def hexDigit : Parser UInt8 := do
|
||||
let b ← any
|
||||
if isHexDigitByte b then
|
||||
if b ≥ '0'.toUInt8 && b ≤ '9'.toUInt8 then return b - '0'.toUInt8
|
||||
else if b ≥ 'A'.toUInt8 && b ≤ 'F'.toUInt8 then return b - 'A'.toUInt8 + 10
|
||||
else return b - 'a'.toUInt8 + 10
|
||||
else fail s!"invalid hex digit {Char.ofUInt8 b |>.quote}"
|
||||
|
||||
/--
|
||||
Parses a hexadecimal integer (one or more hex digits, up to 16 digits).
|
||||
Used for chunk-size lines in chunked transfer encoding.
|
||||
-/
|
||||
partial def hex : Parser Nat := do
|
||||
let rec go (acc : Nat) (count : Nat) : Parser Nat := do
|
||||
match ← optional (attempt hexDigit) with
|
||||
| some d =>
|
||||
if count + 1 > 16 then
|
||||
fail "chunk size too large"
|
||||
else
|
||||
go (acc * 16 + d.toNat) (count + 1)
|
||||
| none =>
|
||||
if count = 0 then
|
||||
-- Preserve EOF as incremental chunk-size parsing can request more data.
|
||||
-- For non-EOF invalid bytes, keep the specific parse failure.
|
||||
let _ ← peek!
|
||||
fail "expected hex digit"
|
||||
else
|
||||
return acc
|
||||
go 0 0
|
||||
|
||||
-- Actual parsers
|
||||
|
||||
/--
|
||||
Parses `HTTP-version = HTTP-name "/" DIGIT "." DIGIT` and returns the major and
|
||||
minor version numbers as a pair.
|
||||
-/
|
||||
def parseHttpVersionNumber : Parser (Nat × Nat) := do
|
||||
skipBytes "HTTP/".toUTF8
|
||||
let major ← digit
|
||||
skipByte '.'.toUInt8
|
||||
let minor ← digit
|
||||
pure ((major.toNat - 48), (minor.toNat - 48))
|
||||
|
||||
/--
|
||||
Parses an HTTP version string and returns the corresponding `Version` value.
|
||||
Fails if the version is not recognized by `Version.ofNumber?`.
|
||||
-/
|
||||
def parseHttpVersion : Parser Version := do
|
||||
let (major, minor) ← parseHttpVersionNumber
|
||||
liftOption <| Version.ofNumber? major minor
|
||||
|
||||
/-
|
||||
method = token
|
||||
|
||||
Every branch is wrapped in `attempt` so that `<|>` always backtracks on
|
||||
failure, even after consuming bytes. This is strictly necessary only for the
|
||||
P-group (POST / PUT / PATCH) which share a common first byte, but wrapping
|
||||
all alternatives keeps the parser defensively correct if new methods are
|
||||
added in the future.
|
||||
-/
|
||||
def parseMethod : Parser Method :=
|
||||
(attempt <| skipBytes "GET".toUTF8 <&> fun _ => Method.get)
|
||||
<|> (attempt <| skipBytes "HEAD".toUTF8 <&> fun _ => Method.head)
|
||||
<|> (attempt <| skipBytes "DELETE".toUTF8 <&> fun _ => Method.delete)
|
||||
<|> (attempt <| skipBytes "TRACE".toUTF8 <&> fun _ => Method.trace)
|
||||
<|> (attempt <| skipBytes "ACL".toUTF8 <&> fun _ => Method.acl)
|
||||
<|> (attempt <| skipBytes "QUERY".toUTF8 <&> fun _ => Method.query)
|
||||
<|> (attempt <| skipBytes "SEARCH".toUTF8 <&> fun _ => Method.search)
|
||||
<|> (attempt <| skipBytes "BASELINE-CONTROL".toUTF8 <&> fun _ => Method.baselineControl)
|
||||
<|> (attempt <| skipBytes "BIND".toUTF8 <&> fun _ => Method.bind)
|
||||
<|> (attempt <| skipBytes "CONNECT".toUTF8 <&> fun _ => Method.connect)
|
||||
<|> (attempt <| skipBytes "CHECKIN".toUTF8 <&> fun _ => Method.checkin)
|
||||
<|> (attempt <| skipBytes "CHECKOUT".toUTF8 <&> fun _ => Method.checkout)
|
||||
<|> (attempt <| skipBytes "COPY".toUTF8 <&> fun _ => Method.copy)
|
||||
<|> (attempt <| skipBytes "LABEL".toUTF8 <&> fun _ => Method.label)
|
||||
<|> (attempt <| skipBytes "LINK".toUTF8 <&> fun _ => Method.link)
|
||||
<|> (attempt <| skipBytes "LOCK".toUTF8 <&> fun _ => Method.lock)
|
||||
<|> (attempt <| skipBytes "MERGE".toUTF8 <&> fun _ => Method.merge)
|
||||
<|> (attempt <| skipBytes "MKACTIVITY".toUTF8 <&> fun _ => Method.mkactivity)
|
||||
<|> (attempt <| skipBytes "MKCALENDAR".toUTF8 <&> fun _ => Method.mkcalendar)
|
||||
<|> (attempt <| skipBytes "MKCOL".toUTF8 <&> fun _ => Method.mkcol)
|
||||
<|> (attempt <| skipBytes "MKREDIRECTREF".toUTF8 <&> fun _ => Method.mkredirectref)
|
||||
<|> (attempt <| skipBytes "MKWORKSPACE".toUTF8 <&> fun _ => Method.mkworkspace)
|
||||
<|> (attempt <| skipBytes "MOVE".toUTF8 <&> fun _ => Method.move)
|
||||
<|> (attempt <| skipBytes "OPTIONS".toUTF8 <&> fun _ => Method.options)
|
||||
<|> (attempt <| skipBytes "ORDERPATCH".toUTF8 <&> fun _ => Method.orderpatch)
|
||||
<|> (attempt <| skipBytes "POST".toUTF8 <&> fun _ => Method.post)
|
||||
<|> (attempt <| skipBytes "PUT".toUTF8 <&> fun _ => Method.put)
|
||||
<|> (attempt <| skipBytes "PATCH".toUTF8 <&> fun _ => Method.patch)
|
||||
<|> (attempt <| skipBytes "PRI".toUTF8 <&> fun _ => Method.pri)
|
||||
<|> (attempt <| skipBytes "PROPFIND".toUTF8 <&> fun _ => Method.propfind)
|
||||
<|> (attempt <| skipBytes "PROPPATCH".toUTF8 <&> fun _ => Method.proppatch)
|
||||
<|> (attempt <| skipBytes "REBIND".toUTF8 <&> fun _ => Method.rebind)
|
||||
<|> (attempt <| skipBytes "REPORT".toUTF8 <&> fun _ => Method.report)
|
||||
<|> (attempt <| skipBytes "UNBIND".toUTF8 <&> fun _ => Method.unbind)
|
||||
<|> (attempt <| skipBytes "UNCHECKOUT".toUTF8 <&> fun _ => Method.uncheckout)
|
||||
<|> (attempt <| skipBytes "UNLINK".toUTF8 <&> fun _ => Method.unlink)
|
||||
<|> (attempt <| skipBytes "UNLOCK".toUTF8 <&> fun _ => Method.unlock)
|
||||
<|> (attempt <| skipBytes "UPDATEREDIRECTREF".toUTF8 <&> fun _ => Method.updateredirectref)
|
||||
<|> (attempt <| skipBytes "UPDATE".toUTF8 <&> fun _ => Method.update)
|
||||
<|> (attempt <| skipBytes "VERSION-CONTROL".toUTF8 <&> fun _ => Method.versionControl)
|
||||
<|> (parseToken 64 *> fail "unrecognized method")
|
||||
|
||||
/--
|
||||
Parses a request-target URI, up to `limits.maxUriLength` bytes.
|
||||
Fails with `"uri too long"` if the target exceeds the configured limit.
|
||||
-/
|
||||
def parseURI (limits : H1.Config) : Parser ByteArray := do
|
||||
let uri ← takeUntilUpTo (· == ' '.toUInt8) limits.maxUriLength
|
||||
if uri.size == limits.maxUriLength then
|
||||
if (← peekWhen? (· != ' '.toUInt8)) |>.isSome then
|
||||
fail "uri too long"
|
||||
|
||||
return uri.toByteArray
|
||||
|
||||
/--
|
||||
Shared core for request-line parsing: parses `request-target SP HTTP-version CRLF`
|
||||
and returns the `RequestTarget` together with the raw major/minor version numbers.
|
||||
|
||||
Both `parseRequestLine` and `parseRequestLineRawVersion` call this after consuming
|
||||
the method token, keeping URI validation and version parsing in one place.
|
||||
-/
|
||||
private def parseRequestLineBody (limits : H1.Config) : Parser (RequestTarget × Nat × Nat) := do
|
||||
let rawUri ← parseURI limits <* sp
|
||||
let uri ← match (Std.Http.URI.Parser.parseRequestTarget <* eof).run rawUri with
|
||||
| .ok res => pure res
|
||||
| .error res => fail res
|
||||
let versionPair ← parseHttpVersionNumber <* crlf
|
||||
return (uri, versionPair)
|
||||
|
||||
/--
|
||||
Parses a request line and returns a fully-typed `Request.Head`.
|
||||
`request-line = method SP request-target SP HTTP-version`
|
||||
-/
|
||||
public def parseRequestLine (limits : H1.Config) : Parser Request.Head := do
|
||||
skipLeadingRequestEmptyLines limits
|
||||
let method ← parseMethod <* sp
|
||||
let (uri, (major, minor)) ← parseRequestLineBody limits
|
||||
if major == 1 ∧ minor == 1 then
|
||||
return ⟨method, .v11, uri, .empty⟩
|
||||
else if major == 1 ∧ minor == 0 then
|
||||
return ⟨method, .v10, uri, .empty⟩
|
||||
else
|
||||
fail "unsupported HTTP version"
|
||||
|
||||
/--
|
||||
Parses a request line and returns the recognized HTTP method and version when available.
|
||||
|
||||
request-line = method SP request-target SP HTTP-version
|
||||
-/
|
||||
public def parseRequestLineRawVersion (limits : H1.Config) : Parser (Method × RequestTarget × Option Version) := do
|
||||
skipLeadingRequestEmptyLines limits
|
||||
let method ← parseMethod <* sp
|
||||
let (uri, (major, minor)) ← parseRequestLineBody limits
|
||||
return (method, uri, Version.ofNumber? major minor)
|
||||
|
||||
/--
|
||||
Parses a single header field line.
|
||||
|
||||
`field-line = field-name ":" OWS field-value OWS`
|
||||
-/
|
||||
def parseFieldLine (limits : H1.Config) : Parser (String × String) := do
|
||||
let name ← parseToken limits.maxHeaderNameLength
|
||||
let value ← skipByte ':'.toUInt8 *> ows limits *> optional (takeWhileUpTo isFieldVChar limits.maxHeaderValueLength) <* ows limits
|
||||
|
||||
let name ← liftOption <| String.fromUTF8? name.toByteArray
|
||||
let value ← liftOption <| String.fromUTF8? <| value.map (·.toByteArray) |>.getD .empty
|
||||
let value := value.trimAsciiEnd.toString
|
||||
|
||||
return (name, value)
|
||||
|
||||
/--
|
||||
Parses a single header field line, or returns `none` when it sees the blank line that
|
||||
terminates the header section.
|
||||
|
||||
```
|
||||
field-line = field-name ":" OWS field-value OWS CRLF
|
||||
```
|
||||
-/
|
||||
public def parseSingleHeader (limits : H1.Config) : Parser (Option (String × String)) := do
|
||||
let next ← peek?
|
||||
if next == some '\r'.toUInt8 ∨ next == some '\n'.toUInt8 then
|
||||
crlf
|
||||
pure none
|
||||
else
|
||||
some <$> (parseFieldLine limits <* crlf)
|
||||
|
||||
/--
|
||||
Parses a backslash-escaped character inside a quoted-string.
|
||||
|
||||
`quoted-pair = "\" ( HTAB / SP / VCHAR )` — strict ASCII-only (no obs-text).
|
||||
-/
|
||||
def parseQuotedPair : Parser UInt8 := do
|
||||
skipByte '\\'.toUInt8
|
||||
let b ← any
|
||||
|
||||
if quotedPairChar (Char.ofUInt8 b) then
|
||||
return b
|
||||
else
|
||||
fail s!"invalid quoted-pair byte: {Char.ofUInt8 b |>.quote}"
|
||||
|
||||
/--
|
||||
Parses a quoted-string value, unescaping quoted-pairs.
|
||||
|
||||
`quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE`
|
||||
-/
|
||||
partial def parseQuotedString (maxLength : Nat) : Parser String := do
|
||||
skipByte '"'.toUInt8
|
||||
|
||||
let rec loop (buf : ByteArray) (length : Nat) : Parser ByteArray := do
|
||||
let b ← any
|
||||
|
||||
if b == '"'.toUInt8 then
|
||||
return buf
|
||||
else if b == '\\'.toUInt8 then
|
||||
let next ← any
|
||||
if quotedPairChar (Char.ofUInt8 next)
|
||||
then
|
||||
let length := length + 1
|
||||
if length > maxLength then
|
||||
fail "quoted-string too long"
|
||||
else
|
||||
loop (buf.push next) length
|
||||
else fail s!"invalid quoted-pair byte: {Char.ofUInt8 next |>.quote}"
|
||||
else if isQdText b then
|
||||
let length := length + 1
|
||||
if length > maxLength then
|
||||
fail "quoted-string too long"
|
||||
else
|
||||
loop (buf.push b) length
|
||||
else
|
||||
fail s!"invalid qdtext byte: {Char.ofUInt8 b |>.quote}"
|
||||
|
||||
liftOption <| String.fromUTF8? (← loop .empty 0)
|
||||
|
||||
-- chunk-ext = *( BWS ";" BWS chunk-ext-name [ BWS "=" BWS chunk-ext-val] )
|
||||
def parseChunkExt (limits : H1.Config) : Parser (Chunk.ExtensionName × Option Chunk.ExtensionValue) := do
|
||||
ows limits *> skipByte ';'.toUInt8 *> ows limits
|
||||
let name ← (liftOption =<< String.fromUTF8? <$> ByteSlice.toByteArray <$> parseToken limits.maxChunkExtNameLength) <* ows limits
|
||||
|
||||
let some name := Chunk.ExtensionName.ofString? name
|
||||
| fail "invalid extension name"
|
||||
|
||||
if (← peekWhen? (· == '='.toUInt8)) |>.isSome then
|
||||
-- RFC 9112 §7.1.1: BWS is allowed around "=".
|
||||
-- The `<* ows limits` after the name already consumed any trailing whitespace,
|
||||
-- so these ows calls are no-ops in practice, but kept for explicit grammar correspondence.
|
||||
ows limits *> skipByte '='.toUInt8 *> ows limits
|
||||
let value ← ows limits *> (parseQuotedString limits.maxChunkExtValueLength <|> liftOption =<< (String.fromUTF8? <$> ByteSlice.toByteArray <$> parseToken limits.maxChunkExtValueLength))
|
||||
|
||||
let some value := Chunk.ExtensionValue.ofString? value
|
||||
| fail "invalid extension value"
|
||||
|
||||
return (name, some value)
|
||||
|
||||
return (name, none)
|
||||
|
||||
/--
|
||||
Parses the size and extensions of a chunk.
|
||||
-/
|
||||
public def parseChunkSize (limits : H1.Config) : Parser (Nat × Array (Chunk.ExtensionName × Option Chunk.ExtensionValue)) := do
|
||||
let size ← hex
|
||||
let ext ← manyItems (optional (attempt (parseChunkExt limits))) limits.maxChunkExtensions
|
||||
crlf
|
||||
return (size, ext)
|
||||
|
||||
/--
|
||||
Result of parsing partial or complete information.
|
||||
-/
|
||||
public inductive TakeResult
|
||||
| complete (data : ByteSlice)
|
||||
| incomplete (data : ByteSlice) (remaining : Nat)
|
||||
|
||||
/--
|
||||
Parses a single chunk in chunked transfer encoding.
|
||||
-/
|
||||
public def parseChunkPartial (limits : H1.Config) : Parser (Option (Nat × Array (Chunk.ExtensionName × Option Chunk.ExtensionValue) × ByteSlice)) := do
|
||||
let (size, ext) ← parseChunkSize limits
|
||||
if size == 0 then
|
||||
return none
|
||||
else
|
||||
let data ← take size
|
||||
return some ⟨size, ext, data⟩
|
||||
|
||||
/--
|
||||
Parses fixed-size data that can be incomplete.
|
||||
-/
|
||||
public def parseFixedSizeData (size : Nat) : Parser TakeResult := fun it =>
|
||||
if it.remainingBytes = 0 then
|
||||
.error it .eof
|
||||
else if it.remainingBytes < size then
|
||||
.success (it.forward it.remainingBytes) (.incomplete it.array[it.idx...(it.idx+it.remainingBytes)] (size - it.remainingBytes))
|
||||
else
|
||||
.success (it.forward size) (.complete (it.array[it.idx...(it.idx+size)]))
|
||||
|
||||
/--
|
||||
Parses fixed-size chunk data that can be incomplete.
|
||||
-/
|
||||
public def parseChunkSizedData (size : Nat) : Parser TakeResult := do
|
||||
match ← parseFixedSizeData size with
|
||||
| .complete data => crlf *> return .complete data
|
||||
| .incomplete data res => return .incomplete data res
|
||||
|
||||
/--
|
||||
Returns `true` if `name` (compared case-insensitively) is a field that MUST NOT appear in HTTP/1.1
|
||||
trailer sections per RFC 9112 §6.5. Forbidden fields are those required for message framing
|
||||
(`content-length`, `transfer-encoding`), routing (`host`), or connection management (`connection`).
|
||||
-/
|
||||
private def isForbiddenTrailerField (name : String) : Bool :=
|
||||
let n := name.toLower
|
||||
n == "content-length" || n == "transfer-encoding" || n == "host" ||
|
||||
n == "connection" || n == "expect" || n == "te" ||
|
||||
n == "authorization" || n == "max-forwards" || n == "cache-control" ||
|
||||
n == "content-encoding" || n == "upgrade" || n == "trailer"
|
||||
|
||||
/--
|
||||
Parses a trailer header (used after a chunked body), rejecting forbidden field names per RFC 9112
|
||||
§6.5. Fields used for message framing (`content-length`, `transfer-encoding`), routing (`host`),
|
||||
or connection management (`connection`, `te`, `upgrade`) are rejected to prevent trailer injection
|
||||
attacks where a downstream proxy might re-interpret them.
|
||||
-/
|
||||
def parseTrailerHeader (limits : H1.Config) : Parser (Option (String × String)) := do
|
||||
let result ← parseSingleHeader limits
|
||||
if let some (name, _) := result then
|
||||
if isForbiddenTrailerField name then
|
||||
fail s!"forbidden trailer field: {name}"
|
||||
return result
|
||||
|
||||
/--
|
||||
Parses trailer headers after a chunked body and returns them as an array of name-value pairs.
|
||||
|
||||
This is exposed for callers that need the trailer values directly (e.g. clients). The
|
||||
internal protocol machine uses `parseLastChunkBody` instead, which discards trailer values.
|
||||
-/
|
||||
public def parseTrailers (limits : H1.Config) : Parser (Array (String × String)) := do
|
||||
let trailers ← manyItems (parseTrailerHeader limits) limits.maxTrailerHeaders
|
||||
crlf
|
||||
return trailers
|
||||
|
||||
/--
|
||||
Returns `true` if `c` is a valid reason-phrase byte (`HTAB / SP / VCHAR`, strict ASCII-only).
|
||||
-/
|
||||
@[inline]
|
||||
def isReasonPhraseByte (c : UInt8) : Bool :=
|
||||
fieldContent (Char.ofUInt8 c)
|
||||
|
||||
/--
|
||||
Parses a reason phrase (text after status code).
|
||||
|
||||
Allows only `HTAB / SP / VCHAR` bytes (strict ASCII-only).
|
||||
-/
|
||||
def parseReasonPhrase (limits : H1.Config) : Parser String := do
|
||||
let bytes ← takeWhileUpTo isReasonPhraseByte limits.maxReasonPhraseLength
|
||||
liftOption <| String.fromUTF8? bytes.toByteArray
|
||||
|
||||
/--
|
||||
Parses a status-code (3 decimal digits), the following reason phrase, and the
|
||||
terminating CRLF; returns a typed `Status`.
|
||||
-/
|
||||
def parseStatusCode (limits : H1.Config) : Parser Status := do
|
||||
let d1 ← digit
|
||||
let d2 ← digit
|
||||
let d3 ← digit
|
||||
let code := (d1.toNat - 48) * 100 + (d2.toNat - 48) * 10 + (d3.toNat - 48)
|
||||
sp
|
||||
let phrase ← parseReasonPhrase limits <* crlf
|
||||
|
||||
if h : IsValidReasonPhrase phrase then
|
||||
if let some status := Status.ofCode (some ⟨phrase, h⟩) code.toUInt16 then
|
||||
return status
|
||||
|
||||
fail "invalid status code"
|
||||
|
||||
/--
|
||||
Parses a status line and returns a fully-typed `Response.Head`.
|
||||
`status-line = HTTP-version SP status-code SP [ reason-phrase ]`
|
||||
Accepts only HTTP/1.1. For parsing where the version may be unrecognized and must be
|
||||
mapped to an error event, use `parseStatusLineRawVersion`.
|
||||
-/
|
||||
public def parseStatusLine (limits : H1.Config) : Parser Response.Head := do
|
||||
let (major, minor) ← parseHttpVersionNumber <* sp
|
||||
let status ← parseStatusCode limits
|
||||
if major == 1 ∧ minor == 1 then
|
||||
return { status, version := .v11, headers := .empty }
|
||||
else if major == 1 ∧ minor == 0 then
|
||||
return { status, version := .v10, headers := .empty }
|
||||
else
|
||||
fail "unsupported HTTP version"
|
||||
|
||||
/--
|
||||
Parses a status line and returns the status code plus recognized HTTP version when available.
|
||||
Consumes and discards the reason phrase.
|
||||
|
||||
status-line = HTTP-version SP status-code SP [ reason-phrase ] CRLF
|
||||
-/
|
||||
public def parseStatusLineRawVersion (limits : H1.Config) : Parser (Status × Option Version) := do
|
||||
let (major, minor) ← parseHttpVersionNumber <* sp
|
||||
let status ← parseStatusCode limits
|
||||
return (status, Version.ofNumber? major minor)
|
||||
|
||||
/--
|
||||
Parses the trailer section that follows the last chunk size line (`0\r\n`).
|
||||
-/
|
||||
public def parseLastChunkBody (limits : H1.Config) : Parser Unit := do
|
||||
discard <| manyItems (parseTrailerHeader limits) limits.maxTrailerHeaders
|
||||
crlf
|
||||
|
||||
end Std.Http.Protocol.H1
|
||||
319
src/Std/Internal/Http/Protocol/H1/Reader.lean
Normal file
319
src/Std/Internal/Http/Protocol/H1/Reader.lean
Normal file
@@ -0,0 +1,319 @@
|
||||
/-
|
||||
Copyright (c) 2025 Lean FRO, LLC. All rights reserved.
|
||||
Released under Apache 2.0 license as described in the file LICENSE.
|
||||
Authors: Sofia Rodrigues
|
||||
-/
|
||||
module
|
||||
|
||||
prelude
|
||||
public import Std.Time
|
||||
public import Std.Internal.Http.Data
|
||||
public import Std.Internal.Http.Internal
|
||||
public import Std.Internal.Http.Protocol.H1.Parser
|
||||
public import Std.Internal.Http.Protocol.H1.Config
|
||||
public import Std.Internal.Http.Protocol.H1.Message
|
||||
public import Std.Internal.Http.Protocol.H1.Error
|
||||
|
||||
public section
|
||||
|
||||
/-!
|
||||
# HTTP/1.1 Reader
|
||||
|
||||
This module defines the reader state machine for parsing incoming HTTP/1.1 messages.
|
||||
It tracks the parsing state including start line, headers, and body handling for
|
||||
both fixed-length and chunked transfer encodings.
|
||||
-/
|
||||
|
||||
namespace Std.Http.Protocol.H1
|
||||
|
||||
set_option linter.all true
|
||||
|
||||
/--
|
||||
The body-framing sub-state of the `Reader` state machine.
|
||||
-/
|
||||
inductive Reader.BodyState where
|
||||
/--
|
||||
Parse fixed-length body bytes, tracking the number of bytes remaining.
|
||||
-/
|
||||
| fixed (remaining : Nat)
|
||||
|
||||
/--
|
||||
Parse the next chunk-size line in chunked transfer encoding.
|
||||
-/
|
||||
| chunkedSize
|
||||
|
||||
/--
|
||||
Parse chunk data for the current chunk.
|
||||
-/
|
||||
| chunkedBody (ext : Array (Chunk.ExtensionName × Option Chunk.ExtensionValue)) (remaining : Nat)
|
||||
|
||||
/--
|
||||
Parse body bytes until EOF (connection close).
|
||||
-/
|
||||
| closeDelimited
|
||||
deriving Inhabited, Repr, BEq
|
||||
|
||||
/--
|
||||
The state of the `Reader` state machine.
|
||||
-/
|
||||
inductive Reader.State (dir : Direction) : Type
|
||||
/--
|
||||
Initial state waiting for HTTP start line.
|
||||
-/
|
||||
| needStartLine : State dir
|
||||
|
||||
/--
|
||||
State waiting for HTTP headers, tracking number of headers parsed.
|
||||
-/
|
||||
| needHeader : Nat → State dir
|
||||
|
||||
/--
|
||||
Unified body-reading state.
|
||||
-/
|
||||
| readBody : Reader.BodyState → State dir
|
||||
|
||||
/--
|
||||
Paused waiting for a `canContinue` decision, carrying the next state.
|
||||
-/
|
||||
| continue : State dir → State dir
|
||||
|
||||
/--
|
||||
State waiting to be able to read new data.
|
||||
-/
|
||||
| pending : State dir
|
||||
|
||||
/--
|
||||
State that it completed a single request or response and can go to the next one
|
||||
-/
|
||||
| complete
|
||||
|
||||
/--
|
||||
State that it has completed and cannot process more data.
|
||||
-/
|
||||
| closed
|
||||
|
||||
/--
|
||||
The input is malformed.
|
||||
-/
|
||||
| failed (error : Error) : State dir
|
||||
deriving Inhabited, Repr, BEq
|
||||
|
||||
/--
|
||||
Manages the reading state of the HTTP parsing and processing machine.
|
||||
-/
|
||||
structure Reader (dir : Direction) where
|
||||
/--
|
||||
The current state of the machine.
|
||||
-/
|
||||
state : Reader.State dir := match dir with | .receiving => .needStartLine | .sending => .pending
|
||||
|
||||
/--
|
||||
The input byte array.
|
||||
-/
|
||||
input : ByteArray.Iterator := ByteArray.emptyWithCapacity 4096 |>.iter
|
||||
|
||||
/--
|
||||
The incoming message head.
|
||||
-/
|
||||
messageHead : Message.Head dir := {}
|
||||
|
||||
/--
|
||||
Count of messages that this connection has already parsed.
|
||||
-/
|
||||
messageCount : Nat := 0
|
||||
|
||||
/--
|
||||
Number of body bytes read for the current message.
|
||||
-/
|
||||
bodyBytesRead : Nat := 0
|
||||
|
||||
/--
|
||||
Number of header bytes accumulated for the current message.
|
||||
Counts name + value bytes plus 4 bytes per line for `: ` and `\r\n`.
|
||||
-/
|
||||
headerBytesRead : Nat := 0
|
||||
|
||||
/--
|
||||
Set when no further input bytes will arrive (the remote end has closed the connection).
|
||||
-/
|
||||
noMoreInput : Bool := false
|
||||
|
||||
namespace Reader
|
||||
|
||||
/--
|
||||
Checks if the reader is in a closed state and cannot process more messages.
|
||||
-/
|
||||
@[inline]
|
||||
def isClosed (reader : Reader dir) : Bool :=
|
||||
match reader.state with
|
||||
| .closed => true
|
||||
| _ => false
|
||||
|
||||
/--
|
||||
Checks if the reader has completed parsing the current message.
|
||||
-/
|
||||
@[inline]
|
||||
def isComplete (reader : Reader dir) : Bool :=
|
||||
match reader.state with
|
||||
| .complete => true
|
||||
| _ => false
|
||||
|
||||
/--
|
||||
Checks if the reader has encountered an error.
|
||||
-/
|
||||
@[inline]
|
||||
def hasFailed (reader : Reader dir) : Bool :=
|
||||
match reader.state with
|
||||
| .failed _ => true
|
||||
| _ => false
|
||||
|
||||
/--
|
||||
Feeds new data into the reader's input buffer.
|
||||
If the current input is exhausted, replaces it; otherwise compacts the buffer
|
||||
by discarding already-parsed bytes before appending.
|
||||
-/
|
||||
@[inline]
|
||||
def feed (data : ByteArray) (reader : Reader dir) : Reader dir :=
|
||||
{ reader with input :=
|
||||
if reader.input.atEnd
|
||||
then data.iter
|
||||
else (reader.input.array.extract reader.input.pos reader.input.array.size ++ data).iter }
|
||||
|
||||
/--
|
||||
Replaces the reader's input iterator with a new one.
|
||||
-/
|
||||
@[inline]
|
||||
def setInput (input : ByteArray.Iterator) (reader : Reader dir) : Reader dir :=
|
||||
{ reader with input }
|
||||
|
||||
/--
|
||||
Updates the message head being constructed.
|
||||
-/
|
||||
@[inline]
|
||||
def setMessageHead (messageHead : Message.Head dir) (reader : Reader dir) : Reader dir :=
|
||||
{ reader with messageHead }
|
||||
|
||||
/--
|
||||
Adds a header to the current message head.
|
||||
-/
|
||||
@[inline]
|
||||
def addHeader (name : Header.Name) (value : Header.Value) (reader : Reader dir) : Reader dir :=
|
||||
match dir with
|
||||
| .sending | .receiving => { reader with messageHead := { reader.messageHead with headers := reader.messageHead.headers.insert name value } }
|
||||
|
||||
/--
|
||||
Closes the reader, transitioning to the closed state.
|
||||
-/
|
||||
@[inline]
|
||||
def close (reader : Reader dir) : Reader dir :=
|
||||
{ reader with state := .closed, noMoreInput := true }
|
||||
|
||||
/--
|
||||
Marks the current message as complete and prepares for the next message.
|
||||
-/
|
||||
@[inline]
|
||||
def markComplete (reader : Reader dir) : Reader dir :=
|
||||
{ reader with
|
||||
state := .complete
|
||||
messageCount := reader.messageCount + 1 }
|
||||
|
||||
/--
|
||||
Transitions the reader to a failed state with the given error.
|
||||
-/
|
||||
@[inline]
|
||||
def fail (error : Error) (reader : Reader dir) : Reader dir :=
|
||||
{ reader with state := .failed error }
|
||||
|
||||
/--
|
||||
Resets the reader to parse a new message on the same connection.
|
||||
-/
|
||||
@[inline]
|
||||
def reset (reader : Reader dir) : Reader dir :=
|
||||
{ reader with
|
||||
state := .needStartLine
|
||||
bodyBytesRead := 0
|
||||
headerBytesRead := 0
|
||||
messageHead := {} }
|
||||
|
||||
/--
|
||||
Checks if more input is needed to continue parsing.
|
||||
-/
|
||||
@[inline]
|
||||
def needsMoreInput (reader : Reader dir) : Bool :=
|
||||
reader.input.atEnd && !reader.noMoreInput &&
|
||||
match reader.state with
|
||||
| .complete | .closed | .failed _ | .«continue» _ => false
|
||||
| _ => true
|
||||
|
||||
/--
|
||||
Returns the current parse error if the reader has failed.
|
||||
-/
|
||||
@[inline]
|
||||
def getError (reader : Reader dir) : Option Error :=
|
||||
match reader.state with
|
||||
| .failed err => some err
|
||||
| _ => none
|
||||
|
||||
/--
|
||||
Gets the number of bytes remaining in the input buffer.
|
||||
-/
|
||||
@[inline]
|
||||
def remainingBytes (reader : Reader dir) : Nat :=
|
||||
reader.input.array.size - reader.input.pos
|
||||
|
||||
/--
|
||||
Advances the input iterator by n bytes.
|
||||
-/
|
||||
@[inline]
|
||||
def advance (n : Nat) (reader : Reader dir) : Reader dir :=
|
||||
{ reader with input := reader.input.forward n }
|
||||
|
||||
/--
|
||||
Transitions to the state for reading headers.
|
||||
-/
|
||||
@[inline]
|
||||
def startHeaders (reader : Reader dir) : Reader dir :=
|
||||
{ reader with state := .needHeader 0, bodyBytesRead := 0, headerBytesRead := 0 }
|
||||
|
||||
/--
|
||||
Adds body bytes parsed for the current message.
|
||||
-/
|
||||
@[inline]
|
||||
def addBodyBytes (n : Nat) (reader : Reader dir) : Reader dir :=
|
||||
{ reader with bodyBytesRead := reader.bodyBytesRead + n }
|
||||
|
||||
/--
|
||||
Adds header bytes accumulated for the current message.
|
||||
-/
|
||||
@[inline]
|
||||
def addHeaderBytes (n : Nat) (reader : Reader dir) : Reader dir :=
|
||||
{ reader with headerBytesRead := reader.headerBytesRead + n }
|
||||
|
||||
/--
|
||||
Transitions to the state for reading a fixed-length body.
|
||||
-/
|
||||
@[inline]
|
||||
def startFixedBody (size : Nat) (reader : Reader dir) : Reader dir :=
|
||||
{ reader with state := .readBody (.fixed size) }
|
||||
|
||||
/--
|
||||
Transitions to the state for reading chunked transfer encoding.
|
||||
-/
|
||||
@[inline]
|
||||
def startChunkedBody (reader : Reader dir) : Reader dir :=
|
||||
{ reader with state := .readBody .chunkedSize }
|
||||
|
||||
/--
|
||||
Marks that no more input will be provided (connection closed).
|
||||
-/
|
||||
@[inline]
|
||||
def markNoMoreInput (reader : Reader dir) : Reader dir :=
|
||||
{ reader with noMoreInput := true }
|
||||
|
||||
/--
|
||||
Checks if the connection should be kept alive for the next message.
|
||||
-/
|
||||
def shouldKeepAlive (reader : Reader dir) : Bool :=
|
||||
reader.messageHead.shouldKeepAlive
|
||||
|
||||
end Reader
|
||||
280
src/Std/Internal/Http/Protocol/H1/Writer.lean
Normal file
280
src/Std/Internal/Http/Protocol/H1/Writer.lean
Normal file
@@ -0,0 +1,280 @@
|
||||
/-
|
||||
Copyright (c) 2025 Lean FRO, LLC. All rights reserved.
|
||||
Released under Apache 2.0 license as described in the file LICENSE.
|
||||
Authors: Sofia Rodrigues
|
||||
-/
|
||||
module
|
||||
|
||||
prelude
|
||||
public import Std.Time
|
||||
public import Std.Internal.Http.Data
|
||||
public import Std.Internal.Http.Internal
|
||||
public import Std.Internal.Http.Protocol.H1.Parser
|
||||
public import Std.Internal.Http.Protocol.H1.Config
|
||||
public import Std.Internal.Http.Protocol.H1.Message
|
||||
public import Std.Internal.Http.Protocol.H1.Error
|
||||
|
||||
public section
|
||||
|
||||
/-!
|
||||
# HTTP/1.1 Writer
|
||||
|
||||
This module defines the writer state machine for generating outgoing HTTP/1.1 messages.
|
||||
It handles encoding headers and body content for both fixed-length and chunked
|
||||
transfer encodings.
|
||||
-/
|
||||
|
||||
namespace Std.Http.Protocol.H1
|
||||
|
||||
set_option linter.all true
|
||||
|
||||
open Internal
|
||||
|
||||
/--
|
||||
The state of the `Writer` state machine.
|
||||
-/
|
||||
inductive Writer.State
|
||||
/--
|
||||
Initial state before any outgoing message has been prepared.
|
||||
-/
|
||||
| pending
|
||||
|
||||
/--
|
||||
Waiting for the application to provide the outgoing message head via `send`.
|
||||
-/
|
||||
| waitingHeaders
|
||||
|
||||
/--
|
||||
The message head has been provided; waiting for `shouldFlush` to become true before
|
||||
serializing headers to output.
|
||||
-/
|
||||
| waitingForFlush
|
||||
|
||||
/--
|
||||
Writing the body output (either fixed-length or chunked).
|
||||
-/
|
||||
| writingBody (mode : Body.Length)
|
||||
|
||||
/--
|
||||
Completed writing a single message and ready to begin the next one.
|
||||
-/
|
||||
| complete
|
||||
|
||||
/--
|
||||
Closed; no further data can be written.
|
||||
-/
|
||||
| closed
|
||||
deriving Inhabited, Repr, BEq
|
||||
|
||||
/--
|
||||
Manages the writing state of the HTTP generating and writing machine.
|
||||
-/
|
||||
structure Writer (dir : Direction) where
|
||||
/--
|
||||
Body chunks supplied by the user, accumulated before being flushed to output.
|
||||
-/
|
||||
userData : Array Chunk := .empty
|
||||
|
||||
/--
|
||||
All the data produced by the writer, ready to be sent to the socket.
|
||||
-/
|
||||
outputData : ChunkedBuffer := .empty
|
||||
|
||||
/--
|
||||
The state of the writer machine.
|
||||
-/
|
||||
state : Writer.State := match dir with | .receiving => .pending | .sending => .waitingHeaders
|
||||
|
||||
/--
|
||||
When the user specifies the exact body size upfront, `Content-Length` framing is
|
||||
used instead of chunked transfer encoding.
|
||||
-/
|
||||
knownSize : Option Body.Length := none
|
||||
|
||||
/--
|
||||
The outgoing message that will be written to the output.
|
||||
-/
|
||||
messageHead : Message.Head dir.swap := {}
|
||||
|
||||
/--
|
||||
Whether the user has called `send` to provide the outgoing message head.
|
||||
-/
|
||||
sentMessage : Bool := false
|
||||
|
||||
/--
|
||||
Set when the user has finished sending body data, allowing fixed-size framing
|
||||
to be determined upfront.
|
||||
-/
|
||||
userClosedBody : Bool := false
|
||||
|
||||
/--
|
||||
When `true`, body bytes are intentionally omitted from the wire for this message
|
||||
(e.g. HEAD responses), while headers/framing metadata may still describe the
|
||||
hypothetical representation.
|
||||
-/
|
||||
omitBody : Bool := false
|
||||
|
||||
/--
|
||||
Running total of bytes across all `userData` chunks, maintained incrementally
|
||||
to avoid an O(n) fold on every fixed-length write step.
|
||||
-/
|
||||
userDataBytes : Nat := 0
|
||||
|
||||
namespace Writer
|
||||
|
||||
/--
|
||||
Returns `true` when no more user body data will arrive: either the user called
|
||||
`closeBody`, or the writer has already transitioned to `complete` or `closed`.
|
||||
|
||||
Note: this does **not** mean the wire is ready to accept new bytes — a `closed`
|
||||
writer cannot send anything. Use this to decide whether to flush pending body
|
||||
data rather than to check writability.
|
||||
-/
|
||||
@[inline]
|
||||
def noMoreUserData {dir} (writer : Writer dir) : Bool :=
|
||||
match writer.state with
|
||||
| .closed | .complete => true
|
||||
| _ => writer.userClosedBody
|
||||
|
||||
/--
|
||||
Checks if the writer is closed (cannot process more data).
|
||||
-/
|
||||
@[inline]
|
||||
def isClosed (writer : Writer dir) : Bool :=
|
||||
match writer.state with
|
||||
| .closed => true
|
||||
| _ => false
|
||||
|
||||
/--
|
||||
Checks if the writer has completed processing a request.
|
||||
-/
|
||||
@[inline]
|
||||
def isComplete (writer : Writer dir) : Bool :=
|
||||
match writer.state with
|
||||
| .complete => true
|
||||
| _ => false
|
||||
|
||||
/--
|
||||
Checks if the writer can accept more data from the user.
|
||||
-/
|
||||
@[inline]
|
||||
def canAcceptData (writer : Writer dir) : Bool :=
|
||||
match writer.state with
|
||||
| .waitingHeaders => true
|
||||
| .waitingForFlush => true
|
||||
| .writingBody _ => !writer.userClosedBody
|
||||
| _ => false
|
||||
|
||||
/--
|
||||
Marks the body as closed, indicating no more user data will be added.
|
||||
-/
|
||||
@[inline]
|
||||
def closeBody (writer : Writer dir) : Writer dir :=
|
||||
{ writer with userClosedBody := true }
|
||||
|
||||
/--
|
||||
Determines the transfer encoding mode based on explicit setting, body closure state, or defaults to chunked.
|
||||
-/
|
||||
def determineTransferMode (writer : Writer dir) : Body.Length :=
|
||||
if let some mode := writer.knownSize then
|
||||
mode
|
||||
else if writer.userClosedBody then
|
||||
.fixed writer.userDataBytes
|
||||
else
|
||||
.chunked
|
||||
|
||||
/--
|
||||
Adds user data chunks to the writer's buffer if the writer can accept data.
|
||||
-/
|
||||
@[inline]
|
||||
def addUserData (data : Array Chunk) (writer : Writer dir) : Writer dir :=
|
||||
if writer.canAcceptData then
|
||||
let extraBytes := data.foldl (fun acc c => acc + c.data.size) 0
|
||||
{ writer with userData := writer.userData ++ data, userDataBytes := writer.userDataBytes + extraBytes }
|
||||
else
|
||||
writer
|
||||
|
||||
/--
|
||||
Writes accumulated user data to output using fixed-size encoding.
|
||||
-/
|
||||
def writeFixedBody (writer : Writer dir) (limitSize : Nat) : Writer dir × Nat :=
|
||||
if writer.userData.size = 0 then
|
||||
(writer, limitSize)
|
||||
else
|
||||
let (chunks, pending, totalSize) := writer.userData.foldl (fun (state : Array ByteArray × Array Chunk × Nat) chunk =>
|
||||
let (acc, pending, size) := state
|
||||
if size >= limitSize then
|
||||
(acc, pending.push chunk, size)
|
||||
else
|
||||
let remaining := limitSize - size
|
||||
let takeSize := min chunk.data.size remaining
|
||||
let dataPart := chunk.data.extract 0 takeSize
|
||||
let acc := if takeSize = 0 then acc else acc.push dataPart
|
||||
let size := size + takeSize
|
||||
if takeSize < chunk.data.size then
|
||||
let pendingChunk : Chunk := { chunk with data := chunk.data.extract takeSize chunk.data.size }
|
||||
(acc, pending.push pendingChunk, size)
|
||||
else
|
||||
(acc, pending, size)
|
||||
) (#[], #[], 0)
|
||||
let outputData := writer.outputData.append (ChunkedBuffer.ofArray chunks)
|
||||
let remaining := limitSize - totalSize
|
||||
({ writer with userData := pending, outputData, userDataBytes := writer.userDataBytes - totalSize }, remaining)
|
||||
|
||||
/--
|
||||
Writes accumulated user data to output using chunked transfer encoding.
|
||||
-/
|
||||
def writeChunkedBody (writer : Writer dir) : Writer dir :=
|
||||
if writer.userData.size = 0 then
|
||||
writer
|
||||
else
|
||||
let data := writer.userData
|
||||
{ writer with userData := #[], userDataBytes := 0, outputData := data.foldl (Encode.encode .v11) writer.outputData }
|
||||
|
||||
/--
|
||||
Writes the final chunk terminator (0\r\n\r\n) and transitions to complete state.
|
||||
-/
|
||||
def writeFinalChunk (writer : Writer dir) : Writer dir :=
|
||||
let writer := writer.writeChunkedBody
|
||||
{ writer with
|
||||
outputData := writer.outputData.write "0\r\n\r\n".toUTF8
|
||||
state := .complete
|
||||
}
|
||||
|
||||
/--
|
||||
Extracts all accumulated output data and returns it with a cleared output buffer.
|
||||
-/
|
||||
@[inline]
|
||||
def takeOutput (writer : Writer dir) : Option (Writer dir × ByteArray) :=
|
||||
let output := writer.outputData.toByteArray
|
||||
some ({ writer with outputData := ChunkedBuffer.empty }, output)
|
||||
|
||||
/--
|
||||
Updates the writer's state machine to a new state.
|
||||
-/
|
||||
@[inline]
|
||||
def setState (state : Writer.State) (writer : Writer dir) : Writer dir :=
|
||||
{ writer with state }
|
||||
|
||||
/--
|
||||
Writes the message headers to the output buffer.
|
||||
-/
|
||||
private def writeHeaders (messageHead : Message.Head dir.swap) (writer : Writer dir) : Writer dir :=
|
||||
{ writer with outputData := Internal.Encode.encode (v := .v11) writer.outputData messageHead }
|
||||
|
||||
/--
|
||||
Checks if the connection should be kept alive based on the Connection header.
|
||||
-/
|
||||
def shouldKeepAlive (writer : Writer dir) : Bool :=
|
||||
writer.messageHead.headers.get? .connection
|
||||
|>.map (fun v => v.value.toLower != "close")
|
||||
|>.getD true
|
||||
|
||||
/--
|
||||
Closes the writer, transitioning to the closed state.
|
||||
-/
|
||||
@[inline]
|
||||
def close (writer : Writer dir) : Writer dir :=
|
||||
{ writer with state := .closed }
|
||||
|
||||
end Writer
|
||||
188
src/Std/Internal/Http/Server.lean
Normal file
188
src/Std/Internal/Http/Server.lean
Normal file
@@ -0,0 +1,188 @@
|
||||
/-
|
||||
Copyright (c) 2025 Lean FRO, LLC. All rights reserved.
|
||||
Released under Apache 2.0 license as described in the file LICENSE.
|
||||
Authors: Sofia Rodrigues
|
||||
-/
|
||||
module
|
||||
|
||||
prelude
|
||||
public import Std.Internal.Async
|
||||
public import Std.Internal.Async.TCP
|
||||
public import Std.Sync.CancellationToken
|
||||
public import Std.Sync.Semaphore
|
||||
public import Std.Internal.Http.Server.Config
|
||||
public import Std.Internal.Http.Server.Handler
|
||||
public import Std.Internal.Http.Server.Connection
|
||||
|
||||
public section
|
||||
|
||||
/-!
|
||||
# HTTP Server
|
||||
|
||||
This module defines a simple, asynchronous HTTP/1.1 server implementation.
|
||||
|
||||
It provides the `Std.Http.Server` structure, which encapsulates all server state, and functions for
|
||||
starting, managing, and gracefully shutting down the server.
|
||||
|
||||
The server runs entirely using `Async` and uses a shared `CancellationContext` to signal shutdowns.
|
||||
Each active client connection is tracked, and the server automatically resolves its shutdown
|
||||
promise once all connections have closed.
|
||||
-/
|
||||
|
||||
namespace Std.Http
|
||||
open Std.Internal.IO.Async TCP
|
||||
|
||||
set_option linter.all true
|
||||
|
||||
/--
|
||||
The `Server` structure holds all state required to manage the lifecycle of an HTTP server, including
|
||||
connection tracking and shutdown coordination.
|
||||
-/
|
||||
structure Server where
|
||||
|
||||
/--
|
||||
The context used for shutting down all connections and the server.
|
||||
-/
|
||||
context : Std.CancellationContext
|
||||
|
||||
/--
|
||||
Active HTTP connections
|
||||
-/
|
||||
activeConnections : Std.Mutex UInt64
|
||||
|
||||
/--
|
||||
Semaphore used to enforce the maximum number of simultaneous active connections.
|
||||
`none` means no connection limit.
|
||||
-/
|
||||
connectionLimit : Option Std.Semaphore
|
||||
|
||||
/--
|
||||
Indicates when the server has successfully shut down.
|
||||
-/
|
||||
shutdownPromise : Std.Channel Unit
|
||||
|
||||
/--
|
||||
Configuration of the server
|
||||
-/
|
||||
config : Std.Http.Config
|
||||
|
||||
namespace Server
|
||||
|
||||
/--
|
||||
Create a new `Server` structure with an optional configuration.
|
||||
-/
|
||||
def new (config : Std.Http.Config := {}) : IO Server := do
|
||||
let context ← Std.CancellationContext.new
|
||||
let activeConnections ← Std.Mutex.new 0
|
||||
let connectionLimit ←
|
||||
if config.maxConnections = 0 then
|
||||
pure none
|
||||
else
|
||||
some <$> Std.Semaphore.new config.maxConnections
|
||||
let shutdownPromise ← Std.Channel.new
|
||||
|
||||
return { context, activeConnections, connectionLimit, shutdownPromise, config }
|
||||
|
||||
/--
|
||||
Triggers cancellation of all requests and the accept loop in the server. This function should be used
|
||||
in conjunction with `waitShutdown` to properly coordinate the shutdown sequence.
|
||||
-/
|
||||
@[inline]
|
||||
def shutdown (s : Server) : Async Unit :=
|
||||
s.context.cancel .shutdown
|
||||
|
||||
/--
|
||||
Waits for the server to shut down. Blocks until another task or async operation calls the `shutdown` function.
|
||||
-/
|
||||
@[inline]
|
||||
def waitShutdown (s : Server) : Async Unit := do
|
||||
Async.ofAsyncTask ((← s.shutdownPromise.recv).map Except.ok)
|
||||
|
||||
/--
|
||||
Returns a `Selector` that waits for the server to shut down.
|
||||
-/
|
||||
@[inline]
|
||||
def waitShutdownSelector (s : Server) : Selector Unit :=
|
||||
s.shutdownPromise.recvSelector
|
||||
|
||||
/--
|
||||
Triggers cancellation of all requests and the accept loop, then waits for the server to fully shut down.
|
||||
This is a convenience function combining `shutdown` and then `waitShutdown`.
|
||||
-/
|
||||
@[inline]
|
||||
def shutdownAndWait (s : Server) : Async Unit := do
|
||||
s.context.cancel .shutdown
|
||||
s.waitShutdown
|
||||
|
||||
@[inline]
|
||||
private def frameCancellation (s : Server) (releaseConnectionPermit : Bool := false)
|
||||
(action : ContextAsync α) : ContextAsync α := do
|
||||
s.activeConnections.atomically (modify (· + 1))
|
||||
try
|
||||
action
|
||||
finally
|
||||
if releaseConnectionPermit then
|
||||
if let some limit := s.connectionLimit then
|
||||
limit.release
|
||||
|
||||
s.activeConnections.atomically do
|
||||
modify (· - 1)
|
||||
|
||||
if (← get) = 0 ∧ (← s.context.isCancelled) then
|
||||
discard <| s.shutdownPromise.send ()
|
||||
|
||||
/--
|
||||
Start a new HTTP/1.1 server on the given socket address. This function uses `Async` to handle tasks
|
||||
and TCP connections, and returns a `Server` structure that can be used to cancel the server.
|
||||
-/
|
||||
def serve {σ : Type} [Handler σ]
|
||||
(addr : Net.SocketAddress)
|
||||
(handler : σ)
|
||||
(config : Config := {}) (backlog : UInt32 := 1024) : Async Server := do
|
||||
|
||||
let httpServer ← Server.new config
|
||||
|
||||
let server ← Socket.Server.mk
|
||||
server.bind addr
|
||||
server.listen backlog
|
||||
server.noDelay
|
||||
|
||||
let runServer := do
|
||||
frameCancellation httpServer (action := do
|
||||
while true do
|
||||
let permitAcquired ←
|
||||
if let some limit := httpServer.connectionLimit then
|
||||
let permit ← limit.acquire
|
||||
await permit
|
||||
pure true
|
||||
else
|
||||
pure false
|
||||
|
||||
let result ← Selectable.one #[
|
||||
.case (server.acceptSelector) (fun x => pure <| some x),
|
||||
.case (← ContextAsync.doneSelector) (fun _ => pure none)
|
||||
]
|
||||
|
||||
match result with
|
||||
| some client =>
|
||||
let extensions ← do
|
||||
match (← EIO.toBaseIO client.getPeerName) with
|
||||
| .ok addr => pure <| Extensions.empty.insert (Server.RemoteAddr.mk addr)
|
||||
| .error _ => pure Extensions.empty
|
||||
|
||||
ContextAsync.background
|
||||
(frameCancellation httpServer (releaseConnectionPermit := permitAcquired)
|
||||
(action := do
|
||||
serveConnection client handler config extensions))
|
||||
| none =>
|
||||
if permitAcquired then
|
||||
if let some limit := httpServer.connectionLimit then
|
||||
limit.release
|
||||
break
|
||||
)
|
||||
|
||||
background (runServer httpServer.context)
|
||||
|
||||
return httpServer
|
||||
|
||||
end Std.Http.Server
|
||||
196
src/Std/Internal/Http/Server/Config.lean
Normal file
196
src/Std/Internal/Http/Server/Config.lean
Normal file
@@ -0,0 +1,196 @@
|
||||
/-
|
||||
Copyright (c) 2025 Lean FRO, LLC. All rights reserved.
|
||||
Released under Apache 2.0 license as described in the file LICENSE.
|
||||
Authors: Sofia Rodrigues
|
||||
-/
|
||||
module
|
||||
|
||||
prelude
|
||||
public import Std.Time
|
||||
public import Std.Internal.Http.Protocol.H1
|
||||
|
||||
public section
|
||||
|
||||
/-!
|
||||
# Config
|
||||
|
||||
This module exposes the `Config`, a structure that describes timeout, request and headers
|
||||
configuration of an HTTP Server.
|
||||
-/
|
||||
|
||||
namespace Std.Http
|
||||
|
||||
set_option linter.all true
|
||||
|
||||
/--
|
||||
Connection limits configuration with validation.
|
||||
-/
|
||||
structure Config where
|
||||
/--
|
||||
Maximum number of simultaneous active connections (default: 1024).
|
||||
Setting this to `0` disables the limit entirely: the server will accept any number of
|
||||
concurrent connections and no semaphore-based cap is enforced. Use with care — an
|
||||
unconstrained server may exhaust file descriptors or memory under adversarial load.
|
||||
-/
|
||||
maxConnections : Nat := 1024
|
||||
|
||||
/--
|
||||
Maximum number of requests per connection.
|
||||
-/
|
||||
maxRequests : Nat := 100
|
||||
|
||||
/--
|
||||
Maximum number of headers allowed per request.
|
||||
-/
|
||||
maxHeaders : Nat := 50
|
||||
|
||||
/--
|
||||
Maximum aggregate byte size of all header field lines in a single message
|
||||
(name + value bytes plus 4 bytes per line for `: ` and `\r\n`). Default: 64 KiB.
|
||||
-/
|
||||
maxHeaderBytes : Nat := 65536
|
||||
|
||||
/--
|
||||
Timeout (in milliseconds) for receiving additional data while a request is actively being
|
||||
processed (e.g. reading the request body). Applies after the request headers have been parsed
|
||||
and replaces the keep-alive timeout for the duration of the request.
|
||||
-/
|
||||
lingeringTimeout : Time.Millisecond.Offset := 10000
|
||||
|
||||
/--
|
||||
Timeout for keep-alive connections
|
||||
-/
|
||||
keepAliveTimeout : { x : Time.Millisecond.Offset // 0 < x } := ⟨12000, by decide⟩
|
||||
|
||||
/--
|
||||
Maximum time (in milliseconds) allowed to receive the complete request headers after the first
|
||||
byte of a new request arrives. This prevents Slowloris-style attacks where a client sends bytes
|
||||
at a slow rate to hold a connection slot open without completing a request. Once a request starts,
|
||||
each individual read must complete within this window. Default: 5 seconds.
|
||||
-/
|
||||
headerTimeout : Time.Millisecond.Offset := 5000
|
||||
|
||||
/--
|
||||
Whether to enable keep-alive connections by default.
|
||||
-/
|
||||
enableKeepAlive : Bool := true
|
||||
|
||||
/--
|
||||
The maximum size that the connection can receive in a single recv call.
|
||||
-/
|
||||
maximumRecvSize : Nat := 8192
|
||||
|
||||
/--
|
||||
Default buffer size for the connection
|
||||
-/
|
||||
defaultPayloadBytes : Nat := 8192
|
||||
|
||||
/--
|
||||
Whether to automatically generate the `Date` header in responses.
|
||||
-/
|
||||
generateDate : Bool := true
|
||||
|
||||
/--
|
||||
The `Server` header value injected into outgoing responses.
|
||||
`none` suppresses the header entirely.
|
||||
-/
|
||||
serverName : Option Header.Value := some (.mk "LeanHTTP/1.1")
|
||||
|
||||
/--
|
||||
Maximum length of request URI (default: 8192 bytes)
|
||||
-/
|
||||
maxUriLength : Nat := 8192
|
||||
|
||||
/--
|
||||
Maximum number of bytes consumed while parsing request start-lines (default: 8192 bytes).
|
||||
-/
|
||||
maxStartLineLength : Nat := 8192
|
||||
|
||||
/--
|
||||
Maximum length of header field name (default: 256 bytes)
|
||||
-/
|
||||
maxHeaderNameLength : Nat := 256
|
||||
|
||||
/--
|
||||
Maximum length of header field value (default: 8192 bytes)
|
||||
-/
|
||||
maxHeaderValueLength : Nat := 8192
|
||||
|
||||
/--
|
||||
Maximum number of spaces in delimiter sequences (default: 16)
|
||||
-/
|
||||
maxSpaceSequence : Nat := 16
|
||||
|
||||
/--
|
||||
Maximum number of leading empty lines (bare CRLF) to skip before a request-line
|
||||
(RFC 9112 §2.2 robustness). Default: 8.
|
||||
-/
|
||||
maxLeadingEmptyLines : Nat := 8
|
||||
|
||||
/--
|
||||
Maximum length of chunk extension name (default: 256 bytes)
|
||||
-/
|
||||
maxChunkExtNameLength : Nat := 256
|
||||
|
||||
/--
|
||||
Maximum length of chunk extension value (default: 256 bytes)
|
||||
-/
|
||||
maxChunkExtValueLength : Nat := 256
|
||||
|
||||
/--
|
||||
Maximum number of bytes consumed while parsing one chunk-size line with extensions (default: 8192 bytes).
|
||||
-/
|
||||
maxChunkLineLength : Nat := 8192
|
||||
|
||||
/--
|
||||
Maximum allowed chunk payload size in bytes (default: 8 MiB).
|
||||
-/
|
||||
maxChunkSize : Nat := 8 * 1024 * 1024
|
||||
|
||||
/--
|
||||
Maximum allowed total body size per request in bytes (default: 64 MiB).
|
||||
-/
|
||||
maxBodySize : Nat := 64 * 1024 * 1024
|
||||
|
||||
/--
|
||||
Maximum length of reason phrase (default: 512 bytes)
|
||||
-/
|
||||
maxReasonPhraseLength : Nat := 512
|
||||
|
||||
/--
|
||||
Maximum number of trailer headers (default: 20)
|
||||
-/
|
||||
maxTrailerHeaders : Nat := 20
|
||||
|
||||
/--
|
||||
Maximum number of extensions on a single chunk-size line (default: 16).
|
||||
-/
|
||||
maxChunkExtensions : Nat := 16
|
||||
|
||||
namespace Config
|
||||
|
||||
/--
|
||||
Converts to HTTP/1.1 config.
|
||||
-/
|
||||
def toH1Config (config : Config) : Protocol.H1.Config where
|
||||
maxMessages := config.maxRequests
|
||||
maxHeaders := config.maxHeaders
|
||||
maxHeaderBytes := config.maxHeaderBytes
|
||||
enableKeepAlive := config.enableKeepAlive
|
||||
agentName := config.serverName
|
||||
maxUriLength := config.maxUriLength
|
||||
maxStartLineLength := config.maxStartLineLength
|
||||
maxHeaderNameLength := config.maxHeaderNameLength
|
||||
maxHeaderValueLength := config.maxHeaderValueLength
|
||||
maxSpaceSequence := config.maxSpaceSequence
|
||||
maxLeadingEmptyLines := config.maxLeadingEmptyLines
|
||||
maxChunkExtensions := config.maxChunkExtensions
|
||||
maxChunkExtNameLength := config.maxChunkExtNameLength
|
||||
maxChunkExtValueLength := config.maxChunkExtValueLength
|
||||
maxChunkLineLength := config.maxChunkLineLength
|
||||
maxChunkSize := config.maxChunkSize
|
||||
maxBodySize := config.maxBodySize
|
||||
maxReasonPhraseLength := config.maxReasonPhraseLength
|
||||
maxTrailerHeaders := config.maxTrailerHeaders
|
||||
|
||||
end Std.Http.Config
|
||||
530
src/Std/Internal/Http/Server/Connection.lean
Normal file
530
src/Std/Internal/Http/Server/Connection.lean
Normal file
@@ -0,0 +1,530 @@
|
||||
/-
|
||||
Copyright (c) 2025 Lean FRO, LLC. All rights reserved.
|
||||
Released under Apache 2.0 license as described in the file LICENSE.
|
||||
Authors: Sofia Rodrigues
|
||||
-/
|
||||
module
|
||||
|
||||
prelude
|
||||
public import Std.Internal.Async.TCP
|
||||
public import Std.Internal.Async.ContextAsync
|
||||
public import Std.Internal.Http.Transport
|
||||
public import Std.Internal.Http.Protocol.H1
|
||||
public import Std.Internal.Http.Server.Config
|
||||
public import Std.Internal.Http.Server.Handler
|
||||
|
||||
public section
|
||||
|
||||
namespace Std
|
||||
namespace Http
|
||||
namespace Server
|
||||
|
||||
open Std Internal IO Async TCP Protocol
|
||||
open Time
|
||||
|
||||
/-!
|
||||
# Connection
|
||||
|
||||
This module defines `Server.Connection`, a structure used to handle a single HTTP connection with
|
||||
possibly multiple requests.
|
||||
-/
|
||||
|
||||
set_option linter.all true
|
||||
|
||||
/--
|
||||
Represents the remote address of a client connection.
|
||||
-/
|
||||
public structure RemoteAddr where
|
||||
/--
|
||||
The socket address of the remote client.
|
||||
-/
|
||||
addr : Net.SocketAddress
|
||||
deriving TypeName
|
||||
|
||||
instance : ToString RemoteAddr where
|
||||
toString addr := toString addr.addr.ipAddr ++ ":" ++ toString addr.addr.port
|
||||
|
||||
/--
|
||||
A single HTTP connection.
|
||||
-/
|
||||
public structure Connection (α : Type) where
|
||||
/--
|
||||
The client connection.
|
||||
-/
|
||||
socket : α
|
||||
|
||||
/--
|
||||
The processing machine for HTTP/1.1.
|
||||
-/
|
||||
machine : H1.Machine .receiving
|
||||
|
||||
/--
|
||||
Extensions to attach to each request (e.g., remote address).
|
||||
-/
|
||||
extensions : Extensions := .empty
|
||||
|
||||
namespace Connection
|
||||
|
||||
/--
|
||||
Events produced by the async select loop in `receiveWithTimeout`.
|
||||
Each variant corresponds to one possible outcome of waiting for I/O.
|
||||
-/
|
||||
private inductive Recv (β : Type)
|
||||
| bytes (x : Option ByteArray)
|
||||
| responseBody (x : Option Chunk)
|
||||
| bodyInterest (x : Bool)
|
||||
| response (x : (Except Error (Response β)))
|
||||
| timeout
|
||||
| keepAliveTimeout
|
||||
| shutdown
|
||||
| close
|
||||
|
||||
/--
|
||||
The set of I/O sources to wait on during a single poll iteration.
|
||||
Each `Option` field is `none` when that source is not currently active.
|
||||
-/
|
||||
private structure PollSources (α β : Type) where
|
||||
socket : Option α
|
||||
expect : Option Nat
|
||||
response : Option (Std.Channel (Except Error (Response β)))
|
||||
responseBody : Option β
|
||||
requestBody : Option Body.Stream
|
||||
timeout : Millisecond.Offset
|
||||
keepAliveTimeout : Option Millisecond.Offset
|
||||
headerTimeout : Option Timestamp
|
||||
connectionContext : CancellationContext
|
||||
|
||||
/--
|
||||
Waits for the next I/O event across all active sources described by `sources`.
|
||||
Computes the socket recv size from `config`, then races all active selectables.
|
||||
Calls `Handler.onFailure` and returns `.close` on transport errors.
|
||||
-/
|
||||
private def pollNextEvent
|
||||
{σ β : Type} [Transport α] [Handler σ] [Body β]
|
||||
(config : Config) (handler : σ) (sources : PollSources α β)
|
||||
: Async (Recv β) := do
|
||||
let expectedBytes := sources.expect
|
||||
|>.getD config.defaultPayloadBytes
|
||||
|>.min config.maximumRecvSize
|
||||
|>.toUInt64
|
||||
|
||||
let mut selectables : Array (Selectable (Recv β)) := #[
|
||||
.case sources.connectionContext.doneSelector (fun _ => do
|
||||
let reason ← sources.connectionContext.getCancellationReason
|
||||
match reason with
|
||||
| some .deadline => pure .timeout
|
||||
| _ => pure .shutdown)
|
||||
]
|
||||
|
||||
if let some socket := sources.socket then
|
||||
selectables := selectables.push (.case (Transport.recvSelector socket expectedBytes) (Recv.bytes · |> pure))
|
||||
|
||||
if let some keepAliveTimeout := sources.keepAliveTimeout then
|
||||
selectables := selectables.push (.case (← Selector.sleep keepAliveTimeout) (fun _ => pure .keepAliveTimeout))
|
||||
else if let some timeout := sources.headerTimeout then
|
||||
selectables := selectables.push (.case (← Selector.sleep (timeout - (← Timestamp.now)).toMilliseconds) (fun _ => pure .timeout))
|
||||
else
|
||||
selectables := selectables.push (.case (← Selector.sleep sources.timeout) (fun _ => pure .timeout))
|
||||
|
||||
if let some responseBody := sources.responseBody then
|
||||
selectables := selectables.push (.case (Body.recvSelector responseBody) (Recv.responseBody · |> pure))
|
||||
|
||||
if let some requestBody := sources.requestBody then
|
||||
selectables := selectables.push (.case (requestBody.interestSelector) (Recv.bodyInterest · |> pure))
|
||||
|
||||
if let some response := sources.response then
|
||||
selectables := selectables.push (.case response.recvSelector (Recv.response · |> pure))
|
||||
|
||||
try Selectable.one selectables
|
||||
catch e =>
|
||||
Handler.onFailure handler e
|
||||
pure .close
|
||||
|
||||
/--
|
||||
Handles the `Expect: 100-continue` protocol for a pending request head.
|
||||
Races between the handler's decision (`Handler.onContinue`), the connection being
|
||||
cancelled, and a lingering timeout. Returns the updated machine and whether
|
||||
`pendingHead` should be cleared (i.e. when the request is rejected).
|
||||
-/
|
||||
private def handleContinueEvent
|
||||
{σ : Type} [Handler σ]
|
||||
(handler : σ) (machine : H1.Machine .receiving) (head : Request.Head)
|
||||
(config : Config) (connectionContext : CancellationContext)
|
||||
: Async (H1.Machine .receiving × Bool) := do
|
||||
|
||||
let continueChannel : Std.Channel Bool ← Std.Channel.new
|
||||
let continueTask ← Handler.onContinue handler head |>.asTask
|
||||
|
||||
BaseIO.chainTask continueTask fun
|
||||
| .ok v => discard <| continueChannel.send v
|
||||
| .error _ => discard <| continueChannel.send false
|
||||
|
||||
let canContinue ← Selectable.one #[
|
||||
.case continueChannel.recvSelector pure,
|
||||
.case connectionContext.doneSelector (fun _ => pure false),
|
||||
.case (← Selector.sleep config.lingeringTimeout) (fun _ => pure false)
|
||||
]
|
||||
|
||||
let status := if canContinue then Status.«continue» else Status.expectationFailed
|
||||
return (machine.canContinue status, !canContinue)
|
||||
|
||||
/--
|
||||
Injects a `Date` header into a response head if `Config.generateDate` is set
|
||||
and the response does not already include one.
|
||||
-/
|
||||
private def prepareResponseHead (config : Config) (head : Response.Head) : Async Response.Head := do
|
||||
if config.generateDate ∧ ¬head.headers.contains Header.Name.date then
|
||||
let now ← Std.Time.DateTime.now (tz := .UTC)
|
||||
return { head with headers := head.headers.insert Header.Name.date (Header.Value.ofString! now.toRFC822String) }
|
||||
else
|
||||
return head
|
||||
|
||||
/--
|
||||
Applies a successful handler response to the machine.
|
||||
Optionally injects a `Date` header, records the known body size, and sends the
|
||||
response head. Returns the updated machine and the body stream to drain, or `none`
|
||||
when the body should be omitted (e.g., for HEAD requests).
|
||||
-/
|
||||
private def applyResponse
|
||||
{β : Type} [Body β]
|
||||
(config : Config) (machine : H1.Machine .receiving) (res : Response β)
|
||||
: Async (H1.Machine .receiving × Option β) := do
|
||||
let size ← Body.getKnownSize res.body
|
||||
let machineSized :=
|
||||
if let some knownSize := size then machine.setKnownSize knownSize
|
||||
else machine
|
||||
let responseHead ← prepareResponseHead config res.line
|
||||
let machineWithHead := machineSized.send responseHead
|
||||
if machineWithHead.writer.omitBody then
|
||||
if ¬(← Body.isClosed res.body) then
|
||||
Body.close res.body
|
||||
return (machineWithHead, none)
|
||||
else
|
||||
return (machineWithHead, some res.body)
|
||||
|
||||
/--
|
||||
All mutable state carried through the connection processing loop.
|
||||
Bundled into a struct so it can be passed to and returned from helper functions.
|
||||
-/
|
||||
private structure ConnectionState (β : Type) where
|
||||
machine : H1.Machine .receiving
|
||||
requestStream : Body.Stream
|
||||
keepAliveTimeout : Option Millisecond.Offset
|
||||
currentTimeout : Millisecond.Offset
|
||||
headerTimeout : Option Timestamp
|
||||
response : Std.Channel (Except Error (Response β))
|
||||
respStream : Option β
|
||||
requiresData : Bool
|
||||
expectData : Option Nat
|
||||
handlerDispatched : Bool
|
||||
pendingHead : Option Request.Head
|
||||
|
||||
/--
|
||||
Processes all H1 events from a single machine step, updating the connection state.
|
||||
Handles keep-alive resets, body-size tracking, `Expect: 100-continue`, and parse errors.
|
||||
Returns the updated state; stops early on `.failed`.
|
||||
-/
|
||||
private def processH1Events
|
||||
{σ β : Type} [Handler σ] [Body β]
|
||||
(handler : σ) (config : Config) (connectionContext : CancellationContext)
|
||||
(events : Array (H1.Event .receiving))
|
||||
(state : ConnectionState β)
|
||||
: Async (ConnectionState β) := do
|
||||
|
||||
let mut st := state
|
||||
|
||||
for event in events do
|
||||
match event with
|
||||
| .needMoreData expect =>
|
||||
st := { st with requiresData := true, expectData := expect }
|
||||
|
||||
| .needAnswer => pure ()
|
||||
|
||||
| .endHeaders head =>
|
||||
|
||||
-- Sets the pending head and removes the KeepAlive or Header timeout.
|
||||
st := { st with
|
||||
currentTimeout := config.lingeringTimeout
|
||||
keepAliveTimeout := none
|
||||
headerTimeout := none
|
||||
pendingHead := some head
|
||||
}
|
||||
|
||||
if let some length := head.getSize true then
|
||||
-- Sets the size of the body that is going out of the connection.
|
||||
Body.setKnownSize st.requestStream (some length)
|
||||
|
||||
| .«continue» =>
|
||||
if let some head := st.pendingHead then
|
||||
let (newMachine, clearPending) ← handleContinueEvent handler st.machine head config connectionContext
|
||||
st := { st with machine := newMachine }
|
||||
if clearPending then
|
||||
st := { st with pendingHead := none }
|
||||
|
||||
| .next =>
|
||||
-- Reset all per-request state for the next pipelined request.
|
||||
if ¬(← Body.isClosed st.requestStream) then
|
||||
Body.close st.requestStream
|
||||
|
||||
if let some res := st.respStream then
|
||||
if ¬(← Body.isClosed res) then
|
||||
Body.close res
|
||||
|
||||
let newStream ← Body.mkStream
|
||||
|
||||
st := { st with
|
||||
requestStream := newStream
|
||||
response := ← Std.Channel.new
|
||||
respStream := none
|
||||
keepAliveTimeout := some config.keepAliveTimeout.val
|
||||
currentTimeout := config.keepAliveTimeout.val
|
||||
headerTimeout := none
|
||||
handlerDispatched := false
|
||||
}
|
||||
|
||||
| .failed err =>
|
||||
Handler.onFailure handler (toString err)
|
||||
|
||||
if ¬(← Body.isClosed st.requestStream) then
|
||||
Body.close st.requestStream
|
||||
|
||||
st := { st with requiresData := false, pendingHead := none }
|
||||
break
|
||||
|
||||
| .closeBody =>
|
||||
if ¬(← Body.isClosed st.requestStream) then
|
||||
Body.close st.requestStream
|
||||
|
||||
| .close => pure ()
|
||||
|
||||
return st
|
||||
|
||||
/--
|
||||
Dispatches a pending request head to the handler if one is waiting.
|
||||
Spawns the handler as an async task and routes its result back through `state.response`.
|
||||
Returns the updated state with `pendingHead` cleared and `handlerDispatched` set.
|
||||
-/
|
||||
private def dispatchPendingRequest
|
||||
{σ : Type} [Handler σ]
|
||||
(handler : σ) (extensions : Extensions) (connectionContext : CancellationContext)
|
||||
(state : ConnectionState (Handler.ResponseBody σ))
|
||||
: Async (ConnectionState (Handler.ResponseBody σ)) := do
|
||||
if let some line := state.pendingHead then
|
||||
|
||||
let task ← Handler.onRequest handler { line, body := state.requestStream, extensions } connectionContext
|
||||
|>.asTask
|
||||
|
||||
BaseIO.chainTask task (discard ∘ state.response.send)
|
||||
return { state with pendingHead := none, handlerDispatched := true }
|
||||
else
|
||||
return state
|
||||
|
||||
/--
|
||||
Processes a single async I/O event and updates the connection state.
|
||||
Returns the updated state and `true` if the connection should be closed immediately.
|
||||
-/
|
||||
private def handleRecvEvent
|
||||
{σ β : Type} [Handler σ] [Body β]
|
||||
(handler : σ) (config : Config)
|
||||
(event : Recv β) (state : ConnectionState β)
|
||||
: Async (ConnectionState β × Bool) := do
|
||||
|
||||
match event with
|
||||
| .bytes (some bs) =>
|
||||
|
||||
let mut st := state
|
||||
|
||||
-- After the first byte after idle we switch from keep-alive timeout to per-request header timeout.
|
||||
if st.keepAliveTimeout.isSome then
|
||||
st := { st with
|
||||
keepAliveTimeout := none
|
||||
headerTimeout := some <| (← Timestamp.now) + config.headerTimeout
|
||||
}
|
||||
|
||||
return ({ st with machine := st.machine.feed bs }, false)
|
||||
|
||||
| .bytes none =>
|
||||
return ({ state with machine := state.machine.noMoreInput }, false)
|
||||
|
||||
| .responseBody (some chunk) =>
|
||||
return ({ state with machine := state.machine.sendData #[chunk] }, false)
|
||||
|
||||
| .responseBody none =>
|
||||
if let some res := state.respStream then
|
||||
if ¬(← Body.isClosed res) then Body.close res
|
||||
return ({ state with machine := state.machine.userClosedBody, respStream := none }, false)
|
||||
|
||||
| .bodyInterest interested =>
|
||||
if interested then
|
||||
let (newMachine, pulledChunk) := state.machine.pullBody
|
||||
let mut st := { state with machine := newMachine }
|
||||
|
||||
if let some pulled := pulledChunk then
|
||||
try st.requestStream.send pulled.chunk pulled.incomplete
|
||||
catch e => Handler.onFailure handler e
|
||||
if pulled.final then
|
||||
if ¬(← Body.isClosed st.requestStream) then
|
||||
Body.close st.requestStream
|
||||
|
||||
return (st, false)
|
||||
else
|
||||
return (state, false)
|
||||
|
||||
| .close => return (state, true)
|
||||
|
||||
| .timeout =>
|
||||
Handler.onFailure handler "request header timeout"
|
||||
return ({ state with machine := state.machine.closeWithError .requestTimeout, handlerDispatched := false }, false)
|
||||
|
||||
| .keepAliveTimeout =>
|
||||
return ({ state with machine := state.machine.closeWithError .requestTimeout, handlerDispatched := false }, false)
|
||||
|
||||
| .shutdown =>
|
||||
return ({ state with machine := state.machine.closeWithError .serviceUnavailable, handlerDispatched := false }, false)
|
||||
|
||||
| .response (.error err) =>
|
||||
Handler.onFailure handler err
|
||||
return ({ state with machine := state.machine.closeWithError .internalServerError, handlerDispatched := false }, false)
|
||||
|
||||
| .response (.ok res) =>
|
||||
if state.machine.failed then
|
||||
if ¬(← Body.isClosed res.body) then Body.close res.body
|
||||
return ({ state with handlerDispatched := false }, false)
|
||||
else
|
||||
let (newMachine, newRespStream) ← applyResponse config state.machine res
|
||||
return ({ state with machine := newMachine, handlerDispatched := false, respStream := newRespStream }, false)
|
||||
|
||||
/--
|
||||
Computes the active `PollSources` for the current connection state.
|
||||
Determines which IO sources need attention and whether to include the socket.
|
||||
-/
|
||||
private def buildPollSources
|
||||
{α β : Type} [Transport α]
|
||||
(socket : α) (connectionContext : CancellationContext) (state : ConnectionState β)
|
||||
: Async (PollSources α β) := do
|
||||
let requestBodyOpen ←
|
||||
if state.machine.canPullBody then pure !(← Body.isClosed state.requestStream)
|
||||
else pure false
|
||||
|
||||
let requestBodyInterested ←
|
||||
if state.machine.canPullBody ∧ requestBodyOpen then state.requestStream.hasInterest
|
||||
else pure false
|
||||
|
||||
let requestBody ←
|
||||
if state.machine.canPullBodyNow ∧ requestBodyOpen then pure (some state.requestStream)
|
||||
else pure none
|
||||
|
||||
-- Include the socket only when there is more to do than waiting for the handler alone.
|
||||
let pollSocket :=
|
||||
state.requiresData ∨ !state.handlerDispatched ∨ state.respStream.isSome ∨
|
||||
state.machine.writer.sentMessage ∨ (state.machine.canPullBody ∧ requestBodyInterested)
|
||||
|
||||
return {
|
||||
socket := if pollSocket then some socket else none
|
||||
expect := state.expectData
|
||||
response := if state.handlerDispatched then some state.response else none
|
||||
responseBody := state.respStream
|
||||
requestBody := requestBody
|
||||
timeout := state.currentTimeout
|
||||
keepAliveTimeout := state.keepAliveTimeout
|
||||
headerTimeout := state.headerTimeout
|
||||
connectionContext := connectionContext
|
||||
}
|
||||
|
||||
/--
|
||||
Runs the main request/response processing loop for a single connection.
|
||||
Drives the HTTP/1.1 state machine through four phases each iteration:
|
||||
send buffered output, process H1 events, dispatch pending requests, poll for I/O.
|
||||
-/
|
||||
private def handle
|
||||
{σ : Type} [Transport α] [h : Handler σ]
|
||||
(connection : Connection α)
|
||||
(config : Config)
|
||||
(connectionContext : CancellationContext)
|
||||
(handler : σ) : Async Unit := do
|
||||
|
||||
let _ : Body (Handler.ResponseBody σ) := Handler.responseBodyInstance
|
||||
|
||||
let socket := connection.socket
|
||||
let initStream ← Body.mkStream
|
||||
|
||||
let mut state : ConnectionState (Handler.ResponseBody σ) := {
|
||||
machine := connection.machine
|
||||
requestStream := initStream
|
||||
keepAliveTimeout := some config.keepAliveTimeout.val
|
||||
currentTimeout := config.keepAliveTimeout.val
|
||||
headerTimeout := none
|
||||
response := ← Std.Channel.new
|
||||
respStream := none
|
||||
requiresData := false
|
||||
expectData := none
|
||||
handlerDispatched := false
|
||||
pendingHead := none
|
||||
}
|
||||
|
||||
while ¬state.machine.halted do
|
||||
|
||||
-- Phase 1: advance the state machine and flush any output.
|
||||
let (newMachine, step) := state.machine.step
|
||||
state := { state with machine := newMachine }
|
||||
|
||||
if step.output.size > 0 then
|
||||
try Transport.sendAll socket step.output.data
|
||||
catch e =>
|
||||
Handler.onFailure handler e
|
||||
break
|
||||
|
||||
-- Phase 2: process all events emitted by this step.
|
||||
state ← processH1Events handler config connectionContext step.events state
|
||||
|
||||
-- Phase 3: dispatch any newly parsed request to the handler.
|
||||
state ← dispatchPendingRequest handler connection.extensions connectionContext state
|
||||
|
||||
-- Phase 4: wait for the next IO event when any source needs attention.
|
||||
if state.requiresData ∨ state.handlerDispatched ∨ state.respStream.isSome ∨ state.machine.canPullBody then
|
||||
state := { state with requiresData := false }
|
||||
let sources ← buildPollSources socket connectionContext state
|
||||
let event ← pollNextEvent config handler sources
|
||||
let (newState, shouldClose) ← handleRecvEvent handler config event state
|
||||
state := newState
|
||||
if shouldClose then break
|
||||
|
||||
-- Clean up: close all open streams and the socket.
|
||||
if ¬(← Body.isClosed state.requestStream) then
|
||||
Body.close state.requestStream
|
||||
|
||||
if let some res := state.respStream then
|
||||
if ¬(← Body.isClosed res) then Body.close res
|
||||
|
||||
Transport.close socket
|
||||
|
||||
end Connection
|
||||
|
||||
/--
|
||||
Handles request/response processing for a single connection using an `Async` handler.
|
||||
The library-level entry point for running a server is `Server.serve`.
|
||||
This function can be used with a `TCP.Socket` or any other type that implements
|
||||
`Transport` to build custom server loops.
|
||||
|
||||
# Example
|
||||
|
||||
```lean
|
||||
-- Create a TCP socket server instance
|
||||
let server ← Socket.Server.mk
|
||||
server.bind addr
|
||||
server.listen backlog
|
||||
|
||||
-- Enter an infinite loop to handle incoming client connections
|
||||
while true do
|
||||
let client ← server.accept
|
||||
background (serveConnection client handler config)
|
||||
```
|
||||
-/
|
||||
def serveConnection
|
||||
{σ : Type} [Transport t] [Handler σ]
|
||||
(client : t) (handler : σ)
|
||||
(config : Config) (extensions : Extensions := .empty) : ContextAsync Unit := do
|
||||
(Connection.mk client { config := config.toH1Config } extensions)
|
||||
|>.handle config (← ContextAsync.getContext) handler
|
||||
|
||||
end Std.Http.Server
|
||||
60
src/Std/Internal/Http/Server/Handler.lean
Normal file
60
src/Std/Internal/Http/Server/Handler.lean
Normal file
@@ -0,0 +1,60 @@
|
||||
/-
|
||||
Copyright (c) 2025 Lean FRO, LLC. All rights reserved.
|
||||
Released under Apache 2.0 license as described in the file LICENSE.
|
||||
Authors: Sofia Rodrigues
|
||||
-/
|
||||
module
|
||||
|
||||
prelude
|
||||
public import Std.Internal.Async
|
||||
public import Std.Internal.Http.Data
|
||||
public import Std.Internal.Async.ContextAsync
|
||||
|
||||
public section
|
||||
|
||||
namespace Std.Http.Server
|
||||
|
||||
open Std.Internal.IO.Async
|
||||
|
||||
set_option linter.all true
|
||||
|
||||
/--
|
||||
A type class for handling HTTP server requests. Implement this class to define how the server
|
||||
responds to incoming requests, failures, and `Expect: 100-continue` headers.
|
||||
-/
|
||||
class Handler (σ : Type) where
|
||||
/--
|
||||
Concrete body type produced by `onRequest`.
|
||||
Defaults to `Body.Any`, but handlers may override it with any reader/writer-compatible body.
|
||||
-/
|
||||
ResponseBody : Type := Body.Any
|
||||
|
||||
/--
|
||||
Body instance required by the connection loop for receiving response chunks.
|
||||
-/
|
||||
[responseBodyInstance : Body ResponseBody]
|
||||
|
||||
/--
|
||||
Called for each incoming HTTP request.
|
||||
-/
|
||||
onRequest (self : σ) (request : Request Body.Stream) : ContextAsync (Response ResponseBody)
|
||||
|
||||
/--
|
||||
Called when an I/O or transport error occurs while processing a request (e.g. broken socket,
|
||||
handler exception). This is a **notification only**: the connection will close regardless of
|
||||
the handler's response. Use this for logging and metrics. The default implementation does nothing.
|
||||
-/
|
||||
onFailure (self : σ) (error : IO.Error) : Async Unit :=
|
||||
pure ()
|
||||
|
||||
/--
|
||||
Called when a request includes an `Expect: 100-continue` header. Return `true` to send a
|
||||
`100 Continue` response and accept the body. If `false` is returned the server sends
|
||||
`417 Expectation Failed`, disables keep-alive, and closes the request body reader.
|
||||
This function is guarded by `Config.lingeringTimeout` and may be cancelled on server shutdown.
|
||||
The default implementation always returns `true`.
|
||||
-/
|
||||
onContinue (self : σ) (request : Request.Head) : Async Bool :=
|
||||
pure true
|
||||
|
||||
end Std.Http.Server
|
||||
243
src/Std/Internal/Http/Test/Helpers.lean
Normal file
243
src/Std/Internal/Http/Test/Helpers.lean
Normal file
@@ -0,0 +1,243 @@
|
||||
/-
|
||||
Copyright (c) 2026 Lean FRO, LLC. All rights reserved.
|
||||
Released under Apache 2.0 license as described in the file LICENSE.
|
||||
Authors: Sofia Rodrigues
|
||||
-/
|
||||
module
|
||||
|
||||
prelude
|
||||
public import Std.Internal.Http.Server
|
||||
public import Std.Internal.Async
|
||||
public import Std.Internal.Async.Timer
|
||||
import Init.Data.String.Legacy
|
||||
|
||||
public section
|
||||
|
||||
open Std.Internal.IO Async
|
||||
open Std Http
|
||||
|
||||
namespace Std.Http.Test
|
||||
|
||||
abbrev TestHandler := Request Body.Stream → ContextAsync (Response Body.Any)
|
||||
|
||||
instance : Std.Http.Server.Handler TestHandler where
|
||||
onRequest handler request := handler request
|
||||
|
||||
/--
|
||||
Default config for server tests. Short lingering timeout, no Date header.
|
||||
-/
|
||||
def defaultConfig : Config :=
|
||||
{ lingeringTimeout := 1000, generateDate := false }
|
||||
|
||||
private def sendRaw
|
||||
(client : Mock.Client) (server : Mock.Server) (raw : ByteArray)
|
||||
(handler : TestHandler) (config : Config) : IO ByteArray :=
|
||||
Async.block do
|
||||
client.send raw
|
||||
Std.Http.Server.serveConnection server handler config |>.run
|
||||
let res ← client.recv?
|
||||
pure (res.getD .empty)
|
||||
|
||||
private def sendClose
|
||||
(client : Mock.Client) (server : Mock.Server) (raw : ByteArray)
|
||||
(handler : TestHandler) (config : Config) : IO ByteArray :=
|
||||
Async.block do
|
||||
client.send raw
|
||||
client.getSendChan.close
|
||||
Std.Http.Server.serveConnection server handler config |>.run
|
||||
let res ← client.recv?
|
||||
pure (res.getD .empty)
|
||||
|
||||
-- Timeout wrapper
|
||||
|
||||
private def withTimeout {α : Type} (name : String) (ms : Nat) (action : IO α) : IO α := do
|
||||
let task ← IO.asTask action
|
||||
let ticks := (ms + 9) / 10
|
||||
let rec loop : Nat → IO α
|
||||
| 0 => do IO.cancel task; throw <| IO.userError s!"'{name}' timed out after {ms}ms"
|
||||
| n + 1 => do
|
||||
if (← IO.getTaskState task) == .finished then
|
||||
match ← IO.wait task with
|
||||
| .ok x => pure x
|
||||
| .error e => throw e
|
||||
else IO.sleep 10; loop n
|
||||
loop ticks
|
||||
|
||||
-- Test grouping
|
||||
|
||||
/--
|
||||
Run `tests` and wrap any failure message with the group name.
|
||||
Use as `#eval runGroup "Topic" do ...`.
|
||||
-/
|
||||
def runGroup (name : String) (tests : IO Unit) : IO Unit :=
|
||||
try tests
|
||||
catch e => throw (IO.userError s!"[{name}]\n{e}")
|
||||
|
||||
-- Per-test runners
|
||||
|
||||
/--
|
||||
Create a fresh mock connection, send `raw`, and run assertions.
|
||||
-/
|
||||
def check
|
||||
(name : String)
|
||||
(raw : String)
|
||||
(handler : TestHandler)
|
||||
(expect : ByteArray → IO Unit)
|
||||
(config : Config := defaultConfig) : IO Unit := do
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendRaw client server raw.toUTF8 handler config
|
||||
try expect response
|
||||
catch e => throw (IO.userError s!"[{name}] {e}")
|
||||
|
||||
/--
|
||||
Like `check` but closes the client channel before running the server.
|
||||
Use for tests involving truncated input or silent-close (EOF-triggered behavior).
|
||||
-/
|
||||
def checkClose
|
||||
(name : String)
|
||||
(raw : String)
|
||||
(handler : TestHandler)
|
||||
(expect : ByteArray → IO Unit)
|
||||
(config : Config := defaultConfig) : IO Unit := do
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendClose client server raw.toUTF8 handler config
|
||||
try expect response
|
||||
catch e => throw (IO.userError s!"[{name}] {e}")
|
||||
|
||||
/--
|
||||
Like `check` wrapped in a wall-clock timeout.
|
||||
Required when the test involves streaming, async timers, or keep-alive behavior.
|
||||
-/
|
||||
def checkTimed
|
||||
(name : String)
|
||||
(ms : Nat := 2000)
|
||||
(raw : String)
|
||||
(handler : TestHandler)
|
||||
(expect : ByteArray → IO Unit)
|
||||
(config : Config := defaultConfig) : IO Unit :=
|
||||
withTimeout name ms <| check name raw handler expect config
|
||||
|
||||
-- Assertion helpers
|
||||
|
||||
/--
|
||||
Assert the response starts with `prefix_` (e.g. `"HTTP/1.1 200"`).
|
||||
-/
|
||||
def assertStatus (response : ByteArray) (prefix_ : String) : IO Unit := do
|
||||
let text := String.fromUTF8! response
|
||||
unless text.startsWith prefix_ do
|
||||
throw <| IO.userError s!"expected status {prefix_.quote}, got:\n{text.quote}"
|
||||
|
||||
/--
|
||||
Assert the response is byte-for-byte equal to `expected`.
|
||||
Use sparingly — prefer `assertStatus` + `assertContains` for 200 responses.
|
||||
-/
|
||||
def assertExact (response : ByteArray) (expected : String) : IO Unit := do
|
||||
let text := String.fromUTF8! response
|
||||
unless text == expected do
|
||||
throw <| IO.userError s!"expected:\n{expected.quote}\ngot:\n{text.quote}"
|
||||
|
||||
/--
|
||||
Assert `needle` appears anywhere in the response.
|
||||
-/
|
||||
def assertContains (response : ByteArray) (needle : String) : IO Unit := do
|
||||
let text := String.fromUTF8! response
|
||||
unless text.contains needle do
|
||||
throw <| IO.userError s!"expected to contain {needle.quote}, got:\n{text.quote}"
|
||||
|
||||
/--
|
||||
Assert `needle` does NOT appear in the response.
|
||||
-/
|
||||
def assertAbsent (response : ByteArray) (needle : String) : IO Unit := do
|
||||
let text := String.fromUTF8! response
|
||||
if text.contains needle then
|
||||
throw <| IO.userError s!"expected NOT to contain {needle.quote}, got:\n{text.quote}"
|
||||
|
||||
/--
|
||||
Assert the response contains exactly `n` occurrences of `"HTTP/1.1 "`.
|
||||
-/
|
||||
def assertResponseCount (response : ByteArray) (n : Nat) : IO Unit := do
|
||||
let text := String.fromUTF8! response
|
||||
let got := (text.splitOn "HTTP/1.1 ").length - 1
|
||||
unless got == n do
|
||||
throw <| IO.userError s!"expected {n} HTTP/1.1 responses, got {got}:\n{text.quote}"
|
||||
|
||||
-- Common fixed response strings
|
||||
|
||||
def r400 : String :=
|
||||
"HTTP/1.1 400 Bad Request\x0d\nServer: LeanHTTP/1.1\x0d\nConnection: close\x0d\nContent-Length: 0\x0d\n\x0d\n"
|
||||
|
||||
def r408 : String :=
|
||||
"HTTP/1.1 408 Request Timeout\x0d\nServer: LeanHTTP/1.1\x0d\nConnection: close\x0d\nContent-Length: 0\x0d\n\x0d\n"
|
||||
|
||||
def r413 : String :=
|
||||
"HTTP/1.1 413 Content Too Large\x0d\nServer: LeanHTTP/1.1\x0d\nConnection: close\x0d\nContent-Length: 0\x0d\n\x0d\n"
|
||||
|
||||
def r417 : String :=
|
||||
"HTTP/1.1 417 Expectation Failed\x0d\nServer: LeanHTTP/1.1\x0d\nConnection: close\x0d\nContent-Length: 0\x0d\n\x0d\n"
|
||||
|
||||
def r431 : String :=
|
||||
"HTTP/1.1 431 Request Header Fields Too Large\x0d\nServer: LeanHTTP/1.1\x0d\nConnection: close\x0d\nContent-Length: 0\x0d\n\x0d\n"
|
||||
|
||||
def r505 : String :=
|
||||
"HTTP/1.1 505 HTTP Version Not Supported\x0d\nServer: LeanHTTP/1.1\x0d\nConnection: close\x0d\nContent-Length: 0\x0d\n\x0d\n"
|
||||
|
||||
-- Standard handlers
|
||||
|
||||
/--
|
||||
Always respond 200 "ok" without reading the request body.
|
||||
-/
|
||||
def okHandler : TestHandler := fun _ => Response.ok |>.text "ok"
|
||||
|
||||
/--
|
||||
Read the full request body and echo it back as text/plain.
|
||||
-/
|
||||
def echoHandler : TestHandler := fun req => do
|
||||
Response.ok |>.text (← req.body.readAll)
|
||||
|
||||
/--
|
||||
Respond 200 with the request URI as the body.
|
||||
-/
|
||||
def uriHandler : TestHandler := fun req =>
|
||||
Response.ok |>.text (toString req.line.uri)
|
||||
|
||||
-- Request builder helpers
|
||||
|
||||
/--
|
||||
Minimal GET request. `extra` is appended as raw header lines (each ending with `\x0d\n`)
|
||||
before the blank line.
|
||||
-/
|
||||
def mkGet (path : String := "/") (extra : String := "") : String :=
|
||||
s!"GET {path} HTTP/1.1\x0d\nHost: example.com\x0d\n{extra}\x0d\n"
|
||||
|
||||
/--
|
||||
GET with `Connection: close`.
|
||||
-/
|
||||
def mkGetClose (path : String := "/") : String :=
|
||||
mkGet path "Connection: close\x0d\n"
|
||||
|
||||
/--
|
||||
POST with a fixed Content-Length body. `extra` is appended before the blank line.
|
||||
-/
|
||||
def mkPost (path : String) (body : String) (extra : String := "") : String :=
|
||||
s!"POST {path} HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: {body.toUTF8.size}\x0d\n{extra}\x0d\n{body}"
|
||||
|
||||
/--
|
||||
POST with Transfer-Encoding: chunked. `chunkedBody` is the pre-formatted body
|
||||
(use `chunk` + `chunkEnd` to build it).
|
||||
-/
|
||||
def mkChunked (path : String) (chunkedBody : String) (extra : String := "") : String :=
|
||||
s!"POST {path} HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\n{extra}\x0d\n{chunkedBody}"
|
||||
|
||||
/--
|
||||
Format a single chunk: `<hex-size>\x0d\n<data>\x0d\n`.
|
||||
-/
|
||||
def chunk (data : String) : String :=
|
||||
let hexSize := Nat.toDigits 16 data.toUTF8.size |> String.ofList
|
||||
s!"{hexSize}\x0d\n{data}\x0d\n"
|
||||
|
||||
/--
|
||||
The terminal zero-chunk that ends a chunked body.
|
||||
-/
|
||||
def chunkEnd : String := "0\x0d\n\x0d\n"
|
||||
|
||||
end Std.Http.Test
|
||||
249
src/Std/Internal/Http/Transport.lean
Normal file
249
src/Std/Internal/Http/Transport.lean
Normal file
@@ -0,0 +1,249 @@
|
||||
/-
|
||||
Copyright (c) 2025 Lean FRO, LLC. All rights reserved.
|
||||
Released under Apache 2.0 license as described in the file LICENSE.
|
||||
Authors: Sofia Rodrigues
|
||||
-/
|
||||
module
|
||||
|
||||
prelude
|
||||
public import Std.Internal.Http.Protocol.H1
|
||||
|
||||
public section
|
||||
|
||||
/-!
|
||||
# Transport
|
||||
|
||||
This module exposes a `Transport` type class that is used to represent different transport mechanisms
|
||||
that can be used with an HTTP connection.
|
||||
-/
|
||||
|
||||
namespace Std.Http
|
||||
open Std Internal IO Async TCP
|
||||
|
||||
set_option linter.all true
|
||||
|
||||
/--
|
||||
Generic HTTP interface that abstracts over different transport mechanisms.
|
||||
-/
|
||||
class Transport (α : Type) where
|
||||
/--
|
||||
Receive data from the client connection, up to the expected size.
|
||||
Returns None if the connection is closed or no data is available.
|
||||
-/
|
||||
recv : α → UInt64 → Async (Option ByteArray)
|
||||
|
||||
/--
|
||||
Send all data through the client connection.
|
||||
-/
|
||||
sendAll : α → Array ByteArray → Async Unit
|
||||
|
||||
/--
|
||||
Get a selector for receiving data asynchronously.
|
||||
-/
|
||||
recvSelector : α → UInt64 → Selector (Option ByteArray)
|
||||
|
||||
/--
|
||||
Close the transport connection.
|
||||
The default implementation is a no-op; override this for transports that require explicit teardown.
|
||||
For `Socket.Client`, the runtime closes the file descriptor when the object is finalized.
|
||||
-/
|
||||
close : α → IO Unit := fun _ => pure ()
|
||||
|
||||
instance : Transport Socket.Client where
|
||||
recv client expect := client.recv? expect
|
||||
sendAll client data := client.sendAll data
|
||||
recvSelector client expect := client.recvSelector expect
|
||||
|
||||
open Internal.IO.Async in
|
||||
|
||||
/--
|
||||
Shared state for a bidirectional mock connection.
|
||||
-/
|
||||
private structure MockLink.SharedState where
|
||||
/--
|
||||
Client to server direction.
|
||||
-/
|
||||
clientToServer : Std.CloseableChannel ByteArray
|
||||
|
||||
/--
|
||||
Server to client direction.
|
||||
-/
|
||||
serverToClient : Std.CloseableChannel ByteArray
|
||||
|
||||
/--
|
||||
Mock client endpoint for testing.
|
||||
-/
|
||||
structure Mock.Client where
|
||||
private shared : MockLink.SharedState
|
||||
|
||||
/--
|
||||
Mock server endpoint for testing.
|
||||
-/
|
||||
structure Mock.Server where
|
||||
private shared : MockLink.SharedState
|
||||
|
||||
namespace Mock
|
||||
|
||||
/--
|
||||
Creates a mock server and client that are connected to each other and share the
|
||||
same underlying state, enabling bidirectional communication.
|
||||
-/
|
||||
def new : BaseIO (Mock.Client × Mock.Server) := do
|
||||
let first ← Std.CloseableChannel.new
|
||||
let second ← Std.CloseableChannel.new
|
||||
|
||||
return (⟨⟨first, second⟩⟩, ⟨⟨first, second⟩⟩)
|
||||
|
||||
/--
|
||||
Receives data from a channel, joining all available data up to the expected size. First does a
|
||||
blocking recv, then greedily consumes available data with tryRecv until `expect` bytes are reached.
|
||||
-/
|
||||
def recvJoined (recvChan : Std.CloseableChannel ByteArray) (expect : Option UInt64) : Async (Option ByteArray) := do
|
||||
match ← await (← recvChan.recv) with
|
||||
| none => return none
|
||||
| some first =>
|
||||
let mut result := first
|
||||
repeat
|
||||
if let some expect := expect then
|
||||
if result.size.toUInt64 ≥ expect then break
|
||||
|
||||
match ← recvChan.tryRecv with
|
||||
| none => break
|
||||
| some chunk => result := result ++ chunk
|
||||
return some result
|
||||
|
||||
/--
|
||||
Sends a single ByteArray through a channel.
|
||||
-/
|
||||
def send (sendChan : Std.CloseableChannel ByteArray) (data : ByteArray) : Async Unit := do
|
||||
Async.ofAsyncTask ((← sendChan.send data) |>.map (Except.mapError (IO.userError ∘ toString)))
|
||||
|
||||
/--
|
||||
Sends ByteArrays through a channel.
|
||||
-/
|
||||
def sendAll (sendChan : Std.CloseableChannel ByteArray) (data : Array ByteArray) : Async Unit := do
|
||||
for chunk in data do
|
||||
send sendChan chunk
|
||||
|
||||
/--
|
||||
Creates a selector for receiving from a channel.
|
||||
-/
|
||||
def recvSelector (recvChan : Std.CloseableChannel ByteArray) : Selector (Option ByteArray) :=
|
||||
recvChan.recvSelector
|
||||
|
||||
end Mock
|
||||
|
||||
namespace Mock.Client
|
||||
|
||||
/--
|
||||
Gets the receive channel for a client (server to client direction).
|
||||
-/
|
||||
def getRecvChan (client : Mock.Client) : Std.CloseableChannel ByteArray :=
|
||||
client.shared.serverToClient
|
||||
|
||||
/--
|
||||
Gets the send channel for a client (client to server direction).
|
||||
-/
|
||||
def getSendChan (client : Mock.Client) : Std.CloseableChannel ByteArray :=
|
||||
client.shared.clientToServer
|
||||
|
||||
/--
|
||||
Sends a single ByteArray.
|
||||
-/
|
||||
def send (client : Mock.Client) (data : ByteArray) : Async Unit :=
|
||||
Mock.send (getSendChan client) data
|
||||
|
||||
/--
|
||||
Receives data, joining all available chunks.
|
||||
-/
|
||||
def recv? (client : Mock.Client) (expect : Option UInt64 := none) : Async (Option ByteArray) :=
|
||||
Mock.recvJoined (getRecvChan client) expect
|
||||
|
||||
/--
|
||||
Tries to receive data without blocking, joining all immediately available chunks.
|
||||
Returns `none` if no data is available.
|
||||
-/
|
||||
def tryRecv? (client : Mock.Client) (_expect : UInt64 := 0) : BaseIO (Option ByteArray) := do
|
||||
match ← (getRecvChan client).tryRecv with
|
||||
| none => return none
|
||||
| some first =>
|
||||
let mut result := first
|
||||
repeat
|
||||
match ← (getRecvChan client).tryRecv with
|
||||
| none => break
|
||||
| some chunk => result := result ++ chunk
|
||||
return some result
|
||||
|
||||
/--
|
||||
Closes the mock server and client.
|
||||
-/
|
||||
def close (client : Mock.Client) : IO Unit := do
|
||||
if !(← client.shared.clientToServer.isClosed) then client.shared.clientToServer.close
|
||||
if !(← client.shared.serverToClient.isClosed) then client.shared.serverToClient.close
|
||||
|
||||
end Mock.Client
|
||||
|
||||
namespace Mock.Server
|
||||
|
||||
/--
|
||||
Gets the receive channel for a server (client to server direction).
|
||||
-/
|
||||
def getRecvChan (server : Mock.Server) : Std.CloseableChannel ByteArray :=
|
||||
server.shared.clientToServer
|
||||
|
||||
/--
|
||||
Gets the send channel for a server (server to client direction).
|
||||
-/
|
||||
def getSendChan (server : Mock.Server) : Std.CloseableChannel ByteArray :=
|
||||
server.shared.serverToClient
|
||||
|
||||
/--
|
||||
Sends a single ByteArray.
|
||||
-/
|
||||
def send (server : Mock.Server) (data : ByteArray) : Async Unit :=
|
||||
Mock.send (getSendChan server) data
|
||||
|
||||
/--
|
||||
Receives data, joining all available chunks.
|
||||
-/
|
||||
def recv? (server : Mock.Server) (expect : Option UInt64 := none) : Async (Option ByteArray) :=
|
||||
Mock.recvJoined (getRecvChan server) expect
|
||||
|
||||
/--
|
||||
Tries to receive data without blocking, joining all immediately available chunks. Returns `none` if no
|
||||
data is available.
|
||||
-/
|
||||
def tryRecv? (server : Mock.Server) (_expect : UInt64 := 0) : BaseIO (Option ByteArray) := do
|
||||
match ← (getRecvChan server).tryRecv with
|
||||
| none => return none
|
||||
| some first =>
|
||||
let mut result := first
|
||||
repeat
|
||||
match ← (getRecvChan server).tryRecv with
|
||||
| none => break
|
||||
| some chunk => result := result ++ chunk
|
||||
return some result
|
||||
|
||||
/--
|
||||
Closes the mock server and client.
|
||||
-/
|
||||
def close (server : Mock.Server) : IO Unit := do
|
||||
if !(← server.shared.clientToServer.isClosed) then server.shared.clientToServer.close
|
||||
if !(← server.shared.serverToClient.isClosed) then server.shared.serverToClient.close
|
||||
|
||||
|
||||
end Mock.Server
|
||||
|
||||
instance : Transport Mock.Client where
|
||||
recv client expect := Mock.recvJoined (Mock.Client.getRecvChan client) (some expect)
|
||||
sendAll client data := Mock.sendAll (Mock.Client.getSendChan client) data
|
||||
recvSelector client _ := Mock.recvSelector (Mock.Client.getRecvChan client)
|
||||
close client := client.close
|
||||
|
||||
instance : Transport Mock.Server where
|
||||
recv server expect := Mock.recvJoined (Mock.Server.getRecvChan server) (some expect)
|
||||
sendAll server data := Mock.sendAll (Mock.Server.getSendChan server) data
|
||||
recvSelector server _ := Mock.recvSelector (Mock.Server.getRecvChan server)
|
||||
close server := server.close
|
||||
|
||||
end Std.Http
|
||||
@@ -44,8 +44,15 @@ protected def Parser.run (p : Parser α) (arr : ByteArray) : Except String α :=
|
||||
Parse a single byte equal to `b`, fails if different.
|
||||
-/
|
||||
@[inline]
|
||||
def pbyte (b : UInt8) : Parser UInt8 := attempt do
|
||||
if (← any) = b then pure b else fail s!"expected: '{b}'"
|
||||
def pbyte (b : UInt8) : Parser UInt8 := fun it =>
|
||||
if h : it.hasNext then
|
||||
let got := it.curr' h
|
||||
if got = b then
|
||||
.success (it.next' h) got
|
||||
else
|
||||
.error it (.other s!"expected: '{b}'")
|
||||
else
|
||||
.error it .eof
|
||||
|
||||
/--
|
||||
Skip a single byte equal to `b`, fails if different.
|
||||
@@ -57,16 +64,29 @@ def skipByte (b : UInt8) : Parser Unit :=
|
||||
/--
|
||||
Skip a sequence of bytes equal to the given `ByteArray`.
|
||||
-/
|
||||
def skipBytes (arr : ByteArray) : Parser Unit := do
|
||||
for b in arr do
|
||||
skipByte b
|
||||
def skipBytes (arr : ByteArray) : Parser Unit := fun it =>
|
||||
let rec go (idx : Nat) (it : ByteArray.Iterator) : ParseResult Unit ByteArray.Iterator :=
|
||||
if h : idx < arr.size then
|
||||
if hnext : it.hasNext then
|
||||
let got := it.curr' hnext
|
||||
let want := arr[idx]
|
||||
if got = want then
|
||||
go (idx + 1) (it.next' hnext)
|
||||
else
|
||||
.error it (.other s!"expected byte {want}, got {got}")
|
||||
else
|
||||
.error it .eof
|
||||
else
|
||||
.success it ()
|
||||
go 0 it
|
||||
|
||||
/--
|
||||
Parse a string by matching its UTF-8 bytes, returns the string on success.
|
||||
-/
|
||||
@[inline]
|
||||
def pstring (s : String) : Parser String := do
|
||||
skipBytes s.toUTF8
|
||||
let utf8 := s.toUTF8
|
||||
skipBytes utf8
|
||||
return s
|
||||
|
||||
/--
|
||||
@@ -193,19 +213,47 @@ def take (n : Nat) : Parser ByteSlice := fun it =>
|
||||
else
|
||||
.success (it.forward n) (it.array[it.idx...(it.idx+n)])
|
||||
|
||||
/--
|
||||
Scans while `pred` is satisfied. Returns `(count, iterator, hitEof)`.
|
||||
-/
|
||||
private partial def scanWhile (pred : UInt8 → Bool) (count : Nat) (iter : ByteArray.Iterator) :
|
||||
Nat × ByteArray.Iterator × Bool :=
|
||||
if h : iter.hasNext then
|
||||
if pred (iter.curr' h) then
|
||||
scanWhile pred (count + 1) (iter.next' h)
|
||||
else
|
||||
(count, iter, false)
|
||||
else
|
||||
(count, iter, true)
|
||||
|
||||
/--
|
||||
Scans while `pred` is satisfied, bounded by `limit`.
|
||||
Returns `(count, iterator, hitEof)`.
|
||||
-/
|
||||
private partial def scanWhileUpTo (pred : UInt8 → Bool) (limit : Nat) (count : Nat)
|
||||
(iter : ByteArray.Iterator) : Nat × ByteArray.Iterator × Bool :=
|
||||
if count ≥ limit then
|
||||
(count, iter, false)
|
||||
else if h : iter.hasNext then
|
||||
if pred (iter.curr' h) then
|
||||
scanWhileUpTo pred limit (count + 1) (iter.next' h)
|
||||
else
|
||||
(count, iter, false)
|
||||
else
|
||||
(count, iter, true)
|
||||
|
||||
/--
|
||||
Parses while a predicate is satisfied.
|
||||
Fails with `.eof` if input ends while the predicate still holds.
|
||||
-/
|
||||
@[inline]
|
||||
partial def takeWhile (pred : UInt8 → Bool) : Parser ByteSlice :=
|
||||
fun it =>
|
||||
let rec findEnd (count : Nat) (iter : ByteArray.Iterator) : Nat × ByteArray.Iterator :=
|
||||
if ¬iter.hasNext then (count, iter)
|
||||
else if pred iter.curr then findEnd (count + 1) iter.next
|
||||
else (count, iter)
|
||||
|
||||
let (length, newIt) := findEnd 0 it
|
||||
.success newIt (it.array[it.idx...(it.idx + length)])
|
||||
let (length, newIt, hitEof) := scanWhile pred 0 it
|
||||
if hitEof then
|
||||
.error newIt .eof
|
||||
else
|
||||
.success newIt (it.array[it.idx...(it.idx + length)])
|
||||
|
||||
/--
|
||||
Parses until a predicate is satisfied (exclusive).
|
||||
@@ -216,16 +264,16 @@ def takeUntil (pred : UInt8 → Bool) : Parser ByteSlice :=
|
||||
|
||||
/--
|
||||
Skips while a predicate is satisfied.
|
||||
Fails with `.eof` if input ends while the predicate still holds.
|
||||
-/
|
||||
@[inline]
|
||||
partial def skipWhile (pred : UInt8 → Bool) : Parser Unit :=
|
||||
fun it =>
|
||||
let rec findEnd (count : Nat) (iter : ByteArray.Iterator) : ByteArray.Iterator :=
|
||||
if ¬iter.hasNext then iter
|
||||
else if pred iter.curr then findEnd (count + 1) iter.next
|
||||
else iter
|
||||
|
||||
.success (findEnd 0 it) ()
|
||||
let (_, newIt, hitEof) := scanWhile pred 0 it
|
||||
if hitEof then
|
||||
.error newIt .eof
|
||||
else
|
||||
.success newIt ()
|
||||
|
||||
/--
|
||||
Skips until a predicate is satisfied.
|
||||
@@ -236,34 +284,31 @@ def skipUntil (pred : UInt8 → Bool) : Parser Unit :=
|
||||
|
||||
/--
|
||||
Parses while a predicate is satisfied, up to a given limit.
|
||||
Fails with `.eof` if input ends before stopping or reaching the limit.
|
||||
-/
|
||||
@[inline]
|
||||
partial def takeWhileUpTo (pred : UInt8 → Bool) (limit : Nat) : Parser ByteSlice :=
|
||||
fun it =>
|
||||
let rec findEnd (count : Nat) (iter : ByteArray.Iterator) : Nat × ByteArray.Iterator :=
|
||||
if count ≥ limit then (count, iter)
|
||||
else if ¬iter.hasNext then (count, iter)
|
||||
else if pred iter.curr then findEnd (count + 1) iter.next
|
||||
else (count, iter)
|
||||
let (length, newIt, hitEof) := scanWhileUpTo pred limit 0 it
|
||||
|
||||
let (length, newIt) := findEnd 0 it
|
||||
.success newIt (it.array[it.idx...(it.idx + length)])
|
||||
if hitEof then
|
||||
.error newIt .eof
|
||||
else
|
||||
.success newIt (it.array[it.idx...(it.idx + length)])
|
||||
|
||||
/--
|
||||
Parses while a predicate is satisfied, up to a given limit, requiring at least one byte.
|
||||
Fails with `.eof` if input ends before stopping or reaching the limit.
|
||||
-/
|
||||
@[inline]
|
||||
def takeWhileUpTo1 (pred : UInt8 → Bool) (limit : Nat) : Parser ByteSlice :=
|
||||
fun it =>
|
||||
let rec findEnd (count : Nat) (iter : ByteArray.Iterator) : Nat × ByteArray.Iterator :=
|
||||
if count ≥ limit then (count, iter)
|
||||
else if ¬iter.hasNext then (count, iter)
|
||||
else if pred iter.curr then findEnd (count + 1) iter.next
|
||||
else (count, iter)
|
||||
let (length, newIt, hitEof) := scanWhileUpTo pred limit 0 it
|
||||
|
||||
let (length, newIt) := findEnd 0 it
|
||||
if length = 0 then
|
||||
.error it (if newIt.atEnd then .eof else .other "expected at least one char")
|
||||
if hitEof then
|
||||
.error newIt .eof
|
||||
else if length = 0 then
|
||||
.error it (.other "expected at least one char")
|
||||
else
|
||||
.success newIt (it.array[it.idx...(it.idx + length)])
|
||||
|
||||
@@ -274,19 +319,42 @@ Parses until a predicate is satisfied (exclusive), up to a given limit.
|
||||
def takeUntilUpTo (pred : UInt8 → Bool) (limit : Nat) : Parser ByteSlice :=
|
||||
takeWhileUpTo (fun b => ¬pred b) limit
|
||||
|
||||
/--
|
||||
Parses while a predicate is satisfied, consuming at most `limit` bytes.
|
||||
Unlike `takeWhileUpTo`, succeeds even if input ends before the predicate stops holding.
|
||||
-/
|
||||
@[inline]
|
||||
def takeWhileAtMost (pred : UInt8 → Bool) (limit : Nat) : Parser ByteSlice :=
|
||||
fun it =>
|
||||
let (length, newIt, _) := scanWhileUpTo pred limit 0 it
|
||||
.success newIt (it.array[it.idx...(it.idx + length)])
|
||||
|
||||
/--
|
||||
Parses while a predicate is satisfied, consuming at most `limit` bytes, requiring at least one.
|
||||
Unlike `takeWhileUpTo1`, succeeds even if input ends before the predicate stops holding.
|
||||
-/
|
||||
@[inline]
|
||||
def takeWhile1AtMost (pred : UInt8 → Bool) (limit : Nat) : Parser ByteSlice :=
|
||||
fun it =>
|
||||
let (length, newIt, _) := scanWhileUpTo pred limit 0 it
|
||||
if length = 0 then
|
||||
.error it (.other "expected at least one char")
|
||||
else
|
||||
.success newIt (it.array[it.idx...(it.idx + length)])
|
||||
|
||||
/--
|
||||
Skips while a predicate is satisfied, up to a given limit.
|
||||
Fails with `.eof` if input ends before stopping or reaching the limit.
|
||||
-/
|
||||
@[inline]
|
||||
partial def skipWhileUpTo (pred : UInt8 → Bool) (limit : Nat) : Parser Unit :=
|
||||
fun it =>
|
||||
let rec findEnd (count : Nat) (iter : ByteArray.Iterator) : ByteArray.Iterator :=
|
||||
if count ≥ limit then iter
|
||||
else if ¬iter.hasNext then iter
|
||||
else if pred iter.curr then findEnd (count + 1) iter.next
|
||||
else iter
|
||||
let (_, newIt, hitEof) := scanWhileUpTo pred limit 0 it
|
||||
|
||||
.success (findEnd 0 it) ()
|
||||
if hitEof then
|
||||
.error newIt .eof
|
||||
else
|
||||
.success newIt ()
|
||||
|
||||
/--
|
||||
Skips until a predicate is satisfied, up to a given limit.
|
||||
|
||||
@@ -11,11 +11,13 @@ public import Std.Sync.Channel
|
||||
public import Std.Sync.Mutex
|
||||
public import Std.Sync.RecursiveMutex
|
||||
public import Std.Sync.Barrier
|
||||
public import Std.Sync.Semaphore
|
||||
public import Std.Sync.SharedMutex
|
||||
public import Std.Sync.Notify
|
||||
public import Std.Sync.Broadcast
|
||||
public import Std.Sync.StreamMap
|
||||
public import Std.Sync.CancellationToken
|
||||
public import Std.Sync.CancellationContext
|
||||
public import Std.Sync.Watch
|
||||
|
||||
@[expose] public section
|
||||
|
||||
96
src/Std/Sync/Semaphore.lean
Normal file
96
src/Std/Sync/Semaphore.lean
Normal file
@@ -0,0 +1,96 @@
|
||||
/-
|
||||
Copyright (c) 2026 Lean FRO, LLC. All rights reserved.
|
||||
Released under Apache 2.0 license as described in the file LICENSE.
|
||||
Authors: Lean FRO Contributors
|
||||
-/
|
||||
module
|
||||
|
||||
prelude
|
||||
public import Init.Data.Queue
|
||||
public import Init.System.Promise
|
||||
public import Std.Sync.Mutex
|
||||
|
||||
public section
|
||||
|
||||
namespace Std
|
||||
|
||||
private structure SemaphoreState where
|
||||
permits : Nat
|
||||
waiters : Std.Queue (IO.Promise Unit) := ∅
|
||||
deriving Nonempty
|
||||
|
||||
/--
|
||||
Counting semaphore.
|
||||
|
||||
`Semaphore.acquire` returns a promise that is resolved once a permit is available.
|
||||
If a permit is currently available, the returned promise is already resolved.
|
||||
`Semaphore.release` either resolves one waiting promise or increments the available permits.
|
||||
-/
|
||||
structure Semaphore where private mk ::
|
||||
private lock : Mutex SemaphoreState
|
||||
|
||||
/--
|
||||
Creates a resolved promise.
|
||||
-/
|
||||
private def mkResolvedPromise [Nonempty α] (a : α) : BaseIO (IO.Promise α) := do
|
||||
let promise ← IO.Promise.new
|
||||
promise.resolve a
|
||||
return promise
|
||||
|
||||
/--
|
||||
Creates a new semaphore with `permits` initially available permits.
|
||||
-/
|
||||
def Semaphore.new (permits : Nat) : BaseIO Semaphore := do
|
||||
return { lock := ← Mutex.new { permits } }
|
||||
|
||||
/--
|
||||
Requests one permit.
|
||||
Returns a promise that resolves once the permit is acquired.
|
||||
-/
|
||||
def Semaphore.acquire (sem : Semaphore) : BaseIO (IO.Promise Unit) := do
|
||||
sem.lock.atomically do
|
||||
let st ← get
|
||||
if st.permits > 0 then
|
||||
set { st with permits := st.permits - 1 }
|
||||
mkResolvedPromise ()
|
||||
else
|
||||
let promise ← IO.Promise.new
|
||||
set { st with waiters := st.waiters.enqueue promise }
|
||||
return promise
|
||||
|
||||
/--
|
||||
Tries to acquire a permit without blocking. Returns `true` on success.
|
||||
-/
|
||||
def Semaphore.tryAcquire (sem : Semaphore) : BaseIO Bool := do
|
||||
sem.lock.atomically do
|
||||
let st ← get
|
||||
if st.permits > 0 then
|
||||
set { st with permits := st.permits - 1 }
|
||||
return true
|
||||
else
|
||||
return false
|
||||
|
||||
/--
|
||||
Releases one permit and resolves one waiting acquirer, if any.
|
||||
-/
|
||||
def Semaphore.release (sem : Semaphore) : BaseIO Unit := do
|
||||
let waiter? ← sem.lock.atomically do
|
||||
let st ← get
|
||||
match st.waiters.dequeue? with
|
||||
| some (waiter, waiters) =>
|
||||
set { st with waiters }
|
||||
return some waiter
|
||||
| none =>
|
||||
set { st with permits := st.permits + 1 }
|
||||
return none
|
||||
if let some waiter := waiter? then
|
||||
waiter.resolve ()
|
||||
|
||||
/--
|
||||
Returns the number of currently available permits.
|
||||
-/
|
||||
def Semaphore.availablePermits (sem : Semaphore) : BaseIO Nat :=
|
||||
sem.lock.atomically do
|
||||
return (← get).permits
|
||||
|
||||
end Std
|
||||
319
src/Std/Sync/Watch.lean
Normal file
319
src/Std/Sync/Watch.lean
Normal file
@@ -0,0 +1,319 @@
|
||||
/-
|
||||
Copyright (c) 2026 Lean FRO, LLC. All rights reserved.
|
||||
Released under Apache 2.0 license as described in the file LICENSE.
|
||||
Authors: Sofia Rodrigues
|
||||
-/
|
||||
module
|
||||
|
||||
prelude
|
||||
public import Init.Data.Queue
|
||||
public import Std.Sync.Mutex
|
||||
public import Std.Internal.Async.Select
|
||||
|
||||
public section
|
||||
|
||||
open Std.Internal.IO.Async
|
||||
|
||||
/-!
|
||||
This module contains the implementation of `Std.Watch`. `Std.Watch` is a single-value watch
|
||||
channel, inspired by [tokio's watch implementation](https://github.com/tokio-rs/tokio/blob/master/tokio/src/sync/watch.rs).
|
||||
|
||||
A watch channel holds a single value that can be updated by the sender. Multiple receivers
|
||||
can independently observe the current value and wait for it to change.
|
||||
|
||||
Unlike `Std.Channel`, a watch channel:
|
||||
- Retains only the latest value, not a queue of messages.
|
||||
- Allows receivers to read the current value without consuming it.
|
||||
- Notifies all receivers when the value changes (broadcast semantics on change).
|
||||
- Returns an error on `changed` if the sender has been dropped.
|
||||
-/
|
||||
|
||||
namespace Std
|
||||
|
||||
/--
|
||||
Errors that may be thrown while interacting with the watch channel API.
|
||||
-/
|
||||
inductive Watch.Error where
|
||||
/--
|
||||
The sender was dropped, so no new values will ever be sent.
|
||||
-/
|
||||
| closed
|
||||
deriving Repr, DecidableEq, Hashable
|
||||
|
||||
instance : ToString Watch.Error where
|
||||
toString
|
||||
| .closed => "watch channel sender was dropped"
|
||||
|
||||
instance : MonadLift (EIO Watch.Error) IO where
|
||||
monadLift x := EIO.toIO (.userError <| toString ·) x
|
||||
|
||||
private inductive Watch.Waiter (α : Type) where
|
||||
| normal (promise : IO.Promise α)
|
||||
| select (waiter : Internal.IO.Async.Waiter α)
|
||||
|
||||
private def Watch.Waiter.resolve (c : Watch.Waiter α) (x : α) : BaseIO Bool := do
|
||||
match c with
|
||||
| .normal promise =>
|
||||
promise.resolve x
|
||||
return true
|
||||
| .select waiter =>
|
||||
waiter.race (return false) fun promise => do
|
||||
promise.resolve (.ok x)
|
||||
return true
|
||||
|
||||
/--
|
||||
The shared state of a watch channel.
|
||||
-/
|
||||
private structure Watch.State (α : Type) where
|
||||
/--
|
||||
The current value held by the watch channel.
|
||||
-/
|
||||
value : α
|
||||
/--
|
||||
Monotonically increasing version. Incremented on every `send`.
|
||||
-/
|
||||
version : Nat
|
||||
/--
|
||||
Whether the sender has been dropped (closed).
|
||||
-/
|
||||
closed : Bool
|
||||
/--
|
||||
Receivers waiting for the value to change.
|
||||
-/
|
||||
waiters : Std.Queue (Watch.Waiter (Except Watch.Error Unit))
|
||||
deriving Nonempty
|
||||
|
||||
/--
|
||||
A watch channel sender. Holds a reference to the shared state and can update the value.
|
||||
-/
|
||||
structure Watch (α : Type) where
|
||||
private mk ::
|
||||
private state : Mutex (Watch.State α)
|
||||
deriving Nonempty
|
||||
|
||||
/--
|
||||
A watch channel receiver. Each receiver independently tracks the version it last observed.
|
||||
-/
|
||||
structure Watch.Receiver (α : Type) where
|
||||
private mk ::
|
||||
private state : Mutex (Watch.State α)
|
||||
private lastSeen : IO.Ref Nat
|
||||
deriving Nonempty
|
||||
|
||||
namespace Watch
|
||||
|
||||
/--
|
||||
Creates a new watch channel with an initial value. Returns the sender and a receiver.
|
||||
-/
|
||||
def new (initial : α) : BaseIO (Watch α × Watch.Receiver α) := do
|
||||
let state ← Mutex.new {
|
||||
value := initial
|
||||
version := 0
|
||||
closed := false
|
||||
waiters := ∅
|
||||
}
|
||||
let lastSeen ← IO.mkRef 0
|
||||
return (⟨state⟩, ⟨state, lastSeen⟩)
|
||||
|
||||
/--
|
||||
Sends a new value, updating the watched value and notifying all waiting receivers.
|
||||
-/
|
||||
def send (w : Watch α) (v : α) : BaseIO Unit := do
|
||||
w.state.atomically do
|
||||
let st ← get
|
||||
let newVersion := st.version + 1
|
||||
set { st with value := v, version := newVersion, waiters := ∅ }
|
||||
for waiter in st.waiters.toArray do
|
||||
discard <| waiter.resolve (.ok ())
|
||||
|
||||
/--
|
||||
Closes the watch channel, signaling to receivers that no more values will be sent.
|
||||
Waiting receivers will be woken up and their `changed` call will return `Watch.Error.closed`.
|
||||
-/
|
||||
def close (w : Watch α) : BaseIO Unit := do
|
||||
w.state.atomically do
|
||||
let st ← get
|
||||
set { st with closed := true, waiters := ∅ }
|
||||
for waiter in st.waiters.toArray do
|
||||
discard <| waiter.resolve (.error .closed)
|
||||
|
||||
/--
|
||||
Returns `true` if the sender has been closed.
|
||||
-/
|
||||
def isClosed (w : Watch α) : BaseIO Bool :=
|
||||
w.state.atomically do
|
||||
return (← get).closed
|
||||
|
||||
/--
|
||||
Returns the current value held by the watch channel, as seen from the sender side.
|
||||
-/
|
||||
def current (w : Watch α) : BaseIO α :=
|
||||
w.state.atomically do
|
||||
return (← MonadState.get).value
|
||||
|
||||
namespace Receiver
|
||||
|
||||
/--
|
||||
Borrow the current value without marking it as seen.
|
||||
-/
|
||||
def borrow (r : Watch.Receiver α) : BaseIO α :=
|
||||
r.state.atomically do
|
||||
return (← get).value
|
||||
|
||||
/--
|
||||
Borrow the current value and mark the current version as seen, so that
|
||||
the next `changed` call will only wake on a strictly newer value.
|
||||
-/
|
||||
def borrowAndUpdate (r : Watch.Receiver α) : BaseIO α := do
|
||||
r.state.atomically do
|
||||
let st ← get
|
||||
r.lastSeen.set st.version
|
||||
return st.value
|
||||
|
||||
/--
|
||||
Returns `true` if the watched value has changed since this receiver last called
|
||||
`borrowAndUpdate` or `changed`.
|
||||
-/
|
||||
def hasChanged (r : Watch.Receiver α) : BaseIO Bool := do
|
||||
r.state.atomically do
|
||||
let st ← get
|
||||
let seen ← r.lastSeen.get
|
||||
return st.version > seen
|
||||
|
||||
/--
|
||||
Wait until the watched value changes relative to the version last seen by this receiver.
|
||||
Returns `ok ()` on success or `error Watch.Error.closed` if the sender was dropped.
|
||||
|
||||
After a successful return the new value can be retrieved with `borrow` or `borrowAndUpdate`.
|
||||
-/
|
||||
partial def changed (r : Watch.Receiver α) : BaseIO (Task (Except Watch.Error Unit)) := do
|
||||
r.state.atomically do
|
||||
let st ← get
|
||||
let seen ← r.lastSeen.get
|
||||
if st.version > seen then
|
||||
r.lastSeen.set st.version
|
||||
return .pure <| .ok ()
|
||||
else if st.closed then
|
||||
return .pure <| .error .closed
|
||||
else
|
||||
let promise ← IO.Promise.new
|
||||
modify fun s => { s with waiters := s.waiters.enqueue (.normal promise) }
|
||||
BaseIO.bindTask promise.result? fun
|
||||
| none => return .pure <| .error .closed
|
||||
| some (Except.error e) => return .pure <| .error e
|
||||
| some (Except.ok ()) =>
|
||||
/- A notification arrived; recurse so `lastSeen` is updated atomically. -/
|
||||
r.changed
|
||||
|
||||
/--
|
||||
Creates a `Selector` that resolves when the watched value changes.
|
||||
-/
|
||||
def changedSelector (r : Watch.Receiver α) : Selector (Except Watch.Error Unit) where
|
||||
tryFn := do
|
||||
r.state.atomically do
|
||||
let st ← get
|
||||
let seen ← r.lastSeen.get
|
||||
if st.version > seen then
|
||||
r.lastSeen.set st.version
|
||||
return some (.ok ())
|
||||
else if st.closed then
|
||||
return some (.error .closed)
|
||||
else
|
||||
return none
|
||||
|
||||
registerFn waiter := do
|
||||
r.state.atomically do
|
||||
let st ← get
|
||||
let seen ← r.lastSeen.get
|
||||
if st.version > seen || st.closed then
|
||||
let result : Except Watch.Error Unit :=
|
||||
if st.version > seen then .ok () else .error .closed
|
||||
if st.version > seen then r.lastSeen.set st.version
|
||||
waiter.race (return ()) fun promise =>
|
||||
promise.resolve (.ok result)
|
||||
else
|
||||
modify fun s => { s with waiters := s.waiters.enqueue (.select waiter) }
|
||||
|
||||
unregisterFn := do
|
||||
r.state.atomically do
|
||||
let st ← get
|
||||
let waiters ← st.waiters.filterM fun
|
||||
| .normal _ => return true
|
||||
| .select w => return !(← w.checkFinished)
|
||||
set { st with waiters }
|
||||
|
||||
end Receiver
|
||||
|
||||
/--
|
||||
A sync wrapper around `Watch.Receiver` for blocking use.
|
||||
-/
|
||||
@[expose] def Sync (α : Type) : Type := Watch α
|
||||
|
||||
/--
|
||||
A sync wrapper around `Watch.Receiver` for blocking use.
|
||||
-/
|
||||
@[expose] def Sync.Receiver (α : Type) : Type := Watch.Receiver α
|
||||
|
||||
namespace Sync
|
||||
|
||||
/--
|
||||
Creates a new watch channel with an initial value. Returns the sender and a sync receiver.
|
||||
-/
|
||||
@[inline]
|
||||
def new (initial : α) : BaseIO (Sync α × Sync.Receiver α) :=
|
||||
Watch.new initial
|
||||
|
||||
/--
|
||||
Sends a new value, updating the watched value and notifying all waiting receivers.
|
||||
-/
|
||||
@[inline]
|
||||
def send (w : Sync α) (v : α) : BaseIO Unit :=
|
||||
Watch.send w v
|
||||
|
||||
/--
|
||||
Closes the watch channel.
|
||||
-/
|
||||
@[inline]
|
||||
def close (w : Sync α) : BaseIO Unit :=
|
||||
Watch.close w
|
||||
|
||||
/--
|
||||
Returns `true` if the sender has been closed.
|
||||
-/
|
||||
@[inline]
|
||||
def isClosed (w : Sync α) : BaseIO Bool :=
|
||||
Watch.isClosed w
|
||||
|
||||
namespace Receiver
|
||||
|
||||
/--
|
||||
Borrow the current value without marking it as seen.
|
||||
-/
|
||||
@[inline]
|
||||
def borrow (r : Sync.Receiver α) : BaseIO α :=
|
||||
Watch.Receiver.borrow r
|
||||
|
||||
/--
|
||||
Borrow the current value and mark it as seen.
|
||||
-/
|
||||
@[inline]
|
||||
def borrowAndUpdate (r : Sync.Receiver α) : BaseIO α :=
|
||||
Watch.Receiver.borrowAndUpdate r
|
||||
|
||||
/--
|
||||
Returns `true` if the watched value has changed since last seen.
|
||||
-/
|
||||
@[inline]
|
||||
def hasChanged (r : Sync.Receiver α) : BaseIO Bool :=
|
||||
Watch.Receiver.hasChanged r
|
||||
|
||||
/--
|
||||
Block until the watched value changes. Returns `ok ()` or `error Watch.Error.closed`.
|
||||
-/
|
||||
def changed (r : Sync.Receiver α) : BaseIO (Except Watch.Error Unit) := do
|
||||
IO.wait (← Watch.Receiver.changed r)
|
||||
|
||||
end Receiver
|
||||
end Sync
|
||||
end Watch
|
||||
end Std
|
||||
@@ -73,6 +73,32 @@ def channelRecvAfterClose : Async Unit := do
|
||||
|
||||
#eval channelRecvAfterClose.block
|
||||
|
||||
-- Test Body.stream runs producer concurrently and transfers chunks
|
||||
|
||||
def bodyStreamSends : Async Unit := do
|
||||
let incoming ← Body.stream fun outgoing => do
|
||||
outgoing.send (Chunk.ofByteArray "x".toUTF8)
|
||||
|
||||
let first ← incoming.recv
|
||||
assert! first.isSome
|
||||
assert! first.get!.data == "x".toUTF8
|
||||
|
||||
let done ← incoming.recv
|
||||
assert! done.isNone
|
||||
|
||||
#eval bodyStreamSends.block
|
||||
|
||||
-- Test Body.stream closes channel when generator throws
|
||||
|
||||
def bodyStreamThrowCloses : Async Unit := do
|
||||
let incoming ← Body.stream fun _ => do
|
||||
throw (.userError "boom")
|
||||
|
||||
let result ← incoming.recv
|
||||
assert! result.isNone
|
||||
|
||||
#eval bodyStreamThrowCloses.block
|
||||
|
||||
-- Test for-in iteration collects chunks until close
|
||||
|
||||
def channelForIn : Async Unit := do
|
||||
@@ -108,6 +134,34 @@ def channelExtensions : Async Unit := do
|
||||
|
||||
#eval channelExtensions.block
|
||||
|
||||
-- Test incomplete sends are collapsed before delivery
|
||||
|
||||
def channelCollapseIncompleteChunks : Async Unit := do
|
||||
let stream ← Body.mkStream
|
||||
|
||||
let first : Chunk := { data := "aaaaaaaaaa".toUTF8, extensions := #[(.mk "part", some <| .ofString! "first")] }
|
||||
let second : Chunk := { data := "bbbbbbbbbb".toUTF8, extensions := #[(.mk "part", some <| .ofString! "second")] }
|
||||
let last : Chunk := { data := "cccccccccccccccccccc".toUTF8, extensions := #[(.mk "part", some <| .ofString! "last")] }
|
||||
|
||||
stream.send first (incomplete := true)
|
||||
stream.send second (incomplete := true)
|
||||
|
||||
let noChunkYet ← stream.tryRecv
|
||||
assert! noChunkYet.isNone
|
||||
|
||||
let sendFinal ← async (t := AsyncTask) <| stream.send last
|
||||
let result ← stream.recv
|
||||
|
||||
assert! result.isSome
|
||||
let merged := result.get!
|
||||
assert! merged.data == "aaaaaaaaaabbbbbbbbbbcccccccccccccccccccc".toUTF8
|
||||
assert! merged.data.size == 40
|
||||
assert! merged.extensions == #[(.mk "part", some <| .ofString! "first")]
|
||||
|
||||
await sendFinal
|
||||
|
||||
#eval channelCollapseIncompleteChunks.block
|
||||
|
||||
-- Test known size metadata
|
||||
|
||||
def channelKnownSize : Async Unit := do
|
||||
@@ -482,6 +536,20 @@ def anyFromEmpty : Async Unit := do
|
||||
|
||||
#eval anyFromEmpty.block
|
||||
|
||||
-- Test Any wrapping an Incoming channel receives chunks
|
||||
|
||||
def anyFromChannel : Async Unit := do
|
||||
let outgoing ← Body.mkStream
|
||||
let any := Body.Any.ofBody outgoing
|
||||
|
||||
let sendTask ← async (t := AsyncTask) <| outgoing.send (Chunk.ofByteArray "data".toUTF8)
|
||||
let result ← any.recv
|
||||
assert! result.isSome
|
||||
assert! result.get!.data == "data".toUTF8
|
||||
await sendTask
|
||||
|
||||
#eval anyFromChannel.block
|
||||
|
||||
-- Test Any.close closes the underlying body
|
||||
|
||||
def anyCloseForwards : Async Unit := do
|
||||
|
||||
286
tests/elab/async_http_body_framing.lean
Normal file
286
tests/elab/async_http_body_framing.lean
Normal file
@@ -0,0 +1,286 @@
|
||||
import Std.Internal.Http.Test.Helpers
|
||||
|
||||
open Std.Internal.IO Async
|
||||
open Std Http Test
|
||||
|
||||
-- RFC 9112 §6: Transfer-Encoding and Content-Length framing
|
||||
|
||||
#eval runGroup "RFC 9112 §6: chunked body baseline" do
|
||||
check "CL body accepted and echoed"
|
||||
(raw := mkPost "/echo" "hello" "Connection: close\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 200" *> assertContains r "hello")
|
||||
|
||||
check "chunked body accepted and echoed"
|
||||
(raw := mkChunked "/" (chunk "hello" ++ chunkEnd) "Connection: close\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 200" *> assertContains r "hello")
|
||||
|
||||
check "chunk-size uppercase and leading zeros accepted"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n000A\x0d\n0123456789\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 200" *> assertContains r "0123456789")
|
||||
|
||||
check "chunk extensions with token and quoted value accepted"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5;ext=val;quoted=\"ok\"\x0d\nhello\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 200" *> assertContains r "hello")
|
||||
|
||||
#eval runGroup "RFC 9112 §6: chunked parse errors" do
|
||||
check "invalid chunk-size token → 400"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\nG\x0d\nabc\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(expect := (assertExact · r400))
|
||||
|
||||
checkClose "chunk terminator must be CRLF → 400"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n3\x0d\nxxx__1a\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(expect := (assertExact · r400))
|
||||
|
||||
checkClose "missing terminal 0-chunk at EOF → 400"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(expect := (assertExact · r400))
|
||||
|
||||
check "chunk-size trailing whitespace → 400"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5 \x0d\nhello\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(expect := (assertExact · r400))
|
||||
|
||||
#eval runGroup "RFC 9112 §6.1: Transfer-Encoding validation (Critical)" do
|
||||
-- TE + CL → request smuggling prevention
|
||||
check "TE + Content-Length → 400"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nContent-Length: 5\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := (assertExact · r400))
|
||||
|
||||
-- chunked must be the last coding
|
||||
check "TE: chunked not last (chunked, gzip) → 400"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked, gzip\x0d\nConnection: close\x0d\n\x0d\nbody")
|
||||
(handler := okHandler)
|
||||
(expect := (assertExact · r400))
|
||||
|
||||
check "TE: duplicate chunked → 400"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked, chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := (assertExact · r400))
|
||||
|
||||
check "TE: gzip alone (no chunked framing) → 400"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: gzip\x0d\nConnection: close\x0d\n\x0d\nbody")
|
||||
(handler := okHandler)
|
||||
(expect := (assertExact · r400))
|
||||
|
||||
check "TE: deflate alone → 400"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: deflate\x0d\nConnection: close\x0d\n\x0d\nbody")
|
||||
(handler := okHandler)
|
||||
(expect := (assertExact · r400))
|
||||
|
||||
check "TE: identity alone → 400"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: identity\x0d\nConnection: close\x0d\n\x0d\nbody")
|
||||
(handler := okHandler)
|
||||
(expect := (assertExact · r400))
|
||||
|
||||
check "TE: malformed token list (leading comma) → 400"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: ,chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := (assertExact · r400))
|
||||
|
||||
check "TE: duplicate header lines with unsupported coding → 400"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nTransfer-Encoding: gzip\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(expect := (assertExact · r400))
|
||||
|
||||
-- NUL and control chars in TE value
|
||||
check "NUL byte in TE value → 400"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunk\x00ed\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := (assertExact · r400))
|
||||
|
||||
check "control char (0x01) in TE value → 400"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunk\x01ed\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := (assertExact · r400))
|
||||
|
||||
#eval runGroup "RFC 9112 §6.1: Transfer-Encoding accepted cases" do
|
||||
check "gzip, chunked accepted — chunked is last"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: gzip, chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 200" *> assertContains r "hello")
|
||||
|
||||
check "br, chunked accepted"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: br, chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 200" *> assertContains r "hello")
|
||||
|
||||
check "mixed-case Chunked accepted (codings are lowercased)"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: Chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 200" *> assertContains r "hello")
|
||||
|
||||
check "TE trailing OWS accepted"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked \x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 200")
|
||||
|
||||
check "gzip+chunked chain is visible to handler in TE header"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: gzip, chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := fun req => do
|
||||
let te := match req.line.headers.getAll? Header.Name.transferEncoding with
|
||||
| some vs => String.intercalate "|" (vs.map (·.value) |>.toList)
|
||||
| none => "<none>"
|
||||
Response.ok |>.text te)
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 200" *> assertContains r "gzip, chunked")
|
||||
|
||||
-- RFC 9112 §6.3: Content-Length
|
||||
|
||||
#eval runGroup "RFC 9112 §6.3: Content-Length" do
|
||||
check "Content-Length with leading zeros accepted"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: 005\x0d\nConnection: close\x0d\n\x0d\nhello")
|
||||
(handler := echoHandler)
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 200" *> assertContains r "hello")
|
||||
|
||||
check "very large Content-Length → 413"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: 99999999999999999999\x0d\nConnection: close\x0d\n\x0d\nhello")
|
||||
(handler := echoHandler)
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 413")
|
||||
|
||||
check "duplicate Content-Length same value → 400"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: 5\x0d\nContent-Length: 5\x0d\nConnection: close\x0d\n\x0d\nhello")
|
||||
(handler := echoHandler)
|
||||
(expect := (assertExact · r400))
|
||||
|
||||
check "duplicate Content-Length different values → 400"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: 3\x0d\nContent-Length: 5\x0d\nConnection: close\x0d\n\x0d\nabc")
|
||||
(handler := echoHandler)
|
||||
(expect := (assertExact · r400))
|
||||
|
||||
check "non-numeric Content-Length → 400"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: abc\x0d\nConnection: close\x0d\n\x0d\nbody")
|
||||
(handler := okHandler)
|
||||
(expect := (assertExact · r400))
|
||||
|
||||
check "negative Content-Length → 400"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: -1\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := (assertExact · r400))
|
||||
|
||||
check "extra bytes beyond CL become next pipelined request"
|
||||
(raw :=
|
||||
"POST / HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: 5\x0d\n\x0d\nhello" ++
|
||||
"GET /second HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := fun req => do
|
||||
let body : String ← req.body.readAll
|
||||
Response.ok |>.text s!"{toString req.line.uri}:{body}")
|
||||
(expect := fun r =>
|
||||
assertResponseCount r 2 *>
|
||||
assertContains r "/:hello" *>
|
||||
assertContains r "/second:" *>
|
||||
assertAbsent r "/second:hello")
|
||||
|
||||
-- Chunk extension limits
|
||||
|
||||
#eval runGroup "Chunk extension name length (default limit = 256)" do
|
||||
check "ext name at 256 bytes → accepted"
|
||||
(raw :=
|
||||
let name := String.ofList (List.replicate 256 'a')
|
||||
s!"POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5;{name}\x0d\nhello\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 200")
|
||||
|
||||
check "ext name at 257 bytes → 400"
|
||||
(raw :=
|
||||
let name := String.ofList (List.replicate 257 'a')
|
||||
s!"POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5;{name}\x0d\nhello\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(expect := (assertExact · r400))
|
||||
|
||||
#eval runGroup "Chunk extension value length (default limit = 256)" do
|
||||
check "ext token value at 256 bytes → accepted"
|
||||
(raw :=
|
||||
let v := String.ofList (List.replicate 256 'v')
|
||||
s!"POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5;ext={v}\x0d\nhello\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 200")
|
||||
|
||||
check "ext token value at 257 bytes → 400"
|
||||
(raw :=
|
||||
let v := String.ofList (List.replicate 257 'v')
|
||||
s!"POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5;ext={v}\x0d\nhello\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(expect := (assertExact · r400))
|
||||
|
||||
check "ext quoted value at 256 bytes → accepted"
|
||||
(raw :=
|
||||
let v := String.ofList (List.replicate 256 'v')
|
||||
s!"POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5;ext=\"{v}\"\x0d\nhello\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 200")
|
||||
|
||||
check "ext quoted value at 257 bytes → 400"
|
||||
(raw :=
|
||||
let v := String.ofList (List.replicate 257 'v')
|
||||
s!"POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5;ext=\"{v}\"\x0d\nhello\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(expect := (assertExact · r400))
|
||||
|
||||
#eval runGroup "Chunk extension count (default limit = 16)" do
|
||||
check "16 extensions → accepted"
|
||||
(raw :=
|
||||
let exts := (List.range 16).foldl (fun acc i => acc ++ s!";e{i}") ""
|
||||
s!"POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5{exts}\x0d\nhello\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 200")
|
||||
|
||||
check "17 extensions → 400"
|
||||
(raw :=
|
||||
let exts := (List.range 17).foldl (fun acc i => acc ++ s!";e{i}") ""
|
||||
s!"POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5{exts}\x0d\nhello\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(expect := (assertExact · r400))
|
||||
|
||||
#eval runGroup "Chunk extension count (custom limit)" do
|
||||
let cfg := { defaultConfig with maxChunkExtensions := 1 }
|
||||
|
||||
check "1 extension with limit=1 → accepted"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5;ext1\x0d\nhello\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(config := cfg)
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 200")
|
||||
|
||||
check "2 extensions with limit=1 → 400"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5;ext1;ext2\x0d\nhello\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(config := cfg)
|
||||
(expect := (assertExact · r400))
|
||||
|
||||
check "0 extensions with limit=1 → accepted"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(config := cfg)
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 200")
|
||||
|
||||
#eval runGroup "Chunk extension misc" do
|
||||
check "name-only extension (no value) → accepted"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5;novalue\x0d\nhello\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 200" *> assertContains r "hello")
|
||||
|
||||
check "extension on terminal zero-chunk → accepted"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0;final-ext=done\x0d\n\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 200")
|
||||
|
||||
check "extension with quoted-string value → accepted"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5;ext=\"hello world\"\x0d\nhello\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 200")
|
||||
|
||||
check "non-token character in ext name → 400"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5;bad@name\x0d\nhello\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(expect := (assertExact · r400))
|
||||
|
||||
check "multiple name=value extensions → accepted"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5;a=1;b=2;c=3\x0d\nhello\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := echoHandler)
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 200")
|
||||
431
tests/elab/async_http_client_security.lean
Normal file
431
tests/elab/async_http_client_security.lean
Normal file
@@ -0,0 +1,431 @@
|
||||
import Std.Internal.Http
|
||||
import Std.Internal.Async
|
||||
import Std.Internal.Async.Timer
|
||||
|
||||
open Std.Internal.IO Async
|
||||
open Std Http
|
||||
|
||||
/-!
|
||||
# HTTP Client Security Tests
|
||||
|
||||
Tests for security properties of the HTTP client:
|
||||
|
||||
- `Authorization` is stripped on cross-scheme redirects (same host+port, different scheme).
|
||||
Before the fix `crossOrigin` checked host+port only; a http→https redirect to the same
|
||||
host+port would silently keep the credential header.
|
||||
|
||||
- Streaming (`.outgoing`) request bodies must not be retried on connection failure.
|
||||
A channel-backed body is consumed on first use; retrying would send an empty body.
|
||||
-/
|
||||
|
||||
private def runWithTimeout (name : String) (timeoutMs : Nat := 3000) (action : IO Unit) : IO Unit := do
|
||||
let task ← IO.asTask action
|
||||
let ticks := (timeoutMs + 9) / 10
|
||||
let rec loop (remaining : Nat) : IO Unit := do
|
||||
if (← IO.getTaskState task) == .finished then
|
||||
match (← IO.wait task) with
|
||||
| .ok x => pure x
|
||||
| .error err => throw err
|
||||
else
|
||||
match remaining with
|
||||
| 0 =>
|
||||
IO.cancel task
|
||||
throw <| IO.userError s!"Test '{name}' timed out after {timeoutMs}ms"
|
||||
| n + 1 =>
|
||||
IO.sleep 10
|
||||
loop n
|
||||
loop ticks
|
||||
|
||||
-- Build a raw HTTP/1.1 response byte string.
|
||||
private def rawResp
|
||||
(status : String) (hdrs : Array (String × String)) (body : String) : ByteArray :=
|
||||
let hdrLines := hdrs.foldl (fun s (k, v) => s ++ s!"{k}: {v}\r\n") ""
|
||||
s!"HTTP/1.1 {status}\r\n{hdrLines}\r\n{body}".toUTF8
|
||||
|
||||
-- ============================================================
|
||||
-- Redirect: Authorization stripped on scheme-change redirect
|
||||
-- ============================================================
|
||||
-- A 302 redirect from http://example.com:443/ to https://example.com:443/r has the
|
||||
-- same host and port but a different scheme. crossOrigin must be true so that the
|
||||
-- Authorization header is stripped before the redirect request is sent.
|
||||
-- ============================================================
|
||||
|
||||
#eval show IO _ from runWithTimeout "scheme-change strips Authorization" 3000 <| Async.block do
|
||||
let (mockClient, mockServer) ← Mock.new
|
||||
let session ← Client.Session.new mockServer (config := {})
|
||||
let cookieJar ← Cookie.Jar.new
|
||||
let some domain := URI.DomainName.ofString? "example.com"
|
||||
| throw (IO.userError "DomainName parse failed")
|
||||
|
||||
-- Agent with scheme=http on port 443. Redirect target https://example.com:443/r
|
||||
-- has same host+port but different scheme → crossOrigin must be true after the fix.
|
||||
let agent : Client.Agent Mock.Server := {
|
||||
session
|
||||
scheme := URI.Scheme.ofString! "http"
|
||||
host := .name domain
|
||||
port := 443
|
||||
cookieJar
|
||||
}
|
||||
|
||||
let request ← Request.new
|
||||
|>.method .get
|
||||
|>.uri! "/"
|
||||
|>.header! "Host" "example.com:443"
|
||||
|>.header! "Authorization" "Bearer secret-token"
|
||||
|>.empty
|
||||
|
||||
let resultPromise : IO.Promise (Except String (Response Body.Stream)) ← IO.Promise.new
|
||||
background do
|
||||
let result : Except String (Response Body.Stream) ← try
|
||||
let resp ← Client.Agent.send agent request
|
||||
pure (Except.ok resp)
|
||||
catch e => pure (Except.error (toString e))
|
||||
discard <| resultPromise.resolve result
|
||||
|
||||
-- First exchange: drain the request, reply with 302 redirecting to HTTPS same host+port.
|
||||
let _ ← mockClient.recv?
|
||||
mockClient.send (rawResp "302 Found"
|
||||
#[("Location", "https://example.com:443/redirected"),
|
||||
("Content-Length", "0"),
|
||||
("Connection", "keep-alive")] "")
|
||||
|
||||
-- Second exchange: receive the redirected request and capture its bytes.
|
||||
let some redirectBytes ← mockClient.recv?
|
||||
| throw (IO.userError "Test failed: no redirect request received")
|
||||
mockClient.send (rawResp "200 OK"
|
||||
#[("Content-Length", "2"), ("Connection", "close")] "ok")
|
||||
|
||||
-- Wait for the agent to finish.
|
||||
match ← await resultPromise.result! with
|
||||
| Except.error e => throw (IO.userError s!"agent error: {e}")
|
||||
| Except.ok _ => pure ()
|
||||
|
||||
let redirectText := String.fromUTF8! redirectBytes
|
||||
if redirectText.contains "Authorization:" then
|
||||
throw <| IO.userError
|
||||
s!"Test 'scheme-change strips Authorization' FAILED: \
|
||||
Authorization header present in redirect request\n{redirectText.quote}"
|
||||
|
||||
-- ============================================================
|
||||
-- Redirect: Authorization preserved on same-origin redirect
|
||||
-- ============================================================
|
||||
-- A 302 redirect to the same scheme, host, and port is a same-origin redirect.
|
||||
-- The Authorization header must NOT be stripped in this case.
|
||||
-- ============================================================
|
||||
|
||||
#eval show IO _ from runWithTimeout "same-origin preserves Authorization" 3000 <| Async.block do
|
||||
let (mockClient, mockServer) ← Mock.new
|
||||
let session ← Client.Session.new mockServer (config := {})
|
||||
let cookieJar ← Cookie.Jar.new
|
||||
let some domain := URI.DomainName.ofString? "example.com"
|
||||
| throw (IO.userError "DomainName parse failed")
|
||||
|
||||
let agent : Client.Agent Mock.Server := {
|
||||
session
|
||||
scheme := URI.Scheme.ofString! "http"
|
||||
host := .name domain
|
||||
port := 80
|
||||
cookieJar
|
||||
}
|
||||
|
||||
let request ← Request.new
|
||||
|>.method .get
|
||||
|>.uri! "/"
|
||||
|>.header! "Host" "example.com"
|
||||
|>.header! "Authorization" "Bearer secret-token"
|
||||
|>.empty
|
||||
|
||||
let resultPromise : IO.Promise (Except String (Response Body.Stream)) ← IO.Promise.new
|
||||
background do
|
||||
let result : Except String (Response Body.Stream) ← try
|
||||
let resp ← Client.Agent.send agent request
|
||||
pure (Except.ok resp)
|
||||
catch e => pure (Except.error (toString e))
|
||||
discard <| resultPromise.resolve result
|
||||
|
||||
-- First exchange: reply with 302 to same scheme+host+port.
|
||||
let _ ← mockClient.recv?
|
||||
mockClient.send (rawResp "302 Found"
|
||||
#[("Location", "http://example.com/same-origin"),
|
||||
("Content-Length", "0"),
|
||||
("Connection", "keep-alive")] "")
|
||||
|
||||
-- Second exchange: receive the redirected request.
|
||||
let some redirectBytes ← mockClient.recv?
|
||||
| throw (IO.userError "Test failed: no redirect request received")
|
||||
mockClient.send (rawResp "200 OK"
|
||||
#[("Content-Length", "2"), ("Connection", "close")] "ok")
|
||||
|
||||
match ← await resultPromise.result! with
|
||||
| Except.error e => throw (IO.userError s!"agent error: {e}")
|
||||
| Except.ok _ => pure ()
|
||||
|
||||
let redirectText := String.fromUTF8! redirectBytes
|
||||
unless redirectText.contains "Authorization:" do
|
||||
throw <| IO.userError
|
||||
s!"Test 'same-origin preserves Authorization' FAILED: \
|
||||
Authorization header was stripped on same-origin redirect\n{redirectText.quote}"
|
||||
|
||||
-- ============================================================
|
||||
-- Body.Any construction
|
||||
-- ============================================================
|
||||
-- Verifies that Body.Any can be constructed from any Body implementation.
|
||||
-- The behavioral property that streaming bodies are consumed on first recv
|
||||
-- (and thus cannot be replayed) is exercised end-to-end by the 307 redirect test below.
|
||||
-- ============================================================
|
||||
|
||||
#eval show IO _ from Async.block do
|
||||
-- Body.Stream: a zero-buffer rendezvous channel.
|
||||
let stream ← Body.mkStream
|
||||
stream.close
|
||||
let _ : Body.Any := Body.Any.ofBody stream
|
||||
|
||||
-- Body.Full: consumed on first recv.
|
||||
let full ← Body.Full.ofByteArray "hello".toUTF8
|
||||
let _ : Body.Any := Body.Any.ofBody full
|
||||
|
||||
-- Body.Empty: trivially closed.
|
||||
let _ : Body.Any := Body.Any.ofBody Body.Empty.mk
|
||||
|
||||
-- ============================================================
|
||||
-- Redirect: non-HTTP/HTTPS scheme in Location is not followed
|
||||
-- ============================================================
|
||||
-- A 302 response with Location: ftp://internal-host/secret must not be followed.
|
||||
-- Before the fix, decideRedirect accepted any scheme that RequestTarget.parse? could
|
||||
-- parse and would try to connect to the ftp host on port 80 (SSRF).
|
||||
-- After the fix, only http:// and https:// redirect targets are followed; everything
|
||||
-- else returns the 3xx response as-is.
|
||||
-- ============================================================
|
||||
|
||||
#eval show IO _ from runWithTimeout "ftp:// redirect not followed" 3000 <| Async.block do
|
||||
let (mockClient, mockServer) ← Mock.new
|
||||
let session ← Client.Session.new mockServer (config := {})
|
||||
let cookieJar ← Cookie.Jar.new
|
||||
let some domain := URI.DomainName.ofString? "example.com"
|
||||
| throw (IO.userError "DomainName parse failed")
|
||||
|
||||
let agent : Client.Agent Mock.Server := {
|
||||
session
|
||||
scheme := URI.Scheme.ofString! "http"
|
||||
host := .name domain
|
||||
port := 80
|
||||
cookieJar
|
||||
}
|
||||
|
||||
let request ← Request.new
|
||||
|>.method .get
|
||||
|>.uri! "/"
|
||||
|>.header! "Host" "example.com"
|
||||
|>.empty
|
||||
|
||||
let resultPromise : IO.Promise (Except String (Response Body.Stream)) ← IO.Promise.new
|
||||
background do
|
||||
let result ← try
|
||||
let resp ← Client.Agent.send agent request
|
||||
pure (Except.ok resp)
|
||||
catch e => pure (Except.error (toString e))
|
||||
discard <| resultPromise.resolve result
|
||||
|
||||
-- Server replies with a redirect to ftp:// (non-HTTP scheme).
|
||||
let _ ← mockClient.recv?
|
||||
mockClient.send (rawResp "302 Found"
|
||||
#[("Location", "ftp://internal-host/secret"),
|
||||
("Content-Length", "0")] "")
|
||||
|
||||
match ← await resultPromise.result! with
|
||||
| Except.error e => throw (IO.userError s!"agent error: {e}")
|
||||
| Except.ok resp =>
|
||||
-- The agent must return the 302 as-is, not follow it.
|
||||
unless resp.line.status == .found do
|
||||
throw <| IO.userError
|
||||
s!"Test 'ftp:// redirect not followed' FAILED: expected 302, got {resp.line.status.toCode}"
|
||||
|
||||
#eval show IO _ from runWithTimeout "file:// redirect not followed" 3000 <| Async.block do
|
||||
let (mockClient, mockServer) ← Mock.new
|
||||
let session ← Client.Session.new mockServer (config := {})
|
||||
let cookieJar ← Cookie.Jar.new
|
||||
let some domain := URI.DomainName.ofString? "example.com"
|
||||
| throw (IO.userError "DomainName parse failed")
|
||||
|
||||
let agent : Client.Agent Mock.Server := {
|
||||
session
|
||||
scheme := URI.Scheme.ofString! "http"
|
||||
host := .name domain
|
||||
port := 80
|
||||
cookieJar
|
||||
}
|
||||
|
||||
let request ← Request.new
|
||||
|>.method .get
|
||||
|>.uri! "/"
|
||||
|>.header! "Host" "example.com"
|
||||
|>.empty
|
||||
|
||||
let resultPromise : IO.Promise (Except String (Response Body.Stream)) ← IO.Promise.new
|
||||
background do
|
||||
let result ← try
|
||||
let resp ← Client.Agent.send agent request
|
||||
pure (Except.ok resp)
|
||||
catch e => pure (Except.error (toString e))
|
||||
discard <| resultPromise.resolve result
|
||||
|
||||
let _ ← mockClient.recv?
|
||||
mockClient.send (rawResp "301 Moved Permanently"
|
||||
#[("Location", "file:///etc/passwd"),
|
||||
("Content-Length", "0")] "")
|
||||
|
||||
match ← await resultPromise.result! with
|
||||
| Except.error e => throw (IO.userError s!"agent error: {e}")
|
||||
| Except.ok resp =>
|
||||
unless resp.line.status == .movedPermanently do
|
||||
throw <| IO.userError
|
||||
s!"Test 'file:// redirect not followed' FAILED: expected 301, got {resp.line.status.toCode}"
|
||||
|
||||
-- ============================================================
|
||||
-- Redirect: https:// redirect IS followed (sanity check)
|
||||
-- ============================================================
|
||||
-- Verify that the scheme restriction doesn't accidentally block legitimate
|
||||
-- https:// redirects (same host, different scheme from http to https).
|
||||
-- ============================================================
|
||||
|
||||
#eval show IO _ from runWithTimeout "https:// redirect is followed" 3000 <| Async.block do
|
||||
let (mockClient, mockServer) ← Mock.new
|
||||
let session ← Client.Session.new mockServer (config := {})
|
||||
let cookieJar ← Cookie.Jar.new
|
||||
let some domain := URI.DomainName.ofString? "example.com"
|
||||
| throw (IO.userError "DomainName parse failed")
|
||||
|
||||
-- Agent with connectTo = none; cross-host redirects return the 3xx as-is.
|
||||
-- We use the same-host case: http://example.com:80/target (same host+port, scheme changes).
|
||||
let agent : Client.Agent Mock.Server := {
|
||||
session
|
||||
scheme := URI.Scheme.ofString! "http"
|
||||
host := .name domain
|
||||
port := 80
|
||||
cookieJar
|
||||
}
|
||||
|
||||
let request ← Request.new
|
||||
|>.method .get
|
||||
|>.uri! "/"
|
||||
|>.header! "Host" "example.com"
|
||||
|>.empty
|
||||
|
||||
let resultPromise : IO.Promise (Except String (Response Body.Stream)) ← IO.Promise.new
|
||||
background do
|
||||
let result ← try
|
||||
let resp ← Client.Agent.send agent request
|
||||
pure (Except.ok resp)
|
||||
catch e => pure (Except.error (toString e))
|
||||
discard <| resultPromise.resolve result
|
||||
|
||||
-- 302 to https://example.com/page — same host+port, scheme differs from http.
|
||||
-- No connectTo factory means cross-origin redirect returns 302 as-is, but at least
|
||||
-- the scheme check must not block it before that point; the 302 must be attempted.
|
||||
let _ ← mockClient.recv?
|
||||
mockClient.send (rawResp "302 Found"
|
||||
#[("Location", "https://example.com/page"),
|
||||
("Content-Length", "0")] "")
|
||||
|
||||
-- https://example.com/page resolves to port 443, which differs from port 80, so
|
||||
-- this is a cross-origin redirect. With connectTo = none the agent returns the 302
|
||||
-- as-is without issuing a second request. Run the optional mock service in the
|
||||
-- background so the main fiber is not blocked when no second request arrives.
|
||||
background do
|
||||
let redirectReqOpt ← mockClient.recv?
|
||||
if redirectReqOpt.isSome then
|
||||
mockClient.send (rawResp "200 OK"
|
||||
#[("Content-Length", "2"), ("Connection", "close")] "ok")
|
||||
|
||||
match ← await resultPromise.result! with
|
||||
| Except.error e => throw (IO.userError s!"agent error: {e}")
|
||||
| Except.ok resp =>
|
||||
-- We accept either 302 (no connectTo for cross-host) or 200 (same-session follow).
|
||||
let code := resp.line.status.toCode
|
||||
unless code == 200 || code == 302 do
|
||||
throw <| IO.userError
|
||||
s!"Test 'https:// redirect is followed' FAILED: unexpected status {code}"
|
||||
|
||||
-- ============================================================
|
||||
-- Redirect: streaming body dropped on method-preserving redirect
|
||||
-- ============================================================
|
||||
-- A 307 redirect preserves the original method and body. When the body is a
|
||||
-- streaming channel (.outgoing) that has already been consumed by the first
|
||||
-- request, it cannot be replayed. The redirect request must send no body
|
||||
-- (Content-Length: 0) rather than silently retransmitting whatever bytes remain
|
||||
-- in the channel (none, so it would be empty anyway — but the fix must explicitly
|
||||
-- classify .outgoing as non-replayable so future semantics stay correct).
|
||||
-- ============================================================
|
||||
|
||||
#eval show IO _ from runWithTimeout "streaming body dropped on 307 redirect" 3000 <| Async.block do
|
||||
let (mockClient, mockServer) ← Mock.new
|
||||
let session ← Client.Session.new mockServer (config := {})
|
||||
let cookieJar ← Cookie.Jar.new
|
||||
let some domain := URI.DomainName.ofString? "example.com"
|
||||
| throw (IO.userError "DomainName parse failed")
|
||||
|
||||
let agent : Client.Agent Mock.Server := {
|
||||
session
|
||||
scheme := URI.Scheme.ofString! "http"
|
||||
host := .name domain
|
||||
port := 80
|
||||
cookieJar
|
||||
}
|
||||
|
||||
let request ← Request.new
|
||||
|>.method .put
|
||||
|>.uri! "/upload"
|
||||
|>.header! "Host" "example.com"
|
||||
|>.stream (fun out => do
|
||||
out.send (Chunk.ofByteArray "payload".toUTF8)
|
||||
out.close)
|
||||
|
||||
let resultPromise : IO.Promise (Except String (Response Body.Stream)) ← IO.Promise.new
|
||||
|
||||
background do
|
||||
let result ← try
|
||||
let resp ← Client.Agent.send agent request
|
||||
pure (Except.ok resp)
|
||||
catch e => pure (Except.error (toString e))
|
||||
discard <| resultPromise.resolve result
|
||||
|
||||
-- First request: drain it completely before replying with 307.
|
||||
-- The body may be Transfer-Encoding: chunked (ends with "0\r\n\r\n") or
|
||||
-- Content-Length (ends with the body bytes) depending on whether the body
|
||||
-- stream was already closed when the H1 machine flushed the headers.
|
||||
-- Accept either encoding to avoid a scheduling-dependent flake.
|
||||
let mut firstBytes := ByteArray.empty
|
||||
repeat
|
||||
let some chunk ← mockClient.recv?
|
||||
| throw (IO.userError "Test failed: connection closed before first request")
|
||||
firstBytes := firstBytes ++ chunk
|
||||
let t := String.fromUTF8! firstBytes
|
||||
if t.endsWith "0\r\n\r\n" || t.endsWith "payload" then break
|
||||
mockClient.send (rawResp "307 Temporary Redirect"
|
||||
#[("Location", "/new-upload"),
|
||||
("Content-Length", "0")] "")
|
||||
|
||||
-- Second request: the redirect. The streaming body is already consumed so
|
||||
-- the client must send no body (Content-Length: 0 or absent, no body bytes).
|
||||
let some redirectBytes ← mockClient.recv?
|
||||
| throw (IO.userError "Test failed: no redirect request received")
|
||||
mockClient.send (rawResp "200 OK"
|
||||
#[("Content-Length", "2"), ("Connection", "close")] "ok")
|
||||
|
||||
match ← await resultPromise.result! with
|
||||
| Except.error e => throw (IO.userError s!"agent error: {e}")
|
||||
| Except.ok _ => pure ()
|
||||
|
||||
let redirectText := String.fromUTF8! redirectBytes
|
||||
-- The redirect request must target /new-upload.
|
||||
unless redirectText.contains "PUT /new-upload" do
|
||||
throw <| IO.userError
|
||||
s!"Test 'streaming body dropped on 307 redirect' FAILED: expected PUT /new-upload\n{redirectText.quote}"
|
||||
-- The body must be empty: Content-Length 0 (or no body bytes after blank line).
|
||||
-- We check that "payload" does not appear in the redirect request.
|
||||
if redirectText.contains "payload" then
|
||||
throw <| IO.userError
|
||||
s!"Test 'streaming body dropped on 307 redirect' FAILED: \
|
||||
streaming body payload present in redirect request\n{redirectText.quote}"
|
||||
|
||||
|
||||
255
tests/elab/async_http_cookie_parser.lean
Normal file
255
tests/elab/async_http_cookie_parser.lean
Normal file
@@ -0,0 +1,255 @@
|
||||
import Std.Internal.Http.Data.Cookie
|
||||
|
||||
open Std.Http
|
||||
|
||||
/-!
|
||||
# Cookie Parser Tests
|
||||
|
||||
Tests for `Set-Cookie` header parsing following RFC 6265 §4.1.
|
||||
-/
|
||||
|
||||
-- Helper: parse a Set-Cookie header value, throw on failure.
|
||||
def parseCookie (s : String) : IO Cookie.Parser.Parsed :=
|
||||
IO.ofExcept (Cookie.Parser.parseSetCookie.run s.toUTF8)
|
||||
|
||||
-- Helper: assert parsing fails.
|
||||
def parseShouldFail (label : String) (s : String) : IO Unit := do
|
||||
match Cookie.Parser.parseSetCookie.run s.toUTF8 with
|
||||
| .ok _ => throw <| IO.userError s!"Test '{label}' failed: expected parse failure but succeeded"
|
||||
| .error _ => pure ()
|
||||
|
||||
-- ============================================================================
|
||||
-- Basic cookie-pair
|
||||
-- ============================================================================
|
||||
|
||||
#eval show IO _ from do
|
||||
-- Minimal name=value
|
||||
let p ← parseCookie "foo=bar"
|
||||
unless p.name == "foo" do
|
||||
throw <| IO.userError s!"Test 'basic name' failed: expected 'foo', got {p.name.quote}"
|
||||
unless p.value == "bar" do
|
||||
throw <| IO.userError s!"Test 'basic value' failed: expected 'bar', got {p.value.quote}"
|
||||
unless p.domain == none do
|
||||
throw <| IO.userError s!"Test 'basic domain absent' failed: expected none, got {repr p.domain}"
|
||||
unless p.path == none do
|
||||
throw <| IO.userError s!"Test 'basic path absent' failed: expected none, got {repr p.path}"
|
||||
unless p.secure == false do
|
||||
throw <| IO.userError "Test 'basic secure absent' failed: expected false"
|
||||
|
||||
-- Empty value is allowed (cookie-value = *cookie-octet)
|
||||
let pEmpty ← parseCookie "session="
|
||||
unless pEmpty.value == "" do
|
||||
throw <| IO.userError s!"Test 'empty value' failed: expected '', got {pEmpty.value.quote}"
|
||||
|
||||
-- Numeric name is not a valid token (digits alone are not all tchar? Actually digits ARE tchar)
|
||||
-- tchar includes DIGIT, so "123" is a valid token
|
||||
let pNum ← parseCookie "123=abc"
|
||||
unless pNum.name == "123" do
|
||||
throw <| IO.userError s!"Test 'numeric name' failed: expected '123', got {pNum.name.quote}"
|
||||
|
||||
-- ============================================================================
|
||||
-- Quoted cookie values
|
||||
-- ============================================================================
|
||||
|
||||
#eval show IO _ from do
|
||||
-- Double-quoted value: quotes are stripped, inner value is returned
|
||||
let p ← parseCookie "id=\"abc123\""
|
||||
unless p.value == "abc123" do
|
||||
throw <| IO.userError s!"Test 'quoted value' failed: expected 'abc123', got {p.value.quote}"
|
||||
|
||||
-- Empty quoted value
|
||||
let pEq ← parseCookie "id=\"\""
|
||||
unless pEq.value == "" do
|
||||
throw <| IO.userError s!"Test 'empty quoted value' failed: expected '', got {pEq.value.quote}"
|
||||
|
||||
-- Quoted value with all valid cookie-octets (excluding DQUOTE, SP, comma, semicolon, backslash)
|
||||
let pOctets ← parseCookie "t=\"!#$%&'*+-.^_`|~\""
|
||||
unless pOctets.value == "!#$%&'*+-.^_`|~" do
|
||||
throw <| IO.userError s!"Test 'quoted cookie-octets' failed: got {pOctets.value.quote}"
|
||||
|
||||
-- ============================================================================
|
||||
-- Domain attribute
|
||||
-- ============================================================================
|
||||
|
||||
#eval show IO _ from do
|
||||
-- Domain present
|
||||
let p ← parseCookie "x=y; Domain=example.com"
|
||||
unless p.domain == some "example.com" do
|
||||
throw <| IO.userError s!"Test 'domain' failed: expected some \"example.com\", got {repr p.domain}"
|
||||
|
||||
-- Leading dot is stripped per RFC 6265 §5.2.3
|
||||
let pDot ← parseCookie "x=y; Domain=.example.com"
|
||||
unless pDot.domain == some "example.com" do
|
||||
throw <| IO.userError s!"Test 'domain leading dot stripped' failed: expected some \"example.com\", got {repr pDot.domain}"
|
||||
|
||||
-- Empty domain attribute → domain is none
|
||||
let pEmpty ← parseCookie "x=y; Domain="
|
||||
unless pEmpty.domain == none do
|
||||
throw <| IO.userError s!"Test 'empty domain' failed: expected none, got {repr pEmpty.domain}"
|
||||
|
||||
-- Dot-only domain → stripped to empty → domain is none
|
||||
let pDotOnly ← parseCookie "x=y; Domain=."
|
||||
unless pDotOnly.domain == none do
|
||||
throw <| IO.userError s!"Test 'dot-only domain' failed: expected none, got {repr pDotOnly.domain}"
|
||||
|
||||
-- ============================================================================
|
||||
-- Path attribute
|
||||
-- ============================================================================
|
||||
|
||||
#eval show IO _ from do
|
||||
-- Valid path starting with /
|
||||
let p ← parseCookie "x=y; Path=/foo/bar"
|
||||
unless p.path == some "/foo/bar" do
|
||||
throw <| IO.userError s!"Test 'path' failed: expected some \"/foo/bar\", got {repr p.path}"
|
||||
|
||||
-- Root path
|
||||
let pRoot ← parseCookie "x=y; Path=/"
|
||||
unless pRoot.path == some "/" do
|
||||
throw <| IO.userError s!"Test 'root path' failed: expected some \"/\", got {repr pRoot.path}"
|
||||
|
||||
-- Path not starting with / → none per RFC 6265 §5.2.4
|
||||
let pNoSlash ← parseCookie "x=y; Path=noslash"
|
||||
unless pNoSlash.path == none do
|
||||
throw <| IO.userError s!"Test 'path without leading slash' failed: expected none, got {repr pNoSlash.path}"
|
||||
|
||||
-- Empty path → none
|
||||
let pEmpty ← parseCookie "x=y; Path="
|
||||
unless pEmpty.path == none do
|
||||
throw <| IO.userError s!"Test 'empty path' failed: expected none, got {repr pEmpty.path}"
|
||||
|
||||
-- ============================================================================
|
||||
-- Secure attribute
|
||||
-- ============================================================================
|
||||
|
||||
#eval show IO _ from do
|
||||
-- Secure present
|
||||
let p ← parseCookie "x=y; Secure"
|
||||
unless p.secure == true do
|
||||
throw <| IO.userError "Test 'secure' failed: expected true"
|
||||
|
||||
-- Secure absent
|
||||
let pNo ← parseCookie "x=y"
|
||||
unless pNo.secure == false do
|
||||
throw <| IO.userError "Test 'secure absent' failed: expected false"
|
||||
|
||||
-- Secure= (with a value — treated as Secure since we match the attr name)
|
||||
let pVal ← parseCookie "x=y; Secure=true"
|
||||
unless pVal.secure == true do
|
||||
throw <| IO.userError "Test 'secure with value' failed: expected true"
|
||||
|
||||
-- ============================================================================
|
||||
-- Combined attributes
|
||||
-- ============================================================================
|
||||
|
||||
#eval show IO _ from do
|
||||
let p ← parseCookie "sessionId=abc123; Domain=example.com; Path=/app; Secure"
|
||||
unless p.name == "sessionId" do
|
||||
throw <| IO.userError s!"Test 'combined name' failed: got {p.name.quote}"
|
||||
unless p.value == "abc123" do
|
||||
throw <| IO.userError s!"Test 'combined value' failed: got {p.value.quote}"
|
||||
unless p.domain == some "example.com" do
|
||||
throw <| IO.userError s!"Test 'combined domain' failed: got {repr p.domain}"
|
||||
unless p.path == some "/app" do
|
||||
throw <| IO.userError s!"Test 'combined path' failed: got {repr p.path}"
|
||||
unless p.secure == true do
|
||||
throw <| IO.userError "Test 'combined secure' failed: expected true"
|
||||
|
||||
-- ============================================================================
|
||||
-- Case-insensitive attribute names (RFC 6265 §5.2)
|
||||
-- ============================================================================
|
||||
|
||||
#eval show IO _ from do
|
||||
-- Uppercase attribute names must be recognized
|
||||
let p ← parseCookie "x=y; DOMAIN=example.com; PATH=/; SECURE"
|
||||
unless p.domain == some "example.com" do
|
||||
throw <| IO.userError s!"Test 'uppercase domain' failed: got {repr p.domain}"
|
||||
unless p.path == some "/" do
|
||||
throw <| IO.userError s!"Test 'uppercase path' failed: got {repr p.path}"
|
||||
unless p.secure == true do
|
||||
throw <| IO.userError "Test 'uppercase secure' failed: expected true"
|
||||
|
||||
-- Mixed-case attribute names
|
||||
let pMixed ← parseCookie "x=y; Domain=a.com; Secure; Path=/x"
|
||||
unless pMixed.domain == some "a.com" do
|
||||
throw <| IO.userError s!"Test 'mixed-case domain' failed: got {repr pMixed.domain}"
|
||||
unless pMixed.secure == true do
|
||||
throw <| IO.userError "Test 'mixed-case secure' failed: expected true"
|
||||
unless pMixed.path == some "/x" do
|
||||
throw <| IO.userError s!"Test 'mixed-case path' failed: got {repr pMixed.path}"
|
||||
|
||||
-- ============================================================================
|
||||
-- Unknown attributes are silently ignored
|
||||
-- ============================================================================
|
||||
|
||||
#eval show IO _ from do
|
||||
-- HttpOnly is silently ignored
|
||||
let p ← parseCookie "x=y; HttpOnly"
|
||||
unless p.name == "x" && p.value == "y" do
|
||||
throw <| IO.userError s!"Test 'HttpOnly ignored' failed: name={p.name.quote} value={p.value.quote}"
|
||||
|
||||
-- Expires is silently ignored
|
||||
let pExp ← parseCookie "x=y; Expires=Thu, 01 Jan 2026 00:00:00 GMT"
|
||||
unless pExp.name == "x" && pExp.value == "y" do
|
||||
throw <| IO.userError s!"Test 'Expires ignored' failed"
|
||||
|
||||
-- SameSite is silently ignored
|
||||
let pSS ← parseCookie "x=y; SameSite=Strict"
|
||||
unless pSS.name == "x" && pSS.value == "y" do
|
||||
throw <| IO.userError s!"Test 'SameSite ignored' failed"
|
||||
|
||||
-- Max-Age is silently ignored
|
||||
let pMaxAge ← parseCookie "x=y; Max-Age=3600"
|
||||
unless pMaxAge.name == "x" && pMaxAge.value == "y" do
|
||||
throw <| IO.userError s!"Test 'Max-Age ignored' failed"
|
||||
|
||||
-- Multiple unknown attributes
|
||||
let pMulti ← parseCookie "x=y; Foo=bar; HttpOnly; Baz; Path=/p"
|
||||
unless pMulti.path == some "/p" do
|
||||
throw <| IO.userError s!"Test 'unknown attrs + path' failed: got {repr pMulti.path}"
|
||||
|
||||
-- ============================================================================
|
||||
-- Duplicate attribute handling (last-write-wins is fine, RFC 6265 §5.3)
|
||||
-- ============================================================================
|
||||
|
||||
#eval show IO _ from do
|
||||
-- Last Domain wins
|
||||
let p ← parseCookie "x=y; Domain=first.com; Domain=second.com"
|
||||
unless p.domain == some "second.com" do
|
||||
throw <| IO.userError s!"Test 'duplicate domain last wins' failed: got {repr p.domain}"
|
||||
|
||||
-- Last Path wins
|
||||
let pPath ← parseCookie "x=y; Path=/first; Path=/second"
|
||||
unless pPath.path == some "/second" do
|
||||
throw <| IO.userError s!"Test 'duplicate path last wins' failed: got {repr pPath.path}"
|
||||
|
||||
-- ============================================================================
|
||||
-- Semicolon spacing: RFC 6265 §4.1 allows optional SP after ";"
|
||||
-- ============================================================================
|
||||
|
||||
#eval show IO _ from do
|
||||
-- No space after semicolon
|
||||
let pNoSp ← parseCookie "x=y;Secure"
|
||||
unless pNoSp.secure == true do
|
||||
throw <| IO.userError "Test 'no space after semicolon' failed: expected Secure=true"
|
||||
|
||||
-- Space after semicolon (standard)
|
||||
let pSp ← parseCookie "x=y; Secure"
|
||||
unless pSp.secure == true do
|
||||
throw <| IO.userError "Test 'space after semicolon' failed: expected Secure=true"
|
||||
|
||||
-- ============================================================================
|
||||
-- Invalid cookie names → parse failure
|
||||
-- ============================================================================
|
||||
|
||||
#eval show IO _ from do
|
||||
-- Empty name is not a valid token (token = 1*tchar, requires at least one char)
|
||||
parseShouldFail "empty name" "=value"
|
||||
|
||||
-- Space in name (space is not a tchar)
|
||||
parseShouldFail "space in name" "foo bar=value"
|
||||
|
||||
-- Semicolon in name (not a tchar)
|
||||
parseShouldFail "semicolon in name" "foo;bar=value"
|
||||
|
||||
-- Missing '=' separator
|
||||
parseShouldFail "missing equals" "nameonly"
|
||||
193
tests/elab/async_http_dispatch.lean
Normal file
193
tests/elab/async_http_dispatch.lean
Normal file
@@ -0,0 +1,193 @@
|
||||
import Std.Internal.Http.Test.Helpers
|
||||
|
||||
open Std.Internal.IO Async
|
||||
open Std Http Test
|
||||
|
||||
-- Basic method dispatch and streaming responses
|
||||
|
||||
#eval runGroup "Basic dispatch" do
|
||||
check "GET with Content-Length header → 200"
|
||||
(raw := "GET / HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: 7\x0d\nConnection: close\x0d\n\x0d\nsurvive")
|
||||
(handler := fun req => do
|
||||
if req.line.method == .get && req.line.headers.hasEntry (.mk "content-length") (.ofString! "7")
|
||||
then Response.ok |>.text "ok"
|
||||
else Response.badRequest |>.text "bad")
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 200" *> assertContains r "ok")
|
||||
|
||||
check "GET → 200 with body"
|
||||
(raw := mkGetClose "/api/users")
|
||||
(handler := fun req => do
|
||||
if req.line.method == .get then Response.ok |>.text "users list"
|
||||
else Response.notFound |>.text "")
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 200" *> assertContains r "users list")
|
||||
|
||||
check "POST with JSON body → 201 Created"
|
||||
(raw := mkPost "/api/users" "{\"name\":\"Alice\"}" "Content-Type: application/json\x0d\nConnection: close\x0d\n")
|
||||
(handler := fun req => do
|
||||
if req.line.headers.hasEntry (.mk "content-type") (.ofString! "application/json")
|
||||
then Response.new |>.status .created |>.text "Created"
|
||||
else Response.badRequest |>.text "")
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 201" *> assertContains r "Created")
|
||||
|
||||
check "DELETE → 204 No Content"
|
||||
(raw := "DELETE /api/users/123 HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := fun req => do
|
||||
if req.line.method == .delete then Response.new |>.status .noContent |>.text ""
|
||||
else Response.notFound |>.text "")
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 204")
|
||||
|
||||
check "HEAD → headers present, body absent"
|
||||
(raw := "HEAD /api/users HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := fun req => do
|
||||
if req.line.method == .head then Response.ok |>.text ""
|
||||
else Response.notFound |>.text "")
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 200" *> assertAbsent r "\x0d\n\x0d\nX")
|
||||
|
||||
check "OPTIONS with Allow header"
|
||||
(raw := "OPTIONS * HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := fun req => do
|
||||
if req.line.method == .options
|
||||
then Response.ok |>.header! "Allow" "GET, POST, DELETE, OPTIONS" |>.text ""
|
||||
else Response.badRequest |>.text "")
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 200" *> assertContains r "Allow: GET")
|
||||
|
||||
check "multiple request headers preserved"
|
||||
(raw := "GET /api/data HTTP/1.1\x0d\nHost: api.example.com\x0d\nAccept: application/json\x0d\nAuthorization: Bearer tok\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := fun req => do
|
||||
if req.line.headers.hasEntry (.mk "authorization") (.ofString! "Bearer tok") &&
|
||||
req.line.headers.hasEntry (.mk "accept") (.ofString! "application/json")
|
||||
then Response.ok |>.text "authenticated"
|
||||
else Response.new |>.status .unauthorized |>.text "unauthorized")
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 200" *> assertContains r "authenticated")
|
||||
|
||||
check "query parameters preserved in URI"
|
||||
(raw := mkGetClose "/api/search?q=test&limit=10")
|
||||
(handler := fun req => do
|
||||
if toString req.line.uri == "/api/search?q=test&limit=10"
|
||||
then Response.ok |>.text "results"
|
||||
else Response.notFound |>.text "")
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 200" *> assertContains r "results")
|
||||
|
||||
check "POST with empty body (CL: 0) → 202 Accepted"
|
||||
(raw := "POST /api/trigger HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: 0\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := fun req => do
|
||||
if req.line.headers.hasEntry (.mk "content-length") (.ofString! "0")
|
||||
then Response.new |>.status .accepted |>.text "triggered"
|
||||
else Response.badRequest |>.text "")
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 202" *> assertContains r "triggered")
|
||||
|
||||
check "URI with percent-encoded characters"
|
||||
(raw := mkGetClose "/api/users/%C3%A9")
|
||||
(handler := fun req => do
|
||||
if toString req.line.uri == "/api/users/%C3%A9"
|
||||
then Response.ok |>.text "found"
|
||||
else Response.notFound |>.text "")
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 200" *> assertContains r "found")
|
||||
|
||||
check "custom response headers preserved"
|
||||
(raw := mkGetClose "/api/data")
|
||||
(handler := fun _ =>
|
||||
Response.ok
|
||||
|>.header! "Cache-Control" "no-cache"
|
||||
|>.header! "X-Custom-Header" "custom-value"
|
||||
|>.text "data")
|
||||
(expect := fun r =>
|
||||
assertStatus r "HTTP/1.1 200" *>
|
||||
assertContains r "Cache-Control: no-cache" *>
|
||||
assertContains r "X-Custom-Header: custom-value")
|
||||
|
||||
check "custom status code (418 I'm a teapot)"
|
||||
(raw := mkGetClose "/api/teapot")
|
||||
(handler := fun _ => Response.new |>.status .imATeapot |>.text "I'm a teapot")
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 418" *> assertContains r "I'm a teapot")
|
||||
|
||||
check "large response body (1000 bytes)"
|
||||
(raw := mkGetClose "/api/large")
|
||||
(handler := fun _ => Response.ok |>.text (String.ofList (List.replicate 1000 'X')))
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 200" *> assertContains r "Content-Length: 1000")
|
||||
|
||||
-- Streaming responses
|
||||
|
||||
#eval runGroup "Streaming responses" do
|
||||
check "streaming with explicit Content-Length"
|
||||
(raw := mkGetClose "/stream")
|
||||
(handler := fun _ => do
|
||||
let stream ← Body.mkStream
|
||||
background do
|
||||
for i in [0:3] do
|
||||
let sleep ← Sleep.mk 5
|
||||
sleep.wait
|
||||
stream.send <| Chunk.ofByteArray s!"chunk{i}\n".toUTF8
|
||||
stream.close
|
||||
return Response.ok
|
||||
|>.header (.mk "content-length") (.mk "21")
|
||||
|>.body stream)
|
||||
(expect := fun r =>
|
||||
assertStatus r "HTTP/1.1 200" *>
|
||||
assertContains r "Content-Length: 21" *>
|
||||
assertContains r "chunk0")
|
||||
|
||||
check "streaming with setKnownSize"
|
||||
(raw := mkGetClose "/stream-sized")
|
||||
(handler := fun _ => do
|
||||
let stream ← Body.mkStream
|
||||
stream.setKnownSize (some (.fixed 15))
|
||||
background do
|
||||
for i in [0:3] do
|
||||
stream.send <| Chunk.ofByteArray s!"data{i}".toUTF8
|
||||
stream.close
|
||||
return Response.ok |>.body stream)
|
||||
(expect := fun r =>
|
||||
assertStatus r "HTTP/1.1 200" *>
|
||||
assertContains r "Content-Length: 15" *>
|
||||
assertContains r "data0")
|
||||
|
||||
check "streaming chunked encoding (unknown size)"
|
||||
(raw := mkGetClose "/stream-chunked")
|
||||
(handler := fun _ => do
|
||||
let stream ← Body.mkStream
|
||||
background do
|
||||
stream.send <| Chunk.ofByteArray "hello".toUTF8
|
||||
stream.send <| Chunk.ofByteArray "world".toUTF8
|
||||
stream.close
|
||||
return Response.ok |>.body stream)
|
||||
(expect := fun r =>
|
||||
assertStatus r "HTTP/1.1 200" *>
|
||||
assertContains r "Transfer-Encoding: chunked" *>
|
||||
assertContains r "hello" *>
|
||||
assertContains r "world")
|
||||
|
||||
check "chunked request + streaming response"
|
||||
(raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\ndata1\x0d\n5\x0d\ndata2\x0d\n0\x0d\n\x0d\n")
|
||||
(handler := fun req => do
|
||||
let stream ← Body.mkStream
|
||||
let te := req.line.headers.get? (.mk "transfer-encoding")
|
||||
if te.isSome then
|
||||
background do
|
||||
stream.send <| Chunk.ofByteArray "response0".toUTF8
|
||||
stream.send <| Chunk.ofByteArray "response1".toUTF8
|
||||
stream.close
|
||||
return Response.ok
|
||||
|>.header (.mk "content-length") (.mk "18")
|
||||
|>.body stream
|
||||
else
|
||||
stream.close
|
||||
return Response.badRequest |>.body stream)
|
||||
(expect := fun r =>
|
||||
assertStatus r "HTTP/1.1 200" *>
|
||||
assertContains r "response0" *>
|
||||
assertContains r "response1")
|
||||
|
||||
check "fixed-length overflow: output stops at announced length"
|
||||
(raw := mkGetClose "/overflow")
|
||||
(handler := fun _ => do
|
||||
let stream ← Body.mkStream
|
||||
background do
|
||||
stream.send <| Chunk.ofByteArray "abcdef".toUTF8
|
||||
stream.close
|
||||
return Response.ok
|
||||
|>.header (.mk "content-length") (.mk "3")
|
||||
|>.body stream)
|
||||
(expect := fun r =>
|
||||
assertStatus r "HTTP/1.1 200" *>
|
||||
assertContains r "Content-Length: 3")
|
||||
@@ -348,21 +348,21 @@ info: "HTTP/1.1 418 I'm a teapot\x0d\n\x0d\n"
|
||||
|
||||
/-! ## Edge cases: Status encoding -/
|
||||
|
||||
-- Status.other 0: minimum possible value
|
||||
-- Status.other 104: minimum valid non-known code (100–103 are all named)
|
||||
/--
|
||||
info: "999 Unknown"
|
||||
-/
|
||||
#guard_msgs in
|
||||
#eval encodeStr (Status.other ⟨999, "Unknown", by decide, by decide, by decide⟩)
|
||||
|
||||
-- Status.other that overlaps with a named status (100 = Continue)
|
||||
-- Status.other 209: non-named code between two known blocks
|
||||
/--
|
||||
info: "888 Unknown"
|
||||
-/
|
||||
#guard_msgs in
|
||||
#eval encodeStr (Status.other ⟨888, "Unknown", by decide, by decide, by decide⟩)
|
||||
|
||||
-- Status.other max UInt16
|
||||
-- Status.other 999: maximum valid code
|
||||
/--
|
||||
info: "999 Unknown"
|
||||
-/
|
||||
|
||||
169
tests/elab/async_http_expect.lean
Normal file
169
tests/elab/async_http_expect.lean
Normal file
@@ -0,0 +1,169 @@
|
||||
import Std.Internal.Http.Test.Helpers
|
||||
|
||||
open Std.Internal.IO Async
|
||||
open Std Http Test
|
||||
|
||||
-- Handlers for Expect: 100-continue testing
|
||||
|
||||
private structure RejectContinueHandler where
|
||||
onRequestCalls : IO.Ref Nat
|
||||
|
||||
instance : Std.Http.Server.Handler RejectContinueHandler where
|
||||
onRequest self _ := do
|
||||
self.onRequestCalls.modify (· + 1)
|
||||
Response.ok |>.text "request-ran"
|
||||
|
||||
onContinue _ _ := pure false
|
||||
|
||||
private structure AcceptContinueHandler where
|
||||
onRequestCalls : IO.Ref Nat
|
||||
|
||||
instance : Std.Http.Server.Handler AcceptContinueHandler where
|
||||
onRequest self request := do
|
||||
self.onRequestCalls.modify (· + 1)
|
||||
let body : String ← request.body.readAll
|
||||
Response.ok |>.text s!"accepted:{body}"
|
||||
|
||||
onContinue _ _ := pure true
|
||||
|
||||
-- Per-test runner for generic handlers
|
||||
|
||||
private def checkH {σ : Type} [Std.Http.Server.Handler σ]
|
||||
(name : String)
|
||||
(raw : String)
|
||||
(handler : σ)
|
||||
(expect : ByteArray → IO Unit)
|
||||
(config : Config := defaultConfig) : IO Unit := do
|
||||
let (client, server) ← Mock.new
|
||||
let response ← Async.block do
|
||||
client.send raw.toUTF8
|
||||
Std.Http.Server.serveConnection server handler config |>.run
|
||||
return (← client.recv?).getD .empty
|
||||
|
||||
try expect response
|
||||
catch e => throw (IO.userError s!"[{name}] {e}")
|
||||
|
||||
private def assertCallCount (ref : IO.Ref Nat) (expected : Nat) : IO Unit := do
|
||||
let got ← ref.get
|
||||
unless got == expected do
|
||||
throw <| IO.userError s!"expected {expected} onRequest calls, got {got}"
|
||||
|
||||
-- RFC 9110 §10.1.1: Expect: 100-continue
|
||||
|
||||
#eval runGroup "Expect: 100-continue — reject" do
|
||||
let calls ← IO.mkRef 0
|
||||
let handler : RejectContinueHandler := { onRequestCalls := calls }
|
||||
|
||||
checkH "rejected Expect → 417, handler not called"
|
||||
(raw := "POST /upload HTTP/1.1\x0d\nHost: example.com\x0d\nExpect: 100-continue\x0d\nContent-Length: 5\x0d\nConnection: close\x0d\n\x0d\nhello")
|
||||
(handler := handler)
|
||||
(expect := fun r =>
|
||||
assertContains r "HTTP/1.1 417 Expectation Failed" *>
|
||||
assertAbsent r "100 Continue" *>
|
||||
assertAbsent r "request-ran" *>
|
||||
assertResponseCount r 1)
|
||||
|
||||
assertCallCount calls 0
|
||||
|
||||
#eval runGroup "Expect: 100-continue — reject blocks pipelining" do
|
||||
let calls ← IO.mkRef 0
|
||||
let handler : RejectContinueHandler := { onRequestCalls := calls }
|
||||
|
||||
checkH "rejected Expect closes exchange, blocks pipelined second request"
|
||||
(raw :=
|
||||
"POST /first HTTP/1.1\x0d\nHost: example.com\x0d\nExpect: 100-continue\x0d\nContent-Length: 5\x0d\n\x0d\nhello" ++
|
||||
"GET /second HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := handler)
|
||||
(expect := fun r =>
|
||||
assertContains r "HTTP/1.1 417 Expectation Failed" *>
|
||||
assertAbsent r "/second")
|
||||
|
||||
assertCallCount calls 0
|
||||
|
||||
#eval runGroup "Expect: 100-continue — accept" do
|
||||
let calls ← IO.mkRef 0
|
||||
let handler : AcceptContinueHandler := { onRequestCalls := calls }
|
||||
|
||||
checkH "accepted Expect → 100 Continue then 200"
|
||||
(raw := "POST /ok HTTP/1.1\x0d\nHost: example.com\x0d\nExpect: 100-continue\x0d\nContent-Length: 5\x0d\nConnection: close\x0d\n\x0d\nhello")
|
||||
(handler := handler)
|
||||
(expect := fun r =>
|
||||
assertContains r "HTTP/1.1 100 Continue" *>
|
||||
assertContains r "HTTP/1.1 200 OK" *>
|
||||
assertContains r "accepted:hello" *>
|
||||
assertResponseCount r 2) -- one interim + one final
|
||||
|
||||
assertCallCount calls 1
|
||||
|
||||
#eval runGroup "Expect: misc" do
|
||||
let rejectCalls ← IO.mkRef 0
|
||||
let rejectHandler : RejectContinueHandler := { onRequestCalls := rejectCalls }
|
||||
|
||||
checkH "non-100 Expect token → normal request, no interim"
|
||||
(raw := "POST /odd HTTP/1.1\x0d\nHost: example.com\x0d\nExpect: something-else\x0d\nContent-Length: 5\x0d\nConnection: close\x0d\n\x0d\nhello")
|
||||
(handler := rejectHandler)
|
||||
(expect := fun r =>
|
||||
assertContains r "HTTP/1.1 200 OK" *>
|
||||
assertContains r "request-ran" *>
|
||||
assertAbsent r "100 Continue")
|
||||
|
||||
assertCallCount rejectCalls 1
|
||||
|
||||
let acceptCalls ← IO.mkRef 0
|
||||
let acceptHandler : AcceptContinueHandler := { onRequestCalls := acceptCalls }
|
||||
|
||||
checkH "Expect: 100-CONTINUE (case-insensitive) → 100 then 200"
|
||||
(raw := "POST /case HTTP/1.1\x0d\nHost: example.com\x0d\nExpect: 100-CONTINUE\x0d\nContent-Length: 5\x0d\nConnection: close\x0d\n\x0d\nhello")
|
||||
(handler := acceptHandler)
|
||||
(expect := fun r =>
|
||||
assertContains r "HTTP/1.1 100 Continue" *>
|
||||
assertContains r "HTTP/1.1 200 OK")
|
||||
|
||||
assertCallCount acceptCalls 1
|
||||
|
||||
let noCalls ← IO.mkRef 0
|
||||
let noExpectHandler : AcceptContinueHandler := { onRequestCalls := noCalls }
|
||||
|
||||
checkH "no Expect header → no 100 Continue emitted"
|
||||
(raw := "POST /no-expect HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: 5\x0d\nConnection: close\x0d\n\x0d\nhello")
|
||||
(handler := noExpectHandler)
|
||||
(expect := fun r =>
|
||||
assertContains r "HTTP/1.1 200 OK" *>
|
||||
assertContains r "accepted:hello" *>
|
||||
assertAbsent r "100 Continue" *>
|
||||
assertResponseCount r 1)
|
||||
|
||||
assertCallCount noCalls 1
|
||||
|
||||
-- Date header generation
|
||||
|
||||
#eval runGroup "Date header" do
|
||||
check "generateDate: true adds Date header"
|
||||
(raw := mkGetClose "/date")
|
||||
(handler := fun _ => Response.ok |>.text "hello")
|
||||
(config := { defaultConfig with generateDate := true })
|
||||
(expect := fun r =>
|
||||
assertStatus r "HTTP/1.1 200" *>
|
||||
assertContains r "Date: ")
|
||||
|
||||
check "generateDate: false omits Date header"
|
||||
(raw := mkGetClose "/no-date")
|
||||
(handler := fun _ => Response.ok |>.text "hello")
|
||||
(config := { defaultConfig with generateDate := false })
|
||||
(expect := fun r =>
|
||||
assertStatus r "HTTP/1.1 200" *>
|
||||
assertAbsent r "Date: ")
|
||||
|
||||
check "user-supplied Date header preserved and not duplicated"
|
||||
(raw := mkGetClose "/custom-date")
|
||||
(handler := fun _ =>
|
||||
Response.ok
|
||||
|>.header! "Date" "Mon, 01 Jan 2024 00:00:00 GMT"
|
||||
|>.text "hello")
|
||||
(config := { defaultConfig with generateDate := true })
|
||||
(expect := fun r => do
|
||||
assertContains r "Date: Mon, 01 Jan 2024 00:00:00 GMT"
|
||||
let text := String.fromUTF8! r
|
||||
let count := (text.splitOn "Date: ").length - 1
|
||||
unless count == 1 do
|
||||
throw <| IO.userError s!"expected 1 Date header, got {count}:\n{text.quote}")
|
||||
630
tests/elab/async_http_fuzz.lean
Normal file
630
tests/elab/async_http_fuzz.lean
Normal file
@@ -0,0 +1,630 @@
|
||||
import Std.Internal.Http
|
||||
import Std.Internal.Async
|
||||
import Std.Internal.Async.Timer
|
||||
|
||||
open Std.Internal.IO Async
|
||||
open Std Http Test
|
||||
|
||||
|
||||
|
||||
def bad400 : String :=
|
||||
"HTTP/1.1 400 Bad Request\x0d\nServer: LeanHTTP/1.1\x0d\nConnection: close\x0d\nContent-Length: 0\x0d\n\x0d\n"
|
||||
|
||||
def runWithTimeout {α : Type} (name : String) (timeoutMs : Nat := 15000) (action : IO α) : IO α := do
|
||||
let task ← IO.asTask action
|
||||
let ticks := (timeoutMs + 9) / 10
|
||||
|
||||
let rec loop (remaining : Nat) : IO α := do
|
||||
if (← IO.getTaskState task) == .finished then
|
||||
match (← IO.wait task) with
|
||||
| .ok x => pure x
|
||||
| .error err => throw err
|
||||
else
|
||||
match remaining with
|
||||
| 0 =>
|
||||
IO.cancel task
|
||||
throw <| IO.userError s!"Test '{name}' timed out after {timeoutMs}ms (possible hang/regression)"
|
||||
| n + 1 =>
|
||||
IO.sleep 10
|
||||
loop n
|
||||
|
||||
loop ticks
|
||||
|
||||
def closeChannelIdempotent {α : Type} (ch : Std.CloseableChannel α) : IO Unit := do
|
||||
match ← EIO.toBaseIO ch.close with
|
||||
| .ok _ => pure ()
|
||||
| .error .alreadyClosed => pure ()
|
||||
| .error err => throw <| IO.userError (toString err)
|
||||
|
||||
def sendRaw
|
||||
(client : Mock.Client)
|
||||
(server : Mock.Server)
|
||||
(raw : ByteArray)
|
||||
(handler : TestHandler)
|
||||
(config : Config := defaultConfig) : IO ByteArray := Async.block do
|
||||
client.send raw
|
||||
Std.Http.Server.serveConnection server handler config
|
||||
|>.run
|
||||
let res ← client.recv?
|
||||
pure (res.getD .empty)
|
||||
|
||||
|
||||
def sendRawAndClose
|
||||
(client : Mock.Client)
|
||||
(server : Mock.Server)
|
||||
(raw : ByteArray)
|
||||
(handler : TestHandler)
|
||||
(config : Config := defaultConfig) : IO ByteArray := Async.block do
|
||||
client.send raw
|
||||
closeChannelIdempotent client.getSendChan
|
||||
Std.Http.Server.serveConnection server handler config
|
||||
|>.run
|
||||
let res ← client.recv?
|
||||
pure (res.getD .empty)
|
||||
|
||||
|
||||
def sendFragmentedAndClose
|
||||
(client : Mock.Client)
|
||||
(server : Mock.Server)
|
||||
(parts : Array ByteArray)
|
||||
(handler : TestHandler)
|
||||
(config : Config := defaultConfig) : IO ByteArray := Async.block do
|
||||
let serverTask ← async (t := AsyncTask) do
|
||||
Std.Http.Server.serveConnection server handler config
|
||||
|>.run
|
||||
|
||||
for part in parts do
|
||||
client.send part
|
||||
|
||||
closeChannelIdempotent client.getSendChan
|
||||
await serverTask
|
||||
|
||||
let res ← client.recv?
|
||||
pure (res.getD .empty)
|
||||
|
||||
|
||||
def responseText (response : ByteArray) : String :=
|
||||
String.fromUTF8! response
|
||||
|
||||
|
||||
def responseBody (response : ByteArray) : String :=
|
||||
let parts := (responseText response).splitOn "\x0d\n\x0d\n"
|
||||
match parts.drop 1 with
|
||||
| [] => ""
|
||||
| body :: _ => body
|
||||
|
||||
|
||||
def assertStatusPrefix (name : String) (response : ByteArray) (prefix_ : String) : IO Unit := do
|
||||
let text := responseText response
|
||||
unless text.startsWith prefix_ do
|
||||
throw <| IO.userError s!"Test '{name}' failed:\nExpected status prefix {prefix_.quote}\nGot:\n{text.quote}"
|
||||
|
||||
|
||||
|
||||
|
||||
def countOccurrences (s : String) (needle : String) : Nat :=
|
||||
if needle.isEmpty then
|
||||
0
|
||||
else
|
||||
(s.splitOn needle).length - 1
|
||||
|
||||
|
||||
def assertStatusCount (name : String) (response : ByteArray) (expected : Nat) : IO Unit := do
|
||||
let text := responseText response
|
||||
let got := countOccurrences text "HTTP/1.1 "
|
||||
if got != expected then
|
||||
throw <| IO.userError s!"Test '{name}' failed:\nExpected {expected} responses but saw {got}\n{text.quote}"
|
||||
|
||||
|
||||
def nextSeed (seed : Nat) : Nat :=
|
||||
(1664525 * seed + 1013904223) % 4294967296
|
||||
|
||||
|
||||
def randBelow (seed : Nat) (maxExclusive : Nat) : Nat × Nat :=
|
||||
let seed' := nextSeed seed
|
||||
if maxExclusive == 0 then
|
||||
(0, seed')
|
||||
else
|
||||
(seed' % maxExclusive, seed')
|
||||
|
||||
|
||||
def randIn (seed : Nat) (low : Nat) (high : Nat) : Nat × Nat :=
|
||||
if high < low then
|
||||
(low, seed)
|
||||
else
|
||||
let (n, seed') := randBelow seed (high - low + 1)
|
||||
(low + n, seed')
|
||||
|
||||
|
||||
def randomAsciiBytes (seed : Nat) (len : Nat) : ByteArray × Nat := Id.run do
|
||||
let mut s := seed
|
||||
let mut out := ByteArray.empty
|
||||
|
||||
for _ in [0:len] do
|
||||
let (r, s') := randBelow s 38
|
||||
s := s'
|
||||
|
||||
let code :=
|
||||
if r < 26 then
|
||||
97 + r
|
||||
else if r < 36 then
|
||||
48 + (r - 26)
|
||||
else if r == 36 then
|
||||
45
|
||||
else
|
||||
95
|
||||
|
||||
out := out.push (UInt8.ofNat code)
|
||||
|
||||
(out, s)
|
||||
|
||||
|
||||
def randomTokenBytes (seed : Nat) (len : Nat) : ByteArray × Nat := Id.run do
|
||||
let mut s := seed
|
||||
let mut out := ByteArray.empty
|
||||
|
||||
for _ in [0:len] do
|
||||
let (r, s') := randBelow s 36
|
||||
s := s'
|
||||
|
||||
let code :=
|
||||
if r < 26 then
|
||||
97 + r
|
||||
else
|
||||
48 + (r - 26)
|
||||
|
||||
out := out.push (UInt8.ofNat code)
|
||||
|
||||
(out, s)
|
||||
|
||||
|
||||
def randomSplit (seed : Nat) (data : ByteArray) (maxPart : Nat := 17) : Array ByteArray × Nat := Id.run do
|
||||
let mut s := seed
|
||||
let mut out : Array ByteArray := #[]
|
||||
let mut i := 0
|
||||
|
||||
while i < data.size do
|
||||
let remaining := data.size - i
|
||||
let upper := Nat.min maxPart remaining
|
||||
let (partLen, s') := randIn s 1 upper
|
||||
s := s'
|
||||
|
||||
out := out.push (data.extract i (i + partLen))
|
||||
i := i + partLen
|
||||
|
||||
(out, s)
|
||||
|
||||
|
||||
def randomChunkedPayload (seed : Nat) (body : ByteArray) : ByteArray × Nat := Id.run do
|
||||
let mut s := seed
|
||||
let mut out := ByteArray.empty
|
||||
let mut i := 0
|
||||
|
||||
while i < body.size do
|
||||
let remaining := body.size - i
|
||||
let maxChunk := Nat.min 9 remaining
|
||||
let (chunkLen, s') := randIn s 1 maxChunk
|
||||
s := s'
|
||||
|
||||
out := out ++ s!"{chunkLen}\x0d\n".toUTF8
|
||||
out := out ++ body.extract i (i + chunkLen)
|
||||
out := out ++ "\x0d\n".toUTF8
|
||||
i := i + chunkLen
|
||||
|
||||
out := out ++ "0\x0d\n\x0d\n".toUTF8
|
||||
(out, s)
|
||||
|
||||
|
||||
def mkContentLengthHead (path : String) (bodySize : Nat) : ByteArray :=
|
||||
s!"POST {path} HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: {bodySize}\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
||||
|
||||
|
||||
def mkChunkedHead (path : String) : ByteArray :=
|
||||
s!"POST {path} HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
||||
|
||||
|
||||
def randomChunkExtensionList (seed : Nat) (count : Nat) : String × Nat := Id.run do
|
||||
let mut s := seed
|
||||
let mut ext := ""
|
||||
|
||||
for _ in [0:count] do
|
||||
let (nameLen, s1) := randIn s 1 3
|
||||
s := s1
|
||||
let (valueLen, s2) := randIn s 1 3
|
||||
s := s2
|
||||
|
||||
let (nameBytes, s3) := randomTokenBytes s nameLen
|
||||
s := s3
|
||||
let (valueBytes, s4) := randomTokenBytes s valueLen
|
||||
s := s4
|
||||
|
||||
let name := String.fromUTF8! nameBytes
|
||||
let value := String.fromUTF8! valueBytes
|
||||
ext := ext ++ s!";{name}={value}"
|
||||
|
||||
(ext, s)
|
||||
|
||||
|
||||
def randomTrailerLines (seed : Nat) (count : Nat) : String × Nat := Id.run do
|
||||
let mut s := seed
|
||||
let mut lines := ""
|
||||
|
||||
for i in [0:count] do
|
||||
let (nameLen, s1) := randIn s 1 4
|
||||
s := s1
|
||||
let (valueLen, s2) := randIn s 1 6
|
||||
s := s2
|
||||
|
||||
let (nameBytes, s3) := randomTokenBytes s nameLen
|
||||
s := s3
|
||||
let (valueBytes, s4) := randomTokenBytes s valueLen
|
||||
s := s4
|
||||
|
||||
let name := String.fromUTF8! nameBytes
|
||||
let value := String.fromUTF8! valueBytes
|
||||
lines := lines ++ s!"X{i}-{name}: {value}\x0d\n"
|
||||
|
||||
(lines, s)
|
||||
|
||||
|
||||
def echoBodyHandler : TestHandler := fun req => do
|
||||
let body : String ← req.body.readAll
|
||||
Response.ok |>.text body
|
||||
|
||||
|
||||
def runPipelinedReadAll
|
||||
(raw : ByteArray)
|
||||
(config : Config := defaultConfig) : IO (ByteArray × Array String) := Async.block do
|
||||
let (client, server) ← Mock.new
|
||||
let seenRef ← IO.mkRef (#[] : Array String)
|
||||
|
||||
let handler : TestHandler := fun req => do
|
||||
let uri := toString req.line.uri
|
||||
seenRef.modify (·.push uri)
|
||||
let _body : String ← req.body.readAll
|
||||
Response.ok |>.text uri
|
||||
|
||||
client.send raw
|
||||
closeChannelIdempotent client.getSendChan
|
||||
|
||||
Std.Http.Server.serveConnection server handler config
|
||||
|>.run
|
||||
|
||||
let response ← client.recv?
|
||||
let seen ← seenRef.get
|
||||
pure (response.getD .empty, seen)
|
||||
|
||||
|
||||
def fuzzContentLengthEcho (iterations : Nat) (seed0 : Nat) : IO Unit := do
|
||||
let mut seed := seed0
|
||||
|
||||
for i in [0:iterations] do
|
||||
let caseSeed := seed
|
||||
|
||||
let (len, seed1) := randIn seed 0 128
|
||||
seed := seed1
|
||||
|
||||
let (body, seed2) := randomAsciiBytes seed len
|
||||
seed := seed2
|
||||
|
||||
let head := mkContentLengthHead s!"/fuzz-cl-{i}" body.size
|
||||
let (bodyParts, seed3) := randomSplit seed body
|
||||
seed := seed3
|
||||
let parts := #[head] ++ bodyParts
|
||||
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendFragmentedAndClose client server parts echoBodyHandler
|
||||
|
||||
let expectedBody := String.fromUTF8! body
|
||||
assertStatusPrefix s!"fuzzContentLengthEcho case={i} seed={caseSeed}" response "HTTP/1.1 200"
|
||||
|
||||
let gotBody := responseBody response
|
||||
if gotBody != expectedBody then
|
||||
throw <| IO.userError s!"fuzzContentLengthEcho case={i} seed={caseSeed} failed:\nExpected body {expectedBody.quote}\nGot body {gotBody.quote}\nFull response:\n{(responseText response).quote}"
|
||||
|
||||
|
||||
def fuzzContentLengthLeadingZerosAccepted (iterations : Nat) (seed0 : Nat) : IO Unit := do
|
||||
let mut seed := seed0
|
||||
|
||||
for i in [0:iterations] do
|
||||
let caseSeed := seed
|
||||
|
||||
let (len, seed1) := randIn seed 1 96
|
||||
seed := seed1
|
||||
|
||||
let (leadingZeros, seed2) := randIn seed 1 5
|
||||
seed := seed2
|
||||
|
||||
let (body, seed3) := randomAsciiBytes seed len
|
||||
seed := seed3
|
||||
|
||||
let clToken := String.ofList (List.replicate leadingZeros '0') ++ toString len
|
||||
let raw :=
|
||||
s!"POST /cl-leading-zeros-{i} HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: {clToken}\x0d\nConnection: close\x0d\n\x0d\n".toUTF8 ++ body
|
||||
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendRaw client server raw echoBodyHandler
|
||||
|
||||
let expectedBody := String.fromUTF8! body
|
||||
assertStatusPrefix s!"fuzzContentLengthLeadingZerosAccepted case={i} seed={caseSeed} len={len} zeros={leadingZeros}" response "HTTP/1.1 200"
|
||||
|
||||
let gotBody := responseBody response
|
||||
if gotBody != expectedBody then
|
||||
throw <| IO.userError s!"fuzzContentLengthLeadingZerosAccepted case={i} seed={caseSeed} failed:\nExpected body {expectedBody.quote}\nGot body {gotBody.quote}\nFull response:\n{(responseText response).quote}"
|
||||
|
||||
|
||||
def fuzzChunkedEcho (iterations : Nat) (seed0 : Nat) : IO Unit := do
|
||||
let mut seed := seed0
|
||||
|
||||
for i in [0:iterations] do
|
||||
let caseSeed := seed
|
||||
|
||||
let (len, seed1) := randIn seed 0 128
|
||||
seed := seed1
|
||||
|
||||
let (body, seed2) := randomAsciiBytes seed len
|
||||
seed := seed2
|
||||
|
||||
let (chunkedBody, seed3) := randomChunkedPayload seed body
|
||||
seed := seed3
|
||||
|
||||
let head := mkChunkedHead s!"/fuzz-chunked-{i}"
|
||||
let raw := head ++ chunkedBody
|
||||
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendRaw client server raw echoBodyHandler
|
||||
|
||||
let expectedBody := String.fromUTF8! body
|
||||
assertStatusPrefix s!"fuzzChunkedEcho case={i} seed={caseSeed}" response "HTTP/1.1 200"
|
||||
|
||||
let gotBody := responseBody response
|
||||
if gotBody != expectedBody then
|
||||
throw <| IO.userError s!"fuzzChunkedEcho case={i} seed={caseSeed} failed:\nExpected body {expectedBody.quote}\nGot body {gotBody.quote}\nFull response:\n{(responseText response).quote}"
|
||||
|
||||
|
||||
def fuzzMixedTransferEncodingAndContentLengthRejected (iterations : Nat) (seed0 : Nat) : IO Unit := do
|
||||
let mut seed := seed0
|
||||
|
||||
for i in [0:iterations] do
|
||||
let caseSeed := seed
|
||||
|
||||
let (len, seed1) := randIn seed 0 96
|
||||
seed := seed1
|
||||
|
||||
let (body, seed2) := randomAsciiBytes seed len
|
||||
seed := seed2
|
||||
|
||||
let (chunkedBody, seed3) := randomChunkedPayload seed body
|
||||
seed := seed3
|
||||
|
||||
let (declaredCl, seed4) := randIn seed 0 128
|
||||
seed := seed4
|
||||
|
||||
let raw :=
|
||||
s!"POST /te-cl-{i} HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nContent-Length: {declaredCl}\x0d\nConnection: close\x0d\n\x0d\n".toUTF8 ++ chunkedBody
|
||||
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendRaw client server raw echoBodyHandler
|
||||
assertExact response bad400
|
||||
|
||||
|
||||
def fuzzInvalidChunkSizeRejected (iterations : Nat) (seed0 : Nat) : IO Unit := do
|
||||
let badTokens : Array String := #["g", "G", "z", "Z", "@", "!", "x"]
|
||||
let mut seed := seed0
|
||||
|
||||
for i in [0:iterations] do
|
||||
let caseSeed := seed
|
||||
|
||||
let (idx, seed1) := randBelow seed badTokens.size
|
||||
seed := seed1
|
||||
|
||||
let token := badTokens[idx]!
|
||||
let raw :=
|
||||
s!"POST /bad-size-{i} HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n{token}\x0d\nabc\x0d\n0\x0d\n\x0d\n".toUTF8
|
||||
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendRaw client server raw echoBodyHandler
|
||||
|
||||
assertExact response bad400
|
||||
|
||||
|
||||
def fuzzDuplicateContentLengthRejected (iterations : Nat) (seed0 : Nat) : IO Unit := do
|
||||
let mut seed := seed0
|
||||
|
||||
for i in [0:iterations] do
|
||||
let caseSeed := seed
|
||||
|
||||
let (cl1, seed1) := randIn seed 0 64
|
||||
seed := seed1
|
||||
|
||||
let (same, seed2) := randBelow seed 2
|
||||
seed := seed2
|
||||
|
||||
let (delta, seed3) := randIn seed 1 10
|
||||
seed := seed3
|
||||
|
||||
let cl2 := if same == 0 then cl1 else cl1 + delta
|
||||
let (body, seed4) := randomAsciiBytes seed cl1
|
||||
seed := seed4
|
||||
|
||||
let raw :=
|
||||
s!"POST /dup-cl-{i} HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: {cl1}\x0d\nContent-Length: {cl2}\x0d\nConnection: close\x0d\n\x0d\n".toUTF8 ++ body
|
||||
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendRaw client server raw echoBodyHandler
|
||||
|
||||
assertExact response bad400
|
||||
|
||||
|
||||
def fuzzChunkExtensionLimits (iterations : Nat) (seed0 : Nat) : IO Unit := do
|
||||
let config : Config := {
|
||||
lingeringTimeout := 1000
|
||||
generateDate := false
|
||||
maxChunkExtNameLength := 4
|
||||
maxChunkExtValueLength := 4
|
||||
}
|
||||
|
||||
let mut seed := seed0
|
||||
|
||||
for i in [0:iterations] do
|
||||
let caseSeed := seed
|
||||
|
||||
let (nameLen, seed1) := randIn seed 1 8
|
||||
seed := seed1
|
||||
|
||||
let (valueLen, seed2) := randIn seed 1 8
|
||||
seed := seed2
|
||||
|
||||
let (nameBytes, seed3) := randomTokenBytes seed nameLen
|
||||
seed := seed3
|
||||
|
||||
let (valueBytes, seed4) := randomTokenBytes seed valueLen
|
||||
seed := seed4
|
||||
|
||||
let name := String.fromUTF8! nameBytes
|
||||
let value := String.fromUTF8! valueBytes
|
||||
|
||||
let raw :=
|
||||
s!"POST /ext-limit-{i} HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n1;{name}={value}\x0d\nx\x0d\n0\x0d\n\x0d\n".toUTF8
|
||||
|
||||
let expectOk := nameLen ≤ 4 ∧ valueLen ≤ 4
|
||||
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendRaw client server raw echoBodyHandler (config := config)
|
||||
|
||||
if expectOk then
|
||||
assertStatusPrefix s!"fuzzChunkExtensionLimits case={i} seed={caseSeed} nameLen={nameLen} valueLen={valueLen}" response "HTTP/1.1 200"
|
||||
else
|
||||
assertExact response bad400
|
||||
|
||||
|
||||
def fuzzChunkExtensionCountLimit (iterations : Nat) (seed0 : Nat) : IO Unit := do
|
||||
let config : Config := {
|
||||
lingeringTimeout := 1000
|
||||
generateDate := false
|
||||
maxChunkExtensions := 2
|
||||
}
|
||||
|
||||
let mut seed := seed0
|
||||
|
||||
for i in [0:iterations] do
|
||||
let caseSeed := seed
|
||||
|
||||
let (extCount, seed1) := randIn seed 0 5
|
||||
seed := seed1
|
||||
|
||||
let (extList, seed2) := randomChunkExtensionList seed extCount
|
||||
seed := seed2
|
||||
|
||||
let raw :=
|
||||
s!"POST /ext-count-{i} HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n1{extList}\x0d\nx\x0d\n0\x0d\n\x0d\n".toUTF8
|
||||
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendRaw client server raw echoBodyHandler (config := config)
|
||||
|
||||
if extCount ≤ 2 then
|
||||
assertStatusPrefix s!"fuzzChunkExtensionCountLimit case={i} seed={caseSeed} extCount={extCount}" response "HTTP/1.1 200"
|
||||
else
|
||||
assertExact response bad400
|
||||
|
||||
|
||||
def fuzzTrailerHeaderCountLimit (iterations : Nat) (seed0 : Nat) : IO Unit := do
|
||||
let config : Config := {
|
||||
lingeringTimeout := 1000
|
||||
generateDate := false
|
||||
maxTrailerHeaders := 2
|
||||
}
|
||||
|
||||
let mut seed := seed0
|
||||
|
||||
for i in [0:iterations] do
|
||||
let caseSeed := seed
|
||||
|
||||
let (trailerCount, seed1) := randIn seed 0 5
|
||||
seed := seed1
|
||||
|
||||
let (trailers, seed2) := randomTrailerLines seed trailerCount
|
||||
seed := seed2
|
||||
|
||||
let raw :=
|
||||
s!"POST /trailers-{i} HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n1\x0d\na\x0d\n0\x0d\n{trailers}\x0d\n".toUTF8
|
||||
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendRaw client server raw echoBodyHandler (config := config)
|
||||
|
||||
if trailerCount ≤ 2 then
|
||||
assertStatusPrefix s!"fuzzTrailerHeaderCountLimit case={i} seed={caseSeed} trailerCount={trailerCount}" response "HTTP/1.1 200"
|
||||
else
|
||||
assertExact response bad400
|
||||
|
||||
def fuzzCompleteFirstBodyAllowsPipeline (iterations : Nat) (seed0 : Nat) : IO Unit := do
|
||||
let mut seed := seed0
|
||||
|
||||
for i in [0:iterations] do
|
||||
let caseSeed := seed
|
||||
|
||||
let (len, seed1) := randIn seed 0 32
|
||||
seed := seed1
|
||||
|
||||
let (body, seed2) := randomAsciiBytes seed len
|
||||
seed := seed2
|
||||
|
||||
let uri1 := s!"/first-complete-{i}"
|
||||
let req1 :=
|
||||
s!"POST {uri1} HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: {len}\x0d\n\x0d\n".toUTF8 ++ body
|
||||
let req2 := "GET /second HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
||||
|
||||
let (response, seen) ← runPipelinedReadAll (req1 ++ req2)
|
||||
|
||||
let text := responseText response
|
||||
assertStatusCount s!"fuzzCompleteFirstBodyAllowsPipeline case={i} seed={caseSeed}" response 2
|
||||
|
||||
unless text.contains uri1 do
|
||||
throw <| IO.userError s!"fuzzCompleteFirstBodyAllowsPipeline case={i} seed={caseSeed} failed:\nMissing first URI {uri1.quote}\n{text.quote}"
|
||||
|
||||
unless text.contains "/second" do
|
||||
throw <| IO.userError s!"fuzzCompleteFirstBodyAllowsPipeline case={i} seed={caseSeed} failed:\nMissing second response\n{text.quote}"
|
||||
|
||||
if seen.size != 2 ∨ seen[0]! != uri1 ∨ seen[1]! != "/second" then
|
||||
throw <| IO.userError s!"fuzzCompleteFirstBodyAllowsPipeline case={i} seed={caseSeed} failed:\nExpected seen=[{uri1.quote}, \"/second\"] got {seen}"
|
||||
|
||||
|
||||
-- Property: Content-Length framing is stable across random payloads and random transport splits.
|
||||
#eval runWithTimeout "fuzz_content_length_echo" 20000 do
|
||||
fuzzContentLengthEcho 40 0x00C0FFEE
|
||||
|
||||
-- Property: Content-Length with randomized leading zeros is accepted and decoded to exact body length.
|
||||
#eval runWithTimeout "fuzz_content_length_leading_zeros_accepted" 20000 do
|
||||
fuzzContentLengthLeadingZerosAccepted 30 0x00CAB005
|
||||
|
||||
-- Property: Chunked framing reconstructs exact bodies under random chunking and transport splits.
|
||||
#eval runWithTimeout "fuzz_chunked_echo" 20000 do
|
||||
fuzzChunkedEcho 40 0x00123456
|
||||
|
||||
-- Property: Mixing Transfer-Encoding with Content-Length is always rejected.
|
||||
#eval runWithTimeout "fuzz_te_cl_mixed_rejected" 20000 do
|
||||
fuzzMixedTransferEncodingAndContentLengthRejected 30 0x0010CE11
|
||||
|
||||
-- Property: Invalid chunk-size tokens are rejected deterministically with 400.
|
||||
#eval runWithTimeout "fuzz_invalid_chunk_size_rejected" 20000 do
|
||||
fuzzInvalidChunkSizeRejected 30 0x00BAD001
|
||||
|
||||
-- Property: Duplicate Content-Length headers are always rejected (same or different values).
|
||||
#eval runWithTimeout "fuzz_duplicate_content_length_rejected" 20000 do
|
||||
fuzzDuplicateContentLengthRejected 30 0x00D0C1A7
|
||||
|
||||
-- Property: Chunk extension name/value limits are enforced under randomized lengths.
|
||||
#eval runWithTimeout "fuzz_chunk_extension_limits" 20000 do
|
||||
fuzzChunkExtensionLimits 40 0x00A11CE5
|
||||
|
||||
-- Property: Chunk extension count limit is enforced under randomized extension lists.
|
||||
#eval runWithTimeout "fuzz_chunk_extension_count_limit" 20000 do
|
||||
fuzzChunkExtensionCountLimit 35 0x00E77E11
|
||||
|
||||
-- Property: Trailer header count limit is enforced under randomized trailer sections.
|
||||
#eval runWithTimeout "fuzz_trailer_header_count_limit" 20000 do
|
||||
fuzzTrailerHeaderCountLimit 35 0x00A71A12
|
||||
|
||||
-- Property: Complete first request body allows pipelined follow-up parsing.
|
||||
#eval runWithTimeout "fuzz_complete_first_body_allows_pipeline" 20000 do
|
||||
fuzzCompleteFirstBodyAllowsPipeline 30 0x00777777
|
||||
450
tests/elab/async_http_fuzz_limits.lean
Normal file
450
tests/elab/async_http_fuzz_limits.lean
Normal file
@@ -0,0 +1,450 @@
|
||||
import Std.Internal.Http
|
||||
import Std.Internal.Async
|
||||
|
||||
open Std.Internal.IO Async
|
||||
open Std Http Test
|
||||
|
||||
/-!
|
||||
# Limit-enforcement fuzzing for the HTTP/1.1 server.
|
||||
|
||||
Tests that every configurable limit in `H1.Config` and `Server.Config` is
|
||||
correctly enforced under randomized inputs. Inspired by hyper's fuzzing of
|
||||
size and count limits.
|
||||
-/
|
||||
|
||||
def closeChannelIdempotent {α : Type} (ch : Std.CloseableChannel α) : IO Unit := do
|
||||
match ← EIO.toBaseIO ch.close with
|
||||
| .ok _ => pure ()
|
||||
| .error .alreadyClosed => pure ()
|
||||
| .error err => throw <| IO.userError (toString err)
|
||||
|
||||
def sendRaw
|
||||
(client : Mock.Client) (server : Mock.Server) (raw : ByteArray)
|
||||
(handler : TestHandler) (config : Config) : IO ByteArray := Async.block do
|
||||
client.send raw
|
||||
Std.Http.Server.serveConnection server handler config |>.run
|
||||
let res ← client.recv?
|
||||
pure (res.getD .empty)
|
||||
|
||||
def sendRawAndClose
|
||||
(client : Mock.Client) (server : Mock.Server) (raw : ByteArray)
|
||||
(handler : TestHandler) (config : Config) : IO ByteArray := Async.block do
|
||||
client.send raw
|
||||
closeChannelIdempotent client.getSendChan
|
||||
Std.Http.Server.serveConnection server handler config |>.run
|
||||
let res ← client.recv?
|
||||
pure (res.getD .empty)
|
||||
|
||||
def runWithTimeout {α : Type} (name : String) (timeoutMs : Nat := 20000) (action : IO α) : IO α := do
|
||||
let task ← IO.asTask action
|
||||
let ticks := (timeoutMs + 9) / 10
|
||||
let rec loop (remaining : Nat) : IO α := do
|
||||
if (← IO.getTaskState task) == .finished then
|
||||
match (← IO.wait task) with
|
||||
| .ok x => pure x
|
||||
| .error err => throw err
|
||||
else
|
||||
match remaining with
|
||||
| 0 => IO.cancel task; throw <| IO.userError s!"Test '{name}' timed out"
|
||||
| n + 1 => IO.sleep 10; loop n
|
||||
loop ticks
|
||||
|
||||
-- PRNG
|
||||
def nextSeed (seed : Nat) : Nat := (1664525 * seed + 1013904223) % 4294967296
|
||||
def randBelow (seed : Nat) (n : Nat) : Nat × Nat :=
|
||||
let s := nextSeed seed
|
||||
(if n == 0 then 0 else s % n, s)
|
||||
def randIn (seed : Nat) (lo hi : Nat) : Nat × Nat :=
|
||||
if hi < lo then (lo, seed) else
|
||||
let (r, s) := randBelow seed (hi - lo + 1)
|
||||
(lo + r, s)
|
||||
|
||||
def randomTokenBytes (seed : Nat) (len : Nat) : ByteArray × Nat := Id.run do
|
||||
let mut s := seed; let mut out := ByteArray.empty
|
||||
for _ in [0:len] do
|
||||
let (r, s') := randBelow s 36; s := s'
|
||||
let code := if r < 26 then 97 + r else 48 + (r - 26)
|
||||
out := out.push (UInt8.ofNat code)
|
||||
(out, s)
|
||||
|
||||
def randomAsciiBytes (seed : Nat) (len : Nat) : ByteArray × Nat := Id.run do
|
||||
let mut s := seed; let mut out := ByteArray.empty
|
||||
for _ in [0:len] do
|
||||
let (r, s') := randBelow s 26; s := s'
|
||||
out := out.push (UInt8.ofNat (97 + r))
|
||||
(out, s)
|
||||
|
||||
private def toHexAux : Nat → Nat → String → String
|
||||
| 0, _, acc => acc
|
||||
| fuel + 1, n, acc =>
|
||||
if n == 0 then acc
|
||||
else
|
||||
let d := n % 16
|
||||
let c : Char := if d < 10 then Char.ofNat (48 + d) else Char.ofNat (87 + d)
|
||||
toHexAux fuel (n / 16) (String.ofList [c] ++ acc)
|
||||
|
||||
def natToHex (n : Nat) : String :=
|
||||
if n == 0 then "0" else toHexAux 16 n ""
|
||||
|
||||
def assertStatusPrefix (name : String) (response : ByteArray) (pfx : String) : IO Unit := do
|
||||
let text := String.fromUTF8! response
|
||||
unless text.startsWith pfx do
|
||||
throw <| IO.userError s!"Test '{name}' failed:\nExpected {pfx.quote}\nGot:\n{text.quote}"
|
||||
|
||||
def countOccurrences (s needle : String) : Nat :=
|
||||
if needle.isEmpty then 0 else (s.splitOn needle).length - 1
|
||||
|
||||
def bad400 : String :=
|
||||
"HTTP/1.1 400 Bad Request\x0d\nServer: LeanHTTP/1.1\x0d\nConnection: close\x0d\nContent-Length: 0\x0d\n\x0d\n"
|
||||
|
||||
def echoBodyHandler : TestHandler := fun req => do
|
||||
let body : String ← req.body.readAll
|
||||
Response.ok |>.text body
|
||||
|
||||
-- ============================================================================
|
||||
-- maxBodySize — Content-Length framing
|
||||
-- ============================================================================
|
||||
|
||||
-- Property: Content-Length body exactly at or below maxBodySize → 200.
|
||||
-- Content-Length body above maxBodySize → 413 (no body bytes needed).
|
||||
def fuzzBodySizeLimitContentLength (iterations : Nat) (seed0 : Nat) : IO Unit := do
|
||||
let limit : Nat := 64
|
||||
let config : Config := { lingeringTimeout := 1000, generateDate := false, maxBodySize := limit }
|
||||
let mut seed := seed0
|
||||
for i in [0:iterations] do
|
||||
let caseSeed := seed
|
||||
let (bodySize, s1) := randIn seed 0 (limit + 20); seed := s1
|
||||
let (bodyBytes, s2) := randomAsciiBytes seed bodySize; seed := s2
|
||||
let raw :=
|
||||
s!"POST /bl-cl-{i} HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: {bodySize}\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
||||
++ bodyBytes
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendRaw client server raw (fun req => do
|
||||
let _body : String ← req.body.readAll; Response.ok |>.text "ok") config
|
||||
if bodySize ≤ limit then
|
||||
assertStatusPrefix s!"fuzzBodySizeLimitCL iter={i} seed={caseSeed} size={bodySize}" response "HTTP/1.1 200"
|
||||
else
|
||||
assertStatusPrefix s!"fuzzBodySizeLimitCL iter={i} seed={caseSeed} size={bodySize}" response "HTTP/1.1 413"
|
||||
|
||||
-- ============================================================================
|
||||
-- maxBodySize — chunked framing
|
||||
-- ============================================================================
|
||||
|
||||
-- Property: chunked bodies with total bytes at or below maxBodySize → 200.
|
||||
-- Chunked bodies exceeding maxBodySize → 413.
|
||||
def fuzzBodySizeLimitChunked (iterations : Nat) (seed0 : Nat) : IO Unit := do
|
||||
let limit : Nat := 64
|
||||
let config : Config := { lingeringTimeout := 1000, generateDate := false, maxBodySize := limit }
|
||||
let mut seed := seed0
|
||||
for i in [0:iterations] do
|
||||
let caseSeed := seed
|
||||
-- Total body size across 1-4 chunks
|
||||
let (totalSize, s1) := randIn seed 0 (limit + 16); seed := s1
|
||||
let (numChunks, s2) := randIn seed 1 4; seed := s2
|
||||
|
||||
-- Build chunks that sum to totalSize
|
||||
let chunkSize := (totalSize + numChunks - 1) / numChunks
|
||||
let mut chunkedBody := ByteArray.empty
|
||||
let mut remaining := totalSize
|
||||
for _ in [0:numChunks] do
|
||||
if remaining == 0 then break
|
||||
let thisChunk := Nat.min chunkSize remaining
|
||||
let (chunkBytes, s3) := randomAsciiBytes seed thisChunk; seed := s3
|
||||
chunkedBody := chunkedBody ++ s!"{natToHex thisChunk}\x0d\n".toUTF8 ++ chunkBytes ++ "\x0d\n".toUTF8
|
||||
remaining := remaining - thisChunk
|
||||
chunkedBody := chunkedBody ++ "0\x0d\n\x0d\n".toUTF8
|
||||
|
||||
let raw :=
|
||||
s!"POST /bl-ch-{i} HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
||||
++ chunkedBody
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendRaw client server raw (fun req => do
|
||||
let _body : String ← req.body.readAll; Response.ok |>.text "ok") config
|
||||
if totalSize ≤ limit then
|
||||
assertStatusPrefix s!"fuzzBodySizeLimitChunked iter={i} seed={caseSeed} total={totalSize}" response "HTTP/1.1 200"
|
||||
else
|
||||
assertStatusPrefix s!"fuzzBodySizeLimitChunked iter={i} seed={caseSeed} total={totalSize}" response "HTTP/1.1 413"
|
||||
|
||||
-- ============================================================================
|
||||
-- maxHeaders — header count limit
|
||||
-- ============================================================================
|
||||
|
||||
-- Property: header count at or below maxHeaders → 200.
|
||||
-- header count above maxHeaders → 400.
|
||||
def fuzzHeaderCountLimit (iterations : Nat) (seed0 : Nat) : IO Unit := do
|
||||
let limit : Nat := 5
|
||||
let config : Config := { lingeringTimeout := 1000, generateDate := false, maxHeaders := limit }
|
||||
let mut seed := seed0
|
||||
for i in [0:iterations] do
|
||||
let caseSeed := seed
|
||||
-- Host counts as 1 header, Connection as 1, so extra headers = headerCount - 2
|
||||
let (headerCount, s1) := randIn seed 2 (limit + 4); seed := s1
|
||||
let extraCount := headerCount - 2 -- we always add Host + Connection
|
||||
|
||||
let mut extraHeaders := ""
|
||||
let mut s := s1
|
||||
for j in [0:extraCount] do
|
||||
let (nameLen, s2) := randIn s 1 8; s := s2
|
||||
let (nameBytes, s3) := randomTokenBytes s nameLen; s := s3
|
||||
let name := String.fromUTF8! nameBytes
|
||||
extraHeaders := extraHeaders ++ s!"X-Extra-{j}-{name}: value\x0d\n"
|
||||
seed := s
|
||||
|
||||
let raw :=
|
||||
s!"GET /hc-{i} HTTP/1.1\x0d\nHost: example.com\x0d\n{extraHeaders}Connection: close\x0d\n\x0d\n".toUTF8
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendRawAndClose client server raw okHandler config
|
||||
-- headerCount includes Host and Connection (always present)
|
||||
if headerCount ≤ limit then
|
||||
assertStatusPrefix s!"fuzzHeaderCount iter={i} seed={caseSeed} count={headerCount}" response "HTTP/1.1 200"
|
||||
else
|
||||
assertStatusPrefix s!"fuzzHeaderCount iter={i} seed={caseSeed} count={headerCount}" response "HTTP/1.1 431"
|
||||
|
||||
-- ============================================================================
|
||||
-- maxHeaderBytes — total header bytes limit
|
||||
-- ============================================================================
|
||||
|
||||
-- Property: headers whose aggregate bytes (name + ": " + value + "\r\n") are at or
|
||||
-- below maxHeaderBytes are accepted; above it they are rejected with 400.
|
||||
def fuzzHeaderTotalBytesLimit (iterations : Nat) (seed0 : Nat) : IO Unit := do
|
||||
-- Fixed baseline: "Host: example.com\r\n" + "Connection: close\r\n" = 20 + 20 = 40 bytes.
|
||||
-- We'll add a single large X-Payload header to cross the boundary.
|
||||
let limit : Nat := 200
|
||||
let config : Config := {
|
||||
lingeringTimeout := 1000
|
||||
generateDate := false
|
||||
maxHeaderBytes := limit
|
||||
maxHeaderValueLength := limit + 100 -- allow value longer than total limit for testing
|
||||
}
|
||||
let baseline := ("Host: example.com\x0d\nConnection: close\x0d\n".toUTF8).size
|
||||
let mut seed := seed0
|
||||
for i in [0:iterations] do
|
||||
let caseSeed := seed
|
||||
-- Pick a value size that puts total bytes just under or over the limit
|
||||
-- Each "X-Payload: " header = name(9) + ": "(2) + value + "\r\n"(2) = value + 13
|
||||
let overhead := baseline + 13 -- "X-Payload" (9) + ": " (2) + "\r\n" (2) + baseline
|
||||
-- We want value sizes that land on both sides of (limit - overhead)
|
||||
let boundary := if limit > overhead then limit - overhead else 0
|
||||
let (valueSize, s1) := randIn seed 0 (boundary + 20); seed := s1
|
||||
let (valueBytes, s2) := randomAsciiBytes seed valueSize; seed := s2
|
||||
let value := String.fromUTF8! valueBytes
|
||||
|
||||
let raw :=
|
||||
s!"GET /hb-{i} HTTP/1.1\x0d\nHost: example.com\x0d\nX-Payload: {value}\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendRawAndClose client server raw okHandler config
|
||||
-- Total bytes = baseline + "X-Payload: " + value + "\r\n"
|
||||
let totalBytes := baseline + 9 + 2 + valueSize + 2
|
||||
if totalBytes ≤ limit then
|
||||
assertStatusPrefix s!"fuzzHeaderBytes iter={i} seed={caseSeed} total={totalBytes}" response "HTTP/1.1 200"
|
||||
else
|
||||
assertStatusPrefix s!"fuzzHeaderBytes iter={i} seed={caseSeed} total={totalBytes}" response "HTTP/1.1 431"
|
||||
|
||||
-- ============================================================================
|
||||
-- maxMessages — requests per connection
|
||||
-- ============================================================================
|
||||
|
||||
-- Property: after maxMessages requests on a single connection, the server
|
||||
-- closes the connection (disables keep-alive). All maxMessages
|
||||
-- requests receive a valid response.
|
||||
def fuzzMaxMessagesPerConnection (iterations : Nat) (seed0 : Nat) : IO Unit := do
|
||||
let limit : Nat := 3
|
||||
let config : Config := {
|
||||
lingeringTimeout := 1000
|
||||
generateDate := false
|
||||
maxRequests := limit
|
||||
}
|
||||
let mut seed := seed0
|
||||
for i in [0:iterations] do
|
||||
let caseSeed := seed
|
||||
let (reqCount, s1) := randIn seed 1 (limit + 3); seed := s1
|
||||
|
||||
-- Build reqCount keep-alive requests followed by close
|
||||
let mut raw := ByteArray.empty
|
||||
for j in [0:reqCount] do
|
||||
let connHeader :=
|
||||
if j + 1 == reqCount then "Connection: close\x0d\n" else "Connection: keep-alive\x0d\n"
|
||||
raw := raw ++ s!"GET /msg-{i}-{j} HTTP/1.1\x0d\nHost: example.com\x0d\n{connHeader}\x0d\n".toUTF8
|
||||
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendRawAndClose client server raw okHandler config
|
||||
let text := String.fromUTF8! response
|
||||
let seen := countOccurrences text "HTTP/1.1 200"
|
||||
let expected := Nat.min reqCount limit
|
||||
if seen != expected then
|
||||
throw <| IO.userError
|
||||
s!"fuzzMaxMessages iter={i} seed={caseSeed} reqCount={reqCount}: expected {expected} responses, got {seen}\n{text.quote}"
|
||||
|
||||
-- ============================================================================
|
||||
-- maxLeadingEmptyLines — leading CRLF before request-line
|
||||
-- ============================================================================
|
||||
|
||||
-- Property: at most maxLeadingEmptyLines bare CRLFs before the request-line are tolerated.
|
||||
-- More than that → 400.
|
||||
def fuzzLeadingEmptyLinesLimit (iterations : Nat) (seed0 : Nat) : IO Unit := do
|
||||
let limit : Nat := 4
|
||||
let config : Config := { lingeringTimeout := 1000, generateDate := false, maxLeadingEmptyLines := limit }
|
||||
let mut seed := seed0
|
||||
for i in [0:iterations] do
|
||||
let caseSeed := seed
|
||||
let (lineCount, s1) := randIn seed 0 (limit + 4); seed := s1
|
||||
let leadingCRLF := (List.replicate lineCount "\x0d\n").foldl (· ++ ·) ""
|
||||
let raw :=
|
||||
(leadingCRLF ++ s!"GET /le-{i} HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n").toUTF8
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendRawAndClose client server raw okHandler config
|
||||
if lineCount ≤ limit then
|
||||
assertStatusPrefix s!"fuzzLeadingEmptyLines iter={i} seed={caseSeed} count={lineCount}" response "HTTP/1.1 200"
|
||||
else
|
||||
assertStatusPrefix s!"fuzzLeadingEmptyLines iter={i} seed={caseSeed} count={lineCount}" response "HTTP/1.1 400"
|
||||
|
||||
-- ============================================================================
|
||||
-- maxSpaceSequence — OWS (optional whitespace) limit
|
||||
-- ============================================================================
|
||||
|
||||
-- Property: OWS sequences at or below maxSpaceSequence are accepted.
|
||||
-- OWS sequences exceeding the limit → 400.
|
||||
def fuzzOWSSequenceLimit (iterations : Nat) (seed0 : Nat) : IO Unit := do
|
||||
let limit : Nat := 4
|
||||
let config : Config := { lingeringTimeout := 1000, generateDate := false, maxSpaceSequence := limit }
|
||||
let mut seed := seed0
|
||||
for i in [0:iterations] do
|
||||
let caseSeed := seed
|
||||
let (spaceCount, s1) := randIn seed 0 (limit + 4); seed := s1
|
||||
let spaces := String.ofList (List.replicate spaceCount ' ')
|
||||
-- OWS appears between ':' and value in header fields
|
||||
let raw :=
|
||||
s!"GET /ows-{i} HTTP/1.1\x0d\nHost: example.com\x0d\nX-OWS:{spaces}value\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendRawAndClose client server raw okHandler config
|
||||
if spaceCount ≤ limit then
|
||||
assertStatusPrefix s!"fuzzOWSLimit iter={i} seed={caseSeed} spaces={spaceCount}" response "HTTP/1.1 200"
|
||||
else
|
||||
assertStatusPrefix s!"fuzzOWSLimit iter={i} seed={caseSeed} spaces={spaceCount}" response "HTTP/1.1 400"
|
||||
|
||||
-- ============================================================================
|
||||
-- maxStartLineLength — request-line length limit
|
||||
-- ============================================================================
|
||||
|
||||
-- Property: request lines at or below maxStartLineLength → 200.
|
||||
-- Request lines above maxStartLineLength → 414 (URI too long) or 400.
|
||||
def fuzzStartLineLengthLimit (iterations : Nat) (seed0 : Nat) : IO Unit := do
|
||||
let limit : Nat := 64
|
||||
let config : Config := {
|
||||
lingeringTimeout := 1000
|
||||
generateDate := false
|
||||
maxStartLineLength := limit
|
||||
maxUriLength := limit
|
||||
}
|
||||
let mut seed := seed0
|
||||
for i in [0:iterations] do
|
||||
let caseSeed := seed
|
||||
-- "GET " (4) + path + " HTTP/1.1\r\n" (12) → path can be up to (limit - 16)
|
||||
let pathBudget := if limit > 16 then limit - 16 else 1
|
||||
let (pathLen, s1) := randIn seed 1 (pathBudget + 10); seed := s1
|
||||
let path := "/" ++ String.ofList (List.replicate (pathLen - 1) 'a')
|
||||
let raw :=
|
||||
s!"GET {path} HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendRawAndClose client server raw okHandler config
|
||||
-- start-line = "GET " + path + " HTTP/1.1\r\n"
|
||||
let lineLen := 4 + pathLen + 11
|
||||
if lineLen ≤ limit then
|
||||
assertStatusPrefix s!"fuzzStartLineLen iter={i} seed={caseSeed} len={lineLen}" response "HTTP/1.1 200"
|
||||
else
|
||||
-- Either 414 (URI too long) or 400
|
||||
let text := String.fromUTF8! response
|
||||
unless text.startsWith "HTTP/1.1 414" || text.startsWith "HTTP/1.1 400" do
|
||||
throw <| IO.userError
|
||||
s!"fuzzStartLineLen iter={i} seed={caseSeed} len={lineLen}: expected 414 or 400, got {text.quote}"
|
||||
|
||||
-- ============================================================================
|
||||
-- maxHeaderNameLength and maxHeaderValueLength
|
||||
-- ============================================================================
|
||||
|
||||
-- Property: header names exceeding maxHeaderNameLength → 400.
|
||||
def fuzzHeaderNameLengthLimit (iterations : Nat) (seed0 : Nat) : IO Unit := do
|
||||
let limit : Nat := 16
|
||||
let config : Config := { lingeringTimeout := 1000, generateDate := false, maxHeaderNameLength := limit }
|
||||
let mut seed := seed0
|
||||
for i in [0:iterations] do
|
||||
let caseSeed := seed
|
||||
let (nameLen, s1) := randIn seed 1 (limit + 8); seed := s1
|
||||
let (nameBytes, s2) := randomTokenBytes seed nameLen; seed := s2
|
||||
let name := String.fromUTF8! nameBytes
|
||||
let raw :=
|
||||
s!"GET /hnl-{i} HTTP/1.1\x0d\nHost: example.com\x0d\n{name}: value\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendRawAndClose client server raw okHandler config
|
||||
if nameLen ≤ limit then
|
||||
assertStatusPrefix s!"fuzzHeaderNameLen iter={i} seed={caseSeed} len={nameLen}" response "HTTP/1.1 200"
|
||||
else
|
||||
assertStatusPrefix s!"fuzzHeaderNameLen iter={i} seed={caseSeed} len={nameLen}" response "HTTP/1.1 400"
|
||||
|
||||
-- Property: header values exceeding maxHeaderValueLength → 400.
|
||||
def fuzzHeaderValueLengthLimit (iterations : Nat) (seed0 : Nat) : IO Unit := do
|
||||
let limit : Nat := 32
|
||||
let config : Config := {
|
||||
lingeringTimeout := 1000
|
||||
generateDate := false
|
||||
maxHeaderValueLength := limit
|
||||
maxHeaderBytes := 65536 -- don't let total bytes limit interfere
|
||||
}
|
||||
let mut seed := seed0
|
||||
for i in [0:iterations] do
|
||||
let caseSeed := seed
|
||||
let (valueLen, s1) := randIn seed 0 (limit + 8); seed := s1
|
||||
let (valueBytes, s2) := randomAsciiBytes seed valueLen; seed := s2
|
||||
let value := String.fromUTF8! valueBytes
|
||||
let raw :=
|
||||
s!"GET /hvl-{i} HTTP/1.1\x0d\nHost: example.com\x0d\nX-Long: {value}\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendRawAndClose client server raw okHandler config
|
||||
if valueLen ≤ limit then
|
||||
assertStatusPrefix s!"fuzzHeaderValueLen iter={i} seed={caseSeed} len={valueLen}" response "HTTP/1.1 200"
|
||||
else
|
||||
assertStatusPrefix s!"fuzzHeaderValueLen iter={i} seed={caseSeed} len={valueLen}" response "HTTP/1.1 400"
|
||||
|
||||
-- ============================================================================
|
||||
-- Run all properties
|
||||
-- ============================================================================
|
||||
|
||||
-- Property: maxBodySize enforced for Content-Length framing.
|
||||
#eval runWithTimeout "fuzz_body_limit_content_length" 30000 do
|
||||
fuzzBodySizeLimitContentLength 50 0x00B0DEC0
|
||||
|
||||
-- Property: maxBodySize enforced for chunked framing.
|
||||
#eval runWithTimeout "fuzz_body_limit_chunked" 30000 do
|
||||
fuzzBodySizeLimitChunked 40 0x00C8BE11
|
||||
|
||||
-- Property: maxHeaders count limit is enforced.
|
||||
#eval runWithTimeout "fuzz_header_count_limit" 30000 do
|
||||
fuzzHeaderCountLimit 50 0x00AA55AA
|
||||
|
||||
-- Property: maxHeaderBytes aggregate limit is enforced.
|
||||
#eval runWithTimeout "fuzz_header_bytes_limit" 30000 do
|
||||
fuzzHeaderTotalBytesLimit 40 0x00FACE77
|
||||
|
||||
-- Property: maxMessages per connection closes keep-alive after the configured count.
|
||||
#eval runWithTimeout "fuzz_max_messages_per_connection" 30000 do
|
||||
fuzzMaxMessagesPerConnection 30 0x00123ABC
|
||||
|
||||
-- Property: maxLeadingEmptyLines limit is enforced.
|
||||
#eval runWithTimeout "fuzz_leading_empty_lines_limit" 30000 do
|
||||
fuzzLeadingEmptyLinesLimit 50 0x00EEF00D
|
||||
|
||||
-- Property: maxSpaceSequence (OWS) limit is enforced.
|
||||
#eval runWithTimeout "fuzz_ows_sequence_limit" 30000 do
|
||||
fuzzOWSSequenceLimit 50 0x00ABE5AB
|
||||
|
||||
-- Property: maxStartLineLength / maxUriLength is enforced, returning 414 or 400.
|
||||
#eval runWithTimeout "fuzz_start_line_length_limit" 30000 do
|
||||
fuzzStartLineLengthLimit 50 0x00C0FFEE
|
||||
|
||||
-- Property: maxHeaderNameLength is enforced.
|
||||
#eval runWithTimeout "fuzz_header_name_length_limit" 30000 do
|
||||
fuzzHeaderNameLengthLimit 50 0x00DEAD01
|
||||
|
||||
-- Property: maxHeaderValueLength is enforced.
|
||||
#eval runWithTimeout "fuzz_header_value_length_limit" 30000 do
|
||||
fuzzHeaderValueLengthLimit 50 0x00BEEF02
|
||||
286
tests/elab/async_http_fuzz_random.lean
Normal file
286
tests/elab/async_http_fuzz_random.lean
Normal file
@@ -0,0 +1,286 @@
|
||||
import Std.Internal.Http
|
||||
import Std.Internal.Async
|
||||
|
||||
open Std.Internal.IO Async
|
||||
open Std Http Test
|
||||
|
||||
/-!
|
||||
# Random-byte fuzzing for the HTTP/1.1 parser.
|
||||
|
||||
Inspired by hyper's `fuzz_h1_req` libFuzzer target. The core property: any byte
|
||||
sequence fed to the server must be handled without panicking, hanging, or
|
||||
producing a malformed response. The server must either:
|
||||
- Send no bytes (connection closed before a complete request arrives), or
|
||||
- Send a response that starts with "HTTP/1.1 ".
|
||||
-/
|
||||
|
||||
def closeChannelIdempotent {α : Type} (ch : Std.CloseableChannel α) : IO Unit := do
|
||||
match ← EIO.toBaseIO ch.close with
|
||||
| .ok _ => pure ()
|
||||
| .error .alreadyClosed => pure ()
|
||||
| .error err => throw <| IO.userError (toString err)
|
||||
|
||||
def sendRawAndClose
|
||||
(client : Mock.Client) (server : Mock.Server) (raw : ByteArray)
|
||||
(handler : TestHandler) (config : Config := defaultConfig) : IO ByteArray := Async.block do
|
||||
client.send raw
|
||||
closeChannelIdempotent client.getSendChan
|
||||
Std.Http.Server.serveConnection server handler config |>.run
|
||||
let res ← client.recv?
|
||||
pure (res.getD .empty)
|
||||
|
||||
def runWithTimeout {α : Type} (name : String) (timeoutMs : Nat := 20000) (action : IO α) : IO α := do
|
||||
let task ← IO.asTask action
|
||||
let ticks := (timeoutMs + 9) / 10
|
||||
let rec loop (remaining : Nat) : IO α := do
|
||||
if (← IO.getTaskState task) == .finished then
|
||||
match (← IO.wait task) with
|
||||
| .ok x => pure x
|
||||
| .error err => throw err
|
||||
else
|
||||
match remaining with
|
||||
| 0 => IO.cancel task; throw <| IO.userError s!"Test '{name}' timed out"
|
||||
| n + 1 => IO.sleep 10; loop n
|
||||
loop ticks
|
||||
|
||||
-- PRNG
|
||||
def nextSeed (seed : Nat) : Nat := (1664525 * seed + 1013904223) % 4294967296
|
||||
def randBelow (seed : Nat) (n : Nat) : Nat × Nat :=
|
||||
let s := nextSeed seed
|
||||
(if n == 0 then 0 else s % n, s)
|
||||
def randIn (seed : Nat) (lo hi : Nat) : Nat × Nat :=
|
||||
if hi < lo then (lo, seed) else
|
||||
let (r, s) := randBelow seed (hi - lo + 1)
|
||||
(lo + r, s)
|
||||
|
||||
-- All 256 byte values
|
||||
def randomFullBytes (seed : Nat) (len : Nat) : ByteArray × Nat := Id.run do
|
||||
let mut s := seed; let mut out := ByteArray.empty
|
||||
for _ in [0:len] do
|
||||
let (r, s') := randBelow s 256; s := s'
|
||||
out := out.push (UInt8.ofNat r)
|
||||
(out, s)
|
||||
|
||||
-- Server-generated responses are always valid ASCII. Verify the response is
|
||||
-- either empty or starts with the HTTP/1.1 status-line prefix.
|
||||
def assertValidHttpOrEmpty (name : String) (response : ByteArray) : IO Unit := do
|
||||
if response.size == 0 then pure ()
|
||||
else
|
||||
let isValidPrefix (pfx : String) :=
|
||||
let b := pfx.toUTF8
|
||||
response.size >= b.size && response.extract 0 b.size == b
|
||||
if isValidPrefix "HTTP/1.1 " || isValidPrefix "HTTP/1.0 " then pure ()
|
||||
else
|
||||
let display := match String.fromUTF8? (response.extract 0 (Nat.min 200 response.size)) with
|
||||
| some s => s.quote | none => "(non-UTF-8 bytes)"
|
||||
throw <| IO.userError
|
||||
s!"Test '{name}' failed:\nResponse is neither empty nor a valid HTTP response:\n{display}"
|
||||
|
||||
-- Property: any fully-random byte sequence never causes a panic or malformed response.
|
||||
-- Direct analogue of hyper's fuzz_h1_req libFuzzer target.
|
||||
def fuzzRandomBytesNoPanic (iterations : Nat) (seed0 : Nat) : IO Unit := do
|
||||
let mut seed := seed0
|
||||
for i in [0:iterations] do
|
||||
let caseSeed := seed
|
||||
let (len, s1) := randIn seed 0 96; seed := s1
|
||||
let (bytes, s2) := randomFullBytes seed len; seed := s2
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendRawAndClose client server bytes okHandler
|
||||
assertValidHttpOrEmpty s!"fuzzRandomBytesNoPanic iter={i} seed={caseSeed} len={len}" response
|
||||
|
||||
-- Property: flipping a single bit in any valid request must not cause a panic.
|
||||
def fuzzBitFlipOnValidRequests (iterations : Nat) (seed0 : Nat) : IO Unit := do
|
||||
let corpus : Array ByteArray := #[
|
||||
"GET / HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n".toUTF8,
|
||||
"POST /submit HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: 5\x0d\nConnection: close\x0d\n\x0d\nhello".toUTF8,
|
||||
"POST /chunked HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\n\x0d\n".toUTF8,
|
||||
"OPTIONS * HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n".toUTF8,
|
||||
"CONNECT example.com:443 HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n".toUTF8,
|
||||
"DELETE /resource HTTP/1.1\x0d\nHost: api.example.com\x0d\nAuthorization: Bearer token123\x0d\nConnection: close\x0d\n\x0d\n".toUTF8,
|
||||
]
|
||||
let mut seed := seed0
|
||||
for i in [0:iterations] do
|
||||
let caseSeed := seed
|
||||
let (idx, s1) := randBelow seed corpus.size; seed := s1
|
||||
let req := corpus[idx]!
|
||||
let (pos, s2) := randBelow seed req.size; seed := s2
|
||||
let (bit, s3) := randBelow seed 8; seed := s3
|
||||
let orig := req[pos]!
|
||||
let mask : UInt8 := (1 : UInt8) <<< bit.toUInt8
|
||||
let flipped := orig ^^^ mask
|
||||
let mutated := (req.extract 0 pos).push flipped ++ req.extract (pos + 1) req.size
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendRawAndClose client server mutated okHandler
|
||||
assertValidHttpOrEmpty
|
||||
s!"fuzzBitFlip iter={i} seed={caseSeed} reqIdx={idx} pos={pos} bit={bit}" response
|
||||
|
||||
-- Property: truncating a valid request at any byte boundary must not cause a panic.
|
||||
def fuzzTruncatedRequests (iterations : Nat) (seed0 : Nat) : IO Unit := do
|
||||
let corpus : Array ByteArray := #[
|
||||
"GET / HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n".toUTF8,
|
||||
"POST /data HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: 4\x0d\nConnection: close\x0d\n\x0d\ndata".toUTF8,
|
||||
"POST /chunked HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n4\x0d\ndata\x0d\n0\x0d\n\x0d\n".toUTF8,
|
||||
"HEAD /resource HTTP/1.1\x0d\nHost: example.com\x0d\nAccept: application/json\x0d\nConnection: close\x0d\n\x0d\n".toUTF8,
|
||||
]
|
||||
let mut seed := seed0
|
||||
for i in [0:iterations] do
|
||||
let caseSeed := seed
|
||||
let (idx, s1) := randBelow seed corpus.size; seed := s1
|
||||
let req := corpus[idx]!
|
||||
let (truncLen, s2) := randBelow seed req.size; seed := s2
|
||||
let truncated := req.extract 0 truncLen
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendRawAndClose client server truncated okHandler
|
||||
assertValidHttpOrEmpty
|
||||
s!"fuzzTruncated iter={i} seed={caseSeed} reqIdx={idx} truncLen={truncLen}" response
|
||||
|
||||
-- Property: a valid HTTP method prefix followed by garbage must not cause a panic.
|
||||
def fuzzMethodPrefixWithGarbage (iterations : Nat) (seed0 : Nat) : IO Unit := do
|
||||
let methods : Array ByteArray := #[
|
||||
"GET ".toUTF8, "POST ".toUTF8, "PUT ".toUTF8, "DELETE ".toUTF8,
|
||||
"HEAD ".toUTF8, "OPTIONS ".toUTF8, "PATCH ".toUTF8, "CONNECT ".toUTF8,
|
||||
"HTTP/1.1 ".toUTF8,
|
||||
ByteArray.empty,
|
||||
]
|
||||
let mut seed := seed0
|
||||
for i in [0:iterations] do
|
||||
let caseSeed := seed
|
||||
let (mIdx, s1) := randBelow seed methods.size; seed := s1
|
||||
let pfx := methods[mIdx]!
|
||||
let (gLen, s2) := randIn seed 0 64; seed := s2
|
||||
let (garbage, s3) := randomFullBytes seed gLen; seed := s3
|
||||
let request := pfx ++ garbage
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendRawAndClose client server request okHandler
|
||||
assertValidHttpOrEmpty
|
||||
s!"fuzzMethodPrefix iter={i} seed={caseSeed} mIdx={mIdx} gLen={gLen}" response
|
||||
|
||||
-- Property: high-byte (0x80–0xFF, non-ASCII) sequences must not cause a panic.
|
||||
def fuzzHighByteValues (iterations : Nat) (seed0 : Nat) : IO Unit := do
|
||||
let mut seed := seed0
|
||||
for i in [0:iterations] do
|
||||
let caseSeed := seed
|
||||
let (len, s1) := randIn seed 0 48; seed := s1
|
||||
let mut out := ByteArray.empty
|
||||
let mut s := s1
|
||||
for _ in [0:len] do
|
||||
let (r, s') := randBelow s 128; s := s'
|
||||
out := out.push (UInt8.ofNat (r + 128))
|
||||
seed := s
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendRawAndClose client server out okHandler
|
||||
assertValidHttpOrEmpty s!"fuzzHighBytes iter={i} seed={caseSeed} len={len}" response
|
||||
|
||||
-- Property: garbage appended after a complete request must not cause a panic.
|
||||
def fuzzGarbageAfterCompleteRequest (iterations : Nat) (seed0 : Nat) : IO Unit := do
|
||||
let validReq :=
|
||||
"GET /check HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
||||
let mut seed := seed0
|
||||
for i in [0:iterations] do
|
||||
let caseSeed := seed
|
||||
let (gLen, s1) := randIn seed 0 32; seed := s1
|
||||
let (garbage, s2) := randomFullBytes seed gLen; seed := s2
|
||||
let request := validReq ++ garbage
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendRawAndClose client server request okHandler
|
||||
assertValidHttpOrEmpty
|
||||
s!"fuzzGarbageAfter iter={i} seed={caseSeed} gLen={gLen}" response
|
||||
|
||||
-- Property: every single-byte input is handled safely (all 256 values).
|
||||
def fuzzSingleByteInputs : IO Unit := do
|
||||
for b in List.range 256 do
|
||||
let bytes := ByteArray.mk #[b.toUInt8]
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendRawAndClose client server bytes okHandler
|
||||
assertValidHttpOrEmpty s!"fuzzSingleByte byte={b}" response
|
||||
|
||||
-- Property: known attack patterns and real-world malformed inputs are handled safely.
|
||||
-- This is the Lean analogue of hyper's denial-of-service and smuggling corpus.
|
||||
def fuzzKnownMaliciousPatterns : IO Unit := do
|
||||
let patterns : Array ByteArray := #[
|
||||
-- TLS 1.2 client hello prefix
|
||||
ByteArray.mk #[0x16, 0x03, 0x01, 0x00, 0xa5, 0x01, 0x00, 0x00],
|
||||
-- TLS 1.3 client hello prefix
|
||||
ByteArray.mk #[0x16, 0x03, 0x03, 0x00, 0x7c, 0x01, 0x00, 0x00],
|
||||
-- HTTP/2 connection preface
|
||||
"PRI * HTTP/2.0\x0d\n\x0d\nSM\x0d\n\x0d\n".toUTF8,
|
||||
-- Bare LF line endings
|
||||
"GET / HTTP/1.1\nHost: example.com\n\n".toUTF8,
|
||||
-- CR-only line endings
|
||||
"GET / HTTP/1.1\x0dHost: example.com\x0d\x0d".toUTF8,
|
||||
-- Null bytes everywhere
|
||||
ByteArray.mk #[0x00, 0x00, 0x00, 0x00],
|
||||
-- CRLF injection attempt in request-line
|
||||
"GET /path%0d%0aInjected: header HTTP/1.1\x0d\nHost: example.com\x0d\n\x0d\n".toUTF8,
|
||||
-- Unicode in path (raw multibyte UTF-8)
|
||||
"GET /caf\xc3\xa9 HTTP/1.1\x0d\nHost: example.com\x0d\n\x0d\n".toUTF8,
|
||||
-- HTTP response sent as a request (should fail, not panic)
|
||||
"HTTP/1.1 200 OK\x0d\nContent-Length: 2\x0d\n\x0d\nok".toUTF8,
|
||||
-- Request smuggling: CL.TE
|
||||
"POST / HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: 6\x0d\nTransfer-Encoding: chunked\x0d\n\x0d\n0\x0d\n\x0d\nX".toUTF8,
|
||||
-- Request smuggling: TE.CL
|
||||
"POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nContent-Length: 3\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\n\x0d\n".toUTF8,
|
||||
-- Duplicate chunked coding
|
||||
"POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked, chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\n\x0d\n".toUTF8,
|
||||
-- TE with tab (whitespace obfuscation)
|
||||
"POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding:\x09chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\n\x0d\n".toUTF8,
|
||||
-- TE with null byte injection
|
||||
"POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunk\x00ed\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\n\x0d\n".toUTF8,
|
||||
-- Extremely long method token
|
||||
(String.ofList (List.replicate 8192 'A') ++ " / HTTP/1.1\x0d\nHost: h\x0d\n\x0d\n").toUTF8,
|
||||
-- SSRF-like absolute-form URI targeting internal host
|
||||
"GET http://169.254.169.254/latest/meta-data/ HTTP/1.1\x0d\nHost: example.com\x0d\n\x0d\n".toUTF8,
|
||||
-- Chunk size with non-hex chars
|
||||
"POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\nGG\x0d\nhello\x0d\n0\x0d\n\x0d\n".toUTF8,
|
||||
-- Chunk size overflow attempt (16+ hex digits)
|
||||
"POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\nffffffffffffffff1\x0d\nhello\x0d\n0\x0d\n\x0d\n".toUTF8,
|
||||
-- Header with embedded CRLF in value
|
||||
"GET / HTTP/1.1\x0d\nHost: example.com\x0d\nX-Inject: foo\x0d\nEvil: injected\x0d\nConnection: close\x0d\n\x0d\n".toUTF8,
|
||||
-- Multiple Host headers (smuggling attempt)
|
||||
"GET / HTTP/1.1\x0d\nHost: example.com\x0d\nHost: evil.com\x0d\nConnection: close\x0d\n\x0d\n".toUTF8,
|
||||
-- Absolute-form URI with bad host overrides Host header
|
||||
"GET http://evil.internal/steal HTTP/1.1\x0d\nHost: public.example.com\x0d\nConnection: close\x0d\n\x0d\n".toUTF8,
|
||||
-- Folded header (obs-fold, rejected per RFC 9112)
|
||||
"GET / HTTP/1.1\x0d\nHost: example.com\x0d\nX-Folded: value\x0d\n continuation\x0d\nConnection: close\x0d\n\x0d\n".toUTF8,
|
||||
]
|
||||
for i in [:patterns.size] do
|
||||
let pattern := patterns[i]!
|
||||
let (client, server) ← Mock.new
|
||||
let response ← sendRawAndClose client server pattern okHandler
|
||||
assertValidHttpOrEmpty s!"fuzzKnownMalicious pattern={i}" response
|
||||
|
||||
-- ============================================================================
|
||||
-- Run all properties
|
||||
-- ============================================================================
|
||||
|
||||
-- Property: any random byte sequence is handled safely (core libFuzzer equivalent).
|
||||
#eval runWithTimeout "fuzz_random_bytes_no_panic" 30000 do
|
||||
fuzzRandomBytesNoPanic 200 0x00FACADE
|
||||
|
||||
-- Property: single bit mutations on valid requests are handled safely.
|
||||
#eval runWithTimeout "fuzz_bit_flip_valid_requests" 30000 do
|
||||
fuzzBitFlipOnValidRequests 150 0x00B1FF10
|
||||
|
||||
-- Property: truncation at any byte boundary is handled safely.
|
||||
#eval runWithTimeout "fuzz_truncated_requests" 30000 do
|
||||
fuzzTruncatedRequests 150 0x00DEAD42
|
||||
|
||||
-- Property: HTTP method prefix followed by random garbage is handled safely.
|
||||
#eval runWithTimeout "fuzz_method_prefix_with_garbage" 30000 do
|
||||
fuzzMethodPrefixWithGarbage 100 0x00CA7500
|
||||
|
||||
-- Property: high-byte (non-ASCII) sequences are handled safely.
|
||||
#eval runWithTimeout "fuzz_high_byte_values" 30000 do
|
||||
fuzzHighByteValues 120 0x00FF8000
|
||||
|
||||
-- Property: garbage appended after a complete request is handled safely.
|
||||
#eval runWithTimeout "fuzz_garbage_after_complete_request" 30000 do
|
||||
fuzzGarbageAfterCompleteRequest 100 0x00A1B2C3
|
||||
|
||||
-- Property: every single-byte input is handled safely (all 256 cases).
|
||||
#eval runWithTimeout "fuzz_single_byte_inputs" 30000 do
|
||||
fuzzSingleByteInputs
|
||||
|
||||
-- Property: known attack patterns and malformed inputs are handled safely.
|
||||
#eval runWithTimeout "fuzz_known_malicious_patterns" 30000 do
|
||||
fuzzKnownMaliciousPatterns
|
||||
317
tests/elab/async_http_h1_incremental.lean
Normal file
317
tests/elab/async_http_h1_incremental.lean
Normal file
@@ -0,0 +1,317 @@
|
||||
import Std.Internal.Http
|
||||
|
||||
open Std Http
|
||||
open Std.Http.Protocol.H1
|
||||
|
||||
private def ensure (name : String) (cond : Bool) (msg : String) : IO Unit := do
|
||||
unless cond do
|
||||
throw <| IO.userError s!"Test '{name}' failed:\n{msg}"
|
||||
|
||||
private def hasFailedEvent (events : Array (Event .receiving)) : Bool :=
|
||||
events.any fun
|
||||
| .failed _ => true
|
||||
| _ => false
|
||||
|
||||
private def hasNeedMoreDataEvent (events : Array (Event .receiving)) : Bool :=
|
||||
events.any fun
|
||||
| .needMoreData _ => true
|
||||
| _ => false
|
||||
|
||||
private def hasEndHeadersEvent (events : Array (Event .receiving)) : Bool :=
|
||||
events.any fun
|
||||
| .endHeaders _ => true
|
||||
| _ => false
|
||||
|
||||
private def hasCloseBodyEvent (events : Array (Event .receiving)) : Bool :=
|
||||
events.any fun
|
||||
| .closeBody => true
|
||||
| _ => false
|
||||
|
||||
private def hasContinueEvent (events : Array (Event .receiving)) : Bool :=
|
||||
events.any fun
|
||||
| .«continue» => true
|
||||
| _ => false
|
||||
|
||||
private def countNeedAnswerEvents (events : Array (Event .receiving)) : Nat :=
|
||||
events.foldl (init := 0) fun n e =>
|
||||
match e with
|
||||
| .needAnswer => n + 1
|
||||
| _ => n
|
||||
|
||||
private def countFailedEvents (events : Array (Event .receiving)) : Nat :=
|
||||
events.foldl (init := 0) fun n e =>
|
||||
match e with
|
||||
| .failed _ => n + 1
|
||||
| _ => n
|
||||
|
||||
private def pulledBodyBytes (chunks : Array PulledChunk) : ByteArray :=
|
||||
chunks.foldl (fun acc c => acc ++ c.chunk.data) .empty
|
||||
|
||||
private def splitEveryByte (data : ByteArray) : Array ByteArray := Id.run do
|
||||
let mut parts : Array ByteArray := #[]
|
||||
for i in [0:data.size] do
|
||||
parts := parts.push (data.extract i (i + 1))
|
||||
parts
|
||||
|
||||
private def nextSeed (seed : Nat) : Nat :=
|
||||
(1664525 * seed + 1013904223) % 4294967296
|
||||
|
||||
private def randBelow (seed : Nat) (maxExclusive : Nat) : Nat × Nat :=
|
||||
let seed' := nextSeed seed
|
||||
if maxExclusive = 0 then
|
||||
(0, seed')
|
||||
else
|
||||
(seed' % maxExclusive, seed')
|
||||
|
||||
private def randIn (seed : Nat) (low : Nat) (high : Nat) : Nat × Nat :=
|
||||
if high < low then
|
||||
(low, seed)
|
||||
else
|
||||
let (n, seed') := randBelow seed (high - low + 1)
|
||||
(low + n, seed')
|
||||
|
||||
private def randomAsciiBytes (seed : Nat) (len : Nat) : ByteArray × Nat := Id.run do
|
||||
let mut s := seed
|
||||
let mut out := ByteArray.empty
|
||||
for _ in [0:len] do
|
||||
let (r, s') := randBelow s 38
|
||||
s := s'
|
||||
let code :=
|
||||
if r < 26 then 97 + r
|
||||
else if r < 36 then 48 + (r - 26)
|
||||
else if r = 36 then 45
|
||||
else 95
|
||||
out := out.push (UInt8.ofNat code)
|
||||
(out, s)
|
||||
|
||||
private def randomSplit (seed : Nat) (data : ByteArray) (maxPart : Nat := 13) : Array ByteArray × Nat := Id.run do
|
||||
let mut s := seed
|
||||
let mut out : Array ByteArray := #[]
|
||||
let mut i := 0
|
||||
while i < data.size do
|
||||
let remaining := data.size - i
|
||||
let upper := Nat.min maxPart remaining
|
||||
let (partLen, s') := randIn s 1 upper
|
||||
s := s'
|
||||
out := out.push (data.extract i (i + partLen))
|
||||
i := i + partLen
|
||||
(out, s)
|
||||
|
||||
private def randomChunkedPayload (seed : Nat) (body : ByteArray) : ByteArray × Nat := Id.run do
|
||||
let mut s := seed
|
||||
let mut out := ByteArray.empty
|
||||
let mut i := 0
|
||||
while i < body.size do
|
||||
let remaining := body.size - i
|
||||
let upper := Nat.min 9 remaining
|
||||
let (chunkLen, s') := randIn s 1 upper
|
||||
s := s'
|
||||
out := out ++ s!"{chunkLen}\r\n".toUTF8
|
||||
out := out ++ body.extract i (i + chunkLen)
|
||||
out := out ++ "\r\n".toUTF8
|
||||
i := i + chunkLen
|
||||
out := out ++ "0\r\n\r\n".toUTF8
|
||||
(out, s)
|
||||
|
||||
private def mkContentLengthRequest (path : String) (body : ByteArray) : ByteArray :=
|
||||
s!"POST {path} HTTP/1.1\r\nHost: example.com\r\nContent-Length: {body.size}\r\nConnection: keep-alive\r\n\r\n".toUTF8 ++ body
|
||||
|
||||
private def mkChunkedRequest (path : String) (chunkedPayload : ByteArray) : ByteArray :=
|
||||
s!"POST {path} HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\n\r\n".toUTF8 ++ chunkedPayload
|
||||
|
||||
private def mkChunkedHead (path : String) : ByteArray :=
|
||||
s!"POST {path} HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\n\r\n".toUTF8
|
||||
|
||||
private structure IncrementalTrace where
|
||||
machine : Machine .receiving
|
||||
events : Array (Event .receiving) := #[]
|
||||
output : ByteArray := .empty
|
||||
pulled : Array PulledChunk := #[]
|
||||
|
||||
private def runIncrementalReceiving
|
||||
(parts : Array ByteArray)
|
||||
(config : Protocol.H1.Config := {}) : IncrementalTrace := Id.run do
|
||||
let mut machine : Machine .receiving := { config := config }
|
||||
let mut events : Array (Event .receiving) := #[]
|
||||
let mut output := ByteArray.empty
|
||||
let mut pulled : Array PulledChunk := #[]
|
||||
|
||||
for part in parts do
|
||||
machine := machine.feed part
|
||||
let (machine', step) := machine.step
|
||||
machine := machine'
|
||||
events := events ++ step.events
|
||||
output := output ++ step.output.toByteArray
|
||||
|
||||
-- Pull at most one body chunk per input part to simulate streaming callers.
|
||||
-- Guard on buffered bytes to avoid calling into body parsing on an empty buffer.
|
||||
if machine.canPullBodyNow && machine.reader.input.remainingBytes > 0 then
|
||||
let (machine', pulledNow?) := machine.pullBody
|
||||
let (machine', pullEvents) := machine'.takeEvents
|
||||
machine := machine'
|
||||
if let some pulledNow := pulledNow? then
|
||||
pulled := pulled.push pulledNow
|
||||
events := events ++ pullEvents
|
||||
else
|
||||
pure ()
|
||||
|
||||
let (machine', finalStep) := machine.step
|
||||
machine := machine'
|
||||
events := events ++ finalStep.events
|
||||
output := output ++ finalStep.output.toByteArray
|
||||
|
||||
-- After all input has arrived, drain the remaining ready body chunks.
|
||||
let mut fuel := 4096
|
||||
while fuel > 0 && machine.canPullBodyNow && machine.reader.input.remainingBytes > 0 do
|
||||
fuel := fuel - 1
|
||||
let (machine', pulledNow?) := machine.pullBody
|
||||
let (machine', pullEvents) := machine'.takeEvents
|
||||
machine := machine'
|
||||
events := events ++ pullEvents
|
||||
match pulledNow? with
|
||||
| some pulledNow =>
|
||||
pulled := pulled.push pulledNow
|
||||
| none =>
|
||||
break
|
||||
|
||||
return { machine, events, output, pulled }
|
||||
|
||||
private def assertIncrementalSuccess
|
||||
(name : String)
|
||||
(trace : IncrementalTrace)
|
||||
(expectedBody : ByteArray)
|
||||
(expectNeedMoreData : Bool := true) : IO Unit := do
|
||||
ensure name (!trace.machine.failed) s!"machine ended failed with events:\n{repr trace.events}"
|
||||
ensure name (!hasFailedEvent trace.events) s!"unexpected failure events:\n{repr trace.events}"
|
||||
ensure name (hasEndHeadersEvent trace.events) s!"missing endHeaders event:\n{repr trace.events}"
|
||||
|
||||
if expectNeedMoreData then
|
||||
ensure name (hasNeedMoreDataEvent trace.events) s!"expected at least one needMoreData event:\n{repr trace.events}"
|
||||
else
|
||||
pure ()
|
||||
|
||||
let got := pulledBodyBytes trace.pulled
|
||||
ensure name (got == expectedBody)
|
||||
s!"body mismatch:\nexpected={String.fromUTF8! expectedBody |>.quote}\nactual={String.fromUTF8! got |>.quote}"
|
||||
|
||||
let expectsPullSignals := expectedBody.size > 0 || trace.pulled.size > 0
|
||||
if expectsPullSignals then
|
||||
ensure name (hasCloseBodyEvent trace.events) s!"missing closeBody event:\n{repr trace.events}"
|
||||
ensure name (trace.pulled.any (·.final)) "expected at least one final pulled chunk"
|
||||
ensure name ((trace.pulled.back?.map (·.final)).getD false) "expected last pulled chunk to be final"
|
||||
else
|
||||
pure ()
|
||||
|
||||
private def runOneStepReceiving
|
||||
(payload : ByteArray)
|
||||
(config : Protocol.H1.Config := {}) :
|
||||
Machine .receiving × StepResult .receiving :=
|
||||
let machine0 : Machine .receiving := { config := config }
|
||||
(machine0.feed payload).step
|
||||
|
||||
private def assertFailedWith
|
||||
(name : String)
|
||||
(payload : ByteArray)
|
||||
(expected : Error)
|
||||
(config : Protocol.H1.Config := {}) : IO Unit := do
|
||||
let (machine, step) := runOneStepReceiving payload config
|
||||
ensure name (hasFailedEvent step.events) s!"expected failure event, events:\n{repr step.events}"
|
||||
ensure name (machine.error == some expected)
|
||||
s!"expected error {repr expected}, got {repr machine.error}"
|
||||
|
||||
-- Deterministic: one-byte incremental content-length request.
|
||||
#eval show IO Unit from do
|
||||
let body := "hello".toUTF8
|
||||
let request := mkContentLengthRequest "/inc-every-byte" body
|
||||
let trace := runIncrementalReceiving (splitEveryByte request)
|
||||
assertIncrementalSuccess "CL one-byte incremental parse" trace body
|
||||
|
||||
-- Deterministic: fragmented chunked request with boundaries through chunk metadata and payload.
|
||||
#eval show IO Unit from do
|
||||
let body := "abcXYZ".toUTF8
|
||||
let payload := "3\r\nabc\r\n3\r\nXYZ\r\n0\r\n\r\n".toUTF8
|
||||
let request := mkChunkedRequest "/inc-chunked" payload
|
||||
let parts : Array ByteArray := #[
|
||||
request.extract 0 17,
|
||||
request.extract 17 39,
|
||||
request.extract 39 58,
|
||||
request.extract 58 (request.size - 4),
|
||||
request.extract (request.size - 4) request.size
|
||||
]
|
||||
let trace := runIncrementalReceiving parts
|
||||
assertIncrementalSuccess "Chunked fragmented parse" trace body
|
||||
|
||||
-- Regression: calling `pullBody` in chunked mode before any chunk-size byte arrives
|
||||
-- must request more data rather than failing the machine.
|
||||
#eval show IO Unit from do
|
||||
let headOnly := mkChunkedHead "/wait-for-chunk-size"
|
||||
let machine0 : Machine .receiving := { config := {} }
|
||||
let (machine1, step1) := (machine0.feed headOnly).step
|
||||
ensure "Chunked pull on empty input (setup)" (!machine1.failed) s!"unexpected setup failure events:\n{repr step1.events}"
|
||||
ensure "Chunked pull on empty input (setup)" (hasEndHeadersEvent step1.events) "expected endHeaders in setup"
|
||||
ensure "Chunked pull on empty input (setup)" machine1.canPullBodyNow "expected body state to be pullable"
|
||||
|
||||
let (machine2, pulled?) := machine1.pullBody
|
||||
let (machine2, pullEvents) := machine2.takeEvents
|
||||
|
||||
ensure "Chunked pull on empty input" pulled?.isNone "expected no pulled body chunk"
|
||||
ensure "Chunked pull on empty input" (!machine2.failed) s!"unexpected machine failure:\n{repr pullEvents}"
|
||||
ensure "Chunked pull on empty input" (hasNeedMoreDataEvent pullEvents)
|
||||
s!"expected needMoreData after empty pull:\n{repr pullEvents}"
|
||||
|
||||
-- Regression: unread buffered input must stay bounded to avoid memory blowups
|
||||
-- when upper layers stall request-body consumption.
|
||||
#eval show IO Unit from do
|
||||
let cfg : Protocol.H1.Config := {
|
||||
maxBodySize := 32
|
||||
maxHeaderBytes := 16
|
||||
maxStartLineLength := 16
|
||||
maxChunkLineLength := 16
|
||||
}
|
||||
let cap := cfg.maxBodySize + cfg.maxHeaderBytes + cfg.maxStartLineLength + cfg.maxChunkLineLength
|
||||
let payload := ByteArray.mk (Array.replicate (cap + 1) (UInt8.ofNat 97))
|
||||
let machine0 : Machine .receiving := { config := cfg }
|
||||
let machine1 := machine0.feed payload
|
||||
|
||||
ensure "Buffered input cap triggers failure" machine1.failed "expected machine to fail on oversized buffered input"
|
||||
ensure "Buffered input cap triggers entityTooLarge" (machine1.error == some .entityTooLarge)
|
||||
s!"expected entityTooLarge failure, got {repr machine1.error}"
|
||||
|
||||
-- Property-style: randomized content-length body and randomized split boundaries.
|
||||
#eval show IO Unit from do
|
||||
let mut seed : Nat := 0x21436587
|
||||
for i in [0:120] do
|
||||
let (bodyLen, s1) := randIn seed 0 128
|
||||
seed := s1
|
||||
let (body, s2) := randomAsciiBytes seed bodyLen
|
||||
seed := s2
|
||||
|
||||
let request := mkContentLengthRequest s!"/prop-cl-{i}" body
|
||||
let (parts, s3) := randomSplit seed request 11
|
||||
seed := s3
|
||||
|
||||
let trace := runIncrementalReceiving parts
|
||||
assertIncrementalSuccess s!"Property CL #{i}" trace body (expectNeedMoreData := parts.size > 1)
|
||||
|
||||
-- Property-style: randomized chunked payload and randomized split boundaries.
|
||||
#eval show IO Unit from do
|
||||
let mut seed : Nat := 0x89abcdef
|
||||
for i in [0:120] do
|
||||
let (bodyLen, s1) := randIn seed 0 128
|
||||
seed := s1
|
||||
let (body, s2) := randomAsciiBytes seed bodyLen
|
||||
seed := s2
|
||||
|
||||
let (payload, s3) := randomChunkedPayload seed body
|
||||
seed := s3
|
||||
let request := mkChunkedRequest s!"/prop-chunked-{i}" payload
|
||||
let (parts, s4) := randomSplit seed request 9
|
||||
seed := s4
|
||||
|
||||
let trace := runIncrementalReceiving parts
|
||||
assertIncrementalSuccess s!"Property chunked #{i}" trace body (expectNeedMoreData := parts.size > 1)
|
||||
|
||||
private def getEndHeadersHead (events : Array (Event .receiving)) : Option Request.Head :=
|
||||
events.findSome? fun
|
||||
| .endHeaders head => some head
|
||||
| _ => none
|
||||
202
tests/elab/async_http_h1_parser_fuzz.lean
Normal file
202
tests/elab/async_http_h1_parser_fuzz.lean
Normal file
@@ -0,0 +1,202 @@
|
||||
import Std.Internal.Http
|
||||
import Std.Internal.Http.Protocol.H1.Parser
|
||||
|
||||
open Std Internal Parsec ByteArray
|
||||
open Std.Http.Protocol.H1
|
||||
|
||||
private def ensure (name : String) (cond : Bool) (msg : String) : IO Unit := do
|
||||
unless cond do
|
||||
throw <| IO.userError s!"{name}: {msg}"
|
||||
|
||||
private def runParser (p : Parser α) (s : String) : Except String α :=
|
||||
match (p <* eof).run s.toUTF8 with
|
||||
| .ok x => .ok x
|
||||
| .error e => .error e
|
||||
|
||||
private def randBelow (gen : StdGen) (maxExclusive : Nat) : Nat × StdGen :=
|
||||
if maxExclusive = 0 then
|
||||
(0, gen)
|
||||
else
|
||||
randNat gen 0 (maxExclusive - 1)
|
||||
|
||||
private def pick! [Inhabited α] (gen : StdGen) (xs : Array α) : α × StdGen :=
|
||||
let (i, gen') := randBelow gen xs.size
|
||||
(xs[i]!, gen')
|
||||
|
||||
private def randomToken (gen : StdGen) (len : Nat) : String × StdGen := Id.run do
|
||||
let mut g := gen
|
||||
let mut out := ""
|
||||
for _ in [0:len] do
|
||||
let (r, g') := randBelow g 38
|
||||
g := g'
|
||||
let c :=
|
||||
if r < 26 then Char.ofNat (97 + r)
|
||||
else if r < 36 then Char.ofNat (48 + (r - 26))
|
||||
else if r = 36 then '-'
|
||||
else '_'
|
||||
out := out.push c
|
||||
(out, g)
|
||||
|
||||
private def randomReason (gen : StdGen) (len : Nat) : String × StdGen := Id.run do
|
||||
let mut g := gen
|
||||
let mut out := ""
|
||||
for _ in [0:len] do
|
||||
let (r, g') := randBelow g 30
|
||||
g := g'
|
||||
let c := if r < 26 then Char.ofNat (65 + r) else ' '
|
||||
out := out.push c
|
||||
(out.trimAscii.toString, g)
|
||||
|
||||
private def pad3 (n : Nat) : String :=
|
||||
if n < 10 then s!"00{n}" else if n < 100 then s!"0{n}" else s!"{n}"
|
||||
|
||||
private def expectRequestOk (s : String) : IO Unit := do
|
||||
match runParser (parseRequestLine {}) s with
|
||||
| .ok _ => pure ()
|
||||
| .error e => throw <| IO.userError s!"expected request-line success for {s.quote}, got: {e}"
|
||||
|
||||
private def expectRequestFail (s : String) : IO Unit := do
|
||||
match runParser (parseRequestLine {}) s with
|
||||
| .ok _ => throw <| IO.userError s!"expected request-line failure for {s.quote}"
|
||||
| .error _ => pure ()
|
||||
|
||||
private def expectStatusOk (s : String) : IO Unit := do
|
||||
match runParser (parseStatusLine {}) s with
|
||||
| .ok _ => pure ()
|
||||
| .error e => throw <| IO.userError s!"expected status-line success for {s.quote}, got: {e}"
|
||||
|
||||
private def expectStatusFail (s : String) : IO Unit := do
|
||||
match runParser (parseStatusLine {}) s with
|
||||
| .ok _ => throw <| IO.userError s!"expected status-line failure for {s.quote}"
|
||||
| .error _ => pure ()
|
||||
|
||||
private def expectOk {α} (name : String) (p : Parser α) (s : String) : IO α := do
|
||||
match runParser p s with
|
||||
| .ok x => pure x
|
||||
| .error e => throw <| IO.userError s!"{name}: expected success for {s.quote}, got {e}"
|
||||
|
||||
private def expectFail {α} (name : String) (p : Parser α) (s : String) : IO Unit := do
|
||||
match runParser p s with
|
||||
| .ok _ => throw <| IO.userError s!"{name}: expected failure for {s.quote}"
|
||||
| .error _ => pure ()
|
||||
|
||||
#eval show IO Unit from do
|
||||
let methods : Array String := #["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD", "CONNECT"]
|
||||
let targets : Array String := #["/", "/a", "/a/b", "/a/b?q=1", "*", "http://example.com", "example.com:443"]
|
||||
let versions : Array String := #["HTTP/1.1", "HTTP/1.0"]
|
||||
|
||||
let mut gen : StdGen := StdGen.mk 0x5eed1234 0x12345
|
||||
for i in [0:400] do
|
||||
let (m, g1) := pick! gen methods
|
||||
let (t, g2) := pick! g1 targets
|
||||
let (v, g3) := pick! g2 versions
|
||||
gen := g3
|
||||
|
||||
let line := s!"{m} {t} {v}\r\n"
|
||||
expectRequestOk line
|
||||
|
||||
-- Mutation 1: drop the first space
|
||||
expectRequestFail s!"{m}{t} {v}\r\n"
|
||||
|
||||
-- Mutation 2: invalid version token
|
||||
expectRequestFail s!"{m} {t} HTTP/2.0\r\n"
|
||||
|
||||
-- Mutation 3: bad method character
|
||||
expectRequestFail s!"{m}! {t} {v}\r\n"
|
||||
|
||||
ensure "request fuzz progress" (i < 100000) "unreachable safety check"
|
||||
|
||||
#eval show IO Unit from do
|
||||
let knownCodes : Array Nat := #[200, 201, 204, 301, 400, 404, 500, 503]
|
||||
let mut gen : StdGen := StdGen.mk 0xabcde123 0x777
|
||||
|
||||
for _ in [0:400] do
|
||||
let (code, g1) := pick! gen knownCodes
|
||||
let (len, g2) := randBelow g1 20
|
||||
let (reasonRaw, g3) := randomReason g2 (len + 1)
|
||||
gen := g3
|
||||
let reason := if reasonRaw.isEmpty then "OK" else reasonRaw
|
||||
|
||||
let line := s!"HTTP/1.1 {pad3 code} {reason}\r\n"
|
||||
expectStatusOk line
|
||||
|
||||
-- Mutation 1: unsupported version
|
||||
expectStatusFail s!"HTTP/2.0 {pad3 code} {reason}\r\n"
|
||||
|
||||
-- Mutation 2: non-digit in status code
|
||||
expectStatusFail s!"HTTP/1.1 A{(pad3 code).drop 1} {reason}\r\n"
|
||||
|
||||
-- Mutation 3: illegal reason byte (DEL)
|
||||
expectStatusFail s!"HTTP/1.1 {pad3 code} bad{Char.ofNat 127}\r\n"
|
||||
|
||||
#eval show IO Unit from do
|
||||
-- Randomized malformed gibberish smoke: parser must simply return error or success,
|
||||
-- but never crash/panic.
|
||||
let mut gen : StdGen := StdGen.mk 0x31415926 0x27182818
|
||||
for _ in [0:300] do
|
||||
let (len, g1) := randBelow gen 80
|
||||
let (tok, g2) := randomToken g1 (len + 1)
|
||||
gen := g2
|
||||
let _ := runParser (parseRequestLine {}) (tok ++ "\r\n")
|
||||
let _ := runParser (parseStatusLine {}) (tok ++ "\r\n")
|
||||
pure ()
|
||||
|
||||
-- Component tests for individual parser parts.
|
||||
#eval show IO Unit from do
|
||||
-- parseSingleHeader
|
||||
let sh1 ← expectOk "parseSingleHeader some" (parseSingleHeader {} <* eof) "Host: x\r\n"
|
||||
ensure "parseSingleHeader some present" sh1.isSome "expected some header"
|
||||
|
||||
let sh2 ← expectOk "parseSingleHeader none" (parseSingleHeader {} <* eof) "\r\n"
|
||||
ensure "parseSingleHeader none present" sh2.isNone "expected header terminator"
|
||||
|
||||
-- parseChunkSize / parseChunkPartial
|
||||
let (n1, ext1) ← expectOk "parseChunkSize bare" (parseChunkSize {} <* eof) "A\r\n"
|
||||
ensure "parseChunkSize value" (n1 == 10) "chunk-size mismatch"
|
||||
ensure "parseChunkSize ext empty" (ext1.isEmpty) "expected no extensions"
|
||||
|
||||
let (n2, ext2) ← expectOk "parseChunkSize ext" (parseChunkSize {} <* eof) "4;foo=bar;baz=\"qux\"\r\n"
|
||||
ensure "parseChunkSize ext value" (n2 == 4) "chunk-size mismatch with ext"
|
||||
ensure "parseChunkSize ext count" (ext2.size == 2) "expected 2 extensions"
|
||||
|
||||
let cp1 ← expectOk "parseChunkPartial some" (parseChunkPartial {} <* eof) "4\r\nWiki"
|
||||
ensure "parseChunkPartial some isSome" cp1.isSome "expected chunk data"
|
||||
ensure "parseChunkPartial some size" ((cp1.map (fun (n, _, _) => n)).getD 0 == 4) "size mismatch"
|
||||
|
||||
let cp0 ← expectOk "parseChunkPartial none" (parseChunkPartial {} <* eof) "0\r\n"
|
||||
ensure "parseChunkPartial none isNone" cp0.isNone "expected last-chunk marker"
|
||||
|
||||
-- parseFixedSizeData / parseChunkSizedData
|
||||
let fs1 ← expectOk "parseFixedSizeData complete" (parseFixedSizeData 4 <* eof) "Wiki"
|
||||
ensure "parseFixedSizeData complete shape"
|
||||
(match fs1 with | .complete _ => true | _ => false)
|
||||
"expected complete result"
|
||||
let fs2 ← expectOk "parseFixedSizeData incomplete" (parseFixedSizeData 4 <* eof) "Wi"
|
||||
ensure "parseFixedSizeData incomplete shape"
|
||||
(match fs2 with | .incomplete _ 2 => true | _ => false)
|
||||
"expected incomplete result with remaining=2"
|
||||
|
||||
let cs1 ← expectOk "parseChunkSizedData complete" (parseChunkSizedData 4 <* eof) "Wiki\r\n"
|
||||
ensure "parseChunkSizedData complete shape"
|
||||
(match cs1 with | .complete _ => true | _ => false)
|
||||
"expected complete chunk-sized result"
|
||||
let cs2 ← expectOk "parseChunkSizedData incomplete" (parseChunkSizedData 4 <* eof) "Wi"
|
||||
ensure "parseChunkSizedData incomplete shape"
|
||||
(match cs2 with | .incomplete _ 2 => true | _ => false)
|
||||
"expected incomplete chunk-sized result with remaining=2"
|
||||
|
||||
-- parseTrailers
|
||||
let trailers ← expectOk "parseTrailers ok" (parseTrailers {} <* eof) "X-Test: a\r\nY-Test: b\r\n\r\n"
|
||||
ensure "parseTrailers count" (trailers.size == 2) "expected 2 trailers"
|
||||
expectFail "parseTrailers forbidden" (parseTrailers {} <* eof) "Content-Length: 1\r\n\r\n"
|
||||
|
||||
-- parseRequestLineRawVersion / parseStatusLineRawVersion
|
||||
let (m1, _, v1) ← expectOk "parseRequestLineRawVersion" (parseRequestLineRawVersion {} <* eof) "GET / HTTP/1.1\r\n"
|
||||
ensure "parseRequestLineRawVersion method" (m1 == Std.Http.Method.get) "method mismatch"
|
||||
ensure "parseRequestLineRawVersion version" (v1 == some Std.Http.Version.v11) "expected recognized v11"
|
||||
let (_, rv) ← expectOk "parseStatusLineRawVersion" (parseStatusLineRawVersion {} <* eof) "HTTP/1.1 204 No Content\r\n"
|
||||
ensure "parseStatusLineRawVersion recognized" (rv == some Std.Http.Version.v11) "expected v11"
|
||||
|
||||
-- parseRequestLine / parseStatusLine failures
|
||||
expectFail "parseRequestLine invalid version" (parseRequestLine {} <* eof) "GET / HTTP/2.0\r\n"
|
||||
expectFail "parseStatusLine invalid version" (parseStatusLine {} <* eof) "HTTP/2.0 200 OK\r\n"
|
||||
757
tests/elab/async_http_h1_rfc_compliance.lean
Normal file
757
tests/elab/async_http_h1_rfc_compliance.lean
Normal file
@@ -0,0 +1,757 @@
|
||||
import Std.Internal.Http
|
||||
|
||||
open Std Http
|
||||
open Std.Http.Test
|
||||
open Std.Http.Protocol.H1
|
||||
open Std.Http.Protocol.H1.Machine
|
||||
|
||||
/-!
|
||||
`MachineTester (dir : Direction)` is a direction-parameterized, chainable builder for exercising
|
||||
`Machine dir`. Operations return a new tester; failures accumulate and are thrown as a single
|
||||
`IO.userError` by `run`.
|
||||
|
||||
Use `MachineTester.receiving` for server-side (request parsing) tests and
|
||||
`MachineTester.sending` for client-side (response parsing) tests.
|
||||
-/
|
||||
|
||||
private structure MachineTester (dir : Direction) where
|
||||
|
||||
/--
|
||||
Human-readable name used in error messages.
|
||||
-/
|
||||
name : String
|
||||
|
||||
/--
|
||||
The machine under test.
|
||||
-/
|
||||
machine : Machine dir
|
||||
|
||||
/--
|
||||
Events and output produced by the most recent `step` or `pullBody`.
|
||||
-/
|
||||
lastStep : Option (StepResult dir) := none
|
||||
|
||||
/--
|
||||
Body chunks collected by `drainBody` / `pullBody`.
|
||||
-/
|
||||
pulled : Array PulledChunk := #[]
|
||||
|
||||
/--
|
||||
Cumulative wire output across all `step` calls.
|
||||
-/
|
||||
allOutput : ByteArray := .empty
|
||||
|
||||
/--
|
||||
Accumulated assertion failures; `run` throws if non-empty.
|
||||
-/
|
||||
errors : Array String := #[]
|
||||
|
||||
namespace MachineTester
|
||||
|
||||
private def addError (t : MachineTester dir) (msg : String) : MachineTester dir :=
|
||||
{ t with errors := t.errors.push s!"[{t.name}] {msg}" }
|
||||
|
||||
private def withLastStep
|
||||
(t : MachineTester dir) (ctx : String)
|
||||
(k : MachineTester dir → StepResult dir → MachineTester dir) : MachineTester dir :=
|
||||
match t.lastStep with
|
||||
| none => t.addError s!"{ctx}: no step result (call .step first)"
|
||||
| some step => k t step
|
||||
|
||||
/-- Create a new tester for the given direction. -/
|
||||
def new (name : String) (config : Protocol.H1.Config := {}) : MachineTester dir :=
|
||||
{ name, machine := { config } }
|
||||
|
||||
/-- Create a new server-side tester (receives requests, sends responses). -/
|
||||
def receiving (name : String) (config : Protocol.H1.Config := {}) : MachineTester .receiving :=
|
||||
{ name, machine := { config } }
|
||||
|
||||
/-- Create a new client-side tester (sends requests, receives responses). -/
|
||||
def sending (name : String) (config : Protocol.H1.Config := {}) : MachineTester .sending :=
|
||||
{ name, machine := { config } }
|
||||
|
||||
/-- Feed a UTF-8 string to the machine. -/
|
||||
def feed (t : MachineTester dir) (data : String) : MachineTester dir :=
|
||||
{ t with machine := t.machine.feed data.toUTF8, lastStep := none }
|
||||
|
||||
/-- Feed raw bytes to the machine. -/
|
||||
def feedBytes (t : MachineTester dir) (data : ByteArray) : MachineTester dir :=
|
||||
{ t with machine := t.machine.feed data, lastStep := none }
|
||||
|
||||
/-- Run one step of the state machine and record the result. -/
|
||||
def step (t : MachineTester dir) : MachineTester dir :=
|
||||
let (machine, stepResult) := t.machine.step
|
||||
let allOutput := t.allOutput ++ stepResult.output.toByteArray
|
||||
{ t with machine, lastStep := some stepResult, allOutput }
|
||||
|
||||
|
||||
def debug (t : MachineTester dir) : MachineTester dir :=
|
||||
dbg_trace "resp: {repr <| t.lastStep.map (StepResult.events)}";
|
||||
t
|
||||
|
||||
/--
|
||||
Pull at most one body chunk. Updates `pulled` and replaces `lastStep` with the
|
||||
events emitted during the pull.
|
||||
-/
|
||||
def pullBody (t : MachineTester dir) : MachineTester dir :=
|
||||
let (machine, chunk?) := t.machine.pullBody
|
||||
let (machine, pullEvents) := machine.takeEvents
|
||||
let pulled := match chunk? with
|
||||
| some chunk => t.pulled.push chunk
|
||||
| none => t.pulled
|
||||
{ t with machine, pulled, lastStep := some { events := pullEvents } }
|
||||
|
||||
/--
|
||||
Drain all immediately-available body chunks.
|
||||
|
||||
The loop exits when `canPullBodyNow` becomes false (either the body is complete
|
||||
or the machine stalls waiting for more data). The stall flag is set internally
|
||||
by `pullBody`, so no explicit `remainingBytes` guard is needed.
|
||||
-/
|
||||
def drainBody (t : MachineTester dir) (fuel : Nat := 4096) : MachineTester dir := Id.run do
|
||||
let mut t := t
|
||||
let mut remaining := fuel
|
||||
while remaining > 0 && t.machine.canPullBodyNow do
|
||||
remaining := remaining - 1
|
||||
t := t.pullBody
|
||||
t
|
||||
|
||||
/-- Set the known outgoing body size (forwarded to the writer). -/
|
||||
def setKnownSize (t : MachineTester dir) (size : Body.Length) : MachineTester dir :=
|
||||
{ t with machine := t.machine.setKnownSize size }
|
||||
|
||||
/-- Signal EOF on the reader input (no more socket bytes will arrive). -/
|
||||
def noMoreInput (t : MachineTester dir) : MachineTester dir :=
|
||||
{ t with machine := t.machine.noMoreInput }
|
||||
|
||||
/-- Send a message head (request for `.sending`, response for `.receiving`). -/
|
||||
def send (t : MachineTester dir) (head : Message.Head dir.swap) : MachineTester dir :=
|
||||
{ t with machine := t.machine.send head }
|
||||
|
||||
/-- Signal that the application has finished writing the message body. -/
|
||||
def userClosedBody (t : MachineTester dir) : MachineTester dir :=
|
||||
{ t with machine := t.machine.userClosedBody }
|
||||
|
||||
/-- Resolve an `Expect: 100-continue` decision. No-op for `.sending` direction. -/
|
||||
def canContinue (t : MachineTester dir) (status : Status) : MachineTester dir :=
|
||||
{ t with machine := t.machine.canContinue status }
|
||||
|
||||
/-- Send an error response with `Connection: close` and shut down input. Only valid for `.receiving`. -/
|
||||
def closeWithError (t : MachineTester .receiving) (status : Status) : MachineTester .receiving :=
|
||||
{ t with machine := t.machine.closeWithError status }
|
||||
|
||||
/-- Enqueue body chunks into the writer buffer. -/
|
||||
def sendData (t : MachineTester dir) (chunks : Array Chunk) : MachineTester dir :=
|
||||
{ t with machine := t.machine.sendData chunks }
|
||||
|
||||
/-- Enqueue raw bytes as a single body chunk into the writer buffer. -/
|
||||
def sendBytes (t : MachineTester dir) (data : ByteArray) : MachineTester dir :=
|
||||
t.sendData #[{ data, extensions := #[] }]
|
||||
|
||||
/-- Assert a predicate on the current machine. -/
|
||||
def assert (t : MachineTester dir) (pred : Machine dir → Bool) (msg : String) : MachineTester dir :=
|
||||
if pred t.machine then t else t.addError msg
|
||||
|
||||
/--
|
||||
Assert that the machine has not failed (reader state ≠ `.failed`).
|
||||
|
||||
**Note:** `machine.failed` returns `false` after any `step` call because `step` transitions
|
||||
the reader from `.failed` to `.closed`. Use `assertNoError` for post-step success checks.
|
||||
-/
|
||||
def assertNoFailure (t : MachineTester dir) : MachineTester dir :=
|
||||
t.assert (fun m => !m.failed)
|
||||
s!"machine should not fail, got error: {repr t.machine.error}"
|
||||
|
||||
/-- Assert that `machine.error` is `none`. -/
|
||||
def assertNoError (t : MachineTester dir) : MachineTester dir :=
|
||||
t.assert (fun m => m.error == none)
|
||||
s!"machine should have no error, got: {repr t.machine.error}"
|
||||
|
||||
/-- Assert that the machine has failed with the given error. -/
|
||||
def assertFailedWith (t : MachineTester dir) (expected : Error) : MachineTester dir :=
|
||||
t.assert (fun m => m.error == some expected)
|
||||
s!"expected error {repr expected}, got {repr t.machine.error}"
|
||||
|
||||
/-- Assert that `canPullBody` is true. -/
|
||||
def assertCanPullBody (t : MachineTester dir) : MachineTester dir :=
|
||||
t.assert (·.canPullBody) "expected canPullBody = true"
|
||||
|
||||
/-- Assert that `canPullBodyNow` is true. -/
|
||||
def assertCanPullBodyNow (t : MachineTester dir) : MachineTester dir :=
|
||||
t.assert (·.canPullBodyNow) "expected canPullBodyNow = true"
|
||||
|
||||
/-- Assert that the reader is in the `complete` state. -/
|
||||
def assertReaderComplete (t : MachineTester dir) : MachineTester dir :=
|
||||
t.assert (·.isReaderComplete)
|
||||
s!"expected reader to be complete, state: {repr t.machine.reader.state}"
|
||||
|
||||
/-- Assert that the reader is in the `closed` state. -/
|
||||
def assertReaderClosed (t : MachineTester dir) : MachineTester dir :=
|
||||
t.assert (·.isReaderClosed)
|
||||
s!"expected reader to be closed, state: {repr t.machine.reader.state}"
|
||||
|
||||
/-- Assert that `keepAlive` is true on the machine. -/
|
||||
def assertKeepAlive (t : MachineTester dir) : MachineTester dir :=
|
||||
t.assert (·.keepAlive) "expected keepAlive = true"
|
||||
|
||||
/-- Assert that `keepAlive` is false on the machine. -/
|
||||
def assertNotKeepAlive (t : MachineTester dir) : MachineTester dir :=
|
||||
t.assert (fun m => !m.keepAlive) "expected keepAlive = false"
|
||||
|
||||
/-- Assert that `isWaitingMessage` is true (writer in `waitingHeaders`, `!sentMessage`). -/
|
||||
def assertIsWaitingMessage (t : MachineTester dir) : MachineTester dir :=
|
||||
t.assert (·.isWaitingMessage) "expected isWaitingMessage = true"
|
||||
|
||||
/-- Assert that `halted` is true (both reader and writer closed, no output). -/
|
||||
def assertHalted (t : MachineTester dir) : MachineTester dir :=
|
||||
t.assert (·.halted) "expected machine to be halted"
|
||||
|
||||
/-- Assert that at least one event in the last step matches `pred`. -/
|
||||
def assertHasEvent
|
||||
(t : MachineTester dir) (pred : Event dir → Bool) (msg : String) : MachineTester dir :=
|
||||
t.withLastStep "assertHasEvent" fun t step =>
|
||||
if step.events.any pred then t
|
||||
else t.addError s!"{msg}\n events: {repr step.events}"
|
||||
|
||||
/-- Assert that no event in the last step matches `pred`. -/
|
||||
def assertNoEvent
|
||||
(t : MachineTester dir) (pred : Event dir → Bool) (msg : String) : MachineTester dir :=
|
||||
t.withLastStep "assertNoEvent" fun t step =>
|
||||
if step.events.any pred then
|
||||
t.addError s!"{msg}\n events: {repr step.events}"
|
||||
else t
|
||||
|
||||
/-- Assert that the last step contains an `endHeaders` event. -/
|
||||
def assertHasEndHeaders (t : MachineTester dir) : MachineTester dir :=
|
||||
t.assertHasEvent (fun | .endHeaders _ => true | _ => false) "expected endHeaders event"
|
||||
|
||||
/-- Assert that the last step contains a `failed` event for the given error. -/
|
||||
def assertHasFailedEvent (t : MachineTester dir) (expected : Error) : MachineTester dir :=
|
||||
t.assertHasEvent (fun | .failed err => err == expected | _ => false)
|
||||
s!"expected failed event with {repr expected}"
|
||||
|
||||
/-- Assert that the last step contains a `closeBody` event. -/
|
||||
def assertHasCloseBody (t : MachineTester dir) : MachineTester dir :=
|
||||
t.assertHasEvent (fun | .closeBody => true | _ => false) "expected closeBody event"
|
||||
|
||||
/-- Assert that the last step contains a `next` event. -/
|
||||
def assertHasNext (t : MachineTester dir) : MachineTester dir :=
|
||||
t.assertHasEvent (fun | .next => true | _ => false) "expected next event"
|
||||
|
||||
/-- Assert that the last step contains a `continue` event. -/
|
||||
def assertHasContinue (t : MachineTester dir) : MachineTester dir :=
|
||||
t.assertHasEvent (fun | .«continue» => true | _ => false) "expected continue event"
|
||||
|
||||
/-- Inspect the `endHeaders` payload in the last step. -/
|
||||
def onEndHeaders
|
||||
(t : MachineTester dir)
|
||||
(f : MachineTester dir → Message.Head dir → MachineTester dir) : MachineTester dir :=
|
||||
t.withLastStep "onEndHeaders" fun t step =>
|
||||
match step.events.findSome? (fun | .endHeaders h => some h | _ => none) with
|
||||
| none => t.addError "onEndHeaders: no endHeaders event in last step"
|
||||
| some head => f t head
|
||||
|
||||
/-- Assert that the cumulative output starts with the given ASCII string. -/
|
||||
def assertOutputStartsWith (t : MachineTester dir) (pfx : String) (msg : String) : MachineTester dir :=
|
||||
let s := String.fromUTF8! t.allOutput
|
||||
if s.startsWith pfx then t
|
||||
else
|
||||
let preview := String.fromUTF8! (t.allOutput.extract 0 (min t.allOutput.size 120))
|
||||
t.addError s!"{msg}: expected output to start with {repr pfx}\n got: {repr preview}"
|
||||
|
||||
/-- Assert that the cumulative output contains the given ASCII substring. -/
|
||||
def assertOutputContains (t : MachineTester dir) (needle : String) (msg : String) : MachineTester dir :=
|
||||
let haystack := t.allOutput
|
||||
let nb := needle.toUTF8
|
||||
let found :=
|
||||
if nb.isEmpty then true
|
||||
else if nb.size > haystack.size then false
|
||||
else (Array.range (haystack.size - nb.size + 1)).any fun i =>
|
||||
(Array.range nb.size).all fun j => haystack.get! (i + j) == nb.get! j
|
||||
if found then t
|
||||
else
|
||||
let preview := String.fromUTF8! (haystack.extract 0 (min haystack.size 160))
|
||||
t.addError s!"{msg}: expected output to contain {repr needle}\n output: {repr preview}"
|
||||
|
||||
/-- Assert that the cumulative output does not contain the given ASCII substring. -/
|
||||
def assertOutputNotContains (t : MachineTester dir) (needle : String) (msg : String) : MachineTester dir :=
|
||||
let haystack := t.allOutput
|
||||
let nb := needle.toUTF8
|
||||
let found :=
|
||||
if nb.isEmpty then true
|
||||
else if nb.size > haystack.size then false
|
||||
else (Array.range (haystack.size - nb.size + 1)).any fun i =>
|
||||
(Array.range nb.size).all fun j => haystack.get! (i + j) == nb.get! j
|
||||
if found then
|
||||
let preview := String.fromUTF8! (haystack.extract 0 (min haystack.size 160))
|
||||
t.addError s!"{msg}: expected output NOT to contain {repr needle}\n output: {repr preview}"
|
||||
else t
|
||||
|
||||
/-- Assert that the total bytes pulled so far equal `expected`. -/
|
||||
def assertPulledBody (t : MachineTester dir) (expected : ByteArray) : MachineTester dir :=
|
||||
let got := t.pulled.foldl (fun acc c => acc ++ c.chunk.data) ByteArray.empty
|
||||
if got == expected then t
|
||||
else t.addError
|
||||
s!"body mismatch:\n expected={String.fromUTF8! expected |>.quote}\n got={String.fromUTF8! got |>.quote}"
|
||||
|
||||
/-- Assert that at least one chunk was pulled and the last one is `final`. -/
|
||||
def assertLastChunkFinal (t : MachineTester dir) : MachineTester dir :=
|
||||
match t.pulled.back? with
|
||||
| none => t.addError "assertLastChunkFinal: no chunks pulled yet"
|
||||
| some chunk =>
|
||||
if chunk.final then t
|
||||
else t.addError "expected last pulled chunk to be final"
|
||||
|
||||
/-- Run a custom check on the current machine state. -/
|
||||
def onMachine (t : MachineTester dir) (f : MachineTester dir → Machine dir → MachineTester dir) : MachineTester dir :=
|
||||
f t t.machine
|
||||
|
||||
/-- Throw if any assertion has failed; otherwise succeed. -/
|
||||
def run (t : MachineTester dir) : IO Unit :=
|
||||
unless t.errors.isEmpty do
|
||||
throw <| IO.userError <| "\n".intercalate t.errors.toList
|
||||
|
||||
end MachineTester
|
||||
|
||||
----------------------------------------------------------------------------------------------------
|
||||
|
||||
private def mkGet10 (path : String := "/") : String :=
|
||||
s!"GET {path} HTTP/1.0\r\n\r\n"
|
||||
|
||||
private def mkPostHead (path : String) (bodyLen : Nat) (extra : String := "") : String :=
|
||||
s!"POST {path} HTTP/1.1\r\nHost: example.com\r\nContent-Length: {bodyLen}\r\n{extra}\r\n"
|
||||
|
||||
private def mkChunkedPost (path : String) (extra : String := "") : String :=
|
||||
s!"POST {path} HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: chunked\r\n{extra}\r\n"
|
||||
|
||||
----------------------------------------------------------------------------------------------------
|
||||
|
||||
private def minimalGetRequest : Request.Head :=
|
||||
{ method := .get, version := .v11, uri := RequestTarget.originForm! "/",
|
||||
headers := Headers.empty.insert! "Host" "example.com" }
|
||||
|
||||
----------------------------------------------------------------------------------------------------
|
||||
|
||||
#eval runGroup "RFC 9112 §2.2: leading CRLF before request-line" do
|
||||
MachineTester.receiving "§2.2: leading CRLF ignored" (config := { maxLeadingEmptyLines := 8 })
|
||||
|>.feed "\r\n\r\nGET / HTTP/1.1\r\nHost: example.com\r\n\r\n"
|
||||
|>.step |>.assertNoError |>.assertHasEndHeaders |>.run
|
||||
|
||||
MachineTester.receiving "§2.2: maximum leading CRLFs" (config := { maxLeadingEmptyLines := 2 })
|
||||
|>.feed "\r\n\r\n\r\nGET / HTTP/1.1\r\nHost: example.com\r\n\r\n"
|
||||
|>.step |>.assertFailedWith .badMessage |>.run
|
||||
|
||||
#eval runGroup "RFC 9112 §2–§3: version and method parsing" do
|
||||
MachineTester.receiving "§2: HTTP/2.0 → unsupportedVersion"
|
||||
|>.feed "GET / HTTP/2.0\r\nHost: example.com\r\n\r\n"
|
||||
|>.step |>.assertFailedWith .unsupportedVersion |>.run
|
||||
|
||||
MachineTester.receiving "§3: unrecognized method → badMessage"
|
||||
|>.feed "LEAN / HTTP/1.1\r\nHost: example.com\r\n\r\n"
|
||||
|>.step |>.assertFailedWith .badMessage |>.run
|
||||
|
||||
MachineTester.receiving "§3: GET method and HTTP/1.1 version parsed correctly"
|
||||
|>.feed "GET /path HTTP/1.1\r\nHost: example.com\r\n\r\n"
|
||||
|>.step |>.assertNoError
|
||||
|>.onEndHeaders (fun t head =>
|
||||
if head.method != .get then t.addError s!"expected method GET, got {repr head.method}"
|
||||
else if head.version != .v11 then t.addError s!"expected HTTP/1.1, got {repr head.version}"
|
||||
else t)
|
||||
|>.run
|
||||
|
||||
#eval runGroup "RFC 9112 §3.2–§3.3: request-target form and Host" do
|
||||
MachineTester.receiving "§3.2.1: origin-form path preserved"
|
||||
|>.feed "GET /api/v1/users?active=true HTTP/1.1\r\nHost: api.example.com\r\n\r\n"
|
||||
|>.step |>.assertNoError
|
||||
|>.onEndHeaders (fun t head =>
|
||||
let path := toString head.uri.path
|
||||
if path != "/api/v1/users" then t.addError s!"expected path /api/v1/users, got {path}"
|
||||
else t)
|
||||
|>.run
|
||||
|
||||
MachineTester.receiving "§3.2: non-OPTIONS asterisk-form → badMessage"
|
||||
|>.feed "GET * HTTP/1.1\r\nHost: example.com\r\n\r\n"
|
||||
|>.step |>.assertFailedWith .badMessage |>.run
|
||||
|
||||
MachineTester.receiving "§3.2: non-CONNECT authority-form → badMessage"
|
||||
|>.feed "GET example.com:80 HTTP/1.1\r\nHost: example.com\r\n\r\n"
|
||||
|>.step |>.assertFailedWith .badMessage |>.run
|
||||
|
||||
MachineTester.receiving "§3.2: origin-form valid for GET"
|
||||
|>.feed "GET /ata HTTP/1.1\r\nHost: example.com\r\n\r\n"
|
||||
|>.step |>.assertNoError |>.run
|
||||
|
||||
MachineTester.receiving "§3.3: absolute-form with explicit :80"
|
||||
|>.feed "GET http://example.com:80/path HTTP/1.1\r\nHost: example.com\r\n\r\n"
|
||||
|>.step |>.assertNoError |>.assertHasEndHeaders |>.run
|
||||
|
||||
MachineTester.receiving "§3.2: HTTP/1.1 missing Host → badMessage"
|
||||
|>.feed "GET / HTTP/1.1\r\n\r\n"
|
||||
|>.step |>.assertFailedWith .badMessage |>.run
|
||||
|
||||
MachineTester.receiving "§3.2: HTTP/1.1 duplicate Host → badMessage"
|
||||
|>.feed "GET / HTTP/1.1\r\nHost: a.com\r\nHost: b.com\r\n\r\n"
|
||||
|>.step |>.assertFailedWith .badMessage |>.run
|
||||
|
||||
-- RFC 9112 §3.2.2: Host is left as-is; authority is available via the URI.
|
||||
MachineTester.receiving "§3.2: absolute-form Host preserved, authority in URI"
|
||||
|>.feed "GET http://example.com:80/path HTTP/1.1\r\nHost: random.com\r\n\r\n"
|
||||
|>.step |>.assertNoError
|
||||
|>.onEndHeaders (fun t head =>
|
||||
let t := match head.headers.get? .host with
|
||||
| some v => if v.value == "random.com" then t else t.addError s!"expected Host=random.com, got {v.value.quote}"
|
||||
| none => t.addError "missing Host header"
|
||||
match head.uri.authority? with
|
||||
| some a => if toString a == "example.com:80" then t else t.addError s!"expected URI authority=example.com:80, got {toString a}"
|
||||
| none => t.addError "missing URI authority")
|
||||
|>.run
|
||||
|
||||
-- Any multi-TE message is rejected as a request-smuggling defence.
|
||||
#eval runGroup "RFC 9112 §6: Transfer-Encoding validation" do
|
||||
MachineTester.receiving "§6.1: multiple Transfer-Encoding headers → badMessage"
|
||||
|>.feed "POST / HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: gzip\r\nTransfer-Encoding: chunked\r\n\r\n"
|
||||
|>.step |>.assertFailedWith .badMessage |>.run
|
||||
|
||||
MachineTester.receiving "§6.1: Transfer-Encoding: identity → badMessage"
|
||||
|>.feed "POST / HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: identity\r\n\r\n"
|
||||
|>.step |>.assertFailedWith .badMessage |>.run
|
||||
|
||||
#eval runGroup "RFC 9112 §6.3: body framing" do
|
||||
MachineTester.receiving "§6.3: no CL/TE → empty body"
|
||||
|>.feed (mkGet "/")
|
||||
|>.step |>.assertNoError |>.assertHasEndHeaders
|
||||
|>.assertCanPullBodyNow |>.drainBody
|
||||
|>.assertPulledBody .empty |>.assertLastChunkFinal |>.run
|
||||
|
||||
let body := "Hello, World!".toUTF8
|
||||
MachineTester.receiving "§6.3: Content-Length body delivered exactly"
|
||||
|>.feed (mkPostHead "/" body.size ++ String.fromUTF8! body)
|
||||
|>.step |>.assertNoError |>.assertHasEndHeaders
|
||||
|>.drainBody |>.assertPulledBody body |>.assertLastChunkFinal |>.run
|
||||
|
||||
MachineTester.receiving "§6.3: CL + TE mixed → badMessage (request-smuggling prevention)"
|
||||
|>.feed "POST / HTTP/1.1\r\nHost: example.com\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\n\r\n"
|
||||
|>.step |>.assertFailedWith .badMessage |>.run
|
||||
|
||||
MachineTester.receiving "§6.3: multiple Content-Length → badMessage"
|
||||
|>.feed "POST / HTTP/1.1\r\nHost: example.com\r\nContent-Length: 5\r\nContent-Length: 5\r\n\r\n"
|
||||
|>.step |>.assertFailedWith .badMessage |>.run
|
||||
|
||||
let respBody := "Hello!".toUTF8
|
||||
MachineTester.sending "§6.3: response Content-Length body delivered exactly"
|
||||
|>.send minimalGetRequest |>.step
|
||||
|>.feed s!"HTTP/1.1 200 OK\r\nContent-Length: {respBody.size}\r\n\r\n"
|
||||
|>.feedBytes respBody |>.step |>.assertNoError
|
||||
|>.drainBody |>.assertPulledBody respBody |>.assertLastChunkFinal |>.run
|
||||
|
||||
let eofBody := "eof body".toUTF8
|
||||
MachineTester.sending "§6.3: EOF-delimited response body; keep-alive disabled"
|
||||
|>.send minimalGetRequest |>.step
|
||||
|>.feed "HTTP/1.1 200 OK\r\n\r\n"
|
||||
|>.step |>.assertNoError |>.assertNotKeepAlive
|
||||
|>.feedBytes eofBody |>.noMoreInput
|
||||
|>.drainBody |>.assertPulledBody eofBody |>.assertLastChunkFinal |>.run
|
||||
|
||||
let partialBody := "hello".toUTF8
|
||||
MachineTester.receiving "§6.3: noMoreInput mid fixed-length body → connectionClosed"
|
||||
|>.feed (mkPostHead "/" 10 ++ String.fromUTF8! partialBody)
|
||||
|>.step |>.assertHasEndHeaders |>.noMoreInput
|
||||
|>.drainBody |>.assertFailedWith .connectionClosed |>.run
|
||||
|
||||
MachineTester.receiving "§6.3: fixed-length body stops at declared byte count"
|
||||
|>.feed (mkPostHead "/" 5 ++ "helloworld")
|
||||
|>.step |>.assertHasEndHeaders
|
||||
|>.drainBody
|
||||
|>.assertPulledBody "hello".toUTF8
|
||||
|>.assertLastChunkFinal |>.assertNoError |>.run
|
||||
|
||||
let part1 := "hel".toUTF8
|
||||
let part2 := "lo".toUTF8
|
||||
MachineTester.receiving "§6.3: fixed-length body assembled across incremental feeds"
|
||||
|>.feed (mkPostHead "/" 5) |>.step |>.assertHasEndHeaders
|
||||
|>.feedBytes part1 |>.pullBody
|
||||
|>.feedBytes part2 |>.pullBody
|
||||
|>.assertPulledBody (part1 ++ part2) |>.assertLastChunkFinal |>.run
|
||||
|
||||
#eval runGroup "RFC 9112 §7.1: chunked transfer encoding" do
|
||||
-- §7.1: chunks decoded to raw body bytes
|
||||
let reqBody := "helloworld".toUTF8
|
||||
MachineTester.receiving "§7.1: chunked TE decoded to raw body"
|
||||
|>.feed (mkChunkedPost "/" ++ "5\r\nhello\r\n5\r\nworld\r\n0\r\n\r\n")
|
||||
|>.step |>.assertNoError |>.assertHasEndHeaders
|
||||
|>.drainBody |>.assertPulledBody reqBody |>.assertLastChunkFinal |>.run
|
||||
|
||||
-- §7.1: zero-length chunked body → empty body, final chunk
|
||||
MachineTester.receiving "§7.1: empty chunked body"
|
||||
|>.feed (mkChunkedPost "/" ++ "0\r\n\r\n")
|
||||
|>.step |>.assertNoError
|
||||
|>.drainBody |>.assertPulledBody .empty |>.assertLastChunkFinal |>.run
|
||||
|
||||
-- §7.1: 200 response with chunked body decoded correctly
|
||||
let respBody := "chunked".toUTF8
|
||||
MachineTester.sending "§7.1: response chunked body decoded"
|
||||
|>.send minimalGetRequest |>.step
|
||||
|>.feed "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n7\r\nchunked\r\n0\r\n\r\n"
|
||||
|>.step |>.assertNoError
|
||||
|>.drainBody |>.assertPulledBody respBody |>.assertLastChunkFinal |>.run
|
||||
|
||||
-- §7.1: EOF mid chunked body (size line received but no chunk data) → connectionClosed
|
||||
MachineTester.receiving "§7.1: noMoreInput mid chunked body → connectionClosed"
|
||||
|>.feed (mkChunkedPost "/" ++ "5\r\n") -- chunk size line but no chunk payload
|
||||
|>.step |>.assertHasEndHeaders |>.noMoreInput
|
||||
|>.pullBody -- tries to read 5-byte chunk data, hits EOF → connectionClosed
|
||||
|>.assertFailedWith .connectionClosed |>.run
|
||||
|
||||
-- §7.1.1: chunk-extension fields preserved in PulledChunk.chunk.extensions
|
||||
let extBody := "hello".toUTF8
|
||||
MachineTester.receiving "§7.1.1: chunk extensions preserved in PulledChunk"
|
||||
|>.feed (mkChunkedPost "/" ++ "5;quality=0.9\r\nhello\r\n0\r\n\r\n")
|
||||
|>.step |>.assertHasEndHeaders
|
||||
|>.drainBody |>.assertPulledBody extBody
|
||||
|>.onMachine (fun t _ =>
|
||||
match t.pulled[0]? with
|
||||
| none => t.addError "no chunks pulled"
|
||||
| some c =>
|
||||
if c.chunk.extensions.isEmpty
|
||||
then t.addError "expected chunk extensions to be non-empty for chunk with extension"
|
||||
else t)
|
||||
|>.run
|
||||
|
||||
-- §7.1: server writes chunk-size lines and terminal 0\r\n\r\n when userClosedBody is called
|
||||
let body1 := "Hello, ".toUTF8
|
||||
let body2 := "World!".toUTF8
|
||||
MachineTester.receiving "§7.1: server sends chunked response — wire encoding correct"
|
||||
|>.feed (mkGet "/") |>.step |>.assertIsWaitingMessage
|
||||
|>.send { status := .ok } |>.setKnownSize .chunked
|
||||
|>.sendBytes body1 |>.step
|
||||
|>.sendBytes body2 |>.userClosedBody |>.step
|
||||
|>.assertOutputContains "7\r\nHello, \r\n" "expected first chunk in wire output"
|
||||
|>.assertOutputContains "6\r\nWorld!\r\n" "expected second chunk in wire output"
|
||||
|>.assertOutputContains "0\r\n\r\n" "expected final empty chunk in wire output"
|
||||
|>.run
|
||||
|
||||
-- §7.1: chunked body assembled when size line and data arrive in separate feeds
|
||||
let splitBody := "hello".toUTF8
|
||||
MachineTester.receiving "§7.1: chunked body assembled across incremental feeds"
|
||||
|>.feed (mkChunkedPost "/" ++ "5\r\n")
|
||||
|>.step |>.assertHasEndHeaders
|
||||
|>.pullBody
|
||||
|>.feedBytes ("hello\r\n".toUTF8)
|
||||
|>.pullBody
|
||||
|>.feed "0\r\n\r\n"
|
||||
|>.pullBody
|
||||
|>.assertPulledBody splitBody |>.assertLastChunkFinal |>.run
|
||||
|
||||
#eval runGroup "RFC 9112 §9.3: connection persistence" do
|
||||
-- §9.3: HTTP/1.1 connections are persistent by default
|
||||
MachineTester.receiving "§9.3: HTTP/1.1 default keep-alive = true"
|
||||
|>.feed (mkGet "/") |>.step |>.assertNoError |>.assertKeepAlive |>.run
|
||||
|
||||
-- §9.3: HTTP/1.0 connections are not persistent by default
|
||||
MachineTester.receiving "§9.3: HTTP/1.0 default keep-alive = false"
|
||||
|>.feed (mkGet10 "/") |>.step |>.assertNoError |>.assertNotKeepAlive |>.run
|
||||
|
||||
-- §9.3: explicit (redundant) Connection: keep-alive MUST NOT suppress default keep-alive
|
||||
MachineTester.receiving "§9.3: HTTP/1.1 explicit Connection:keep-alive → keepAlive = true"
|
||||
|>.feed "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: keep-alive\r\n\r\n"
|
||||
|>.step |>.assertNoError |>.assertKeepAlive |>.run
|
||||
|
||||
-- §9.3: after completing first request, machine resets and parses second (pipelining)
|
||||
let req1 := mkGet "/"
|
||||
let req2 := mkGet "/second"
|
||||
MachineTester.receiving "§9.3: pipelining — two requests on one connection"
|
||||
|>.feed (req1 ++ req2) |>.step |>.assertHasEndHeaders |>.assertKeepAlive
|
||||
|>.send { status := .ok } |>.setKnownSize (.fixed 0) |>.userClosedBody
|
||||
|>.drainBody |>.step |>.assertHasNext
|
||||
|>.step |>.assertHasEndHeaders |>.run
|
||||
|
||||
-- §9.3: pipelining after CL:0 body — second request parses correctly
|
||||
let post1 := "POST /a HTTP/1.1\r\nHost: example.com\r\nContent-Length: 0\r\n\r\n"
|
||||
let get2 := "GET /b HTTP/1.1\r\nHost: example.com\r\n\r\n"
|
||||
MachineTester.receiving "§9.3: pipelining after CL:0 body — second request path correct"
|
||||
|>.feed (post1 ++ get2) |>.step |>.assertHasEndHeaders
|
||||
|>.drainBody |>.assertPulledBody .empty
|
||||
|>.send { status := .ok } |>.setKnownSize (.fixed 0) |>.userClosedBody
|
||||
|>.step |>.assertHasNext
|
||||
|>.step |>.assertHasEndHeaders
|
||||
|>.onEndHeaders (fun t head =>
|
||||
let path := toString head.uri.path
|
||||
if path == "/b" then t
|
||||
else t.addError s!"expected second request path /b, got {path}")
|
||||
|>.run
|
||||
|
||||
-- §9.3: resetForNextMessage must preserve the noMoreInput flag so the machine
|
||||
-- halts after the pipeline is exhausted and the socket has closed
|
||||
let req1 := mkGet "/first"
|
||||
let req2 := mkGet "/second"
|
||||
MachineTester.receiving "§9.3: resetForNextMessage preserves noMoreInput after pipeline exhausted"
|
||||
|>.feed (req1 ++ req2) |>.noMoreInput
|
||||
|>.step |>.assertHasEndHeaders
|
||||
|>.send { status := .ok } |>.setKnownSize (.fixed 0) |>.userClosedBody
|
||||
|>.drainBody |>.step |>.assertHasNext
|
||||
|>.step |>.assertHasEndHeaders
|
||||
|>.send { status := .ok } |>.setKnownSize (.fixed 0) |>.userClosedBody
|
||||
|>.drainBody |>.step
|
||||
|>.assertHasEvent (fun | .close => true | _ => false)
|
||||
"expected .close after pipeline exhausted on closed socket"
|
||||
|>.step |>.assertHalted |>.run
|
||||
|
||||
#eval runGroup "RFC 9112 §9.6: connection close" do
|
||||
-- §9.6: Connection: close in request → keep-alive = false
|
||||
MachineTester.receiving "§9.6: HTTP/1.1 Connection:close → keep-alive = false"
|
||||
|>.feed "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n"
|
||||
|>.step |>.assertNoError |>.assertNotKeepAlive |>.run
|
||||
|
||||
-- §9.6: Connection: close injected into response when keep-alive is false
|
||||
MachineTester.receiving "§9.6: Connection:close injected into response when not keep-alive"
|
||||
|>.feed "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n"
|
||||
|>.step |>.assertNotKeepAlive
|
||||
|>.send { status := .ok } |>.setKnownSize (.fixed 0) |>.userClosedBody |>.step
|
||||
|>.assertOutputContains "Connection: close" "expected Connection: close in response" |>.run
|
||||
|
||||
-- §9.6: HTTP/1.1 response with Connection: close → keep-alive disabled
|
||||
MachineTester.sending "§9.6: HTTP/1.1 response Connection:close → keep-alive = false"
|
||||
|>.send minimalGetRequest |>.step
|
||||
|>.feed "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
|
||||
|>.step |>.assertNoError |>.assertNotKeepAlive |>.run
|
||||
|
||||
#eval runGroup "RFC 9110 §6–§7: header handling" do
|
||||
-- §6.3: custom application headers are preserved in the parsed head
|
||||
MachineTester.receiving "§6.3: custom headers preserved"
|
||||
|>.feed "GET / HTTP/1.1\r\nHost: example.com\r\nX-Custom: my-value\r\nAuthorization: Bearer tok\r\n\r\n"
|
||||
|>.step |>.assertNoError
|
||||
|>.onEndHeaders (fun t head =>
|
||||
let custom := (head.headers.get? (.ofString! "X-Custom")).map (·.value)
|
||||
if custom != some "my-value" then t.addError s!"X-Custom mismatch: {repr custom}"
|
||||
else t)
|
||||
|>.run
|
||||
|
||||
-- §7.2: empty Host header is valid for origin-form in HTTP/1.1
|
||||
MachineTester.receiving "§7.2: HTTP/1.1 empty Host valid for origin-form"
|
||||
|>.feed "GET / HTTP/1.1\r\nHost: \r\n\r\n"
|
||||
|>.step |>.assertNoError |>.assertHasEndHeaders |>.run
|
||||
|
||||
#eval runGroup "RFC 9110 §15.5.14: Expect: 100-continue" do
|
||||
-- accepted: body becomes readable after canContinue .continue
|
||||
let body := "hello".toUTF8
|
||||
MachineTester.receiving "§15.5.14: Expect:100-continue accepted → body readable"
|
||||
|>.feed (mkPostHead "/upload" body.size "Expect: 100-continue\r\n")
|
||||
|>.step |>.assertHasEndHeaders |>.assertHasContinue
|
||||
|>.canContinue .«continue»
|
||||
|>.feedBytes body |>.step |>.assertCanPullBodyNow
|
||||
|>.drainBody |>.assertPulledBody body |>.run
|
||||
|
||||
-- rejected: reader closed, body not delivered
|
||||
MachineTester.receiving "§15.5.14: Expect:100-continue rejected → reader closed"
|
||||
|>.feed (mkPostHead "/upload" 5 "Expect: 100-continue\r\n")
|
||||
|>.step |>.assertHasContinue
|
||||
|>.canContinue .expectationFailed
|
||||
|>.assertReaderClosed
|
||||
|>.assert (fun m => !m.canPullBody) "body must not be pullable after rejection" |>.run
|
||||
|
||||
-- CL:0 + Expect:100-continue → empty body after acceptance
|
||||
MachineTester.receiving "§10.1.1: Expect:100-continue + Content-Length:0 → empty body after acceptance"
|
||||
|>.feed "POST / HTTP/1.1\r\nHost: example.com\r\nContent-Length: 0\r\nExpect: 100-continue\r\n\r\n"
|
||||
|>.step |>.assertHasEndHeaders |>.assertHasContinue
|
||||
|>.canContinue .«continue»
|
||||
|>.pullBody |>.assertPulledBody .empty |>.assertLastChunkFinal |>.run
|
||||
|
||||
-- §15.5.14: writer completing a non-1xx response while reader is in .continue state
|
||||
-- must force-close the reader (body will never arrive after rejection)
|
||||
MachineTester.receiving "§15.5.14: non-final send while reader awaits canContinue → .close emitted"
|
||||
|>.feed (mkPostHead "/upload" 5 "Expect: 100-continue\r\n")
|
||||
|>.step |>.assertHasContinue
|
||||
|>.send { status := .expectationFailed } |>.setKnownSize (.fixed 0) |>.userClosedBody
|
||||
|>.step
|
||||
|>.assertHasEvent (fun | .close => true | _ => false)
|
||||
"expected .close: writer completed after rejecting Expect: 100-continue, body will never arrive"
|
||||
|>.run
|
||||
|
||||
#eval runGroup "RFC 9110 §15.2: informational responses" do
|
||||
-- §15.2: multiple 1xx responses before the real response are all skipped
|
||||
MachineTester.sending "§15.2: multiple 1xx responses all skipped"
|
||||
|>.send minimalGetRequest |>.step
|
||||
|>.feed "HTTP/1.1 100 Continue\r\n\r\n" |>.step
|
||||
|>.feed "HTTP/1.1 102 Processing\r\n\r\n" |>.step
|
||||
|>.feed "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n" |>.step
|
||||
|>.assertNoError
|
||||
|>.onEndHeaders (fun t head =>
|
||||
if head.status != .ok then
|
||||
t.addError s!"expected 200 OK after 1xx chain, got {repr head.status}"
|
||||
else t)
|
||||
|>.run
|
||||
|
||||
-- §15.2: 1xx responses are valid while awaiting canContinue; writer must remain open
|
||||
MachineTester.receiving "§15.2: 1xx interim response while reader awaits canContinue → writer stays open"
|
||||
|>.feed (mkPostHead "/upload" 5 "Expect: 100-continue\r\n")
|
||||
|>.step |>.assertHasContinue
|
||||
|>.send { status := .processing } |>.step
|
||||
|>.assertIsWaitingMessage |>.run
|
||||
|
||||
#eval runGroup "RFC 7231 §4.3.2: HEAD request handling" do
|
||||
-- body bytes suppressed; Content-Length metadata still emitted so client knows size
|
||||
let body := "hello".toUTF8
|
||||
MachineTester.receiving "§4.3.2: HEAD response — body bytes suppressed, Content-Length preserved"
|
||||
|>.feed "HEAD / HTTP/1.1\r\nHost: example.com\r\n\r\n"
|
||||
|>.step |>.assertIsWaitingMessage
|
||||
|>.send { status := .ok, headers := Headers.empty.insert! "Content-Length" "5" }
|
||||
|>.sendBytes body |>.userClosedBody |>.step |>.step
|
||||
|>.assertOutputContains "HTTP/1.1 200" "expected 200 OK status line"
|
||||
|>.assertOutputContains "Content-Length: 5" "expected Content-Length metadata preserved"
|
||||
|>.assertOutputNotContains "hello" "HEAD response must not contain body bytes on the wire"
|
||||
|>.run
|
||||
|
||||
#eval runGroup "RFC 2616: HTTP/1.0 compatibility" do
|
||||
-- RFC 2068 §19.4.6: HTTP/1.0 does not define chunked transfer encoding
|
||||
MachineTester.receiving "§19.4.6: chunked TE on HTTP/1.0 → badMessage"
|
||||
|>.feed "POST / HTTP/1.0\r\nTransfer-Encoding: chunked\r\n\r\n"
|
||||
|>.step |>.assertFailedWith .badMessage |>.run
|
||||
|
||||
-- RFC 2616 §8.1: HTTP/1.0 does not default to persistent connections
|
||||
MachineTester.sending "§8.1: client receives HTTP/1.0 response → keepAlive = false"
|
||||
|>.send minimalGetRequest |>.step
|
||||
|>.feed "HTTP/1.0 200 OK\r\nContent-Length: 0\r\n\r\n"
|
||||
|>.step |>.assertNoError |>.assertNotKeepAlive |>.run
|
||||
|
||||
-- RFC 2616 §14.23: Host header is optional in HTTP/1.0
|
||||
MachineTester.receiving "§14.23: HTTP/1.0 without Host → valid"
|
||||
|>.feed "GET / HTTP/1.0\r\n\r\n"
|
||||
|>.step |>.assertNoError |>.assertHasEndHeaders |>.run
|
||||
|
||||
MachineTester.receiving "§14.23: HTTP/1.0 single Host → valid"
|
||||
|>.feed "GET / HTTP/1.0\r\nHost: example.com\r\n\r\n"
|
||||
|>.step |>.assertNoError |>.assertHasEndHeaders |>.run
|
||||
|
||||
MachineTester.receiving "§14.23: HTTP/1.0 duplicate Host → badMessage (To avoid exploits)"
|
||||
|>.feed "GET / HTTP/1.0\r\nHost: a.com\r\nHost: b.com\r\n\r\n"
|
||||
|>.step |>.assertFailedWith .badMessage |>.run
|
||||
|
||||
-- RFC 2616 §19.7.1: HTTP/1.0 + Connection: keep-alive → keep-alive enabled
|
||||
MachineTester.receiving "§19.7.1: HTTP/1.0 Connection:keep-alive → keep-alive = true"
|
||||
|>.feed "GET / HTTP/1.0\r\nConnection: keep-alive\r\n\r\n"
|
||||
|>.step |>.assertNoError |>.assertKeepAlive |>.run
|
||||
|
||||
-- §19.7.1: HTTP/1.0 keep-alive allows a second request on the same connection
|
||||
let http10req1 := "GET / HTTP/1.0\r\nConnection: keep-alive\r\n\r\n"
|
||||
let http10req2 := "GET /second HTTP/1.0\r\n\r\n"
|
||||
MachineTester.receiving "§19.7.1: HTTP/1.0 keep-alive pipelining — second request parsed"
|
||||
|>.feed (http10req1 ++ http10req2) |>.step |>.assertHasEndHeaders |>.assertKeepAlive
|
||||
|>.send { status := .ok } |>.setKnownSize (.fixed 0) |>.userClosedBody
|
||||
|>.drainBody |>.step |>.assertHasNext
|
||||
|>.step |>.assertHasEndHeaders |>.run
|
||||
|
||||
-- §19.7.1: client side — response Connection:keep-alive → keepAlive = true
|
||||
MachineTester.sending "§19.7.1: client receives HTTP/1.0 + Connection:keep-alive → keepAlive = true"
|
||||
|>.send minimalGetRequest |>.step
|
||||
|>.feed "HTTP/1.0 200 OK\r\nConnection: keep-alive\r\nContent-Length: 0\r\n\r\n"
|
||||
|>.step |>.assertNoError |>.assertKeepAlive |>.run
|
||||
|
||||
-- §19.7.1: when serving an HTTP/1.0 keep-alive connection, the response must include
|
||||
-- Connection: keep-alive so the client knows to reuse the connection
|
||||
MachineTester.receiving "§19.7.1: HTTP/1.0 keep-alive → Connection: keep-alive injected in response"
|
||||
|>.feed "GET / HTTP/1.0\r\nConnection: keep-alive\r\n\r\n"
|
||||
|>.step |>.assertKeepAlive
|
||||
|>.send { status := .ok } |>.setKnownSize (.fixed 0) |>.userClosedBody
|
||||
|>.step |>.step
|
||||
|>.assertOutputContains "Connection: keep-alive"
|
||||
"RFC 2616 §19.7.1: HTTP/1.0 keep-alive response must include Connection: keep-alive" |>.run
|
||||
351
tests/elab/async_http_hang_regressions.lean
Normal file
351
tests/elab/async_http_hang_regressions.lean
Normal file
@@ -0,0 +1,351 @@
|
||||
import Std.Internal.Http
|
||||
import Std.Internal.Async
|
||||
import Std.Internal.Async.Timer
|
||||
|
||||
open Std.Internal.IO Async
|
||||
open Std Http Test
|
||||
|
||||
def runWithTimeout {α : Type} (name : String) (timeoutMs : Nat := 2000) (action : IO α) : IO α := do
|
||||
let task ← IO.asTask action
|
||||
let ticks := (timeoutMs + 9) / 10
|
||||
|
||||
let rec loop (remaining : Nat) : IO α := do
|
||||
if (← IO.getTaskState task) == .finished then
|
||||
match (← IO.wait task) with
|
||||
| .ok x => pure x
|
||||
| .error err => throw err
|
||||
else
|
||||
match remaining with
|
||||
| 0 =>
|
||||
IO.cancel task
|
||||
throw <| IO.userError s!"Test '{name}' timed out after {timeoutMs}ms (possible hang/loop)"
|
||||
| n + 1 =>
|
||||
IO.sleep 10
|
||||
loop n
|
||||
|
||||
loop ticks
|
||||
|
||||
def sendRaw (client : Mock.Client) (server : Mock.Server) (raw : ByteArray)
|
||||
(handler : TestHandler) (config : Config := { lingeringTimeout := 500, generateDate := false }) : IO ByteArray := Async.block do
|
||||
client.send raw
|
||||
Std.Http.Server.serveConnection server handler config
|
||||
|>.run
|
||||
let res ← client.recv?
|
||||
pure <| res.getD .empty
|
||||
|
||||
def sendRawTimed (name : String) (raw : ByteArray)
|
||||
(handler : TestHandler) (config : Config := { lingeringTimeout := 500, generateDate := false }) : IO ByteArray :=
|
||||
runWithTimeout name 2000 do
|
||||
let (client, server) ← Mock.new
|
||||
sendRaw client server raw handler config
|
||||
|
||||
def runClosedClientTimed (name : String) (raw : ByteArray)
|
||||
(handler : TestHandler) (config : Config := { lingeringTimeout := 500, generateDate := false }) : IO Unit :=
|
||||
runWithTimeout name 2000 do
|
||||
Async.block do
|
||||
let (client, server) ← Mock.new
|
||||
client.send raw
|
||||
client.close
|
||||
Std.Http.Server.serveConnection server handler config
|
||||
|>.run
|
||||
|
||||
def countOccurrences (s : String) (needle : String) : Nat :=
|
||||
if needle.isEmpty then 0 else (s.splitOn needle).length - 1
|
||||
|
||||
def assertStatusPrefix (name : String) (response : ByteArray) (prefix_ : String) : IO Unit := do
|
||||
let text := String.fromUTF8! response
|
||||
unless text.startsWith prefix_ do
|
||||
throw <| IO.userError s!"Test '{name}' failed:\nExpected prefix: {prefix_.quote}\nGot:\n{text.quote}"
|
||||
|
||||
def assertNotContains (name : String) (response : ByteArray) (needle : String) : IO Unit := do
|
||||
let text := String.fromUTF8! response
|
||||
if text.contains needle then
|
||||
throw <| IO.userError s!"Test '{name}' failed:\nDid not expect {needle.quote}\nGot:\n{text.quote}"
|
||||
|
||||
def assertEndsWith (name : String) (response : ByteArray) (suffix_ : String) : IO Unit := do
|
||||
let text := String.fromUTF8! response
|
||||
unless text.endsWith suffix_ do
|
||||
throw <| IO.userError s!"Test '{name}' failed:\nExpected suffix: {suffix_.quote}\nGot:\n{text.quote}"
|
||||
|
||||
def assertStatusCount (name : String) (response : ByteArray) (expected : Nat) : IO Unit := do
|
||||
let text := String.fromUTF8! response
|
||||
let got := countOccurrences text "HTTP/1.1 "
|
||||
unless got == expected do
|
||||
throw <| IO.userError s!"Test '{name}' failed:\nExpected {expected} HTTP responses, got {got}\n{text.quote}"
|
||||
|
||||
def onesChunked (n : Nat) : String := Id.run do
|
||||
let mut body := ""
|
||||
for i in [0:n] do
|
||||
body := body ++ s!"{toString i |>.length}\x0d\n{toString i}\x0d\n"
|
||||
body ++ "0\x0d\n\x0d\n"
|
||||
|
||||
def ignoreHandler : TestHandler := fun _ => Response.ok |>.text "ok"
|
||||
|
||||
def echoBodyHandler : TestHandler := fun req => do
|
||||
let mut body := ByteArray.empty
|
||||
for chunk in req.body do
|
||||
body := body ++ chunk.data
|
||||
Response.ok |>.text (String.fromUTF8! body)
|
||||
|
||||
def firstChunkHandler : TestHandler := fun req => do
|
||||
let first ← req.body.recv
|
||||
let text :=
|
||||
match first with
|
||||
| some chunk => String.fromUTF8! chunk.data
|
||||
| none => "none"
|
||||
Response.ok |>.text text
|
||||
|
||||
def streamPiecesHandler (n : Nat) : TestHandler := fun _ => do
|
||||
let outgoing ← Body.mkStream
|
||||
background do
|
||||
for i in [0:n] do
|
||||
outgoing.send <| Chunk.ofByteArray s!"piece-{i};".toUTF8
|
||||
outgoing.close
|
||||
return Response.ok
|
||||
|>.body outgoing
|
||||
|
||||
def stressResponseHandler (n : Nat) : TestHandler := fun _ => do
|
||||
let outgoing ← Body.mkStream
|
||||
background do
|
||||
for i in [0:n] do
|
||||
outgoing.send <| Chunk.ofByteArray s!"x{i},".toUTF8
|
||||
outgoing.close
|
||||
return Response.ok
|
||||
|>.body outgoing
|
||||
|
||||
-- 01: Ignore fixed-size request body and respond immediately.
|
||||
#eval runWithTimeout "01_ignore_fixed_length_body" 2000 do
|
||||
let raw := "POST /fixed HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: 10\x0d\nConnection: close\x0d\n\x0d\n0123456789".toUTF8
|
||||
let response ← sendRawTimed "01_ignore_fixed_length_body/send" raw ignoreHandler
|
||||
assertStatusPrefix "01_ignore_fixed_length_body" response "HTTP/1.1 200"
|
||||
|
||||
-- 02: Ignore chunked request body and respond immediately.
|
||||
#eval runWithTimeout "02_ignore_chunked_body" 2000 do
|
||||
let raw := "POST /chunked HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n6\x0d\n world\x0d\n0\x0d\n\x0d\n".toUTF8
|
||||
let response ← sendRawTimed "02_ignore_chunked_body/send" raw ignoreHandler
|
||||
assertStatusPrefix "02_ignore_chunked_body" response "HTTP/1.1 200"
|
||||
|
||||
-- 03: Large fixed-size body ignored by handler (regression for stalled body transfer).
|
||||
#eval runWithTimeout "03_ignore_large_fixed_body" 2000 do
|
||||
let body := String.ofList (List.replicate 8192 'A')
|
||||
let raw := s!"POST /large HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: 8192\x0d\nConnection: close\x0d\n\x0d\n{body}".toUTF8
|
||||
let response ← sendRawTimed "03_ignore_large_fixed_body/send" raw ignoreHandler
|
||||
assertStatusPrefix "03_ignore_large_fixed_body" response "HTTP/1.1 200"
|
||||
|
||||
-- 04: Read full request body and echo it.
|
||||
#eval runWithTimeout "04_echo_full_body" 2000 do
|
||||
let raw := "POST /echo HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: 11\x0d\nConnection: close\x0d\n\x0d\nhello world".toUTF8
|
||||
let response ← sendRawTimed "04_echo_full_body/send" raw echoBodyHandler
|
||||
assertContains response "hello world"
|
||||
|
||||
-- 05: Read only first chunk and reply (should not deadlock connection).
|
||||
#eval runWithTimeout "05_read_first_chunk_only" 2000 do
|
||||
let raw := "POST /first HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: 11\x0d\nConnection: close\x0d\n\x0d\nhello world".toUTF8
|
||||
let response ← sendRawTimed "05_read_first_chunk_only/send" raw firstChunkHandler
|
||||
assertStatusPrefix "05_read_first_chunk_only" response "HTTP/1.1 200"
|
||||
assertContains response "hello world"
|
||||
|
||||
-- 06: Stream many response chunks.
|
||||
#eval runWithTimeout "06_stream_many_response_chunks" 2000 do
|
||||
let raw := "GET /stream HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
||||
let response ← sendRawTimed "06_stream_many_response_chunks/send" raw (streamPiecesHandler 40)
|
||||
assertStatusPrefix "06_stream_many_response_chunks" response "HTTP/1.1 200"
|
||||
assertContains response "piece-0;"
|
||||
assertContains response "piece-39;"
|
||||
|
||||
-- 07: Stream response with known fixed size.
|
||||
#eval runWithTimeout "07_stream_known_size" 2000 do
|
||||
let raw := "GET /known HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
||||
let response ← sendRawTimed "07_stream_known_size/send" raw (fun _ => do
|
||||
let outgoing ← Body.mkStream
|
||||
outgoing.setKnownSize (some (.fixed 8))
|
||||
background do
|
||||
outgoing.send <| Chunk.ofByteArray "abcd".toUTF8
|
||||
outgoing.send <| Chunk.ofByteArray "efgh".toUTF8
|
||||
outgoing.close
|
||||
return Response.ok
|
||||
|>.body outgoing)
|
||||
assertStatusPrefix "07_stream_known_size" response "HTTP/1.1 200"
|
||||
assertContains response "Content-Length: 8"
|
||||
assertContains response "abcdefgh"
|
||||
|
||||
-- 08: Use interestSelector before sending response data.
|
||||
#eval runWithTimeout "08_interest_selector_gating" 2000 do
|
||||
let raw := "GET /interest HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
||||
let response ← sendRawTimed "08_interest_selector_gating/send" raw (fun _ => do
|
||||
let outgoing ← Body.mkStream
|
||||
background do
|
||||
let interested ← Selectable.one #[
|
||||
.case outgoing.interestSelector pure
|
||||
]
|
||||
if interested then
|
||||
outgoing.send <| Chunk.ofByteArray "interest-ok".toUTF8
|
||||
outgoing.close
|
||||
return Response.ok
|
||||
|>.body outgoing)
|
||||
assertStatusPrefix "08_interest_selector_gating" response "HTTP/1.1 200"
|
||||
assertContains response "interest-ok"
|
||||
|
||||
-- 09: Incomplete sends collapse into one payload.
|
||||
#eval runWithTimeout "09_incomplete_send_collapse" 2000 do
|
||||
let raw := "GET /collapse HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
||||
let response ← sendRawTimed "09_incomplete_send_collapse/send" raw (fun _ => do
|
||||
let outgoing ← Body.mkStream
|
||||
background do
|
||||
outgoing.send ({ data := "hello ".toUTF8, extensions := #[] } : Chunk) (incomplete := true)
|
||||
outgoing.send ({ data := "wor".toUTF8, extensions := #[] } : Chunk) (incomplete := true)
|
||||
outgoing.send ({ data := "ld".toUTF8, extensions := #[] } : Chunk)
|
||||
outgoing.close
|
||||
return Response.ok
|
||||
|>.body outgoing)
|
||||
assertStatusPrefix "09_incomplete_send_collapse" response "HTTP/1.1 200"
|
||||
assertContains response "hello world"
|
||||
|
||||
-- 10: Pipeline fixed-body POST then GET.
|
||||
#eval runWithTimeout "10_pipeline_fixed_then_get" 2000 do
|
||||
let raw := ("POST /one HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: 5\x0d\n\x0d\nhello" ++
|
||||
"GET /two HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n").toUTF8
|
||||
let response ← sendRawTimed "10_pipeline_fixed_then_get/send" raw uriHandler
|
||||
assertStatusCount "10_pipeline_fixed_then_get" response 2
|
||||
assertContains response "/one"
|
||||
assertContains response "/two"
|
||||
|
||||
-- 11: Pipeline chunked-body POST then GET.
|
||||
#eval runWithTimeout "11_pipeline_chunked_then_get" 2000 do
|
||||
let raw := ("POST /chunk HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\n\x0d\n" ++
|
||||
"GET /two HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n").toUTF8
|
||||
let response ← sendRawTimed "11_pipeline_chunked_then_get/send" raw uriHandler
|
||||
assertStatusCount "11_pipeline_chunked_then_get" response 2
|
||||
assertContains response "/chunk"
|
||||
assertContains response "/two"
|
||||
|
||||
-- 12: Malformed first request should not loop into second.
|
||||
#eval runWithTimeout "12_malformed_closes_connection" 2000 do
|
||||
let raw := ("GET / HTTP/1.1\x0d\nHost: example.com\x0d\nBadHeader value\x0d\n\x0d\n" ++
|
||||
"GET /second HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n").toUTF8
|
||||
let response ← sendRawTimed "12_malformed_closes_connection/send" raw uriHandler
|
||||
assertStatusPrefix "12_malformed_closes_connection" response "HTTP/1.1 400"
|
||||
assertStatusCount "12_malformed_closes_connection" response 1
|
||||
|
||||
-- 13: Client closes while server is streaming response.
|
||||
#eval runWithTimeout "13_client_close_while_streaming" 2000 do
|
||||
let raw := "GET /close-stream HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
||||
runClosedClientTimed "13_client_close_while_streaming/run" raw (stressResponseHandler 600)
|
||||
|
||||
-- 14: Client closes before sending full body.
|
||||
#eval runWithTimeout "14_client_close_mid_body" 2000 do
|
||||
let raw := "POST /mid-body HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: 100\x0d\nConnection: close\x0d\n\x0d\nabcde".toUTF8
|
||||
runClosedClientTimed "14_client_close_mid_body/run" raw ignoreHandler
|
||||
|
||||
-- 15: Handler throws while request body is present.
|
||||
#eval runWithTimeout "15_handler_throw_unread_body" 2000 do
|
||||
let raw := "POST /throw HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: 5\x0d\nConnection: close\x0d\n\x0d\nhello".toUTF8
|
||||
let response ← sendRawTimed "15_handler_throw_unread_body/send" raw (fun _ => throw <| IO.userError "boom")
|
||||
assertStatusPrefix "15_handler_throw_unread_body" response "HTTP/1.1 500"
|
||||
|
||||
-- 16: Many tiny chunked request chunks ignored by handler.
|
||||
#eval runWithTimeout "16_many_tiny_chunked_ignored" 2000 do
|
||||
let body := onesChunked 80
|
||||
let raw := s!"POST /tiny HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n{body}".toUTF8
|
||||
let response ← sendRawTimed "16_many_tiny_chunked_ignored/send" raw ignoreHandler
|
||||
assertStatusPrefix "16_many_tiny_chunked_ignored" response "HTTP/1.1 200"
|
||||
|
||||
-- 17: Many tiny chunked request chunks consumed and counted.
|
||||
#eval runWithTimeout "17_many_tiny_chunked_consumed" 2000 do
|
||||
let body := onesChunked 25
|
||||
let raw := s!"POST /count HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n{body}".toUTF8
|
||||
let response ← sendRawTimed "17_many_tiny_chunked_consumed/send" raw (fun req => do
|
||||
let mut count := 0
|
||||
for _ in req.body do
|
||||
count := count + 1
|
||||
Response.ok |>.text (toString count))
|
||||
assertStatusPrefix "17_many_tiny_chunked_consumed" response "HTTP/1.1 200"
|
||||
assertEndsWith "17_many_tiny_chunked_consumed" response "25"
|
||||
|
||||
pure ()
|
||||
|
||||
-- 18: Stress response streaming with many chunks and active client.
|
||||
#eval runWithTimeout "18_stress_streaming_active_client" 2000 do
|
||||
let raw := "GET /stress HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
||||
let response ← sendRawTimed "18_stress_streaming_active_client/send" raw (stressResponseHandler 120)
|
||||
assertStatusPrefix "18_stress_streaming_active_client" response "HTTP/1.1 200"
|
||||
assertContains response "x0,"
|
||||
assertContains response "x119,"
|
||||
|
||||
-- 19: Pipeline with large unread first body still processes second request.
|
||||
#eval runWithTimeout "19_pipeline_large_unread_then_get" 2000 do
|
||||
let body := String.ofList (List.replicate 5000 'b')
|
||||
let raw := (s!"POST /big HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: 5000\x0d\n\x0d\n{body}" ++
|
||||
"GET /after HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n").toUTF8
|
||||
let response ← sendRawTimed "19_pipeline_large_unread_then_get/send" raw uriHandler
|
||||
assertStatusCount "19_pipeline_large_unread_then_get" response 2
|
||||
assertContains response "/big"
|
||||
assertContains response "/after"
|
||||
|
||||
-- 20: Triple pipeline mixed body styles.
|
||||
#eval runWithTimeout "20_triple_pipeline_mixed" 2000 do
|
||||
let raw := ("POST /a HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: 4\x0d\n\x0d\ndata" ++
|
||||
"POST /b HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\n\x0d\n3\x0d\nhey\x0d\n0\x0d\n\x0d\n" ++
|
||||
"GET /c HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n").toUTF8
|
||||
let response ← sendRawTimed "20_triple_pipeline_mixed/send" raw uriHandler
|
||||
assertStatusCount "20_triple_pipeline_mixed" response 3
|
||||
assertContains response "/a"
|
||||
assertContains response "/b"
|
||||
assertContains response "/c"
|
||||
|
||||
-- 21: Slow/incomplete active body transfer must time out (no connection pinning).
|
||||
#eval runWithTimeout "21_incomplete_slow_post_times_out" 2000 do
|
||||
let raw := "POST /slow HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: 100\x0d\nConnection: close\x0d\n\x0d\nabcde".toUTF8
|
||||
let response ← sendRawTimed
|
||||
"21_incomplete_slow_post_times_out/send"
|
||||
raw
|
||||
(fun req => do
|
||||
let _s : String ← req.body.readAll
|
||||
Response.ok |>.text "unreachable")
|
||||
(config := { lingeringTimeout := 200, generateDate := false })
|
||||
assertStatusPrefix "21_incomplete_slow_post_times_out" response "HTTP/1.1 408"
|
||||
|
||||
-- 22: Keep-alive + unknown-size stream flushes once first chunk is available.
|
||||
#eval runWithTimeout "22_keepalive_unknown_size_flushes_early" 3000 do
|
||||
Async.block do
|
||||
let (client, server) ← Mock.new
|
||||
let handler : TestHandler := fun _ => do
|
||||
let outgoing ← Body.mkStream
|
||||
background do
|
||||
outgoing.send <| Chunk.ofByteArray "aaa".toUTF8
|
||||
let sleep ← Sleep.mk 300
|
||||
sleep.wait
|
||||
outgoing.send <| Chunk.ofByteArray "bbb".toUTF8
|
||||
outgoing.close
|
||||
return Response.ok
|
||||
|>.body outgoing
|
||||
|
||||
background <| (Std.Http.Server.serveConnection server handler {
|
||||
lingeringTimeout := 800
|
||||
keepAliveTimeout := ⟨1500, by decide⟩
|
||||
generateDate := false
|
||||
}).run
|
||||
|
||||
client.send "GET /stream HTTP/1.1\x0d\nHost: example.com\x0d\n\x0d\n".toUTF8
|
||||
|
||||
let mut early : Option ByteArray := none
|
||||
for _ in [0:5] do
|
||||
if early.isNone then
|
||||
let sleep ← Sleep.mk 40
|
||||
sleep.wait
|
||||
early ← client.tryRecv?
|
||||
|
||||
let earlyBytes := early.getD ByteArray.empty
|
||||
if earlyBytes.isEmpty then
|
||||
throw <| IO.userError "Test '22_keepalive_unknown_size_flushes_early' failed:\nExpected early streamed bytes before producer EOF"
|
||||
|
||||
assertContains earlyBytes "Transfer-Encoding: chunked"
|
||||
assertContains earlyBytes "aaa"
|
||||
assertNotContains "22_keepalive_unknown_size_flushes_early no second chunk yet" earlyBytes "bbb"
|
||||
|
||||
let sleep ← Sleep.mk 420
|
||||
sleep.wait
|
||||
let later := (← client.tryRecv?).getD ByteArray.empty
|
||||
assertContains later "bbb"
|
||||
|
||||
client.close
|
||||
@@ -1,4 +1,5 @@
|
||||
import Std.Internal.Http.Data.Headers
|
||||
import Std.Internal.Http.Protocol.H1
|
||||
|
||||
open Std.Http
|
||||
open Std.Http.Header
|
||||
@@ -338,3 +339,42 @@ info: ("connection", "keep-alive,close")
|
||||
let c : Header.Connection := ⟨#["keep-alive", "close"], by native_decide⟩
|
||||
let (name, value) := Header.Connection.serialize c
|
||||
return (name.value, value.value)
|
||||
|
||||
/-! ## Aggregate header byte limit (maxHeaderBytes) -/
|
||||
|
||||
section HeaderByteLimit
|
||||
open Std.Http.Protocol.H1
|
||||
|
||||
-- Helper: feed all bytes at once and run one step, return the machine state.
|
||||
private def runMachine (raw : String) (cfg : Config) : Machine .receiving :=
|
||||
let machine : Machine .receiving := { config := cfg }
|
||||
(machine.feed raw.toUTF8).step.1
|
||||
|
||||
-- With a tight limit (35 bytes), two headers whose combined byte count exceeds
|
||||
-- the limit should cause the machine to fail.
|
||||
-- "host" (4) + "example.com" (11) + 4 = 19 bytes for the first header.
|
||||
-- "x-a" (3) + "somevalue1" (10) + 4 = 17 bytes for the second → total 36 > 35.
|
||||
#guard
|
||||
let raw := "GET / HTTP/1.1\r\nhost: example.com\r\nx-a: somevalue1\r\n\r\n"
|
||||
let cfg : Config := { maxHeaderBytes := 35 }
|
||||
(runMachine raw cfg).failed
|
||||
|
||||
-- With a generous limit the same request succeeds (machine is not failed).
|
||||
#guard
|
||||
let raw := "GET / HTTP/1.1\r\nhost: example.com\r\nx-a: somevalue1\r\n\r\n"
|
||||
let cfg : Config := { maxHeaderBytes := 100 }
|
||||
!(runMachine raw cfg).failed
|
||||
|
||||
-- Exactly at the boundary: 19 bytes for host header alone, limit = 19 → ok.
|
||||
#guard
|
||||
let raw := "GET / HTTP/1.1\r\nhost: example.com\r\n\r\n"
|
||||
let cfg : Config := { maxHeaderBytes := 19 }
|
||||
!(runMachine raw cfg).failed
|
||||
|
||||
-- One byte under the two-header total → second header pushes it over.
|
||||
#guard
|
||||
let raw := "GET / HTTP/1.1\r\nhost: example.com\r\nx-a: somevalue1\r\n\r\n"
|
||||
let cfg : Config := { maxHeaderBytes := 19 }
|
||||
(runMachine raw cfg).failed
|
||||
|
||||
end HeaderByteLimit
|
||||
|
||||
150
tests/elab/async_http_keepalive.lean
Normal file
150
tests/elab/async_http_keepalive.lean
Normal file
@@ -0,0 +1,150 @@
|
||||
import Std.Internal.Http.Test.Helpers
|
||||
|
||||
open Std.Internal.IO Async
|
||||
open Std Http Test
|
||||
|
||||
-- Helper: run pipelined raw request string, closing the client after send.
|
||||
-- Returns (response bytes, list of URIs seen by the handler).
|
||||
private def runPipelined
|
||||
(raw : String)
|
||||
(readBody : Bool := true)
|
||||
(config : Config := defaultConfig) : IO (ByteArray × Array String) := Async.block do
|
||||
let (client, server) ← Mock.new
|
||||
let seenRef ← IO.mkRef (#[] : Array String)
|
||||
|
||||
let handler : TestHandler := fun req => do
|
||||
seenRef.modify (·.push (toString req.line.uri))
|
||||
let body ←
|
||||
if readBody then req.body.readAll
|
||||
else pure "<ignored>"
|
||||
Response.ok |>.text s!"{toString req.line.uri}:{body}"
|
||||
|
||||
client.send raw.toUTF8
|
||||
client.getSendChan.close
|
||||
Std.Http.Server.serveConnection server handler config |>.run
|
||||
|
||||
let response ← client.recv?
|
||||
let seen ← seenRef.get
|
||||
pure (response.getD .empty, seen)
|
||||
|
||||
private def assertSeenCount (seen : Array String) (expected : Nat) : IO Unit := do
|
||||
unless seen.size == expected do
|
||||
throw <| IO.userError s!"expected {expected} handler calls, got {seen.size}: {seen}"
|
||||
|
||||
-- HTTP/1.1 keep-alive behavior
|
||||
|
||||
#eval runGroup "Keep-alive: basic" do
|
||||
check "two sequential keep-alive requests → 2 responses"
|
||||
(raw :=
|
||||
"GET /first HTTP/1.1\x0d\nHost: example.com\x0d\n\x0d\n" ++
|
||||
"GET /second HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := fun req => Response.ok |>.text (toString req.line.uri))
|
||||
(expect := fun r =>
|
||||
assertResponseCount r 2 *>
|
||||
assertContains r "/first" *>
|
||||
assertContains r "/second")
|
||||
|
||||
check "Connection: close on first request blocks pipelined second"
|
||||
(raw :=
|
||||
"GET /first HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n" ++
|
||||
"GET /second HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := fun req => Response.ok |>.text (toString req.line.uri))
|
||||
(expect := fun r =>
|
||||
assertResponseCount r 1 *>
|
||||
assertContains r "/first" *>
|
||||
assertAbsent r "/second")
|
||||
|
||||
check "enableKeepAlive: false → one response only"
|
||||
(raw :=
|
||||
"GET /1 HTTP/1.1\x0d\nHost: example.com\x0d\n\x0d\n" ++
|
||||
"GET /2 HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := fun req => Response.ok |>.text (toString req.line.uri))
|
||||
(config := { defaultConfig with enableKeepAlive := false, lingeringTimeout := 3000 })
|
||||
(expect := fun r =>
|
||||
assertResponseCount r 1 *>
|
||||
assertContains r "/1" *>
|
||||
assertAbsent r "/2")
|
||||
|
||||
check "maxRequests: 2 caps third request"
|
||||
(raw :=
|
||||
"GET /0 HTTP/1.1\x0d\nHost: example.com\x0d\n\x0d\n" ++
|
||||
"GET /1 HTTP/1.1\x0d\nHost: example.com\x0d\n\x0d\n" ++
|
||||
"GET /2 HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := fun req => Response.ok |>.text (toString req.line.uri))
|
||||
(config := { defaultConfig with maxRequests := 2, lingeringTimeout := 3000 })
|
||||
(expect := fun r =>
|
||||
assertResponseCount r 2 *>
|
||||
assertContains r "/0" *>
|
||||
assertContains r "/1" *>
|
||||
assertAbsent r "/2")
|
||||
|
||||
-- Body draining between keep-alive requests
|
||||
|
||||
#eval runGroup "Keep-alive: unread body draining" do
|
||||
check "handler ignores fixed-size body → next keep-alive works"
|
||||
(raw :=
|
||||
"POST /ignore HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: 5\x0d\n\x0d\nhello" ++
|
||||
"GET /after HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := fun req => Response.ok |>.text (toString req.line.uri))
|
||||
(config := { defaultConfig with lingeringTimeout := 3000 })
|
||||
(expect := fun r =>
|
||||
assertResponseCount r 2 *>
|
||||
assertContains r "/ignore" *>
|
||||
assertContains r "/after")
|
||||
|
||||
check "handler ignores chunked body → next keep-alive works"
|
||||
(raw :=
|
||||
"POST /chunked HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\n\x0d\n" ++
|
||||
"GET /next HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := fun req => Response.ok |>.text (toString req.line.uri))
|
||||
(config := { defaultConfig with lingeringTimeout := 3000 })
|
||||
(expect := fun r =>
|
||||
assertResponseCount r 2 *>
|
||||
assertContains r "/chunked" *>
|
||||
assertContains r "/next")
|
||||
|
||||
-- Pipelining after exact Content-Length
|
||||
|
||||
#eval runGroup "Keep-alive: pipelined requests after exact CL" do
|
||||
let (response, seen) ← runPipelined
|
||||
("POST /first HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: 3\x0d\n\x0d\nabc" ++
|
||||
"GET /second HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
|
||||
assertResponseCount response 2
|
||||
assertContains response "/first"
|
||||
assertContains response "/second"
|
||||
assertSeenCount seen 2
|
||||
|
||||
#eval runGroup "Keep-alive: incomplete body blocks pipelining" do
|
||||
let (response1, seen1) ← runPipelined
|
||||
("POST /first HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: 10\x0d\n\x0d\nabc" ++
|
||||
"GET /second HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
|
||||
assertContains response1 "/first"
|
||||
assertAbsent response1 "/second"
|
||||
assertSeenCount seen1 1
|
||||
|
||||
let (response2, _) ← runPipelined
|
||||
("POST /chunked-first HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\n\x0d\nF\x0d\nhel" ++
|
||||
"GET /second HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
|
||||
assertAbsent response2 "/second"
|
||||
|
||||
#eval runGroup "Keep-alive: CL=0 and complete chunked allow immediate next" do
|
||||
let (resp1, seen1) ← runPipelined
|
||||
("POST /empty HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: 0\x0d\n\x0d\n" ++
|
||||
"GET /tail HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
|
||||
assertResponseCount resp1 2
|
||||
assertContains resp1 "/empty"
|
||||
assertContains resp1 "/tail"
|
||||
assertSeenCount seen1 2
|
||||
|
||||
let (resp2, seen2) ← runPipelined
|
||||
("POST /chunked HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\n\x0d\n" ++
|
||||
"GET /tail HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
|
||||
assertResponseCount resp2 2
|
||||
assertContains resp2 "/chunked"
|
||||
assertContains resp2 "/tail"
|
||||
assertSeenCount seen2 2
|
||||
133
tests/elab/async_http_redirect.lean
Normal file
133
tests/elab/async_http_redirect.lean
Normal file
@@ -0,0 +1,133 @@
|
||||
import Std.Internal.Http
|
||||
import Std.Internal.Async
|
||||
import Std.Internal.Async.Timer
|
||||
|
||||
open Std.Internal.IO Async
|
||||
open Std Http
|
||||
|
||||
/-!
|
||||
# HTTP Client Redirect Tests
|
||||
|
||||
Tests for redirect behavior of the HTTP client:
|
||||
|
||||
- Streaming request body must NOT be forwarded to the redirect target.
|
||||
A 302 redirect changes POST → GET; the original body is a stream that has
|
||||
already been (or is being) sent on the first connection. Forwarding it to the
|
||||
redirect target would (a) send garbage bytes on a GET and (b) leak data that
|
||||
the caller did not intend to share with the redirect destination.
|
||||
-/
|
||||
|
||||
private def runWithTimeout (name : String) (timeoutMs : Nat := 3000) (action : IO Unit) : IO Unit := do
|
||||
let task ← IO.asTask action
|
||||
let ticks := (timeoutMs + 9) / 10
|
||||
let rec loop (remaining : Nat) : IO Unit := do
|
||||
if (← IO.getTaskState task) == .finished then
|
||||
match (← IO.wait task) with
|
||||
| .ok x => pure x
|
||||
| .error err => throw err
|
||||
else
|
||||
match remaining with
|
||||
| 0 =>
|
||||
IO.cancel task
|
||||
throw <| IO.userError s!"Test '{name}' timed out after {timeoutMs}ms"
|
||||
| n + 1 =>
|
||||
IO.sleep 10
|
||||
loop n
|
||||
loop ticks
|
||||
|
||||
private def rawResp
|
||||
(status : String) (hdrs : Array (String × String)) (body : String) : ByteArray :=
|
||||
let hdrLines := hdrs.foldl (fun s (k, v) => s ++ s!"{k}: {v}\r\n") ""
|
||||
s!"HTTP/1.1 {status}\r\n{hdrLines}\r\n{body}".toUTF8
|
||||
|
||||
-- ============================================================
|
||||
-- Redirect: streaming body NOT forwarded on 302 (POST → GET)
|
||||
-- ============================================================
|
||||
-- A POST with a streaming body receives a 302 Found redirect. The client must
|
||||
-- follow the redirect as GET /redirected (RFC 9110 §15.4.3) and must NOT
|
||||
-- include the original streaming body in the redirect request.
|
||||
--
|
||||
-- This matters because:
|
||||
-- 1. The body is a live stream — consuming it on the redirect would steal
|
||||
-- data intended for the original endpoint.
|
||||
-- 2. A GET request must not carry a body (RFC 9110 §9.3.1).
|
||||
-- 3. The stream has already been (partially) sent; retransmitting whatever
|
||||
-- bytes remain would produce a malformed or empty body at the new target.
|
||||
-- ============================================================
|
||||
|
||||
#eval show IO _ from runWithTimeout "streaming body not sent on 302 redirect" 3000 <| Async.block do
|
||||
let (mockClient, mockServer) ← Mock.new
|
||||
let session ← Client.Session.new mockServer (config := {})
|
||||
let cookieJar ← Cookie.Jar.new
|
||||
let some domain := URI.DomainName.ofString? "example.com"
|
||||
| throw (IO.userError "DomainName parse failed")
|
||||
|
||||
let agent : Client.Agent Mock.Server := {
|
||||
session
|
||||
scheme := URI.Scheme.ofString! "http"
|
||||
host := .name domain
|
||||
port := 80
|
||||
cookieJar
|
||||
}
|
||||
|
||||
-- POST with a streaming body.
|
||||
let request ← Request.new
|
||||
|>.method .post
|
||||
|>.uri! "/upload"
|
||||
|>.header! "Host" "example.com"
|
||||
|>.stream (fun out => do
|
||||
out.send (Chunk.ofByteArray "secret-payload".toUTF8)
|
||||
out.close)
|
||||
|
||||
let resultPromise : IO.Promise (Except String (Response Body.Stream)) ← IO.Promise.new
|
||||
|
||||
background do
|
||||
let result ← try
|
||||
let resp ← Client.Agent.send agent request
|
||||
pure (Except.ok resp)
|
||||
catch e => pure (Except.error (toString e))
|
||||
discard <| resultPromise.resolve result
|
||||
|
||||
-- First request: drain the POST body completely before replying with 302.
|
||||
-- Accept chunked (ends with "0\r\n\r\n") or fixed-length (ends with body bytes).
|
||||
let mut firstBytes := ByteArray.empty
|
||||
repeat
|
||||
let some chunk ← mockClient.recv?
|
||||
| throw (IO.userError "Test failed: connection closed before first request")
|
||||
firstBytes := firstBytes ++ chunk
|
||||
let t := String.fromUTF8! firstBytes
|
||||
if t.endsWith "0\r\n\r\n" || t.endsWith "secret-payload" then break
|
||||
mockClient.send (rawResp "302 Found"
|
||||
#[("Location", "/redirected"),
|
||||
("Content-Length", "0"),
|
||||
("Connection", "keep-alive")] "")
|
||||
|
||||
-- Second request: the redirect. Must be GET /redirected with no body.
|
||||
let some redirectBytes ← mockClient.recv?
|
||||
| throw (IO.userError "Test failed: no redirect request received")
|
||||
mockClient.send (rawResp "200 OK"
|
||||
#[("Content-Length", "2"), ("Connection", "close")] "ok")
|
||||
|
||||
match ← await resultPromise.result! with
|
||||
| Except.error e => throw (IO.userError s!"agent error: {e}")
|
||||
| Except.ok _ => pure ()
|
||||
|
||||
let redirectText := String.fromUTF8! redirectBytes
|
||||
|
||||
-- The redirect must use GET.
|
||||
unless redirectText.startsWith "GET " do
|
||||
throw <| IO.userError
|
||||
s!"Test 'streaming body not sent on 302 redirect' FAILED: \
|
||||
expected GET request, got:\n{redirectText.quote}"
|
||||
|
||||
-- The redirect target must be /redirected.
|
||||
unless redirectText.contains "GET /redirected" do
|
||||
throw <| IO.userError
|
||||
s!"Test 'streaming body not sent on 302 redirect' FAILED: \
|
||||
expected GET /redirected, got:\n{redirectText.quote}"
|
||||
|
||||
-- The streaming body must not appear in the redirect request.
|
||||
if redirectText.contains "secret-payload" then
|
||||
throw <| IO.userError
|
||||
s!"Test 'streaming body not sent on 302 redirect' FAILED: \
|
||||
streaming body 'secret-payload' present in redirect request\n{redirectText.quote}"
|
||||
126
tests/elab/async_http_request_headers.lean
Normal file
126
tests/elab/async_http_request_headers.lean
Normal file
@@ -0,0 +1,126 @@
|
||||
import Std.Internal.Http.Test.Helpers
|
||||
|
||||
open Std.Internal.IO Async
|
||||
open Std Http Test
|
||||
|
||||
-- Shared fixtures
|
||||
|
||||
private def ok200 : String :=
|
||||
"HTTP/1.1 200 OK\x0d\nContent-Type: text/plain; charset=utf-8\x0d\nServer: LeanHTTP/1.1\x0d\nConnection: close\x0d\nContent-Length: 2\x0d\n\x0d\nok"
|
||||
|
||||
-- RFC 9112 §5: Header fields — syntax and byte-level validation
|
||||
|
||||
#eval runGroup "RFC 9112 §5: header field syntax" do
|
||||
check "header without colon → 400"
|
||||
(raw := "GET / HTTP/1.1\x0d\nHost: example.com\x0d\nBadHeader value\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
check "leading whitespace (obs-fold) → 400"
|
||||
(raw := "GET / HTTP/1.1\x0d\nHost: example.com\x0d\n X-Bad: folded\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
check "space in header name → 400"
|
||||
(raw := "GET / HTTP/1.1\x0d\nHost: example.com\x0d\nBad Header: value\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
check "bare LF line endings → 400"
|
||||
(raw := "GET / HTTP/1.1\nHost: example.com\nConnection: close\n\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
check "tab in header value → accepted"
|
||||
(raw := "GET / HTTP/1.1\x0d\nHost: example.com\x0d\nX-Tab: value\twith\ttabs\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r ok200)
|
||||
|
||||
check "additional colon in header value stays in value"
|
||||
(raw := "GET / HTTP/1.1\x0d\nHost: example.com\x0d\nBad:Name: value\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r ok200)
|
||||
|
||||
check "CRLF in header value parsed as two separate headers → 200"
|
||||
(raw := "GET / HTTP/1.1\x0d\nHost: example.com\x0d\nX-Inject: value\x0d\nEvil: injected\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r ok200)
|
||||
|
||||
-- Critical: NUL and control chars in header names/values
|
||||
|
||||
#eval runGroup "RFC 9110 §5.5: invalid bytes in header fields (Critical)" do
|
||||
check "NUL in header name → 400"
|
||||
(raw := "GET / HTTP/1.1\x0d\nHost: example.com\x0d\nX-Bad\x00Header: value\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
check "NUL in header value → 400"
|
||||
(raw := "GET / HTTP/1.1\x0d\nHost: example.com\x0d\nX-Header: bad\x00value\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
check "control char (0x01) in header value → 400"
|
||||
(raw := "GET / HTTP/1.1\x0d\nHost: example.com\x0d\nX-Header: bad\x01value\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
-- RFC 9112 §6.3 / §9110 §8.6: header size limits
|
||||
|
||||
#eval runGroup "Header size limits" do
|
||||
check "header name > 256 bytes → 400"
|
||||
(raw :=
|
||||
let longName := String.ofList (List.replicate 257 'X')
|
||||
s!"GET / HTTP/1.1\x0d\nHost: example.com\x0d\n{longName}: value\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
check "header value too long → 400"
|
||||
(raw :=
|
||||
let longVal := String.ofList (List.replicate 9000 'x')
|
||||
s!"GET / HTTP/1.1\x0d\nHost: example.com\x0d\nX-Long: {longVal}\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
check "too many headers (101) → 431"
|
||||
(raw := Id.run do
|
||||
let mut raw := "GET / HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n"
|
||||
for i in [0:101] do
|
||||
raw := raw ++ s!"X-Header-{i}: value{i}\x0d\n"
|
||||
return raw ++ "\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r431)
|
||||
|
||||
check "total header bytes too large → 431"
|
||||
(raw := Id.run do
|
||||
let mut raw := "GET / HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n"
|
||||
let v := String.ofList (List.replicate 200 'a')
|
||||
for i in [0:200] do
|
||||
raw := raw ++ s!"X-Header-{i}: {v}\x0d\n"
|
||||
return raw ++ "\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r431)
|
||||
|
||||
check "request-line too long → 400"
|
||||
(raw :=
|
||||
let longUri := "/" ++ String.ofList (List.replicate 2000 'a')
|
||||
s!"GET {longUri} HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
#eval runGroup "maxStartLineLength config" do
|
||||
let cfg : Config := { defaultConfig with maxStartLineLength := 16384 }
|
||||
|
||||
let segment := String.ofList (List.replicate 255 'a')
|
||||
let maxUri := "/" ++ String.intercalate "/" (List.replicate 32 segment)
|
||||
|
||||
check "URI at maxStartLineLength limit → 200"
|
||||
(raw := s!"GET {maxUri} HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(config := cfg)
|
||||
(expect := fun r => assertExact r ok200)
|
||||
|
||||
check "URI one byte over limit → 414"
|
||||
(raw := s!"GET {maxUri}/x HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(config := cfg)
|
||||
(expect := fun r => assertStatus r "HTTP/1.1 414")
|
||||
166
tests/elab/async_http_request_line.lean
Normal file
166
tests/elab/async_http_request_line.lean
Normal file
@@ -0,0 +1,166 @@
|
||||
import Std.Internal.Http.Test.Helpers
|
||||
|
||||
open Std.Internal.IO Async
|
||||
open Std Http Test
|
||||
|
||||
-- Shared fixtures
|
||||
|
||||
private def ok200 : String :=
|
||||
"HTTP/1.1 200 OK\x0d\nContent-Type: text/plain; charset=utf-8\x0d\nServer: LeanHTTP/1.1\x0d\nConnection: close\x0d\nContent-Length: 2\x0d\n\x0d\nok"
|
||||
|
||||
-- RFC 9112 §3: Request Line
|
||||
|
||||
#eval runGroup "RFC 9112 §3: request-line parse failures" do
|
||||
check "missing version → 400"
|
||||
(raw := "GET /\x0d\nHost: example.com\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
check "missing URI (double space) → 400"
|
||||
(raw := "GET HTTP/1.1\x0d\nHost: example.com\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
check "extra spaces in request-line → 400"
|
||||
(raw := "GET / HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
check "whitespace-only request-line → 400"
|
||||
(raw := " \x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
check "no spaces in request-line → 400"
|
||||
(raw := "GETHTTP/1.1\x0d\nHost: example.com\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
check "garbage after request-line version → 400"
|
||||
(raw := "GET / HTTP/1.1 xxxxxx\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
-- Empty connection: no bytes → silent close, no response
|
||||
checkClose "empty connection → silent close"
|
||||
(raw := "")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r "")
|
||||
|
||||
#eval runGroup "RFC 9112 §2.2: leading CRLF before request-line" do
|
||||
check "single leading CRLF accepted"
|
||||
(raw := "\x0d\nGET / HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r ok200)
|
||||
|
||||
-- RFC 9112 §9: HTTP version
|
||||
|
||||
#eval runGroup "RFC 9112 §9: HTTP version" do
|
||||
check "HTTP/2.0 → 505"
|
||||
(raw := "GET / HTTP/2.0\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r505)
|
||||
|
||||
-- RFC 9110 §9: Methods
|
||||
|
||||
#eval runGroup "RFC 9110 §9: method validation" do
|
||||
check "unknown method FOOBAR → 400"
|
||||
(raw := "FOOBAR / HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
check "lowercase method → 400"
|
||||
(raw := "get / HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
check "non-ASCII method → 400"
|
||||
(raw := "GÉT / HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
check "very long unrecognized method → 400"
|
||||
(raw :=
|
||||
let m := String.ofList (List.replicate 20 'G')
|
||||
s!"{m} / HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
check "token method with hyphen (X-CUSTOM) → 400"
|
||||
(raw := "X-CUSTOM / HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
-- RFC 9112 §3.2: Request target forms
|
||||
|
||||
#eval runGroup "RFC 9112 §3.2: request target forms" do
|
||||
check "GET authority-form → 400"
|
||||
(raw := "GET example.com:443 HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
check "CONNECT authority-form accepted"
|
||||
(raw := "CONNECT example.com:443 HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r ok200)
|
||||
|
||||
check "CONNECT authority-form port mismatch → 400"
|
||||
(raw := "CONNECT example.com:444 HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
check "GET asterisk-form → 400"
|
||||
(raw := "GET * HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
check "OPTIONS * accepted"
|
||||
(raw := "OPTIONS * HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r ok200)
|
||||
|
||||
check "absolute-form URI accepted"
|
||||
(raw := "GET http://example.com/path HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r ok200)
|
||||
|
||||
-- RFC 9112 §3.3: Early invalid bytes
|
||||
|
||||
#eval runGroup "RFC 9112 §3: early invalid bytes" do
|
||||
checkClose "NUL byte → 400"
|
||||
(raw := "\x00")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
checkClose "SP byte → 400"
|
||||
(raw := "\x20")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
checkClose "TLS client-hello prefix → 400"
|
||||
(raw := "\x16\x03\x01\x00\xa5")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
-- RFC 7230 §5.4: Host header
|
||||
|
||||
#eval runGroup "RFC 7230 §5.4: Host header" do
|
||||
check "missing Host header → 400"
|
||||
(raw := "GET / HTTP/1.1\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
check "empty Host allowed for origin-form"
|
||||
(raw := "GET / HTTP/1.1\x0d\nHost: \x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r ok200)
|
||||
|
||||
check "multiple Host headers → 400"
|
||||
(raw := "GET / HTTP/1.1\x0d\nHost: example.com\x0d\nHost: other.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r r400)
|
||||
|
||||
check "absolute-form: URI authority takes precedence over Host"
|
||||
(raw := "GET http://good.example/path HTTP/1.1\x0d\nHost: good.example\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r ok200)
|
||||
103
tests/elab/async_http_response_framing.lean
Normal file
103
tests/elab/async_http_response_framing.lean
Normal file
@@ -0,0 +1,103 @@
|
||||
import Std.Internal.Http.Test.Helpers
|
||||
|
||||
open Std.Internal.IO Async
|
||||
open Std Http Test
|
||||
|
||||
-- Shared fixtures
|
||||
|
||||
private def ok200Head : String :=
|
||||
"HTTP/1.1 200 OK\x0d\nContent-Type: text/plain; charset=utf-8\x0d\nServer: LeanHTTP/1.1\x0d\nConnection: close\x0d\nContent-Length: 2\x0d\n\x0d\n"
|
||||
|
||||
-- RFC 9110 §9.3.2: HEAD
|
||||
|
||||
#eval runGroup "RFC 9110 §9.3.2: HEAD response framing" do
|
||||
check "HEAD omits body bytes, preserves headers"
|
||||
(raw := "HEAD / HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := okHandler)
|
||||
(expect := fun r => assertExact r ok200Head)
|
||||
|
||||
check "GET and HEAD produce identical header sections"
|
||||
(raw := "GET /frame HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n")
|
||||
(handler := fun _ => Response.ok |>.text "hello")
|
||||
(expect := fun getResp => do
|
||||
-- Run HEAD against the same handler
|
||||
let (client2, server2) ← Mock.new
|
||||
let headResp ← Async.block do
|
||||
client2.send "HEAD /frame HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
||||
Std.Http.Server.serveConnection server2 (show TestHandler from fun _ => Response.ok |>.text "hello") defaultConfig |>.run
|
||||
return (← client2.recv?).getD .empty
|
||||
|
||||
let getHeaders := (String.fromUTF8! getResp).splitOn "\x0d\n\x0d\n" |>.headD ""
|
||||
let headHeaders := (String.fromUTF8! headResp).splitOn "\x0d\n\x0d\n" |>.headD ""
|
||||
unless getHeaders == headHeaders do
|
||||
throw <| IO.userError s!"headers differ:\nGET: {getHeaders.quote}\nHEAD: {headHeaders.quote}"
|
||||
assertContains getResp "hello" *>
|
||||
assertAbsent headResp "hello")
|
||||
|
||||
-- RFC 9110 §15.4: 304 and 204 response framing
|
||||
|
||||
#eval runGroup "RFC 9110 §15.4: 304 Not Modified preserves explicit Content-Length" do
|
||||
-- Direct machine test: write a 304 head with Content-Length: 5 and verify it is preserved
|
||||
let request := "GET /cache HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
||||
let machine0 : Protocol.H1.Machine .receiving := { config := {} }
|
||||
let (machine1, _) := (machine0.feed request).step
|
||||
let headers304 := Headers.empty.insert Header.Name.contentLength (Header.Value.ofString! "5")
|
||||
let (_, step304) := (machine1.send ({ status := .notModified, headers := headers304 } : Response.Head)).step
|
||||
let text304 := String.fromUTF8! step304.output.toByteArray
|
||||
unless text304.contains "HTTP/1.1 304 Not Modified" do
|
||||
throw <| IO.userError s!"expected 304 status in output:\n{text304.quote}"
|
||||
unless text304.contains "Content-Length: 5" do
|
||||
throw <| IO.userError s!"expected Content-Length: 5 preserved:\n{text304.quote}"
|
||||
if text304.contains "Content-Length: 0" then
|
||||
throw <| IO.userError s!"unexpected rewritten Content-Length: 0:\n{text304.quote}"
|
||||
|
||||
#eval runGroup "RFC 9110 §15.3.5: 204 No Content strips framing headers" do
|
||||
let request := "GET /empty HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
||||
let machine0 : Protocol.H1.Machine .receiving := { config := {} }
|
||||
let (machine1, _) := (machine0.feed request).step
|
||||
let headers204 := Headers.empty.insert Header.Name.contentLength (Header.Value.ofString! "9")
|
||||
let (_, step204) := (machine1.send ({ status := .noContent, headers := headers204 } : Response.Head)).step
|
||||
let text204 := String.fromUTF8! step204.output.toByteArray
|
||||
unless step204.output.size > 0 do
|
||||
throw <| IO.userError "expected serialized response output"
|
||||
unless text204.contains "HTTP/1.1 204 No Content" do
|
||||
throw <| IO.userError s!"expected 204 status:\n{text204.quote}"
|
||||
if text204.contains "Content-Length:" || text204.contains "Transfer-Encoding:" then
|
||||
throw <| IO.userError s!"unexpected framing headers in 204:\n{text204.quote}"
|
||||
|
||||
-- RFC 9112 §9.6: Client-mode — parsing responses
|
||||
|
||||
#eval runGroup "RFC 9112 §9.6: client-mode response parsing" do
|
||||
-- Parse a 200 response with headers
|
||||
let machineA : Protocol.H1.Machine .sending := { config := {}, reader := { state := .needStartLine } }
|
||||
let rawA := "HTTP/1.1 200 OK\x0d\nContent-Length: 0\x0d\nConnection: close\x0d\n\x0d\n"
|
||||
let (machineA', stepA) := (machineA.feed rawA.toUTF8).step
|
||||
if stepA.events.any (fun | .failed _ => true | _ => false) then
|
||||
throw <| IO.userError s!"unexpected failure parsing 200 response: {repr stepA.events}"
|
||||
unless stepA.events.any (fun | .endHeaders _ => true | _ => false) do
|
||||
throw <| IO.userError s!"missing endHeaders event: {repr stepA.events}"
|
||||
unless machineA'.reader.messageHead.status == .ok do
|
||||
throw <| IO.userError s!"unexpected status: {repr machineA'.reader.messageHead.status}"
|
||||
unless machineA'.reader.messageHead.headers.hasEntry Header.Name.contentLength (Header.Value.ofString! "0") do
|
||||
throw <| IO.userError "missing Content-Length header in parsed response"
|
||||
|
||||
-- Parse headerless 204
|
||||
let machineB : Protocol.H1.Machine .sending := { config := {}, reader := { state := .needStartLine } }
|
||||
let rawB := "HTTP/1.1 204 No Content\x0d\n\x0d\n"
|
||||
let (_, stepB) := (machineB.feed rawB.toUTF8).step
|
||||
if stepB.events.any (fun | .failed _ => true | _ => false) then
|
||||
throw <| IO.userError s!"unexpected failure parsing 204: {repr stepB.events}"
|
||||
if stepB.events.any (fun | .needMoreData _ => true | _ => false) then
|
||||
throw <| IO.userError s!"unexpected needMoreData for 204: {repr stepB.events}"
|
||||
unless stepB.events.any (fun | .endHeaders _ => true | _ => false) do
|
||||
throw <| IO.userError s!"missing endHeaders for 204: {repr stepB.events}"
|
||||
|
||||
-- 204 with Content-Length in response: body framing should be ignored
|
||||
let machineC : Protocol.H1.Machine .sending := { config := {}, reader := { state := .needStartLine } }
|
||||
let rawC := "HTTP/1.1 204 No Content\x0d\nContent-Length: 5\x0d\n\x0d\nHELLO"
|
||||
let (machineC', stepC) := (machineC.feed rawC.toUTF8).step
|
||||
if stepC.events.any (fun | .failed _ => true | _ => false) then
|
||||
throw <| IO.userError s!"unexpected failure for 204 with framing: {repr stepC.events}"
|
||||
-- The 5 bytes of "HELLO" should remain unread
|
||||
unless machineC'.reader.input.remainingBytes == 5 do
|
||||
throw <| IO.userError s!"expected 5 unread bytes, got {machineC'.reader.input.remainingBytes}"
|
||||
248
tests/elab/async_http_trailers.lean
Normal file
248
tests/elab/async_http_trailers.lean
Normal file
@@ -0,0 +1,248 @@
|
||||
import Std.Internal.Http
|
||||
import Std.Internal.Async
|
||||
|
||||
open Std.Internal.IO Async
|
||||
open Std Http Test
|
||||
open Std.Http.Internal
|
||||
|
||||
def sendRaw
|
||||
(client : Mock.Client)
|
||||
(server : Mock.Server)
|
||||
(raw : ByteArray)
|
||||
(handler : TestHandler)
|
||||
(config : Config := { lingeringTimeout := 3000, generateDate := false }) : IO ByteArray := Async.block do
|
||||
client.send raw
|
||||
Std.Http.Server.serveConnection server handler config
|
||||
|>.run
|
||||
let res ← client.recv?
|
||||
pure <| res.getD .empty
|
||||
|
||||
def sendRawAndClose
|
||||
(client : Mock.Client)
|
||||
(server : Mock.Server)
|
||||
(raw : ByteArray)
|
||||
(handler : TestHandler)
|
||||
(config : Config := { lingeringTimeout := 1000, generateDate := false }) : IO ByteArray := Async.block do
|
||||
client.send raw
|
||||
client.close
|
||||
Std.Http.Server.serveConnection server handler config
|
||||
|>.run
|
||||
let res ← client.recv?
|
||||
pure <| res.getD .empty
|
||||
|
||||
def bodyHandler : TestHandler :=
|
||||
fun req => do
|
||||
let body : String ← req.body.readAll
|
||||
Response.ok |>.text body
|
||||
|
||||
def bad400 : String :=
|
||||
"HTTP/1.1 400 Bad Request\x0d\nServer: LeanHTTP/1.1\x0d\nConnection: close\x0d\nContent-Length: 0\x0d\n\x0d\n"
|
||||
|
||||
-- Chunked body without trailers.
|
||||
#eval show IO _ from do
|
||||
let (client, server) ← Mock.new
|
||||
let raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\n\x0d\n".toUTF8
|
||||
let response ← sendRaw client server raw bodyHandler
|
||||
assertStatus response "HTTP/1.1 200"
|
||||
assertContains response "hello"
|
||||
|
||||
-- Single trailer header.
|
||||
#eval show IO _ from do
|
||||
let (client, server) ← Mock.new
|
||||
let raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\nChecksum: abc123\x0d\n\x0d\n".toUTF8
|
||||
let response ← sendRaw client server raw bodyHandler
|
||||
assertStatus response "HTTP/1.1 200"
|
||||
assertContains response "hello"
|
||||
|
||||
-- Multiple trailer headers.
|
||||
#eval show IO _ from do
|
||||
let (client, server) ← Mock.new
|
||||
let raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\nChecksum: abc123\x0d\nExpires: Thu, 01 Dec 1994 16:00:00 GMT\x0d\nX-Custom: value\x0d\n\x0d\n".toUTF8
|
||||
let response ← sendRaw client server raw bodyHandler
|
||||
assertStatus response "HTTP/1.1 200"
|
||||
assertContains response "hello"
|
||||
|
||||
-- Terminal chunk extensions can precede trailers.
|
||||
#eval show IO _ from do
|
||||
let (client, server) ← Mock.new
|
||||
let raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0;ext=val\x0d\nX-Trailer: yes\x0d\n\x0d\n".toUTF8
|
||||
let response ← sendRaw client server raw bodyHandler
|
||||
assertStatus response "HTTP/1.1 200"
|
||||
assertContains response "hello"
|
||||
|
||||
-- Trailer name and value limits.
|
||||
#eval show IO _ from do
|
||||
let exactName := String.ofList (List.replicate 256 'X')
|
||||
let longName := String.ofList (List.replicate 257 'X')
|
||||
let exactValue := String.ofList (List.replicate 8192 'v')
|
||||
let longValue := String.ofList (List.replicate 8193 'v')
|
||||
|
||||
let (clientA, serverA) ← Mock.new
|
||||
let rawA := s!"POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n3\x0d\nabc\x0d\n0\x0d\n{exactName}: value\x0d\n\x0d\n".toUTF8
|
||||
let responseA ← sendRaw clientA serverA rawA bodyHandler
|
||||
assertStatus responseA "HTTP/1.1 200"
|
||||
|
||||
let (clientB, serverB) ← Mock.new
|
||||
let rawB := s!"POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n3\x0d\nabc\x0d\n0\x0d\n{longName}: value\x0d\n\x0d\n".toUTF8
|
||||
let responseB ← sendRaw clientB serverB rawB bodyHandler
|
||||
assertExact responseB bad400
|
||||
|
||||
let (clientC, serverC) ← Mock.new
|
||||
let rawC := s!"POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n3\x0d\nabc\x0d\n0\x0d\nX-Exact: {exactValue}\x0d\n\x0d\n".toUTF8
|
||||
let responseC ← sendRaw clientC serverC rawC bodyHandler
|
||||
assertStatus responseC "HTTP/1.1 200"
|
||||
|
||||
let (clientD, serverD) ← Mock.new
|
||||
let rawD := s!"POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n3\x0d\nabc\x0d\n0\x0d\nX-Too-Long: {longValue}\x0d\n\x0d\n".toUTF8
|
||||
let responseD ← sendRaw clientD serverD rawD bodyHandler
|
||||
assertExact responseD bad400
|
||||
|
||||
-- maxTrailerHeaders enforcement.
|
||||
#eval show IO _ from do
|
||||
let config2 : Config := { lingeringTimeout := 3000, maxTrailerHeaders := 2, generateDate := false }
|
||||
|
||||
let (clientA, serverA) ← Mock.new
|
||||
let okRaw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n3\x0d\nabc\x0d\n0\x0d\nT1: a\x0d\nT2: b\x0d\n\x0d\n".toUTF8
|
||||
let okResponse ← sendRaw clientA serverA okRaw bodyHandler (config := config2)
|
||||
assertStatus okResponse "HTTP/1.1 200"
|
||||
|
||||
let (clientB, serverB) ← Mock.new
|
||||
let badRaw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n3\x0d\nabc\x0d\n0\x0d\nT1: a\x0d\nT2: b\x0d\nT3: c\x0d\n\x0d\n".toUTF8
|
||||
let badResponse ← sendRaw clientB serverB badRaw bodyHandler (config := config2)
|
||||
assertExact badResponse bad400
|
||||
|
||||
let config0 : Config := { lingeringTimeout := 3000, maxTrailerHeaders := 0, generateDate := false }
|
||||
|
||||
let (clientC, serverC) ← Mock.new
|
||||
let rejectAny := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n3\x0d\nabc\x0d\n0\x0d\nX-Trailer: rejected\x0d\n\x0d\n".toUTF8
|
||||
let responseC ← sendRaw clientC serverC rejectAny bodyHandler (config := config0)
|
||||
assertExact responseC bad400
|
||||
|
||||
let (clientD, serverD) ← Mock.new
|
||||
let noTrailer := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n3\x0d\nabc\x0d\n0\x0d\n\x0d\n".toUTF8
|
||||
let responseD ← sendRaw clientD serverD noTrailer bodyHandler (config := config0)
|
||||
assertStatus responseD "HTTP/1.1 200"
|
||||
|
||||
-- Trailer syntax validation.
|
||||
#eval show IO _ from do
|
||||
let (clientA, serverA) ← Mock.new
|
||||
let noColon := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n3\x0d\nabc\x0d\n0\x0d\nBadTrailer value\x0d\n\x0d\n".toUTF8
|
||||
let responseA ← sendRaw clientA serverA noColon bodyHandler
|
||||
assertExact responseA bad400
|
||||
|
||||
let (clientB, serverB) ← Mock.new
|
||||
let leadingWS := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n3\x0d\nabc\x0d\n0\x0d\n X-Bad: folded\x0d\n\x0d\n".toUTF8
|
||||
let responseB ← sendRaw clientB serverB leadingWS bodyHandler
|
||||
assertExact responseB bad400
|
||||
|
||||
let (clientC, serverC) ← Mock.new
|
||||
let spaceName := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n3\x0d\nabc\x0d\n0\x0d\nBad Name: value\x0d\n\x0d\n".toUTF8
|
||||
let responseC ← sendRaw clientC serverC spaceName bodyHandler
|
||||
assertExact responseC bad400
|
||||
|
||||
-- Trailer byte-level validation.
|
||||
#eval show IO _ from do
|
||||
let (clientA, serverA) ← Mock.new
|
||||
let beforeName := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n3\x0d\nabc\x0d\n0\x0d\nX-Bad".toUTF8
|
||||
let afterName := "Name: value\x0d\n\x0d\n".toUTF8
|
||||
let responseA ← sendRaw clientA serverA (beforeName ++ ByteArray.mk #[0] ++ afterName) bodyHandler
|
||||
assertExact responseA bad400
|
||||
|
||||
let (clientB, serverB) ← Mock.new
|
||||
let beforeValue := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n3\x0d\nabc\x0d\n0\x0d\nX-Header: bad".toUTF8
|
||||
let afterValue := "value\x0d\n\x0d\n".toUTF8
|
||||
let responseB ← sendRaw clientB serverB (beforeValue ++ ByteArray.mk #[0] ++ afterValue) bodyHandler
|
||||
assertExact responseB bad400
|
||||
|
||||
let (clientC, serverC) ← Mock.new
|
||||
let responseC ← sendRaw clientC serverC (beforeValue ++ ByteArray.mk #[0x01] ++ afterValue) bodyHandler
|
||||
assertExact responseC bad400
|
||||
|
||||
-- Incomplete trailer section with client close yields no response bytes.
|
||||
#eval show IO _ from do
|
||||
let (client, server) ← Mock.new
|
||||
let raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n3\x0d\nabc\x0d\n0\x0d\nX-Trailer: value\x0d\n".toUTF8
|
||||
let response ← sendRawAndClose client server raw bodyHandler
|
||||
assert! response.size == 0
|
||||
|
||||
-- Trailer encoding emits terminal chunk plus trailer headers.
|
||||
#eval show IO _ from Async.block do
|
||||
let trailer := Trailer.empty
|
||||
|>.insert (.mk "checksum") (.mk "abc123")
|
||||
|>.insert (.mk "expires") (.mk "Thu, 01 Dec 1994")
|
||||
let encoded := (Encode.encode (v := .v11) ChunkedBuffer.empty trailer).toByteArray
|
||||
let text := String.fromUTF8! encoded
|
||||
assert! text.contains "0\x0d\n"
|
||||
assert! text.contains "Checksum: abc123\x0d\n"
|
||||
assert! text.contains "Expires: Thu, 01 Dec 1994\x0d\n"
|
||||
|
||||
-- Empty trailer encoding is exactly terminal chunk CRLF CRLF.
|
||||
#eval show IO _ from Async.block do
|
||||
let encoded := (Encode.encode (v := .v11) ChunkedBuffer.empty Trailer.empty).toByteArray
|
||||
let text := String.fromUTF8! encoded
|
||||
assert! text == "0\x0d\n\x0d\n"
|
||||
|
||||
-- Trailer injection: forbidden field names must be rejected (RFC 9112 §6.5).
|
||||
-- A client injecting framing or routing fields via trailers could confuse proxies.
|
||||
#eval show IO _ from do
|
||||
-- content-length in trailer must be rejected
|
||||
let (clientA, serverA) ← Mock.new
|
||||
let rawA := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\nContent-Length: 1000\x0d\n\x0d\n".toUTF8
|
||||
let responseA ← sendRaw clientA serverA rawA bodyHandler
|
||||
assertExact responseA bad400
|
||||
|
||||
-- transfer-encoding in trailer must be rejected
|
||||
let (clientB, serverB) ← Mock.new
|
||||
let rawB := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\nTransfer-Encoding: chunked\x0d\n\x0d\n".toUTF8
|
||||
let responseB ← sendRaw clientB serverB rawB bodyHandler
|
||||
assertExact responseB bad400
|
||||
|
||||
-- host in trailer must be rejected
|
||||
let (clientC, serverC) ← Mock.new
|
||||
let rawC := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\nHost: evil.example\x0d\n\x0d\n".toUTF8
|
||||
let responseC ← sendRaw clientC serverC rawC bodyHandler
|
||||
assertExact responseC bad400
|
||||
|
||||
-- connection in trailer must be rejected
|
||||
let (clientD, serverD) ← Mock.new
|
||||
let rawD := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\nConnection: keep-alive\x0d\n\x0d\n".toUTF8
|
||||
let responseD ← sendRaw clientD serverD rawD bodyHandler
|
||||
assertExact responseD bad400
|
||||
|
||||
-- authorization in trailer must be rejected
|
||||
let (clientE, serverE) ← Mock.new
|
||||
let rawE := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\nAuthorization: Bearer token\x0d\n\x0d\n".toUTF8
|
||||
let responseE ← sendRaw clientE serverE rawE bodyHandler
|
||||
assertExact responseE bad400
|
||||
|
||||
-- cache-control in trailer must be rejected
|
||||
let (clientF, serverF) ← Mock.new
|
||||
let rawF := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\nCache-Control: no-cache\x0d\n\x0d\n".toUTF8
|
||||
let responseF ← sendRaw clientF serverF rawF bodyHandler
|
||||
assertExact responseF bad400
|
||||
|
||||
-- te in trailer must be rejected
|
||||
let (clientG, serverG) ← Mock.new
|
||||
let rawG := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\nTE: trailers\x0d\n\x0d\n".toUTF8
|
||||
let responseG ← sendRaw clientG serverG rawG bodyHandler
|
||||
assertExact responseG bad400
|
||||
|
||||
-- Forbidden trailer field names are rejected regardless of case.
|
||||
#eval show IO _ from do
|
||||
let (clientA, serverA) ← Mock.new
|
||||
let rawA := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\nCONTENT-LENGTH: 0\x0d\n\x0d\n".toUTF8
|
||||
let responseA ← sendRaw clientA serverA rawA bodyHandler
|
||||
assertExact responseA bad400
|
||||
|
||||
let (clientB, serverB) ← Mock.new
|
||||
let rawB := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\nContent-Length: 0\x0d\nChecksum: abc\x0d\n\x0d\n".toUTF8
|
||||
let responseB ← sendRaw clientB serverB rawB bodyHandler
|
||||
assertExact responseB bad400
|
||||
|
||||
-- Non-forbidden custom trailers are still allowed after the fix.
|
||||
#eval show IO _ from do
|
||||
let (client, server) ← Mock.new
|
||||
let raw := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\nChecksum: deadbeef\x0d\nX-Timing: 12ms\x0d\n\x0d\n".toUTF8
|
||||
let response ← sendRaw client server raw bodyHandler
|
||||
assertStatus response "HTTP/1.1 200"
|
||||
assertContains response "hello"
|
||||
@@ -212,6 +212,7 @@ info: some " "
|
||||
#eval parseCheck "https://user:pass@secure.example.com/private"
|
||||
#eval parseCheck "/double//slash//path"
|
||||
#eval parseCheck "http://user%40example:pass%3Aword@host.com"
|
||||
#eval parseCheck "http://[::ffff:192.168.1.1]/path"
|
||||
#eval parseCheck "http://example.com:/"
|
||||
#eval parseCheck "http://example.com:/?q=1"
|
||||
#eval parseCheck "///////"
|
||||
@@ -261,6 +262,8 @@ info: some " "
|
||||
#eval parseCheckFail ""
|
||||
#eval parseCheckFail "[::1"
|
||||
#eval parseCheckFail "[:::1]:80"
|
||||
#eval parseCheckFail "http://exa_mple.com/path"
|
||||
#eval parseCheckFail "http://[fe80::1%25eth0]/path"
|
||||
#eval parseCheckFail "#frag"
|
||||
#eval parseCheckFail "/path/\n"
|
||||
#eval parseCheckFail "/path/\u0000"
|
||||
@@ -925,3 +928,73 @@ info: Std.Http.RequestTarget.absoluteForm
|
||||
#eval show IO _ from do
|
||||
let result ← runParser parseRequestTarget "http://123abc.example.com/page"
|
||||
IO.println (repr result)
|
||||
|
||||
-- parseScheme: first byte uses `satisfy isAlphaByte` (not `takeWhile1AtMost`).
|
||||
-- Schemes that start with a non-alpha byte must be rejected.
|
||||
#eval parseCheckFail "1http://example.com/path"
|
||||
#eval parseCheckFail "+http://example.com/path"
|
||||
#eval parseCheckFail "-http://example.com/path"
|
||||
#eval parseCheckFail ".http://example.com/path"
|
||||
|
||||
-- Scheme body allows '+', '-', '.'.
|
||||
#eval parseCheck "coap+tcp://example.com/path"
|
||||
#eval parseCheck "svn+ssh://example.com/path"
|
||||
#eval parseCheck "my.scheme://example.com/path"
|
||||
#eval parseCheck "a-b://example.com/path"
|
||||
|
||||
-- Single-letter scheme is valid.
|
||||
#eval parseCheck "a://example.com/path"
|
||||
|
||||
-- parsePortNumber now uses `takeWhileAtMost` (succeeds at EOF) instead of
|
||||
-- `takeWhileUpTo1` (would fail with .eof). Verify a port at the very end of
|
||||
-- the input still parses correctly.
|
||||
#guard
|
||||
match (parseRequestTarget <* Std.Internal.Parsec.eof).run "example.com:8080".toUTF8 with
|
||||
| .ok (.authorityForm auth) => auth.port == .value 8080
|
||||
| _ => false
|
||||
|
||||
-- Port 0 is technically valid (toNat? succeeds).
|
||||
#guard
|
||||
match (parseRequestTarget <* Std.Internal.Parsec.eof).run "example.com:0".toUTF8 with
|
||||
| .ok (.authorityForm auth) => auth.port == .value 0
|
||||
| _ => false
|
||||
|
||||
-- Port > 65535 must be rejected. Use an unambiguous authority URL so the
|
||||
-- number is definitely parsed as a port, not as a path segment.
|
||||
#eval parseCheckFail "http://example.com:65536/path"
|
||||
#eval parseCheckFail "http://example.com:99999/path"
|
||||
|
||||
-- parseQuery now uses `split '&'` instead of `splitOn "&"`.
|
||||
-- A trailing `&` is accepted and produces an empty-key entry; it is not a
|
||||
-- parse failure.
|
||||
#guard
|
||||
match (parseRequestTarget <* Std.Internal.Parsec.eof).run "/path?key=val&".toUTF8 with
|
||||
| .ok result => result.query.size == 2
|
||||
| .error _ => false
|
||||
|
||||
-- parseQuery uses `split '='` instead of `splitOn "="`.
|
||||
-- A pair containing more than one unencoded `=` must be rejected because the
|
||||
-- three-element split falls into the error branch.
|
||||
#eval parseCheckFail "/path?key=a=b"
|
||||
|
||||
-- A percent-encoded `=` in the value is fine; `%3D` is preserved as-is in
|
||||
-- the EncodedQueryParam.
|
||||
/--
|
||||
info: some (some "a%3Db")
|
||||
-/
|
||||
#guard_msgs in
|
||||
#eval show IO _ from do
|
||||
let result ← runParser parseRequestTarget "/path?key=a%3Db"
|
||||
IO.println (repr (result.query.find? "key"))
|
||||
|
||||
-- IPv4/IPv6 parsing now uses `takeWhile1AtMost` (still requires ≥1 byte).
|
||||
-- Both types continue to work at the very end of the input.
|
||||
#guard
|
||||
match (parseRequestTarget <* Std.Internal.Parsec.eof).run "192.168.0.1:80".toUTF8 with
|
||||
| .ok (.authorityForm auth) => toString auth.host == "192.168.0.1"
|
||||
| _ => false
|
||||
|
||||
#guard
|
||||
match (parseRequestTarget <* Std.Internal.Parsec.eof).run "http://[::1]/".toUTF8 with
|
||||
| .ok (.absoluteForm uri) => uri.authority.any fun a => match a.host with | .ipv6 _ => true | _ => false
|
||||
| _ => false
|
||||
|
||||
Reference in New Issue
Block a user