• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

randombit / botan / 26995937053

04 Jun 2026 09:38PM UTC coverage: 89.394% (-2.3%) from 91.672%
26995937053

push

github

web-flow
Merge pull request #5642 from randombit/jack/prefetch-in-ks

Improve prefetching for table based implementations

110588 of 123708 relevant lines covered (89.39%)

11056434.37 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.08
/src/tests/test_http.cpp
1
/*
2
* (C) 2026 Jack Lloyd
3
*
4
* Botan is released under the Simplified BSD License (see license.txt)
5
*/
6

7
#include "tests.h"
8

9
#if defined(BOTAN_HAS_HTTP_UTIL)
10
   #include <botan/uri.h>
11
   #include <botan/internal/http_util.h>
12
   #include <botan/internal/socket.h>
13
   #include <cstring>
14
#endif
15

16
namespace Botan_Tests {
17

18
#if defined(BOTAN_HAS_HTTP_UTIL)
19

20
namespace {
21

22
std::string body_as_string(const Botan::HTTP::Response& r) {
4✔
23
   return std::string(r.body().begin(), r.body().end());
4✔
24
}
25

26
/*
27
* Simple canned-response mock for the http_exch_fn test seam. Captures every
28
* request and returns the next queued Response in order.
29
*/
30
class MockServer final {
31
   public:
32
      explicit MockServer(std::vector<Botan::HTTP::Response> responses) : m_responses(std::move(responses)) {}
19✔
33

34
      Botan::HTTP::Response handle(std::string_view hostname,
26✔
35
                                   std::string_view service,
36
                                   std::string_view message,
37
                                   std::optional<size_t> max_body_size) {
38
         m_hostnames.emplace_back(hostname);
26✔
39
         m_services.emplace_back(service);
26✔
40
         m_messages.emplace_back(message);
26✔
41
         m_max_body_sizes.push_back(max_body_size);
26✔
42
         if(m_call_count >= m_responses.size()) {
26✔
43
            throw Botan::HTTP::HTTP_Error("MockServer: no canned response for call");
×
44
         }
45
         return m_responses[m_call_count++];
26✔
46
      }
47

48
      Botan::HTTP::http_exch_fn as_exch_fn() {
20✔
49
         return [this](std::string_view h, std::string_view s, std::string_view m, std::optional<size_t> mbs) {
19✔
50
            return handle(h, s, m, mbs);
26✔
51
         };
19✔
52
      }
53

54
      size_t calls() const { return m_call_count; }
9✔
55

56
      const std::string& request(size_t i) const { return m_messages.at(i); }
17✔
57

58
      const std::string& hostname(size_t i) const { return m_hostnames.at(i); }
3✔
59

60
      const std::string& service(size_t i) const { return m_services.at(i); }
3✔
61

62
      std::optional<size_t> max_body_size(size_t i) const { return m_max_body_sizes.at(i); }
2✔
63

64
   private:
65
      std::vector<Botan::HTTP::Response> m_responses;
66
      std::vector<std::string> m_hostnames;
67
      std::vector<std::string> m_services;
68
      std::vector<std::string> m_messages;
69
      std::vector<std::optional<size_t>> m_max_body_sizes;
70
      size_t m_call_count = 0;
71
};
72

73
/*
74
* A read-only fake Socket that yields canned bytes in fixed-size chunks.
75
* The chunk size lets tests exercise both "everything in one read" and
76
* "response arrives split across reads".
77
*/
78
class MockSocket final : public Botan::OS::Socket {
32✔
79
   public:
80
      MockSocket(std::string bytes, size_t chunk_size) : m_bytes(std::move(bytes)), m_chunk(chunk_size) {}
64✔
81

82
      void write(std::span<const uint8_t> /*buf*/) override {
×
83
         // Parse tests don't care about the request side.
84
      }
×
85

86
      size_t read(uint8_t* buf, size_t len) override {
100✔
87
         const size_t take = std::min({len, m_chunk, m_bytes.size() - m_pos});
100✔
88
         std::memcpy(buf, m_bytes.data() + m_pos, take);
100✔
89
         m_pos += take;
100✔
90
         return take;
100✔
91
      }
92

93
   private:
94
      std::string m_bytes;
95
      size_t m_chunk;
96
      size_t m_pos = 0;
97
};
98

99
// Default timeout for socket-backed parse tests. Reads return immediately,
100
// so this is just a generous bound to keep the deadline check happy.
101
inline std::chrono::milliseconds parse_test_timeout() {
32✔
102
   return std::chrono::seconds(30);
2✔
103
}
104

105
Botan::HTTP::Response parse_via_socket(std::string raw,
30✔
106
                                       std::optional<size_t> max_body_size = std::nullopt,
107
                                       size_t chunk_size = 64 * 1024) {
108
   MockSocket socket(std::move(raw), chunk_size);
30✔
109
   return Botan::HTTP::read_response_from_socket(socket, parse_test_timeout(), max_body_size);
30✔
110
}
16✔
111

112
Botan::HTTP::Response make_response(unsigned int status,
26✔
113
                                    std::string_view message = "OK",
114
                                    std::map<std::string, std::string> headers = {},
115
                                    std::string_view body = "") {
116
   Botan::HTTP::Headers h;
26✔
117
   for(auto& [k, v] : headers) {
41✔
118
      h.emplace(k, v);
15✔
119
   }
120
   std::vector<uint8_t> body_bytes(body.begin(), body.end());
26✔
121
   return Botan::HTTP::Response(status, std::string(message), std::move(body_bytes), std::move(h));
78✔
122
}
26✔
123

124
class HTTP_Parse_Tests final : public Test {
1✔
125
   private:
126
      static Test::Result test_minimal_response() {
1✔
127
         Test::Result result("HTTP response parser minimal");
1✔
128

129
         const std::string raw =
1✔
130
            "HTTP/1.0 200 OK\r\n"
131
            "Content-Length: 5\r\n"
132
            "\r\n"
133
            "hello";
1✔
134

135
         const auto resp = parse_via_socket(raw);
2✔
136
         result.test_u32_eq("status code", resp.status_code(), 200);
1✔
137
         result.test_str_eq("status message", resp.status_message(), "OK");
2✔
138
         result.test_str_eq("body", body_as_string(resp), "hello");
1✔
139
         result.test_is_true("Content-Length header", resp.headers().contains("Content-Length"));
1✔
140
         return result;
2✔
141
      }
1✔
142

143
      static Test::Result test_zero_header_response() {
1✔
144
         Test::Result result("HTTP response parser zero headers");
1✔
145

146
         const std::string raw = "HTTP/1.0 200 OK\r\n\r\n";
1✔
147

148
         const auto resp = parse_via_socket(raw);
2✔
149
         result.test_u32_eq("status code", resp.status_code(), 200);
1✔
150
         result.test_str_eq("status message", resp.status_message(), "OK");
2✔
151
         result.test_sz_eq("no headers", resp.headers().size(), 0);
1✔
152
         result.test_sz_eq("empty body", resp.body().size(), 0);
1✔
153
         return result;
2✔
154
      }
1✔
155

156
      static Test::Result test_non_200_status() {
1✔
157
         Test::Result result("HTTP response parser non-200 status");
1✔
158

159
         const std::string raw =
1✔
160
            "HTTP/1.0 404 Not Found\r\n"
161
            "Content-Length: 0\r\n"
162
            "\r\n";
1✔
163

164
         const auto resp = parse_via_socket(raw);
2✔
165
         result.test_u32_eq("status code", resp.status_code(), 404);
1✔
166
         result.test_str_eq("status message", resp.status_message(), "Not Found");
2✔
167
         result.test_sz_eq("body empty", resp.body().size(), 0);
1✔
168
         return result;
2✔
169
      }
1✔
170

171
      static Test::Result test_no_content_length_eof_terminated() {
1✔
172
         Test::Result result("HTTP response parser no Content-Length");
1✔
173

174
         const std::string raw =
1✔
175
            "HTTP/1.0 200 OK\r\n"
176
            "Content-Type: text/plain\r\n"
177
            "\r\n"
178
            "body without explicit length";
1✔
179

180
         const auto resp = parse_via_socket(raw);
2✔
181
         result.test_u32_eq("status code", resp.status_code(), 200);
1✔
182
         result.test_str_eq("body", body_as_string(resp), "body without explicit length");
1✔
183
         return result;
1✔
184
      }
1✔
185

186
      static Test::Result test_case_insensitive_content_length() {
1✔
187
         Test::Result result("HTTP response parser case-insensitive Content-Length");
1✔
188

189
         const std::string raw =
1✔
190
            "HTTP/1.0 200 OK\r\n"
191
            "content-length: 3\r\n"
192
            "\r\n"
193
            "abc";
1✔
194

195
         const auto resp = parse_via_socket(raw);
2✔
196
         result.test_sz_eq("body size matches", resp.body().size(), 3);
1✔
197
         result.test_is_true("lookup by canonical case", resp.headers().contains("Content-Length"));
1✔
198
         return result;
2✔
199
      }
1✔
200

201
      static Test::Result test_no_header_terminator() {
1✔
202
         Test::Result result("HTTP response parser missing terminator");
1✔
203
         const std::string raw = "HTTP/1.0 200 OK\r\nContent-Length: 0\r\n";
1✔
204
         result.test_throws<Botan::HTTP::HTTP_Error>("no header terminator", [&] { (void)parse_via_socket(raw); });
2✔
205
         return result;
1✔
206
      }
1✔
207

208
      static Test::Result test_status_line_malformed() {
1✔
209
         Test::Result result("HTTP response parser malformed status");
1✔
210
         const std::string raw = "NotHTTP/1.0 200 OK\r\n\r\n";
1✔
211
         result.test_throws<Botan::HTTP::HTTP_Error>("non-HTTP status line", [&] { (void)parse_via_socket(raw); });
2✔
212
         return result;
1✔
213
      }
1✔
214

215
      static Test::Result test_status_code_out_of_range() {
1✔
216
         Test::Result result("HTTP response parser status code range");
1✔
217
         result.test_throws<Botan::HTTP::HTTP_Error>("code > 599",
1✔
218
                                                     [&] { (void)parse_via_socket("HTTP/1.0 9999999 X\r\n\r\n"); });
1✔
219
         result.test_throws<Botan::HTTP::HTTP_Error>("code < 100",
1✔
220
                                                     [&] { (void)parse_via_socket("HTTP/1.0 42 X\r\n\r\n"); });
1✔
221
         return result;
1✔
222
      }
×
223

224
      static Test::Result test_content_length_not_digits() {
1✔
225
         Test::Result result("HTTP response parser Content-Length must be digits");
1✔
226
         const std::string raw =
1✔
227
            "HTTP/1.0 200 OK\r\n"
228
            "Content-Length: abc\r\n"
229
            "\r\n";
1✔
230
         result.test_throws<Botan::HTTP::HTTP_Error>("non-digit Content-Length", [&] { (void)parse_via_socket(raw); });
2✔
231
         return result;
1✔
232
      }
1✔
233

234
      static Test::Result test_transfer_encoding_rejected() {
1✔
235
         Test::Result result("HTTP response parser rejects Transfer-Encoding");
1✔
236
         const std::string raw =
1✔
237
            "HTTP/1.0 200 OK\r\n"
238
            "Transfer-Encoding: chunked\r\n"
239
            "\r\n"
240
            "0\r\n\r\n";
1✔
241
         result.test_throws<Botan::HTTP::HTTP_Error>("Transfer-Encoding present", [&] { (void)parse_via_socket(raw); });
2✔
242
         return result;
1✔
243
      }
1✔
244

245
      static Test::Result test_transfer_encoding_case_insensitive() {
1✔
246
         Test::Result result("HTTP response parser Transfer-Encoding case-insensitive");
1✔
247
         const std::string raw =
1✔
248
            "HTTP/1.0 200 OK\r\n"
249
            "transfer-encoding: gzip\r\n"
250
            "\r\n";
1✔
251
         result.test_throws<Botan::HTTP::HTTP_Error>("lowercase Transfer-Encoding still rejected",
1✔
252
                                                     [&] { (void)parse_via_socket(raw); });
2✔
253
         return result;
1✔
254
      }
1✔
255

256
      static Test::Result test_duplicate_header_rejected() {
1✔
257
         Test::Result result("HTTP response parser rejects duplicate header");
1✔
258
         const std::string raw =
1✔
259
            "HTTP/1.0 200 OK\r\n"
260
            "Content-Length: 0\r\n"
261
            "content-length: 0\r\n"
262
            "\r\n";
1✔
263
         result.test_throws<Botan::HTTP::HTTP_Error>("case-insensitive duplicate",
1✔
264
                                                     [&] { (void)parse_via_socket(raw); });
2✔
265
         return result;
1✔
266
      }
1✔
267

268
      static Test::Result test_empty_header_name_rejected() {
1✔
269
         Test::Result result("HTTP response parser rejects empty name");
1✔
270
         const std::string raw =
1✔
271
            "HTTP/1.0 200 OK\r\n"
272
            ": orphan value\r\n"
273
            "\r\n";
1✔
274
         result.test_throws<Botan::HTTP::HTTP_Error>("empty name", [&] { (void)parse_via_socket(raw); });
2✔
275
         return result;
1✔
276
      }
1✔
277

278
      static Test::Result test_invalid_header_name_char_rejected() {
1✔
279
         Test::Result result("HTTP response parser rejects non-tchar in name");
1✔
280
         const std::string raw =
1✔
281
            "HTTP/1.0 200 OK\r\n"
282
            "Bad Name: value\r\n"  // space is not a tchar
283
            "\r\n";
1✔
284
         result.test_throws<Botan::HTTP::HTTP_Error>("space in name", [&] { (void)parse_via_socket(raw); });
2✔
285
         return result;
1✔
286
      }
1✔
287

288
      static Test::Result test_content_length_mismatch() {
1✔
289
         Test::Result result("HTTP response parser Content-Length mismatch");
1✔
290
         const std::string raw =
1✔
291
            "HTTP/1.0 200 OK\r\n"
292
            "Content-Length: 100\r\n"
293
            "\r\n"
294
            "short";
1✔
295
         result.test_throws<Botan::HTTP::HTTP_Error>("body shorter than CL", [&] { (void)parse_via_socket(raw); });
2✔
296
         return result;
1✔
297
      }
1✔
298

299
      static Test::Result test_content_length_exceeds_max() {
1✔
300
         Test::Result result("HTTP response parser Content-Length > max");
1✔
301
         const std::string raw =
1✔
302
            "HTTP/1.0 200 OK\r\n"
303
            "Content-Length: 10000\r\n"
304
            "\r\n";
1✔
305
         result.test_throws<Botan::HTTP::HTTP_Error>("CL exceeds policy", [&] { (void)parse_via_socket(raw, 1024); });
2✔
306
         return result;
1✔
307
      }
1✔
308

309
      static Test::Result test_body_without_cl_exceeds_max() {
1✔
310
         Test::Result result("HTTP response parser body without CL exceeds max");
1✔
311
         const std::string raw =
1✔
312
            "HTTP/1.0 200 OK\r\n"
313
            "\r\n"
314
            "this body is more than ten bytes";
1✔
315
         result.test_throws<Botan::HTTP::HTTP_Error>("body exceeds policy", [&] { (void)parse_via_socket(raw, 10); });
2✔
316
         return result;
1✔
317
      }
1✔
318

319
      static Test::Result test_body_within_max() {
1✔
320
         Test::Result result("HTTP response parser body within max");
1✔
321
         const std::string raw =
1✔
322
            "HTTP/1.0 200 OK\r\n"
323
            "Content-Length: 4\r\n"
324
            "\r\n"
325
            "abcd";
1✔
326
         const auto resp = parse_via_socket(raw, 4);
2✔
327
         result.test_sz_eq("body size", resp.body().size(), 4);
1✔
328
         return result;
2✔
329
      }
1✔
330

331
      static Test::Result test_oversized_headers_rejected() {
1✔
332
         Test::Result result("HTTP response parser rejects oversized headers");
1✔
333
         // Build a response whose header block exceeds the 16 KB internal cap.
334
         std::string raw = "HTTP/1.0 200 OK\r\n";
1✔
335
         raw += "X-Padding: ";
1✔
336
         raw.append(20 * 1024, 'a');
1✔
337
         raw += "\r\n\r\n";
1✔
338
         result.test_throws<Botan::HTTP::HTTP_Error>("header section > 16 KB", [&] { (void)parse_via_socket(raw); });
2✔
339
         return result;
1✔
340
      }
1✔
341

342
      static Test::Result test_ows_variants_accepted() {
1✔
343
         Test::Result result("HTTP response parser accepts RFC 9110 OWS variants");
1✔
344

345
         // RFC 9110 5.5: field-line = field-name ":" OWS field-value OWS
346
         // OWS = *( SP / HTAB ); zero or more SP or HTAB on either side.
347
         struct Case {
18✔
348
               std::string line;
349
               std::string expected_value;
350
         };
351

352
         const std::vector<Case> cases{
1✔
353
            {"Content-Length:0", "0"},      // no OWS at all
1✔
354
            {"Content-Length: 0", "0"},     // single SP after colon (canonical)
355
            {"Content-Length:  0", "0"},    // two SP after colon
356
            {"Content-Length:\t0", "0"},    // HTAB after colon
357
            {"Content-Length: \t 0", "0"},  // mixed OWS after colon
358
            {"Content-Length: 0 ", "0"},    // trailing OWS
359
            {"Content-Length: 0\t", "0"},   // trailing HTAB
360
            {"X-Empty:", ""},               // empty value, no OWS
361
            {"X-Empty: ", ""},              // empty value, trailing OWS only
362
         };
10✔
363

364
         for(const auto& c : cases) {
10✔
365
            const std::string raw = "HTTP/1.0 200 OK\r\n" + c.line + "\r\n\r\n";
18✔
366
            try {
9✔
367
               const auto resp = parse_via_socket(raw);
18✔
368
               // Find the field name (everything before the first ':')
369
               const auto colon = c.line.find(':');
9✔
370
               const auto name = c.line.substr(0, colon);
9✔
371
               const auto it = resp.headers().find(name);
9✔
372
               if(result.test_is_true("header present: " + c.line, it != resp.headers().end())) {
9✔
373
                  result.test_str_eq("value trimmed for: " + c.line, it->second, c.expected_value);
18✔
374
               }
375
            } catch(const Botan::HTTP::HTTP_Error& e) {
9✔
376
               result.test_failure("unexpected throw on '" + c.line + "': " + e.what());
×
377
            }
×
378
         }
9✔
379
         return result;
1✔
380
      }
3✔
381

382
      static Test::Result test_byte_at_a_time_reads() {
1✔
383
         Test::Result result("HTTP response parser handles 1-byte-at-a-time reads");
1✔
384
         const std::string raw =
1✔
385
            "HTTP/1.0 200 OK\r\n"
386
            "Content-Length: 5\r\n"
387
            "Server: tiny\r\n"
388
            "\r\n"
389
            "hello";
1✔
390
         MockSocket socket(raw, 1);  // worst-case chunking
2✔
391
         const auto resp = Botan::HTTP::read_response_from_socket(socket, parse_test_timeout(), std::nullopt);
1✔
392
         result.test_u32_eq("status code", resp.status_code(), 200);
1✔
393
         result.test_str_eq("body", body_as_string(resp), "hello");
1✔
394
         result.test_str_eq("Server header", resp.headers().find("Server")->second, "tiny");
1✔
395
         return result;
2✔
396
      }
1✔
397

398
      static Test::Result test_header_terminator_split_across_reads() {
1✔
399
         Test::Result result("HTTP response parser handles header terminator split across reads");
1✔
400
         const std::string raw =
1✔
401
            "HTTP/1.0 200 OK\r\n"
402
            "Content-Length: 3\r\n"
403
            "\r\n"
404
            "abc";
1✔
405
         // Choose a chunk size that places the "\r\n\r\n" boundary across two reads.
406
         MockSocket socket(raw, 19);
2✔
407
         const auto resp = Botan::HTTP::read_response_from_socket(socket, parse_test_timeout(), std::nullopt);
1✔
408
         result.test_str_eq("body", body_as_string(resp), "abc");
1✔
409
         return result;
2✔
410
      }
1✔
411

412
      static Test::Result test_header_lookup_is_case_insensitive() {
1✔
413
         Test::Result result("HTTP::Response::headers case-insensitive lookup");
1✔
414
         const std::string raw =
1✔
415
            "HTTP/1.0 301 Moved Permanently\r\n"
416
            "LOCATION: http://other.example/\r\n"
417
            "Content-Length: 0\r\n"
418
            "\r\n";
1✔
419
         const auto resp = parse_via_socket(raw);
2✔
420
         result.test_is_true("find Location regardless of case", resp.headers().contains("Location"));
1✔
421
         result.test_is_true("contains Location regardless of case", resp.headers().contains("location"));
1✔
422
         return result;
2✔
423
      }
1✔
424

425
   public:
426
      std::vector<Test::Result> run() override {
1✔
427
         return {
1✔
428
            test_minimal_response(),
429
            test_zero_header_response(),
430
            test_non_200_status(),
431
            test_no_content_length_eof_terminated(),
432
            test_case_insensitive_content_length(),
433
            test_no_header_terminator(),
434
            test_status_line_malformed(),
435
            test_status_code_out_of_range(),
436
            test_content_length_not_digits(),
437
            test_transfer_encoding_rejected(),
438
            test_transfer_encoding_case_insensitive(),
439
            test_duplicate_header_rejected(),
440
            test_empty_header_name_rejected(),
441
            test_invalid_header_name_char_rejected(),
442
            test_content_length_mismatch(),
443
            test_content_length_exceeds_max(),
444
            test_body_without_cl_exceeds_max(),
445
            test_body_within_max(),
446
            test_header_lookup_is_case_insensitive(),
447
            test_ows_variants_accepted(),
448
            test_oversized_headers_rejected(),
449
            test_byte_at_a_time_reads(),
450
            test_header_terminator_split_across_reads(),
451
         };
24✔
452
      }
1✔
453
};
454

455
BOTAN_REGISTER_TEST("http", "http_parse", HTTP_Parse_Tests);
456

457
class HTTP_Request_Tests final : public Test {
1✔
458
   private:
459
      // The first line of an HTTP message starts with "<verb> <target> HTTP/1.0\r\n".
460
      // Pull out the request-target so tests can assert on it.
461
      static std::string request_target(std::string_view message) {
5✔
462
         const auto sp1 = message.find(' ');
5✔
463
         const auto sp2 = message.find(' ', sp1 + 1);
5✔
464
         return std::string(message.substr(sp1 + 1, sp2 - sp1 - 1));
5✔
465
      }
466

467
      static std::string verb(std::string_view message) {
2✔
468
         const auto sp1 = message.find(' ');
2✔
469
         return std::string(message.substr(0, sp1));
2✔
470
      }
471

472
      static bool message_contains(std::string_view message, std::string_view needle) {
7✔
473
         return message.find(needle) != std::string_view::npos;
7✔
474
      }
475

476
      static Test::Result test_get_request_shape() {
1✔
477
         Test::Result result("HTTP::GET_sync request shape");
1✔
478
         MockServer mock({make_response(200, "OK", {{"Content-Length", "0"}}, "")});
4✔
479
         const auto uri = Botan::URI::parse("http://example.com/path/to/resource").value();
1✔
480

481
         (void)Botan::HTTP::http_sync(
1✔
482
            mock.as_exch_fn(), "GET", uri, "", std::vector<uint8_t>(), Botan::HTTP::RequestLimits());
2✔
483

484
         result.test_sz_eq("one call", mock.calls(), 1);
1✔
485
         result.test_str_eq("verb is GET", verb(mock.request(0)), "GET");
1✔
486
         result.test_str_eq("request-target", request_target(mock.request(0)), "/path/to/resource");
1✔
487
         result.test_is_true("has Host header", message_contains(mock.request(0), "Host: example.com\r\n"));
1✔
488
         result.test_is_true("Connection: close", message_contains(mock.request(0), "Connection: close\r\n"));
1✔
489
         result.test_is_false("no Content-Length", message_contains(mock.request(0), "Content-Length:"));
1✔
490
         result.test_str_eq("hostname passed to exch", mock.hostname(0), "example.com");
1✔
491
         result.test_str_eq("service is http (no port given)", mock.service(0), "http");
1✔
492
         return result;
1✔
493
      }
4✔
494

495
      static Test::Result test_post_request_shape() {
1✔
496
         Test::Result result("HTTP::POST_sync request shape");
1✔
497
         MockServer mock({make_response(200, "OK", {{"Content-Length", "2"}}, "ok")});
4✔
498
         const auto uri = Botan::URI::parse("http://example.com/submit").value();
1✔
499
         const std::vector<uint8_t> body{'h', 'i'};
1✔
500

501
         (void)Botan::HTTP::http_sync(mock.as_exch_fn(), "POST", uri, "text/plain", body, Botan::HTTP::RequestLimits());
1✔
502

503
         result.test_str_eq("verb is POST", verb(mock.request(0)), "POST");
1✔
504
         result.test_is_true("Content-Length: 2", message_contains(mock.request(0), "Content-Length: 2\r\n"));
1✔
505
         result.test_is_true("Content-Type set", message_contains(mock.request(0), "Content-Type: text/plain\r\n"));
1✔
506
         result.test_is_true("body appended", message_contains(mock.request(0), "\r\n\r\nhi"));
1✔
507
         return result;
1✔
508
      }
4✔
509

510
      static Test::Result test_query_in_request_target() {
1✔
511
         Test::Result result("HTTP request target includes query");
1✔
512
         MockServer mock({make_response(200)});
2✔
513
         const auto uri = Botan::URI::parse("http://example.com/api?id=42&n=1").value();
1✔
514
         (void)Botan::HTTP::http_sync(mock.as_exch_fn(), "GET", uri, "", {}, Botan::HTTP::RequestLimits());
2✔
515
         result.test_str_eq("query preserved", request_target(mock.request(0)), "/api?id=42&n=1");
1✔
516
         return result;
1✔
517
      }
3✔
518

519
      static Test::Result test_fragment_excluded_from_request_target() {
1✔
520
         Test::Result result("HTTP request target excludes fragment (RFC 9110 7.1)");
1✔
521
         MockServer mock({make_response(200)});
2✔
522
         const auto uri = Botan::URI::parse("http://example.com/page#section").value();
1✔
523
         (void)Botan::HTTP::http_sync(mock.as_exch_fn(), "GET", uri, "", {}, Botan::HTTP::RequestLimits());
2✔
524
         result.test_str_eq("no fragment in target", request_target(mock.request(0)), "/page");
1✔
525
         return result;
1✔
526
      }
3✔
527

528
      static Test::Result test_empty_path_defaults_to_slash() {
1✔
529
         Test::Result result("HTTP request target defaults empty path to /");
1✔
530
         MockServer mock({make_response(200), make_response(200)});
3✔
531
         const auto bare = Botan::URI::parse("http://example.com").value();
1✔
532
         (void)Botan::HTTP::http_sync(mock.as_exch_fn(), "GET", bare, "", {}, Botan::HTTP::RequestLimits());
2✔
533
         result.test_str_eq("no path => /", request_target(mock.request(0)), "/");
1✔
534

535
         const auto query_only = Botan::URI::parse("http://example.com?q=1").value();
2✔
536
         (void)Botan::HTTP::http_sync(mock.as_exch_fn(), "GET", query_only, "", {}, Botan::HTTP::RequestLimits());
2✔
537
         result.test_str_eq("no path with query => /?q=1", request_target(mock.request(1)), "/?q=1");
1✔
538
         return result;
1✔
539
      }
3✔
540

541
      static Test::Result test_ipv6_host_header_bracketed() {
1✔
542
         Test::Result result("HTTP Host header brackets IPv6");
1✔
543
         MockServer mock({make_response(200)});
2✔
544
         const auto uri = Botan::URI::parse("http://[::1]:8080/").value();
1✔
545
         (void)Botan::HTTP::http_sync(mock.as_exch_fn(), "GET", uri, "", {}, Botan::HTTP::RequestLimits());
2✔
546
         result.test_is_true("Host has brackets and port",
1✔
547
                             message_contains(mock.request(0), "Host: [0:0:0:0:0:0:0:1]:8080\r\n"));
1✔
548
         result.test_str_eq("service is the port", mock.service(0), "8080");
1✔
549
         return result;
1✔
550
      }
3✔
551

552
      static Test::Result test_https_scheme_rejected() {
1✔
553
         Test::Result result("HTTP rejects non-http scheme");
1✔
554
         MockServer mock({});
1✔
555
         const auto uri = Botan::URI::parse("https://example.com/").value();
2✔
556
         result.test_throws<Botan::HTTP::HTTP_Error>("https URI", [&] {
1✔
557
            (void)Botan::HTTP::http_sync(mock.as_exch_fn(), "GET", uri, "", {}, Botan::HTTP::RequestLimits());
2✔
558
         });
×
559
         result.test_sz_eq("never called", mock.calls(), 0);
1✔
560
         return result;
1✔
561
      }
1✔
562

563
      static Test::Result test_max_body_size_propagated() {
1✔
564
         Test::Result result("HTTP max_body_size reaches the transact callback");
1✔
565
         MockServer mock({make_response(200)});
2✔
566
         const auto uri = Botan::URI::parse("http://example.com/").value();
1✔
567
         (void)Botan::HTTP::http_sync(
1✔
568
            mock.as_exch_fn(), "GET", uri, "", {}, Botan::HTTP::RequestLimits().set_max_body_size(8192));
2✔
569
         result.test_is_true("max_body_size present", mock.max_body_size(0).has_value());
1✔
570
         result.test_sz_eq("max_body_size value", *mock.max_body_size(0), 8192);
1✔
571
         return result;
1✔
572
      }
3✔
573

574
   public:
575
      std::vector<Test::Result> run() override {
1✔
576
         return {
1✔
577
            test_get_request_shape(),
578
            test_post_request_shape(),
579
            test_query_in_request_target(),
580
            test_fragment_excluded_from_request_target(),
581
            test_empty_path_defaults_to_slash(),
582
            test_ipv6_host_header_bracketed(),
583
            test_https_scheme_rejected(),
584
            test_max_body_size_propagated(),
585
         };
9✔
586
      }
1✔
587
};
588

589
BOTAN_REGISTER_TEST("http", "http_request", HTTP_Request_Tests);
590

591
class HTTP_Redirect_Tests final : public Test {
1✔
592
   private:
593
      static std::string verb_of(std::string_view message) { return std::string(message.substr(0, message.find(' '))); }
4✔
594

595
      static Test::Result test_301_preserves_method_for_get() {
1✔
596
         Test::Result result("HTTP 301 with GET: re-issues GET to new URL");
1✔
597
         MockServer mock({
1✔
598
            make_response(301, "Moved", {{"Location", "http://other.example/new"}}),
2✔
599
            make_response(200, "OK", {{"Content-Length", "2"}}, "ok"),
2✔
600
         });
3✔
601
         const auto uri = Botan::URI::parse("http://example.com/old").value();
1✔
602
         const auto resp = Botan::HTTP::http_sync(
1✔
603
            mock.as_exch_fn(), "GET", uri, "", {}, Botan::HTTP::RequestLimits().set_max_redirects(2));
2✔
604
         result.test_sz_eq("two calls", mock.calls(), 2);
1✔
605
         result.test_str_eq("second call is GET", verb_of(mock.request(1)), "GET");
1✔
606
         result.test_str_eq("hostname switched", mock.hostname(1), "other.example");
1✔
607
         result.test_u32_eq("final status", resp.status_code(), 200);
1✔
608
         return result;
1✔
609
      }
5✔
610

611
      static Test::Result test_303_post_downgrades_to_get() {
1✔
612
         Test::Result result("HTTP 303 with POST: downgrades to GET (drops body)");
1✔
613
         MockServer mock({
1✔
614
            make_response(303, "See Other", {{"Location", "http://example.com/result"}}),
2✔
615
            make_response(200, "OK"),
616
         });
3✔
617
         const auto uri = Botan::URI::parse("http://example.com/submit").value();
1✔
618
         const std::vector<uint8_t> body{'p', 'a', 'y', 'l', 'o', 'a', 'd'};
1✔
619

620
         (void)Botan::HTTP::http_sync(
1✔
621
            mock.as_exch_fn(), "POST", uri, "text/plain", body, Botan::HTTP::RequestLimits().set_max_redirects(1));
1✔
622

623
         result.test_sz_eq("two calls", mock.calls(), 2);
1✔
624
         result.test_str_eq("second call switched to GET", verb_of(mock.request(1)), "GET");
1✔
625
         const auto& msg = mock.request(1);
1✔
626
         result.test_is_false("body dropped on 303 -> GET", msg.find("\r\n\r\npayload") != std::string::npos);
1✔
627
         return result;
1✔
628
      }
4✔
629

630
      static Test::Result test_307_post_preserves_method_and_body() {
1✔
631
         Test::Result result("HTTP 307 with POST: preserves method and body");
1✔
632
         MockServer mock({
1✔
633
            make_response(307, "Temporary Redirect", {{"Location", "http://example.com/v2"}}),
2✔
634
            make_response(200, "OK", {{"Content-Length", "2"}}, "ok"),
2✔
635
         });
3✔
636
         const auto uri = Botan::URI::parse("http://example.com/submit").value();
1✔
637
         const std::vector<uint8_t> body{'p', 'a', 'y', 'l', 'o', 'a', 'd'};
1✔
638

639
         (void)Botan::HTTP::http_sync(mock.as_exch_fn(),
1✔
640
                                      "POST",
641
                                      uri,
642
                                      "application/octet-stream",
643
                                      body,
644
                                      Botan::HTTP::RequestLimits().set_max_redirects(1));
1✔
645

646
         result.test_sz_eq("two calls", mock.calls(), 2);
1✔
647
         result.test_str_eq("second call still POST", verb_of(mock.request(1)), "POST");
1✔
648
         result.test_is_true("body re-sent", mock.request(1).find("\r\n\r\npayload") != std::string::npos);
1✔
649
         result.test_is_true("Content-Type re-sent",
1✔
650
                             mock.request(1).find("Content-Type: application/octet-stream\r\n") != std::string::npos);
1✔
651
         return result;
1✔
652
      }
5✔
653

654
      static Test::Result test_308_preserves_method() {
1✔
655
         Test::Result result("HTTP 308: preserves method");
1✔
656
         MockServer mock({
1✔
657
            make_response(308, "Permanent Redirect", {{"Location", "http://example.com/new"}}),
2✔
658
            make_response(200, "OK"),
659
         });
3✔
660
         const auto uri = Botan::URI::parse("http://example.com/old").value();
1✔
661
         (void)Botan::HTTP::http_sync(mock.as_exch_fn(),
1✔
662
                                      "POST",
663
                                      uri,
664
                                      "text/plain",
665
                                      std::vector<uint8_t>{'x'},
2✔
666
                                      Botan::HTTP::RequestLimits().set_max_redirects(1));
1✔
667
         result.test_str_eq("second call still POST", verb_of(mock.request(1)), "POST");
1✔
668
         return result;
1✔
669
      }
4✔
670

671
      static Test::Result test_redirect_count_exceeded() {
1✔
672
         Test::Result result("HTTP redirect count exceeded");
1✔
673
         MockServer mock({
1✔
674
            make_response(301, "Moved", {{"Location", "http://example.com/a"}}),
2✔
675
            make_response(301, "Moved", {{"Location", "http://example.com/b"}}),
2✔
676
         });
3✔
677
         const auto uri = Botan::URI::parse("http://example.com/start").value();
1✔
678
         result.test_throws<Botan::HTTP::HTTP_Error>("exceeds redirect budget", [&] {
1✔
679
            (void)Botan::HTTP::http_sync(
×
680
               mock.as_exch_fn(), "GET", uri, "", {}, Botan::HTTP::RequestLimits().set_max_redirects(1));
2✔
681
         });
×
682
         result.test_sz_eq("stopped at the second response", mock.calls(), 2);
1✔
683
         return result;
1✔
684
      }
5✔
685

686
      static Test::Result test_redirect_to_invalid_url() {
1✔
687
         Test::Result result("HTTP redirect to unparsable URL");
1✔
688
         MockServer mock({
1✔
689
            make_response(301, "Moved", {{"Location", "::not a url::"}}),
2✔
690
         });
2✔
691
         const auto uri = Botan::URI::parse("http://example.com/").value();
1✔
692
         result.test_throws<Botan::HTTP::HTTP_Error>("invalid Location", [&] {
1✔
693
            (void)Botan::HTTP::http_sync(
×
694
               mock.as_exch_fn(), "GET", uri, "", {}, Botan::HTTP::RequestLimits().set_max_redirects(1));
2✔
695
         });
×
696
         return result;
1✔
697
      }
4✔
698

699
      static Test::Result test_redirect_to_non_http_rejected() {
1✔
700
         Test::Result result("HTTP redirect to non-http scheme rejected");
1✔
701
         MockServer mock({
1✔
702
            make_response(301, "Moved", {{"Location", "https://example.com/"}}),
2✔
703
         });
2✔
704
         const auto uri = Botan::URI::parse("http://example.com/").value();
1✔
705
         result.test_throws<Botan::HTTP::HTTP_Error>("https Location", [&] {
1✔
706
            (void)Botan::HTTP::http_sync(
×
707
               mock.as_exch_fn(), "GET", uri, "", {}, Botan::HTTP::RequestLimits().set_max_redirects(1));
2✔
708
         });
×
709
         return result;
1✔
710
      }
4✔
711

712
      static Test::Result test_path_absolute_location_resolved() {
1✔
713
         Test::Result result("HTTP path-absolute Location resolved against request URI");
1✔
714
         MockServer mock({
1✔
715
            make_response(301, "Moved", {{"Location", "/v2/resource?id=1"}}),
2✔
716
            make_response(200, "OK"),
717
         });
3✔
718
         const auto uri = Botan::URI::parse("http://example.com:8080/v1/old").value();
1✔
719
         (void)Botan::HTTP::http_sync(
1✔
720
            mock.as_exch_fn(), "GET", uri, "", {}, Botan::HTTP::RequestLimits().set_max_redirects(1));
2✔
721
         result.test_sz_eq("two calls", mock.calls(), 2);
1✔
722
         result.test_str_eq("same authority retained", mock.hostname(1), "example.com");
1✔
723
         result.test_str_eq("same port retained", mock.service(1), "8080");
1✔
724
         result.test_is_true("request-target uses Location path+query",
1✔
725
                             mock.request(1).find("GET /v2/resource?id=1 HTTP/1.0\r\n") != std::string::npos);
1✔
726
         return result;
1✔
727
      }
4✔
728

729
      static Test::Result test_network_path_location_rejected() {
1✔
730
         Test::Result result("HTTP network-path Location (//host/p) rejected");
1✔
731
         MockServer mock({
1✔
732
            make_response(301, "Moved", {{"Location", "//evil.example/new"}}),
2✔
733
         });
2✔
734
         const auto uri = Botan::URI::parse("http://example.com/").value();
1✔
735
         result.test_throws<Botan::HTTP::HTTP_Error>("// prefix not resolved", [&] {
1✔
736
            (void)Botan::HTTP::http_sync(
×
737
               mock.as_exch_fn(), "GET", uri, "", {}, Botan::HTTP::RequestLimits().set_max_redirects(1));
2✔
738
         });
×
739
         return result;
1✔
740
      }
4✔
741

742
      static Test::Result test_userinfo_not_forwarded_on_redirect() {
1✔
743
         Test::Result result("HTTP path-absolute redirect strips userinfo");
1✔
744
         MockServer mock({
1✔
745
            make_response(301, "Moved", {{"Location", "/elsewhere"}}),
2✔
746
            make_response(200, "OK"),
747
         });
3✔
748
         const auto uri = Botan::URI::parse("http://user:pass@example.com/start").value();
1✔
749
         (void)Botan::HTTP::http_sync(
1✔
750
            mock.as_exch_fn(), "GET", uri, "", {}, Botan::HTTP::RequestLimits().set_max_redirects(1));
2✔
751
         result.test_sz_eq("two calls", mock.calls(), 2);
1✔
752
         result.test_is_false("no userinfo in second Host header",
1✔
753
                              mock.request(1).find("user:pass") != std::string::npos);
1✔
754
         result.test_is_true("plain Host header on redirect",
1✔
755
                             mock.request(1).find("Host: example.com\r\n") != std::string::npos);
1✔
756
         return result;
1✔
757
      }
4✔
758

759
      static Test::Result test_redirect_without_location_returned() {
1✔
760
         Test::Result result("HTTP 3xx without Location returned to caller");
1✔
761
         MockServer mock({make_response(301, "Moved")});
2✔
762
         const auto uri = Botan::URI::parse("http://example.com/").value();
1✔
763
         const auto resp = Botan::HTTP::http_sync(
1✔
764
            mock.as_exch_fn(), "GET", uri, "", {}, Botan::HTTP::RequestLimits().set_max_redirects(1));
2✔
765
         result.test_sz_eq("one call only", mock.calls(), 1);
1✔
766
         result.test_u32_eq("status passed through", resp.status_code(), 301);
1✔
767
         return result;
1✔
768
      }
3✔
769

770
   public:
771
      std::vector<Test::Result> run() override {
1✔
772
         return {
1✔
773
            test_301_preserves_method_for_get(),
774
            test_303_post_downgrades_to_get(),
775
            test_307_post_preserves_method_and_body(),
776
            test_308_preserves_method(),
777
            test_redirect_count_exceeded(),
778
            test_redirect_to_invalid_url(),
779
            test_redirect_to_non_http_rejected(),
780
            test_path_absolute_location_resolved(),
781
            test_network_path_location_rejected(),
782
            test_userinfo_not_forwarded_on_redirect(),
783
            test_redirect_without_location_returned(),
784
         };
12✔
785
      }
1✔
786
};
787

788
BOTAN_REGISTER_TEST("http", "http_redirect", HTTP_Redirect_Tests);
789

790
class HTTP_URL_Encode_Tests final : public Test {
1✔
791
   public:
792
      std::vector<Test::Result> run() override {
1✔
793
         Test::Result result("HTTP::url_encode");
1✔
794

795
         result.test_str_eq("alpha-numeric passes through", Botan::HTTP::url_encode("hello123"), "hello123");
1✔
796
         result.test_str_eq("unreserved passes through", Botan::HTTP::url_encode("a-b_c.d~e"), "a-b_c.d~e");
1✔
797
         result.test_str_eq("space encoded", Botan::HTTP::url_encode("a b"), "a%20b");
1✔
798
         result.test_str_eq("slash encoded", Botan::HTTP::url_encode("/"), "%2F");
1✔
799
         result.test_str_eq("high-bit byte encodes to two hex digits", Botan::HTTP::url_encode("\xff"), "%FF");
1✔
800
         result.test_str_eq("empty input", Botan::HTTP::url_encode(""), "");
1✔
801

802
         return {result};
3✔
803
      }
2✔
804
};
805

806
BOTAN_REGISTER_TEST("http", "http_url_encode", HTTP_URL_Encode_Tests);
807

808
}  // namespace
809

810
#endif
811

812
}  // namespace Botan_Tests
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc