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

randombit / botan / 38342549149

12 Oct 2026 08:36PM UTC coverage: 92.197% (+2.3%) from 89.925%
38342549149

push

github

web-flow
Merge pull request #6028 from randombit/jack/ppc64-extn

Fixes and improvements for POWER systems

135167 of 146607 relevant lines covered (92.2%)

8789246.0 hits per line

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

96.82
/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)) {}
25 ✔
33

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

48
         // Simulate a slow server; spin rather than sleep to avoid needing threads
49
         const auto until = std::chrono::steady_clock::now() + m_delay;
33 ✔
50
         while(std::chrono::steady_clock::now() < until) {}
2,074,460 ✔
51

52
         return m_responses[m_call_count++];
33 ✔
53
      }
54

55
      Botan::HTTP::http_exch_fn as_exch_fn() {
26 ✔
56
         return
26 ✔
57
            [this](
24 ✔
58
               std::string_view h, std::string_view s, std::string_view m, std::chrono::milliseconds t, size_t mbs) {
59
               return handle(h, s, m, t, mbs);
33 ✔
60
            };
25 ✔
61
      }
62

63
      void set_delay(std::chrono::milliseconds delay) { m_delay = delay; }
2 ✔
64

65
      size_t calls() const { return m_call_count; }
13 ✔
66

67
      const std::string& request(size_t i) const { return m_messages.at(i); }
18 ✔
68

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

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

73
      size_t max_body_size(size_t i) const { return m_max_body_sizes.at(i); }
4 ✔
74

75
      std::chrono::milliseconds timeout(size_t i) const { return m_timeouts.at(i); }
6 ✔
76

77
   private:
78
      std::vector<Botan::HTTP::Response> m_responses;
79
      std::vector<std::string> m_hostnames;
80
      std::vector<std::string> m_services;
81
      std::vector<std::string> m_messages;
82
      std::vector<std::chrono::milliseconds> m_timeouts;
83
      std::vector<size_t> m_max_body_sizes;
84
      std::chrono::milliseconds m_delay{0};
85
      size_t m_call_count = 0;
86
};
87

88
/*
89
* A read-only fake Socket that yields canned bytes in fixed-size chunks.
90
* The chunk size lets tests exercise both "everything in one read" and
91
* "response arrives split across reads".
92
*/
93
class MockSocket final : public Botan::OS::Socket {
42 ✔
94
   public:
95
      MockSocket(std::string bytes, size_t chunk_size) : m_bytes(std::move(bytes)), m_chunk(chunk_size) {}
84 ✔
96

97
      void write(std::span<const uint8_t> /*buf*/) override {
×
98
         // Parse tests don't care about the request side.
99
      }
×
100

101
      size_t read(uint8_t* buf, size_t len) override {
111 ✔
102
         const size_t take = std::min({len, m_chunk, m_bytes.size() - m_pos});
111 ✔
103
         std::memcpy(buf, m_bytes.data() + m_pos, take);
111 ✔
104
         m_pos += take;
111 ✔
105
         return take;
111 ✔
106
      }
107

108
   private:
109
      std::string m_bytes;
110
      size_t m_chunk;
111
      size_t m_pos = 0;
112
};
113

114
// Default timeout for socket-backed parse tests. Reads return immediately,
115
// so this is just a generous bound to keep the deadline check happy.
116
inline std::chrono::milliseconds parse_test_timeout() {
42 ✔
117
   return std::chrono::seconds(30);
42 ✔
118
}
119

120
Botan::HTTP::Response parse_via_socket(std::string raw,
40 ✔
121
                                       size_t max_body_size = Botan::HTTP::RequestLimits().max_body_size(),
122
                                       size_t chunk_size = 64 * 1024) {
123
   MockSocket socket(std::move(raw), chunk_size);
40 ✔
124
   return Botan::HTTP::read_response_from_socket(socket, parse_test_timeout(), max_body_size);
40 ✔
125
}
17 ✔
126

127
Botan::HTTP::Response make_response(unsigned int status,
36 ✔
128
                                    std::string_view message = "OK",
129
                                    std::map<std::string, std::string> headers = {},
130
                                    std::string_view body = "") {
131
   Botan::HTTP::Headers h;
36 ✔
132
   for(auto& [k, v] : headers) {
55 ✔
133
      h.emplace(k, v);
19 ✔
134
   }
135
   std::vector<uint8_t> body_bytes(body.begin(), body.end());
36 ✔
136
   return Botan::HTTP::Response(status, std::string(message), std::move(body_bytes), std::move(h));
108 ✔
137
}
36 ✔
138

139
class HTTP_Parse_Tests final : public Test {
1 ✔
140
   private:
141
      static Test::Result test_minimal_response() {
1 ✔
142
         Test::Result result("HTTP response parser minimal");
1 ✔
143

144
         const std::string raw =
1 ✔
145
            "HTTP/1.0 200 OK\r\n"
146
            "Content-Length: 5\r\n"
147
            "\r\n"
148
            "hello";
1 ✔
149

150
         const auto resp = parse_via_socket(raw);
2 ✔
151
         result.test_u32_eq("status code", resp.status_code(), 200);
1 ✔
152
         result.test_str_eq("status message", resp.status_message(), "OK");
2 ✔
153
         result.test_str_eq("body", body_as_string(resp), "hello");
1 ✔
154
         result.test_is_true("Content-Length header", resp.headers().contains("Content-Length"));
1 ✔
155
         return result;
2 ✔
156
      }
1 ✔
157

158
      static Test::Result test_zero_header_response() {
1 ✔
159
         Test::Result result("HTTP response parser zero headers");
1 ✔
160

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

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

171
      static Test::Result test_non_200_status() {
1 ✔
172
         Test::Result result("HTTP response parser non-200 status");
1 ✔
173

174
         const std::string raw =
1 ✔
175
            "HTTP/1.0 404 Not Found\r\n"
176
            "Content-Length: 0\r\n"
177
            "\r\n";
1 ✔
178

179
         const auto resp = parse_via_socket(raw);
2 ✔
180
         result.test_u32_eq("status code", resp.status_code(), 404);
1 ✔
181
         result.test_str_eq("status message", resp.status_message(), "Not Found");
2 ✔
182
         result.test_sz_eq("body empty", resp.body().size(), 0);
1 ✔
183
         return result;
1 ✔
184
      }
1 ✔
185

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

189
         const std::string raw =
1 ✔
190
            "HTTP/1.0 200 OK\r\n"
191
            "Content-Type: text/plain\r\n"
192
            "\r\n"
193
            "body without explicit length";
1 ✔
194

195
         const auto resp = parse_via_socket(raw);
2 ✔
196
         result.test_u32_eq("status code", resp.status_code(), 200);
1 ✔
197
         result.test_str_eq("body", body_as_string(resp), "body without explicit length");
1 ✔
198
         return result;
2 ✔
199
      }
1 ✔
200

201
      static Test::Result test_case_insensitive_content_length() {
1 ✔
202
         Test::Result result("HTTP response parser case-insensitive Content-Length");
1 ✔
203

204
         const std::string raw =
1 ✔
205
            "HTTP/1.0 200 OK\r\n"
206
            "content-length: 3\r\n"
207
            "\r\n"
208
            "abc";
1 ✔
209

210
         const auto resp = parse_via_socket(raw);
2 ✔
211
         result.test_sz_eq("body size matches", resp.body().size(), 3);
1 ✔
212
         result.test_is_true("lookup by canonical case", resp.headers().contains("Content-Length"));
1 ✔
213
         return result;
2 ✔
214
      }
1 ✔
215

216
      static Test::Result test_no_header_terminator() {
1 ✔
217
         Test::Result result("HTTP response parser missing terminator");
1 ✔
218
         const std::string raw = "HTTP/1.0 200 OK\r\nContent-Length: 0\r\n";
1 ✔
219
         result.test_throws<Botan::HTTP::HTTP_Error>("no header terminator", [&] { (void)parse_via_socket(raw); });
4 ✔
220
         return result;
1 ✔
221
      }
1 ✔
222

223
      static Test::Result test_status_line_malformed() {
1 ✔
224
         Test::Result result("HTTP response parser malformed status");
1 ✔
225
         const std::string raw = "NotHTTP/1.0 200 OK\r\n\r\n";
1 ✔
226
         result.test_throws<Botan::HTTP::HTTP_Error>("non-HTTP status line", [&] { (void)parse_via_socket(raw); });
4 ✔
227
         return result;
1 ✔
228
      }
1 ✔
229

230
      static Test::Result test_status_code_out_of_range() {
1 ✔
231
         Test::Result result("HTTP response parser status code range");
1 ✔
232
         result.test_throws<Botan::HTTP::HTTP_Error>("code > 599",
1 ✔
233
                                                     [&] { (void)parse_via_socket("HTTP/1.0 9999999 X\r\n\r\n"); });
2 ✔
234
         result.test_throws<Botan::HTTP::HTTP_Error>("code < 100",
1 ✔
235
                                                     [&] { (void)parse_via_socket("HTTP/1.0 42 X\r\n\r\n"); });
2 ✔
236
         return result;
1 ✔
237
      }
×
238

239
      static Test::Result test_content_length_not_digits() {
1 ✔
240
         Test::Result result("HTTP response parser Content-Length must be digits");
1 ✔
241
         const std::string raw =
1 ✔
242
            "HTTP/1.0 200 OK\r\n"
243
            "Content-Length: abc\r\n"
244
            "\r\n";
1 ✔
245
         result.test_throws<Botan::HTTP::HTTP_Error>("non-digit Content-Length", [&] { (void)parse_via_socket(raw); });
4 ✔
246
         return result;
1 ✔
247
      }
1 ✔
248

249
      static Test::Result test_transfer_encoding_rejected() {
1 ✔
250
         Test::Result result("HTTP response parser rejects Transfer-Encoding");
1 ✔
251
         const std::string raw =
1 ✔
252
            "HTTP/1.0 200 OK\r\n"
253
            "Transfer-Encoding: chunked\r\n"
254
            "\r\n"
255
            "0\r\n\r\n";
1 ✔
256
         result.test_throws<Botan::HTTP::HTTP_Error>("Transfer-Encoding present", [&] { (void)parse_via_socket(raw); });
4 ✔
257
         return result;
1 ✔
258
      }
1 ✔
259

260
      static Test::Result test_transfer_encoding_case_insensitive() {
1 ✔
261
         Test::Result result("HTTP response parser Transfer-Encoding case-insensitive");
1 ✔
262
         const std::string raw =
1 ✔
263
            "HTTP/1.0 200 OK\r\n"
264
            "transfer-encoding: gzip\r\n"
265
            "\r\n";
1 ✔
266
         result.test_throws<Botan::HTTP::HTTP_Error>("lowercase Transfer-Encoding still rejected",
1 ✔
267
                                                     [&] { (void)parse_via_socket(raw); });
4 ✔
268
         return result;
1 ✔
269
      }
1 ✔
270

271
      static Test::Result test_duplicate_header_rejected() {
1 ✔
272
         Test::Result result("HTTP response parser rejects duplicate header");
1 ✔
273
         const std::string raw =
1 ✔
274
            "HTTP/1.0 200 OK\r\n"
275
            "Content-Length: 0\r\n"
276
            "content-length: 0\r\n"
277
            "\r\n";
1 ✔
278
         result.test_throws<Botan::HTTP::HTTP_Error>("case-insensitive duplicate",
1 ✔
279
                                                     [&] { (void)parse_via_socket(raw); });
4 ✔
280
         return result;
1 ✔
281
      }
1 ✔
282

283
      static Test::Result test_empty_header_name_rejected() {
1 ✔
284
         Test::Result result("HTTP response parser rejects empty name");
1 ✔
285
         const std::string raw =
1 ✔
286
            "HTTP/1.0 200 OK\r\n"
287
            ": orphan value\r\n"
288
            "\r\n";
1 ✔
289
         result.test_throws<Botan::HTTP::HTTP_Error>("empty name", [&] { (void)parse_via_socket(raw); });
4 ✔
290
         return result;
1 ✔
291
      }
1 ✔
292

293
      static Test::Result test_invalid_header_name_char_rejected() {
1 ✔
294
         Test::Result result("HTTP response parser rejects non-tchar in name");
1 ✔
295
         const std::string raw =
1 ✔
296
            "HTTP/1.0 200 OK\r\n"
297
            "Bad Name: value\r\n"  // space is not a tchar
298
            "\r\n";
1 ✔
299
         result.test_throws<Botan::HTTP::HTTP_Error>("space in name", [&] { (void)parse_via_socket(raw); });
4 ✔
300
         return result;
1 ✔
301
      }
1 ✔
302

303
      static Test::Result test_content_length_mismatch() {
1 ✔
304
         Test::Result result("HTTP response parser Content-Length mismatch");
1 ✔
305
         const std::string raw =
1 ✔
306
            "HTTP/1.0 200 OK\r\n"
307
            "Content-Length: 100\r\n"
308
            "\r\n"
309
            "short";
1 ✔
310
         result.test_throws<Botan::HTTP::HTTP_Error>("body shorter than CL", [&] { (void)parse_via_socket(raw); });
4 ✔
311
         return result;
1 ✔
312
      }
1 ✔
313

314
      static Test::Result test_content_length_exceeds_max() {
1 ✔
315
         Test::Result result("HTTP response parser Content-Length > max");
1 ✔
316
         const std::string raw =
1 ✔
317
            "HTTP/1.0 200 OK\r\n"
318
            "Content-Length: 10000\r\n"
319
            "\r\n";
1 ✔
320
         result.test_throws<Botan::HTTP::HTTP_Error>("CL exceeds policy", [&] { (void)parse_via_socket(raw, 1024); });
2 ✔
321
         return result;
1 ✔
322
      }
1 ✔
323

324
      static Test::Result test_huge_content_length_default_limit() {
1 ✔
325
         Test::Result result("HTTP response parser huge Content-Length with default limit");
1 ✔
326
         const std::string raw =
1 ✔
327
            "HTTP/1.0 200 OK\r\n"
328
            "Content-Length: 18446744073709551615\r\n"
329
            "\r\n";
1 ✔
330
         result.test_throws<Botan::HTTP::HTTP_Error>("huge CL rejected", [&] { (void)parse_via_socket(raw); });
4 ✔
331

332
         const std::string over_default =
1 ✔
333
            "HTTP/1.0 200 OK\r\n"
334
            "Content-Length: 8388609\r\n"
335
            "\r\n";
1 ✔
336
         result.test_throws<Botan::HTTP::HTTP_Error>("CL just over 8 MiB rejected",
1 ✔
337
                                                     [&] { (void)parse_via_socket(over_default); });
4 ✔
338
         return result;
1 ✔
339
      }
1 ✔
340

341
      static Test::Result test_body_without_cl_exceeds_max() {
1 ✔
342
         Test::Result result("HTTP response parser body without CL exceeds max");
1 ✔
343
         const std::string raw =
1 ✔
344
            "HTTP/1.0 200 OK\r\n"
345
            "\r\n"
346
            "this body is more than ten bytes";
1 ✔
347
         result.test_throws<Botan::HTTP::HTTP_Error>("body exceeds policy", [&] { (void)parse_via_socket(raw, 10); });
2 ✔
348
         return result;
1 ✔
349
      }
1 ✔
350

351
      static Test::Result test_body_within_max() {
1 ✔
352
         Test::Result result("HTTP response parser body within max");
1 ✔
353
         const std::string raw =
1 ✔
354
            "HTTP/1.0 200 OK\r\n"
355
            "Content-Length: 4\r\n"
356
            "\r\n"
357
            "abcd";
1 ✔
358
         const auto resp = parse_via_socket(raw, 4);
2 ✔
359
         result.test_sz_eq("body size", resp.body().size(), 4);
1 ✔
360
         return result;
2 ✔
361
      }
1 ✔
362

363
      static Test::Result test_oversized_headers_rejected() {
1 ✔
364
         Test::Result result("HTTP response parser rejects oversized headers");
1 ✔
365
         // Build a response whose header block exceeds the 16 KB internal cap.
366
         std::string raw = "HTTP/1.0 200 OK\r\n";
1 ✔
367
         raw += "X-Padding: ";
1 ✔
368
         raw.append(20 * 1024, 'a');
1 ✔
369
         raw += "\r\n\r\n";
1 ✔
370
         result.test_throws<Botan::HTTP::HTTP_Error>("header section > 16 KB", [&] { (void)parse_via_socket(raw); });
4 ✔
371
         return result;
1 ✔
372
      }
1 ✔
373

374
      static Test::Result test_control_chars_in_value_rejected() {
1 ✔
375
         Test::Result result("HTTP response parser rejects control characters in field values");
1 ✔
376

377
         const std::vector<std::pair<std::string, std::string>> bad = {
1 ✔
378
            {"bare LF", std::string("X-Foo: a\nb")},
×
379
            {"bare CR", std::string("X-Foo: a\rb")},
1 ✔
380
            {"NUL", std::string("X-Foo: a\0b", 10)},
1 ✔
381
            {"DEL",
382
             std::string("X-Foo: a\x7F"
1 ✔
383
                         "b")},
384
            {"other C0",
385
             std::string("X-Foo: a\x01"
1 ✔
386
                         "b")},
387
            {"escape", std::string("X-Foo: \x1B[2Jb")},
1 ✔
388
         };
9 ✔
389

390
         for(const auto& [what, line] : bad) {
7 ✔
391
            const std::string raw = "HTTP/1.0 200 OK\r\n" + line + "\r\n\r\n";
12 ✔
392
            result.test_throws<Botan::HTTP::HTTP_Error>(what, [&] { (void)parse_via_socket(raw); });
30 ✔
393
         }
6 ✔
394

395
         const std::string status_lf = "HTTP/1.0 200 O\nK\r\n\r\n";
1 ✔
396
         result.test_throws<Botan::HTTP::HTTP_Error>("bare LF in status line",
1 ✔
397
                                                     [&] { (void)parse_via_socket(status_lf); });
4 ✔
398

399
         const std::string ok =
1 ✔
400
            "HTTP/1.0 200 OK\r\n"
401
            "X-Foo: a\tb \x80\xFF~\r\n"
402
            "\r\n";
1 ✔
403
         const auto resp = parse_via_socket(ok);
2 ✔
404
         result.test_str_eq("HTAB, SP and obs-text accepted", resp.headers().at("X-Foo"), "a\tb \x80\xFF~");
1 ✔
405
         return result;
1 ✔
406
      }
8 ✔
407

408
      static Test::Result test_ows_variants_accepted() {
1 ✔
409
         Test::Result result("HTTP response parser accepts RFC 9110 OWS variants");
1 ✔
410

411
         // RFC 9110 5.5: field-line = field-name ":" OWS field-value OWS
412
         // OWS = *( SP / HTAB ); zero or more SP or HTAB on either side.
413
         struct Case {
18 ✔
414
               std::string line;
415
               std::string expected_value;
416
         };
417

418
         const std::vector<Case> cases{
1 ✔
419
            {"Content-Length:0", "0"},      // no OWS at all
1 ✔
420
            {"Content-Length: 0", "0"},     // single SP after colon (canonical)
421
            {"Content-Length:  0", "0"},    // two SP after colon
422
            {"Content-Length:\t0", "0"},    // HTAB after colon
423
            {"Content-Length: \t 0", "0"},  // mixed OWS after colon
424
            {"Content-Length: 0 ", "0"},    // trailing OWS
425
            {"Content-Length: 0\t", "0"},   // trailing HTAB
426
            {"X-Empty:", ""},               // empty value, no OWS
427
            {"X-Empty: ", ""},              // empty value, trailing OWS only
428
         };
10 ✔
429

430
         for(const auto& c : cases) {
10 ✔
431
            const std::string raw = "HTTP/1.0 200 OK\r\n" + c.line + "\r\n\r\n";
18 ✔
432
            try {
9 ✔
433
               const auto resp = parse_via_socket(raw);
18 ✔
434
               // Find the field name (everything before the first ':')
435
               const auto colon = c.line.find(':');
9 ✔
436
               const auto name = c.line.substr(0, colon);
9 ✔
437
               const auto it = resp.headers().find(name);
9 ✔
438
               if(result.test_is_true("header present: " + c.line, it != resp.headers().end())) {
9 ✔
439
                  result.test_str_eq("value trimmed for: " + c.line, it->second, c.expected_value);
18 ✔
440
               }
441
            } catch(const Botan::HTTP::HTTP_Error& e) {
9 ✔
442
               result.test_failure("unexpected throw on '" + c.line + "': " + e.what());
×
443
            }
×
444
         }
9 ✔
445
         return result;
1 ✔
446
      }
3 ✔
447

448
      static Test::Result test_byte_at_a_time_reads() {
1 ✔
449
         Test::Result result("HTTP response parser handles 1-byte-at-a-time reads");
1 ✔
450
         const std::string raw =
1 ✔
451
            "HTTP/1.0 200 OK\r\n"
452
            "Content-Length: 5\r\n"
453
            "Server: tiny\r\n"
454
            "\r\n"
455
            "hello";
1 ✔
456
         MockSocket socket(raw, 1);  // worst-case chunking
2 ✔
457
         const auto resp = Botan::HTTP::read_response_from_socket(socket, parse_test_timeout(), 1024);
1 ✔
458
         result.test_u32_eq("status code", resp.status_code(), 200);
1 ✔
459
         result.test_str_eq("body", body_as_string(resp), "hello");
1 ✔
460
         result.test_str_eq("Server header", resp.headers().find("Server")->second, "tiny");
1 ✔
461
         return result;
2 ✔
462
      }
1 ✔
463

464
      static Test::Result test_header_terminator_split_across_reads() {
1 ✔
465
         Test::Result result("HTTP response parser handles header terminator split across reads");
1 ✔
466
         const std::string raw =
1 ✔
467
            "HTTP/1.0 200 OK\r\n"
468
            "Content-Length: 3\r\n"
469
            "\r\n"
470
            "abc";
1 ✔
471
         // Choose a chunk size that places the "\r\n\r\n" boundary across two reads.
472
         MockSocket socket(raw, 19);
2 ✔
473
         const auto resp = Botan::HTTP::read_response_from_socket(socket, parse_test_timeout(), 1024);
1 ✔
474
         result.test_str_eq("body", body_as_string(resp), "abc");
1 ✔
475
         return result;
2 ✔
476
      }
1 ✔
477

478
      static Test::Result test_header_lookup_is_case_insensitive() {
1 ✔
479
         Test::Result result("HTTP::Response::headers case-insensitive lookup");
1 ✔
480
         const std::string raw =
1 ✔
481
            "HTTP/1.0 301 Moved Permanently\r\n"
482
            "LOCATION: http://other.example/\r\n"
483
            "Content-Length: 0\r\n"
484
            "\r\n";
1 ✔
485
         const auto resp = parse_via_socket(raw);
2 ✔
486
         result.test_is_true("find Location regardless of case", resp.headers().contains("Location"));
1 ✔
487
         result.test_is_true("contains Location regardless of case", resp.headers().contains("location"));
1 ✔
488
         return result;
2 ✔
489
      }
1 ✔
490

491
   public:
492
      std::vector<Test::Result> run() override {
1 ✔
493
         return {
1 ✔
494
            test_minimal_response(),
495
            test_zero_header_response(),
496
            test_non_200_status(),
497
            test_no_content_length_eof_terminated(),
498
            test_case_insensitive_content_length(),
499
            test_no_header_terminator(),
500
            test_status_line_malformed(),
501
            test_status_code_out_of_range(),
502
            test_content_length_not_digits(),
503
            test_transfer_encoding_rejected(),
504
            test_transfer_encoding_case_insensitive(),
505
            test_duplicate_header_rejected(),
506
            test_empty_header_name_rejected(),
507
            test_invalid_header_name_char_rejected(),
508
            test_control_chars_in_value_rejected(),
509
            test_content_length_mismatch(),
510
            test_content_length_exceeds_max(),
511
            test_huge_content_length_default_limit(),
512
            test_body_without_cl_exceeds_max(),
513
            test_body_within_max(),
514
            test_header_lookup_is_case_insensitive(),
515
            test_ows_variants_accepted(),
516
            test_oversized_headers_rejected(),
517
            test_byte_at_a_time_reads(),
518
            test_header_terminator_split_across_reads(),
519
         };
26 ✔
520
      }
1 ✔
521
};
522

523
BOTAN_REGISTER_TEST("http", "http_parse", HTTP_Parse_Tests);
524

525
class HTTP_Request_Tests final : public Test {
1 ✔
526
   private:
527
      // The first line of an HTTP message starts with "<verb> <target> HTTP/1.0\r\n".
528
      // Pull out the request-target so tests can assert on it.
529
      static std::string request_target(std::string_view message) {
5 ✔
530
         const auto sp1 = message.find(' ');
5 ✔
531
         const auto sp2 = message.find(' ', sp1 + 1);
5 ✔
532
         return std::string(message.substr(sp1 + 1, sp2 - sp1 - 1));
5 ✔
533
      }
534

535
      static std::string verb(std::string_view message) {
2 ✔
536
         const auto sp1 = message.find(' ');
2 ✔
537
         return std::string(message.substr(0, sp1));
2 ✔
538
      }
539

540
      static bool message_contains(std::string_view message, std::string_view needle) {
7 ✔
541
         return message.find(needle) != std::string_view::npos;
7 ✔
542
      }
543

544
      static Test::Result test_get_request_shape() {
1 ✔
545
         Test::Result result("HTTP::GET_sync request shape");
1 ✔
546
         MockServer mock({make_response(200, "OK", {{"Content-Length", "0"}}, "")});
4 ✔
547
         const auto uri = Botan::URI::from_string("http://example.com/path/to/resource").value();
1 ✔
548

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

552
         result.test_sz_eq("one call", mock.calls(), 1);
1 ✔
553
         result.test_str_eq("verb is GET", verb(mock.request(0)), "GET");
1 ✔
554
         result.test_str_eq("request-target", request_target(mock.request(0)), "/path/to/resource");
1 ✔
555
         result.test_is_true("has Host header", message_contains(mock.request(0), "Host: example.com\r\n"));
1 ✔
556
         result.test_is_true("Connection: close", message_contains(mock.request(0), "Connection: close\r\n"));
1 ✔
557
         result.test_is_false("no Content-Length", message_contains(mock.request(0), "Content-Length:"));
1 ✔
558
         result.test_str_eq("hostname passed to exch", mock.hostname(0), "example.com");
1 ✔
559
         result.test_str_eq("service is http (no port given)", mock.service(0), "http");
1 ✔
560
         return result;
1 ✔
561
      }
4 ✔
562

563
      static Test::Result test_post_request_shape() {
1 ✔
564
         Test::Result result("HTTP::POST_sync request shape");
1 ✔
565
         MockServer mock({make_response(200, "OK", {{"Content-Length", "2"}}, "ok")});
4 ✔
566
         const auto uri = Botan::URI::from_string("http://example.com/submit").value();
1 ✔
567
         const std::vector<uint8_t> body{'h', 'i'};
1 ✔
568

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

571
         result.test_str_eq("verb is POST", verb(mock.request(0)), "POST");
1 ✔
572
         result.test_is_true("Content-Length: 2", message_contains(mock.request(0), "Content-Length: 2\r\n"));
1 ✔
573
         result.test_is_true("Content-Type set", message_contains(mock.request(0), "Content-Type: text/plain\r\n"));
1 ✔
574
         result.test_is_true("body appended", message_contains(mock.request(0), "\r\n\r\nhi"));
1 ✔
575
         return result;
1 ✔
576
      }
4 ✔
577

578
      static Test::Result test_query_in_request_target() {
1 ✔
579
         Test::Result result("HTTP request target includes query");
1 ✔
580
         MockServer mock({make_response(200)});
2 ✔
581
         const auto uri = Botan::URI::from_string("http://example.com/api?id=42&n=1").value();
1 ✔
582
         (void)Botan::HTTP::http_sync(mock.as_exch_fn(), "GET", uri, "", {}, Botan::HTTP::RequestLimits());
2 ✔
583
         result.test_str_eq("query preserved", request_target(mock.request(0)), "/api?id=42&n=1");
1 ✔
584
         return result;
1 ✔
585
      }
3 ✔
586

587
      static Test::Result test_fragment_excluded_from_request_target() {
1 ✔
588
         Test::Result result("HTTP request target excludes fragment (RFC 9110 7.1)");
1 ✔
589
         MockServer mock({make_response(200)});
2 ✔
590
         const auto uri = Botan::URI::from_string("http://example.com/page#section").value();
1 ✔
591
         (void)Botan::HTTP::http_sync(mock.as_exch_fn(), "GET", uri, "", {}, Botan::HTTP::RequestLimits());
2 ✔
592
         result.test_str_eq("no fragment in target", request_target(mock.request(0)), "/page");
1 ✔
593
         return result;
1 ✔
594
      }
3 ✔
595

596
      static Test::Result test_empty_path_defaults_to_slash() {
1 ✔
597
         Test::Result result("HTTP request target defaults empty path to /");
1 ✔
598
         MockServer mock({make_response(200), make_response(200)});
3 ✔
599
         const auto bare = Botan::URI::from_string("http://example.com").value();
1 ✔
600
         (void)Botan::HTTP::http_sync(mock.as_exch_fn(), "GET", bare, "", {}, Botan::HTTP::RequestLimits());
2 ✔
601
         result.test_str_eq("no path => /", request_target(mock.request(0)), "/");
1 ✔
602

603
         const auto query_only = Botan::URI::from_string("http://example.com?q=1").value();
2 ✔
604
         (void)Botan::HTTP::http_sync(mock.as_exch_fn(), "GET", query_only, "", {}, Botan::HTTP::RequestLimits());
2 ✔
605
         result.test_str_eq("no path with query => /?q=1", request_target(mock.request(1)), "/?q=1");
1 ✔
606
         return result;
1 ✔
607
      }
3 ✔
608

609
      static Test::Result test_ipv6_host_header_bracketed() {
1 ✔
610
         Test::Result result("HTTP Host header brackets IPv6");
1 ✔
611
         MockServer mock({make_response(200)});
2 ✔
612
         const auto uri = Botan::URI::from_string("http://[::1]:8080/").value();
1 ✔
613
         (void)Botan::HTTP::http_sync(mock.as_exch_fn(), "GET", uri, "", {}, Botan::HTTP::RequestLimits());
2 ✔
614
         result.test_is_true("Host has brackets and port", message_contains(mock.request(0), "Host: [::1]:8080\r\n"));
1 ✔
615
         result.test_str_eq("service is the port", mock.service(0), "8080");
1 ✔
616
         return result;
1 ✔
617
      }
3 ✔
618

619
      static Test::Result test_https_scheme_rejected() {
1 ✔
620
         Test::Result result("HTTP rejects non-http scheme");
1 ✔
621
         MockServer mock({});
1 ✔
622
         const auto uri = Botan::URI::from_string("https://example.com/").value();
2 ✔
623
         result.test_throws<Botan::HTTP::HTTP_Error>("https URI", [&] {
1 ✔
624
            (void)Botan::HTTP::http_sync(mock.as_exch_fn(), "GET", uri, "", {}, Botan::HTTP::RequestLimits());
2 ✔
625
         });
×
626
         result.test_sz_eq("never called", mock.calls(), 0);
1 ✔
627
         return result;
1 ✔
628
      }
1 ✔
629

630
      static Test::Result test_max_body_size_propagated() {
1 ✔
631
         Test::Result result("HTTP max_body_size reaches the transact callback");
1 ✔
632
         MockServer mock({make_response(200)});
2 ✔
633
         const auto uri = Botan::URI::from_string("http://example.com/").value();
1 ✔
634
         (void)Botan::HTTP::http_sync(
1 ✔
635
            mock.as_exch_fn(), "GET", uri, "", {}, Botan::HTTP::RequestLimits().set_max_body_size(8192));
2 ✔
636
         result.test_sz_eq("max_body_size value", mock.max_body_size(0), 8192);
1 ✔
637
         return result;
1 ✔
638
      }
3 ✔
639

640
      static Test::Result test_default_max_body_size() {
1 ✔
641
         Test::Result result("HTTP max_body_size defaults to 8 MiB");
1 ✔
642
         MockServer mock({make_response(200)});
2 ✔
643
         const auto uri = Botan::URI::from_string("http://example.com/").value();
1 ✔
644
         (void)Botan::HTTP::http_sync(mock.as_exch_fn(), "GET", uri, "", {}, Botan::HTTP::RequestLimits());
2 ✔
645
         result.test_sz_eq("max_body_size value", mock.max_body_size(0), 8 * 1024 * 1024);
1 ✔
646
         return result;
1 ✔
647
      }
3 ✔
648

649
      static Test::Result test_timeout_propagated() {
1 ✔
650
         Test::Result result("HTTP timeout reaches the transact callback");
1 ✔
651
         MockServer mock({make_response(200)});
2 ✔
652
         const auto uri = Botan::URI::from_string("http://example.com/").value();
1 ✔
653
         const auto timeout = std::chrono::milliseconds(60000);
1 ✔
654
         (void)Botan::HTTP::http_sync(
1 ✔
655
            mock.as_exch_fn(), "GET", uri, "", {}, Botan::HTTP::RequestLimits().set_timeout(timeout));
2 ✔
656
         result.test_is_true("timeout positive", mock.timeout(0) > std::chrono::milliseconds::zero());
1 ✔
657
         result.test_is_true("timeout at most configured", mock.timeout(0) <= timeout);
1 ✔
658
         return result;
1 ✔
659
      }
3 ✔
660

661
      static Test::Result test_zero_timeout_rejected() {
1 ✔
662
         Test::Result result("HTTP zero timeout fails before connecting");
1 ✔
663
         MockServer mock({make_response(200)});
2 ✔
664
         const auto uri = Botan::URI::from_string("http://example.com/").value();
1 ✔
665
         result.test_throws<Botan::HTTP::HTTP_Error>("zero timeout", [&] {
1 ✔
666
            (void)Botan::HTTP::http_sync(mock.as_exch_fn(),
1 ✔
667
                                         "GET",
668
                                         uri,
669
                                         "",
670
                                         {},
671
                                         Botan::HTTP::RequestLimits().set_timeout(std::chrono::milliseconds(0)));
1 ✔
672
         });
×
673
         result.test_sz_eq("never called", mock.calls(), 0);
1 ✔
674
         return result;
1 ✔
675
      }
3 ✔
676

677
   public:
678
      std::vector<Test::Result> run() override {
1 ✔
679
         return {
1 ✔
680
            test_get_request_shape(),
681
            test_post_request_shape(),
682
            test_query_in_request_target(),
683
            test_fragment_excluded_from_request_target(),
684
            test_empty_path_defaults_to_slash(),
685
            test_ipv6_host_header_bracketed(),
686
            test_https_scheme_rejected(),
687
            test_max_body_size_propagated(),
688
            test_default_max_body_size(),
689
            test_timeout_propagated(),
690
            test_zero_timeout_rejected(),
691
         };
12 ✔
692
      }
1 ✔
693
};
694

695
BOTAN_REGISTER_TEST("http", "http_request", HTTP_Request_Tests);
696

697
class HTTP_Redirect_Tests final : public Test {
1 ✔
698
   private:
699
      static std::string verb_of(std::string_view message) { return std::string(message.substr(0, message.find(' '))); }
4 ✔
700

701
      static Test::Result test_redirects_disabled_by_default() {
1 ✔
702
         Test::Result result("HTTP redirects are disabled by default");
1 ✔
703
         MockServer mock({
1 ✔
704
            make_response(303, "See Other", {{"Location", "http://127.0.0.1/private-control?operation=export"}}),
2 ✔
705
            make_response(200, "OK"),
706
         });
3 ✔
707
         const auto uri = Botan::URI::from_string("http://ocsp.example/status").value();
1 ✔
708

709
         result.test_throws<Botan::HTTP::HTTP_Error>("default redirect budget rejects redirect", [&] {
1 ✔
710
            (void)Botan::HTTP::http_sync(mock.as_exch_fn(),
2 ✔
711
                                         "POST",
712
                                         uri,
713
                                         "application/ocsp-request",
714
                                         std::vector<uint8_t>{0x30, 0x00},
2 ✔
715
                                         Botan::HTTP::RequestLimits());
×
716
         });
×
717
         result.test_sz_eq("only the configured origin was contacted", mock.calls(), 1);
1 ✔
718
         return result;
1 ✔
719
      }
4 ✔
720

721
      static Test::Result test_301_preserves_method_for_get() {
1 ✔
722
         Test::Result result("HTTP 301 with GET: re-issues GET to new URL");
1 ✔
723
         MockServer mock({
1 ✔
724
            make_response(301, "Moved", {{"Location", "http://other.example/new"}}),
2 ✔
725
            make_response(200, "OK", {{"Content-Length", "2"}}, "ok"),
2 ✔
726
         });
3 ✔
727
         const auto uri = Botan::URI::from_string("http://example.com/old").value();
1 ✔
728
         const auto resp = Botan::HTTP::http_sync(
1 ✔
729
            mock.as_exch_fn(), "GET", uri, "", {}, Botan::HTTP::RequestLimits().set_max_redirects(2));
2 ✔
730
         result.test_sz_eq("two calls", mock.calls(), 2);
1 ✔
731
         result.test_str_eq("second call is GET", verb_of(mock.request(1)), "GET");
1 ✔
732
         result.test_str_eq("hostname switched", mock.hostname(1), "other.example");
1 ✔
733
         result.test_u32_eq("final status", resp.status_code(), 200);
1 ✔
734
         return result;
1 ✔
735
      }
5 ✔
736

737
      static Test::Result test_303_post_downgrades_to_get() {
1 ✔
738
         Test::Result result("HTTP 303 with POST: downgrades to GET (drops body)");
1 ✔
739
         MockServer mock({
1 ✔
740
            make_response(303, "See Other", {{"Location", "http://example.com/result"}}),
2 ✔
741
            make_response(200, "OK"),
742
         });
3 ✔
743
         const auto uri = Botan::URI::from_string("http://example.com/submit").value();
1 ✔
744
         const std::vector<uint8_t> body{'p', 'a', 'y', 'l', 'o', 'a', 'd'};
1 ✔
745

746
         (void)Botan::HTTP::http_sync(
1 ✔
747
            mock.as_exch_fn(), "POST", uri, "text/plain", body, Botan::HTTP::RequestLimits().set_max_redirects(1));
1 ✔
748

749
         result.test_sz_eq("two calls", mock.calls(), 2);
1 ✔
750
         result.test_str_eq("second call switched to GET", verb_of(mock.request(1)), "GET");
1 ✔
751
         const auto& msg = mock.request(1);
1 ✔
752
         result.test_is_false("body dropped on 303 -> GET", msg.find("\r\n\r\npayload") != std::string::npos);
1 ✔
753
         return result;
1 ✔
754
      }
4 ✔
755

756
      static Test::Result test_307_post_preserves_method_and_body() {
1 ✔
757
         Test::Result result("HTTP 307 with POST: preserves method and body");
1 ✔
758
         MockServer mock({
1 ✔
759
            make_response(307, "Temporary Redirect", {{"Location", "http://example.com/v2"}}),
2 ✔
760
            make_response(200, "OK", {{"Content-Length", "2"}}, "ok"),
2 ✔
761
         });
3 ✔
762
         const auto uri = Botan::URI::from_string("http://example.com/submit").value();
1 ✔
763
         const std::vector<uint8_t> body{'p', 'a', 'y', 'l', 'o', 'a', 'd'};
1 ✔
764

765
         (void)Botan::HTTP::http_sync(mock.as_exch_fn(),
1 ✔
766
                                      "POST",
767
                                      uri,
768
                                      "application/octet-stream",
769
                                      body,
770
                                      Botan::HTTP::RequestLimits().set_max_redirects(1));
1 ✔
771

772
         result.test_sz_eq("two calls", mock.calls(), 2);
1 ✔
773
         result.test_str_eq("second call still POST", verb_of(mock.request(1)), "POST");
1 ✔
774
         result.test_is_true("body re-sent", mock.request(1).find("\r\n\r\npayload") != std::string::npos);
1 ✔
775
         result.test_is_true("Content-Type re-sent",
1 ✔
776
                             mock.request(1).find("Content-Type: application/octet-stream\r\n") != std::string::npos);
1 ✔
777
         return result;
1 ✔
778
      }
5 ✔
779

780
      static Test::Result test_308_preserves_method() {
1 ✔
781
         Test::Result result("HTTP 308: preserves method");
1 ✔
782
         MockServer mock({
1 ✔
783
            make_response(308, "Permanent Redirect", {{"Location", "http://example.com/new"}}),
2 ✔
784
            make_response(200, "OK"),
785
         });
3 ✔
786
         const auto uri = Botan::URI::from_string("http://example.com/old").value();
1 ✔
787
         (void)Botan::HTTP::http_sync(mock.as_exch_fn(),
1 ✔
788
                                      "POST",
789
                                      uri,
790
                                      "text/plain",
791
                                      std::vector<uint8_t>{'x'},
2 ✔
792
                                      Botan::HTTP::RequestLimits().set_max_redirects(1));
1 ✔
793
         result.test_str_eq("second call still POST", verb_of(mock.request(1)), "POST");
1 ✔
794
         return result;
1 ✔
795
      }
4 ✔
796

797
      static Test::Result test_redirect_count_exceeded() {
1 ✔
798
         Test::Result result("HTTP redirect count exceeded");
1 ✔
799
         MockServer mock({
1 ✔
800
            make_response(301, "Moved", {{"Location", "http://example.com/a"}}),
2 ✔
801
            make_response(301, "Moved", {{"Location", "http://example.com/b"}}),
2 ✔
802
         });
3 ✔
803
         const auto uri = Botan::URI::from_string("http://example.com/start").value();
1 ✔
804
         result.test_throws<Botan::HTTP::HTTP_Error>("exceeds redirect budget", [&] {
1 ✔
805
            (void)Botan::HTTP::http_sync(
×
806
               mock.as_exch_fn(), "GET", uri, "", {}, Botan::HTTP::RequestLimits().set_max_redirects(1));
2 ✔
807
         });
×
808
         result.test_sz_eq("stopped at the second response", mock.calls(), 2);
1 ✔
809
         return result;
1 ✔
810
      }
5 ✔
811

812
      static Test::Result test_redirect_to_invalid_url() {
1 ✔
813
         Test::Result result("HTTP redirect to unparsable URL");
1 ✔
814
         MockServer mock({
1 ✔
815
            make_response(301, "Moved", {{"Location", "::not a url::"}}),
2 ✔
816
         });
2 ✔
817
         const auto uri = Botan::URI::from_string("http://example.com/").value();
1 ✔
818
         result.test_throws<Botan::HTTP::HTTP_Error>("invalid Location", [&] {
1 ✔
819
            (void)Botan::HTTP::http_sync(
×
820
               mock.as_exch_fn(), "GET", uri, "", {}, Botan::HTTP::RequestLimits().set_max_redirects(1));
2 ✔
821
         });
×
822
         return result;
1 ✔
823
      }
4 ✔
824

825
      static Test::Result test_redirect_to_non_http_rejected() {
1 ✔
826
         Test::Result result("HTTP redirect to non-http scheme rejected");
1 ✔
827
         MockServer mock({
1 ✔
828
            make_response(301, "Moved", {{"Location", "https://example.com/"}}),
2 ✔
829
         });
2 ✔
830
         const auto uri = Botan::URI::from_string("http://example.com/").value();
1 ✔
831
         result.test_throws<Botan::HTTP::HTTP_Error>("https Location", [&] {
1 ✔
832
            (void)Botan::HTTP::http_sync(
×
833
               mock.as_exch_fn(), "GET", uri, "", {}, Botan::HTTP::RequestLimits().set_max_redirects(1));
2 ✔
834
         });
×
835
         return result;
1 ✔
836
      }
4 ✔
837

838
      static Test::Result test_path_absolute_location_resolved() {
1 ✔
839
         Test::Result result("HTTP path-absolute Location resolved against request URI");
1 ✔
840
         MockServer mock({
1 ✔
841
            make_response(301, "Moved", {{"Location", "/v2/resource?id=1"}}),
2 ✔
842
            make_response(200, "OK"),
843
         });
3 ✔
844
         const auto uri = Botan::URI::from_string("http://example.com:8080/v1/old").value();
1 ✔
845
         (void)Botan::HTTP::http_sync(
1 ✔
846
            mock.as_exch_fn(), "GET", uri, "", {}, Botan::HTTP::RequestLimits().set_max_redirects(1));
2 ✔
847
         result.test_sz_eq("two calls", mock.calls(), 2);
1 ✔
848
         result.test_str_eq("same authority retained", mock.hostname(1), "example.com");
1 ✔
849
         result.test_str_eq("same port retained", mock.service(1), "8080");
1 ✔
850
         result.test_is_true("request-target uses Location path+query",
1 ✔
851
                             mock.request(1).find("GET /v2/resource?id=1 HTTP/1.0\r\n") != std::string::npos);
1 ✔
852
         return result;
1 ✔
853
      }
4 ✔
854

855
      static Test::Result test_network_path_location_rejected() {
1 ✔
856
         Test::Result result("HTTP network-path Location (//host/p) rejected");
1 ✔
857
         MockServer mock({
1 ✔
858
            make_response(301, "Moved", {{"Location", "//evil.example/new"}}),
2 ✔
859
         });
2 ✔
860
         const auto uri = Botan::URI::from_string("http://example.com/").value();
1 ✔
861
         result.test_throws<Botan::HTTP::HTTP_Error>("// prefix not resolved", [&] {
1 ✔
862
            (void)Botan::HTTP::http_sync(
×
863
               mock.as_exch_fn(), "GET", uri, "", {}, Botan::HTTP::RequestLimits().set_max_redirects(1));
2 ✔
864
         });
×
865
         return result;
1 ✔
866
      }
4 ✔
867

868
      static Test::Result test_userinfo_not_forwarded_on_redirect() {
1 ✔
869
         Test::Result result("HTTP path-absolute redirect strips userinfo");
1 ✔
870
         MockServer mock({
1 ✔
871
            make_response(301, "Moved", {{"Location", "/elsewhere"}}),
2 ✔
872
            make_response(200, "OK"),
873
         });
3 ✔
874
         const auto uri = Botan::URI::from_string("http://user:pass@example.com/start").value();
1 ✔
875
         (void)Botan::HTTP::http_sync(
1 ✔
876
            mock.as_exch_fn(), "GET", uri, "", {}, Botan::HTTP::RequestLimits().set_max_redirects(1));
2 ✔
877
         result.test_sz_eq("two calls", mock.calls(), 2);
1 ✔
878
         result.test_is_false("no userinfo in second Host header",
1 ✔
879
                              mock.request(1).find("user:pass") != std::string::npos);
1 ✔
880
         result.test_is_true("plain Host header on redirect",
1 ✔
881
                             mock.request(1).find("Host: example.com\r\n") != std::string::npos);
1 ✔
882
         return result;
1 ✔
883
      }
4 ✔
884

885
      static Test::Result test_redirect_without_location_returned() {
1 ✔
886
         Test::Result result("HTTP 3xx without Location returned to caller");
1 ✔
887
         MockServer mock({make_response(301, "Moved")});
2 ✔
888
         const auto uri = Botan::URI::from_string("http://example.com/").value();
1 ✔
889
         const auto resp = Botan::HTTP::http_sync(
1 ✔
890
            mock.as_exch_fn(), "GET", uri, "", {}, Botan::HTTP::RequestLimits().set_max_redirects(1));
2 ✔
891
         result.test_sz_eq("one call only", mock.calls(), 1);
1 ✔
892
         result.test_u32_eq("status passed through", resp.status_code(), 301);
1 ✔
893
         return result;
1 ✔
894
      }
3 ✔
895

896
      static Test::Result test_redirects_share_one_deadline() {
1 ✔
897
         Test::Result result("HTTP redirects share the caller's deadline");
1 ✔
898
         MockServer mock({
1 ✔
899
            make_response(301, "Moved", {{"Location", "http://other.example/1"}}),
2 ✔
900
            make_response(301, "Moved", {{"Location", "http://other.example/2"}}),
2 ✔
901
            make_response(200, "OK"),
902
         });
4 ✔
903
         const auto delay = std::chrono::milliseconds(20);
1 ✔
904
         mock.set_delay(delay);
1 ✔
905
         const auto uri = Botan::URI::from_string("http://example.com/").value();
1 ✔
906
         const auto limits =
1 ✔
907
            Botan::HTTP::RequestLimits().set_max_redirects(5).set_timeout(std::chrono::milliseconds(60000));
1 ✔
908

909
         const auto resp = Botan::HTTP::http_sync(mock.as_exch_fn(), "GET", uri, "", {}, limits);
2 ✔
910
         result.test_u32_eq("final status", resp.status_code(), 200);
1 ✔
911
         result.test_sz_eq("all hops attempted", mock.calls(), 3);
1 ✔
912
         // Each hop spins for at least `delay`, so the next hop's budget must shrink by at least that much
913
         result.test_is_true("second hop budget reduced", mock.timeout(1) <= mock.timeout(0) - delay);
1 ✔
914
         result.test_is_true("third hop budget reduced", mock.timeout(2) <= mock.timeout(1) - delay);
1 ✔
915
         return result;
1 ✔
916
      }
5 ✔
917

918
      static Test::Result test_redirect_after_deadline_not_attempted() {
1 ✔
919
         Test::Result result("HTTP redirect is not followed once the deadline has passed");
1 ✔
920
         MockServer mock({
1 ✔
921
            make_response(301, "Moved", {{"Location", "http://other.example/1"}}),
2 ✔
922
            make_response(200, "OK"),
923
         });
3 ✔
924
         // The first hop spins for the entire budget, so the deadline has passed when it returns
925
         const auto timeout = std::chrono::milliseconds(100);
1 ✔
926
         mock.set_delay(timeout);
1 ✔
927
         const auto uri = Botan::URI::from_string("http://example.com/").value();
1 ✔
928
         const auto limits = Botan::HTTP::RequestLimits().set_max_redirects(5).set_timeout(timeout);
1 ✔
929

930
         result.test_throws<Botan::HTTP::HTTP_Error>("redirect chain exceeds total timeout", [&] {
1 ✔
931
            (void)Botan::HTTP::http_sync(mock.as_exch_fn(), "GET", uri, "", {}, limits);
2 ✔
932
         });
×
933
         result.test_sz_eq("second hop never attempted", mock.calls(), 1);
1 ✔
934
         return result;
1 ✔
935
      }
4 ✔
936

937
   public:
938
      std::vector<Test::Result> run() override {
1 ✔
939
         return {
1 ✔
940
            test_redirects_disabled_by_default(),
941
            test_redirects_share_one_deadline(),
942
            test_redirect_after_deadline_not_attempted(),
943
            test_301_preserves_method_for_get(),
944
            test_303_post_downgrades_to_get(),
945
            test_307_post_preserves_method_and_body(),
946
            test_308_preserves_method(),
947
            test_redirect_count_exceeded(),
948
            test_redirect_to_invalid_url(),
949
            test_redirect_to_non_http_rejected(),
950
            test_path_absolute_location_resolved(),
951
            test_network_path_location_rejected(),
952
            test_userinfo_not_forwarded_on_redirect(),
953
            test_redirect_without_location_returned(),
954
         };
15 ✔
955
      }
1 ✔
956
};
957

958
BOTAN_REGISTER_TEST("http", "http_redirect", HTTP_Redirect_Tests);
959

960
class HTTP_URL_Encode_Tests final : public Test {
1 ✔
961
   public:
962
      std::vector<Test::Result> run() override {
1 ✔
963
         Test::Result result("HTTP::url_encode");
1 ✔
964

965
         result.test_str_eq("alpha-numeric passes through", Botan::HTTP::url_encode("hello123"), "hello123");
1 ✔
966
         result.test_str_eq("unreserved passes through", Botan::HTTP::url_encode("a-b_c.d~e"), "a-b_c.d~e");
1 ✔
967
         result.test_str_eq("space encoded", Botan::HTTP::url_encode("a b"), "a%20b");
1 ✔
968
         result.test_str_eq("slash encoded", Botan::HTTP::url_encode("/"), "%2F");
1 ✔
969
         result.test_str_eq("high-bit byte encodes to two hex digits", Botan::HTTP::url_encode("\xff"), "%FF");
1 ✔
970
         result.test_str_eq("empty input", Botan::HTTP::url_encode(""), "");
1 ✔
971

972
         return {result};
3 ✔
973
      }
2 ✔
974
};
975

976
BOTAN_REGISTER_TEST("http", "http_url_encode", HTTP_URL_Encode_Tests);
977

978
}  // namespace
979

980
#endif
981

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

© 2026 Coveralls, Inc