Coverage Report

Created: 2026-08-31 06:49

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/src/curl_fuzzer/proto_fuzzer/mock_server.cc
Line
Count
Source
1
/*
2
 * Copyright (C) Max Dymond, <cmeister2@gmail.com>, et al.
3
 *
4
 * SPDX-License-Identifier: curl
5
 */
6
7
/// @file
8
/// @brief Implementation of MockConnection and MockServer.
9
10
#include "proto_fuzzer/mock_server.h"
11
12
#include <fcntl.h>
13
#include <string.h>
14
#include <sys/select.h>
15
#include <sys/socket.h>
16
#include <sys/types.h>
17
#include <unistd.h>
18
19
#include <algorithm>
20
#include <cstddef>
21
#include <cstdint>
22
#include <limits>
23
#include <string>
24
#include <vector>
25
26
#include "proto_fuzzer/scenario_limits.h"
27
#include "proto_fuzzer/ws_frame.h"
28
29
namespace proto_fuzzer {
30
31
namespace {
32
33
// fd_set can only represent file descriptors < FD_SETSIZE. Reject any pair that couldn't participate in select()
34
// without memory corruption.
35
19.3k
bool FdFitsInFdSet(int fd) { return fd >= 0 && fd < FD_SETSIZE; }
36
37
}  // namespace
38
39
/// @class proto_fuzzer::MockConnection
40
/// @brief Owns one half of a socketpair used to feed canned responses to libcurl. The destructor closes the server-side
41
/// fd; the client-side fd is handed to libcurl via CURLOPT_OPENSOCKETFUNCTION and becomes curl's to close.
42
43
/// Construct a non-blocking AF_UNIX/SOCK_STREAM socketpair. Both fds are validated to fit inside FD_SETSIZE; on any
44
/// failure ok() returns false and the instance is unusable.
45
9.66k
MockConnection::MockConnection() : server_fd_(-1), client_fd_(-1), drain_limit_(0) {
46
9.66k
  int fds[2];
47
48
9.66k
  if (socketpair(AF_UNIX, SOCK_STREAM, 0, fds) != 0) {
49
0
    return;
50
0
  }
51
52
  // The fds must be small enough to fit in an fd_set for select(). If not, close them and fail the constructor.
53
9.66k
  if (!FdFitsInFdSet(fds[0]) || !FdFitsInFdSet(fds[1])) {
54
0
    close(fds[0]);
55
0
    close(fds[1]);
56
0
    return;
57
0
  }
58
59
  // Set the server-side fd non-blocking so we can write to it without risk of hanging the fuzzer.
60
9.66k
  int flags = fcntl(fds[0], F_GETFL, 0);
61
9.66k
  if (flags < 0 || fcntl(fds[0], F_SETFL, flags | O_NONBLOCK) < 0) {
62
0
    close(fds[0]);
63
0
    close(fds[1]);
64
0
    return;
65
0
  }
66
67
  // Success: store the file descriptors.
68
9.66k
  server_fd_ = fds[0];
69
9.66k
  client_fd_ = fds[1];
70
9.66k
}
71
72
/// Close the server-side fd (and the client-side fd if it was never handed off via take_client_fd()).
73
9.66k
MockConnection::~MockConnection() {
74
9.66k
  if (server_fd_ >= 0) {
75
9.66k
    close(server_fd_);
76
9.66k
  }
77
9.66k
  if (client_fd_ >= 0) {
78
0
    close(client_fd_);
79
0
  }
80
9.66k
}
81
82
/// @return true if the underlying socketpair was set up successfully.
83
9.66k
bool MockConnection::ok() const { return server_fd_ >= 0; }
84
85
/// @return the server-side fd (still owned by this MockConnection).
86
0
int MockConnection::server_fd() const { return server_fd_; }
87
88
/// Hand the client-side fd to libcurl. After this call the caller owns the fd and the MockConnection will not close it
89
/// on destruction.
90
/// @return the client-side socket fd as a curl_socket_t.
91
9.66k
curl_socket_t MockConnection::take_client_fd() {
92
9.66k
  int fd = client_fd_;
93
9.66k
  client_fd_ = -1;
94
9.66k
  return static_cast<curl_socket_t>(fd);
95
9.66k
}
96
97
/// Write 'size' bytes from 'data' to the server fd, looping until the whole
98
/// buffer is sent or a short/failed write occurs. MSG_NOSIGNAL is a harness
99
/// invariant rather than a curl behavior choice: a response race may leave the
100
/// peer closed, and that must look like an ordinary failed mock write instead
101
/// of terminating the fuzz process with SIGPIPE.
102
/// @param data Buffer to send.
103
/// @param size Number of bytes in 'data'.
104
/// @return false on short or failed write (treat the connection as lost).
105
26.5k
bool MockConnection::WriteAll(const unsigned char* data, std::size_t size) {
106
26.5k
  if (server_fd_ < 0) {
107
0
    return false;
108
0
  }
109
26.5k
  std::size_t written = 0;
110
53.1k
  while (written < size) {
111
26.5k
    ssize_t n = ::send(server_fd_, data + written, size - written, MSG_NOSIGNAL);
112
26.5k
    if (n <= 0) {
113
0
      return false;
114
0
    }
115
26.5k
    written += static_cast<std::size_t>(n);
116
26.5k
  }
117
26.5k
  return true;
118
26.5k
}
119
120
/// Drain bytes curl has written. When a backpressure drain limit has been
121
/// applied (see ApplyBackpressure), stops after drain_limit_ bytes so the
122
/// kernel recv buffer stays near-full and curl keeps seeing short writes.
123
/// Otherwise drains until read() returns 0/EAGAIN, matching legacy behaviour.
124
/// @return number of bytes consumed during this call.
125
66.0k
std::size_t MockConnection::DrainIncoming() {
126
66.0k
  if (server_fd_ < 0) {
127
0
    return 0;
128
0
  }
129
66.0k
  unsigned char scratch[4096];
130
66.0k
  std::size_t drained = 0;
131
88.6k
  while (drain_limit_ == 0 || drained < drain_limit_) {
132
77.3k
    std::size_t want = sizeof(scratch);
133
77.3k
    if (drain_limit_ != 0) {
134
21.4k
      const std::size_t remaining = drain_limit_ - drained;
135
21.4k
      if (remaining < want) {
136
19.1k
        want = remaining;
137
19.1k
      }
138
21.4k
    }
139
77.3k
    ssize_t n = ::read(server_fd_, scratch, want);
140
77.3k
    if (n <= 0) {
141
54.7k
      break;
142
54.7k
    }
143
22.6k
    drained += static_cast<std::size_t>(n);
144
22.6k
  }
145
66.0k
  return drained;
146
66.0k
}
147
148
/// Tighten both halves of the socketpair buffer and/or cap DrainIncoming's
149
/// per-call byte budget. SO_RCVBUF on the server fd caps how much curl can
150
/// push into the pipe; SO_SNDBUF on the client fd (which curl will soon own
151
/// but hasn't yet, so we can still tune it) caps how much curl's send() can
152
/// buffer before short-writing. Linux socketpairs effectively use max(SNDBUF,
153
/// RCVBUF*2) as pipe capacity, so we need both to see short writes reliably.
154
/// See header docs.
155
9.66k
void MockConnection::ApplyBackpressure(int recv_buf_bytes, std::size_t drain_limit) {
156
9.66k
  if (recv_buf_bytes > 0) {
157
685
    if (server_fd_ >= 0) {
158
685
      (void)setsockopt(server_fd_, SOL_SOCKET, SO_RCVBUF, &recv_buf_bytes, sizeof(recv_buf_bytes));
159
685
    }
160
685
    if (client_fd_ >= 0) {
161
685
      (void)setsockopt(client_fd_, SOL_SOCKET, SO_SNDBUF, &recv_buf_bytes, sizeof(recv_buf_bytes));
162
685
    }
163
685
  }
164
9.66k
  drain_limit_ = drain_limit;
165
9.66k
}
166
167
/// Non-blocking read: append whatever bytes are currently available on the
168
/// server fd to 'out'. Used by the WS handshake path to collect curl's HTTP
169
/// Upgrade request without losing any bytes.
170
/// @param out Destination buffer; unchanged if no bytes are pending.
171
5.53k
void MockConnection::ReadAvailable(std::string* out) {
172
5.53k
  if (server_fd_ < 0 || out == nullptr) {
173
0
    return;
174
0
  }
175
5.53k
  unsigned char scratch[4096];
176
8.10k
  while (true) {
177
8.10k
    ssize_t n = ::read(server_fd_, scratch, sizeof(scratch));
178
8.10k
    if (n <= 0) {
179
5.53k
      break;
180
5.53k
    }
181
2.57k
    out->append(reinterpret_cast<const char*>(scratch), static_cast<std::size_t>(n));
182
2.57k
  }
183
5.53k
}
184
185
/// Signal end-of-response to libcurl by half-closing the write side.
186
7.71k
void MockConnection::ShutdownWrite() {
187
7.71k
  if (server_fd_ < 0) {
188
0
    return;
189
0
  }
190
7.71k
  ::shutdown(server_fd_, SHUT_WR);
191
7.71k
}
192
193
/// @class proto_fuzzer::MockServer
194
/// @brief Orchestrates a bounded sequence of mock HTTP exchanges: installs
195
/// socket callbacks on an easy handle, assigns a response script to each new
196
/// socket, and feeds queued chunks as libcurl reads them.
197
198
/// Construct an idle MockServer with no scripted responses or open peers.
199
/// DriveScenario() configures it from a Scenario before curl can open a socket.
200
7.93k
MockServer::MockServer() : script_count_(0), next_script_(0), active_script_(nullptr) {}
201
202
/// Default destructor; current and previous MockConnections clean up their
203
/// server-side descriptors only after curl has been removed from the multi.
204
7.93k
MockServer::~MockServer() = default;
205
206
/// Build the complete borrowed script table before entering curl. The primary
207
/// Connection always occupies slot zero for backwards compatibility; only
208
/// three subsequent pointers are retained so protobuf mutations cannot
209
/// allocate socketpairs or prolong redirects in proportion to repeated-field
210
/// size. The Scenario passed by ScenarioRunner outlives this synchronous drive,
211
/// which makes borrowing safe while avoiding response-byte copies.
212
/// @param scenario Source of the primary and bounded follow-on scripts.
213
7.93k
void MockServer::SetScripts(const curl::fuzzer::proto::Scenario& scenario) {
214
7.93k
  script_count_ = 0;
215
7.93k
  next_script_ = 0;
216
7.93k
  active_script_ = nullptr;
217
7.93k
  connection_.reset();
218
7.93k
  previous_connections_.clear();
219
220
7.93k
  const auto append_script = [this](const curl::fuzzer::proto::Connection& connection) {
221
7.93k
    ConnectionScript& script = scripts_[script_count_++];
222
7.93k
    script.connection = &connection;
223
7.93k
    script.raw_chunk_count = std::min<std::size_t>(scenario_limits::kMaxResponseChunks, connection.on_readable_size());
224
7.93k
    const std::size_t frame_budget = scenario_limits::kMaxResponseChunks - script.raw_chunk_count;
225
7.93k
    script.frame_chunk_count = std::min<std::size_t>(frame_budget, connection.server_frames_size());
226
7.93k
    script.next_chunk = 0;
227
7.93k
  };
228
229
7.93k
  append_script(scenario.connection());
230
7.93k
  const std::size_t subsequent_count = std::min<std::size_t>(
231
7.93k
      scenario_limits::kMaxConnections - 1, static_cast<std::size_t>(scenario.subsequent_connections_size()));
232
7.93k
  for (std::size_t i = 0; i < subsequent_count; ++i) {
233
0
    append_script(scenario.subsequent_connections(static_cast<int>(i)));
234
0
  }
235
7.93k
}
236
237
/// @return true if at least one on_readable chunk has not yet been sent.
238
65.3k
bool MockServer::has_more_chunks() const {
239
65.3k
  return active_script_ != nullptr && active_script_->next_chunk < active_script_->chunk_count();
240
65.3k
}
241
242
/// Called by the OPENSOCKETFUNCTION trampoline in the base class. Creates the
243
/// MockConnection, writes initial_response into it, and returns the
244
/// client-side fd to hand to libcurl.
245
/// @return the client-side fd to hand to libcurl, or CURL_SOCKET_BAD on
246
///         failure.
247
8.64k
curl_socket_t MockServer::HandleOpenSocket() {
248
8.64k
  if (next_script_ >= script_count_) {
249
    // Refusing a fifth socket keeps redirect loops bounded even if curl's own
250
    // redirect limit is mutated upward or an authentication scheme retries.
251
912
    return CURL_SOCKET_BAD;
252
912
  }
253
254
7.72k
  if (connection_) {
255
    // libcurl owns the corresponding client fd, so retain the server half
256
    // until the easy handle leaves the multi instead of closing it at the
257
    // moment a redirect or retry opens its replacement.
258
0
    previous_connections_.push_back(std::move(connection_));
259
0
  }
260
261
7.72k
  active_script_ = &scripts_[next_script_++];
262
7.72k
  const curl::fuzzer::proto::Connection& script_connection = *active_script_->connection;
263
7.72k
  connection_ = std::make_unique<MockConnection>();
264
7.72k
  if (!connection_->ok()) {
265
0
    connection_.reset();
266
0
    active_script_ = nullptr;
267
0
    return CURL_SOCKET_BAD;
268
0
  }
269
270
  // Target policies clamp/clear these values before execution. Saturating the
271
  // compatibility lane's raw uint32 avoids implementation-defined narrowing
272
  // while preserving its ability to request any representable socket size.
273
7.72k
  const std::uint32_t int_max = static_cast<std::uint32_t>(std::numeric_limits<int>::max());
274
7.72k
  const auto& backpressure = script_connection.backpressure();
275
7.72k
  const int recv_buf_bytes = static_cast<int>(std::min(backpressure.recv_buf_bytes(), int_max));
276
7.72k
  connection_->ApplyBackpressure(recv_buf_bytes, static_cast<std::size_t>(backpressure.drain_limit()));
277
278
7.72k
  const std::string& initial_response = script_connection.initial_response();
279
7.72k
  if (!initial_response.empty()) {
280
5.27k
    if (!connection_->WriteAll(reinterpret_cast<const unsigned char*>(initial_response.data()),
281
5.27k
                               initial_response.size())) {
282
0
      connection_.reset();
283
0
      active_script_ = nullptr;
284
0
      return CURL_SOCKET_BAD;
285
0
    }
286
5.27k
  }
287
7.72k
  if (active_script_->chunk_count() == 0) {
288
3.38k
    connection_->ShutdownWrite();
289
3.38k
  }
290
7.72k
  return connection_->take_client_fd();
291
7.72k
}
292
293
/// Push the next queued chunk. Called by the drive loop when curl is ready
294
/// for more data.
295
/// @return true when a chunk was consumed from the script.
296
16.7k
bool MockServer::DeliverNextChunk() {
297
16.7k
  if (!connection_ || !has_more_chunks()) {
298
0
    return false;
299
0
  }
300
16.7k
  connection_->DrainIncoming();
301
16.7k
  const std::size_t chunk_index = active_script_->next_chunk++;
302
16.7k
  const auto& script_connection = *active_script_->connection;
303
16.7k
  if (chunk_index < active_script_->raw_chunk_count) {
304
11.7k
    const std::string& chunk = script_connection.on_readable(static_cast<int>(chunk_index));
305
11.7k
    if (!chunk.empty()) {
306
11.0k
      connection_->WriteAll(reinterpret_cast<const unsigned char*>(chunk.data()), chunk.size());
307
11.0k
    }
308
11.7k
  } else {
309
    // HTTP scenarios historically accept structured WebSocket frames as raw
310
    // response bytes after every on_readable chunk. Serialize only the frame
311
    // curl is about to receive: follow-on scripts and capped suffix frames may
312
    // never be consumed, so eagerly materialising all of them wastes mutations.
313
4.97k
    const std::size_t frame_index = chunk_index - active_script_->raw_chunk_count;
314
4.97k
    const std::string chunk = SerializeWebSocketFrame(script_connection.server_frames(static_cast<int>(frame_index)));
315
4.97k
    if (!chunk.empty()) {
316
4.97k
      connection_->WriteAll(reinterpret_cast<const unsigned char*>(chunk.data()), chunk.size());
317
4.97k
    }
318
4.97k
  }
319
16.7k
  if (!has_more_chunks()) {
320
3.80k
    connection_->ShutdownWrite();
321
3.80k
  }
322
16.7k
  return true;
323
16.7k
}
324
325
/// Drain every live mock peer. Previous connections are normally quiescent,
326
/// but curl can finish sending a request body or close one after it has begun
327
/// resolving/opening the redirect target. Servicing both sides makes that
328
/// overlap deterministic without conflating their response scripts.
329
31.8k
std::size_t MockServer::DrainIncomingConnections() {
330
31.8k
  std::size_t drained = 0;
331
31.8k
  for (const auto& previous : previous_connections_) {
332
0
    drained += previous->DrainIncoming();
333
0
  }
334
31.8k
  if (connection_) {
335
31.8k
    drained += connection_->DrainIncoming();
336
31.8k
  }
337
31.8k
  return drained;
338
31.8k
}
339
340
/// Seed the mock from the scenario, then drive the perform loop until curl is
341
/// done or a deterministic operation/idle budget is hit. Ordinary scenarios
342
/// never wait; explicit backpressure scenarios may use short select() waits.
343
/// @param multi    caller-owned multi; 'easy' is already added.
344
/// @param easy     the curl easy handle attached to this mock.
345
/// @param scenario source of the initial_response and on_readable chunks.
346
7.93k
void MockServer::RunLoop(CURLM* multi, CURL* easy, const curl::fuzzer::proto::Scenario& scenario) {
347
7.93k
  (void)easy;
348
7.93k
  SetScripts(scenario);
349
350
7.93k
  int still_running = 1;
351
7.93k
  int idle_iterations = 0;
352
7.93k
  int drive_iterations = 0;
353
7.93k
  bool pollset_probed = false;
354
  // Each newly opened socket can carry its own pressure settings. Inspect only
355
  // the borrowed, runtime-bounded scripts: ignored repeated protobuf entries must
356
  // not opt a fast transfer into timed waits.
357
7.93k
  const bool timed_drive =
358
7.93k
      std::any_of(scripts_.begin(), scripts_.begin() + script_count_, [](const ConnectionScript& script) {
359
7.93k
        const auto& backpressure = script.connection->backpressure();
360
7.93k
        return backpressure.recv_buf_bytes() != 0 || backpressure.drain_limit() != 0;
361
7.93k
      });
362
7.93k
  const int idle_limit = timed_drive ? kMaxTimedIdleIterations : kMaxIdleIterations;
363
7.93k
  CURLMcode rc = CURLM_OK;
364
365
39.7k
  while (still_running && idle_iterations < idle_limit && drive_iterations++ < kMaxDriveIterations) {
366
39.7k
    bool made_progress = false;
367
39.7k
    const int running_before = still_running;
368
39.7k
    rc = curl_multi_perform(multi, &still_running);
369
39.7k
    if (rc != CURLM_OK) {
370
0
      break;
371
0
    }
372
39.7k
    made_progress = still_running != running_before;
373
39.7k
    if (timed_drive && still_running && !pollset_probed) {
374
      // Probe only after curl has built the socket/filter chain. A zero-timeout
375
      // poll adds no wall-clock wait; restricting it to the timing lane keeps
376
      // the fixed HTTP target free of a per-input polling syscall.
377
627
      ProbeMultiPollset(multi);
378
627
      pollset_probed = true;
379
627
    }
380
39.7k
    if (!still_running) {
381
7.90k
      break;
382
7.90k
    }
383
384
    // Always drain whatever curl has written. Under backpressure the kernel
385
    // recv buffer would otherwise stay full — curl short-writes, the mock
386
    // never consumes, and the transfer wedges until the drive budget. With
387
    // drain_limit set this still honours the per-tick byte budget.
388
31.8k
    made_progress = DrainIncomingConnections() != 0 || made_progress;
389
31.8k
    if (has_more_chunks()) {
390
16.7k
      made_progress = DeliverNextChunk() || made_progress;
391
16.7k
    }
392
393
31.8k
    if (made_progress) {
394
24.3k
      idle_iterations = 0;
395
24.3k
      continue;
396
24.3k
    }
397
398
    // Ordinary socketpair scenarios never sleep: repeated no-progress
399
    // performs are enough to settle curl's local state machine. Only an
400
    // explicit BackpressureConfig opts into short waits so timeout-related
401
    // behaviour remains fuzzable without taxing every corpus entry.
402
7.53k
    if (timed_drive) {
403
7.47k
      (void)WaitOnMultiFdset(multi, &rc);
404
7.47k
      if (rc != CURLM_OK) {
405
0
        break;
406
0
      }
407
7.47k
    }
408
7.53k
    ++idle_iterations;
409
7.53k
  }
410
7.93k
}
411
412
}  // namespace proto_fuzzer