Coverage Report

Created: 2026-07-30 07:26

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 <string>
22
#include <utility>
23
#include <vector>
24
25
#include "proto_fuzzer/ws_frame.h"
26
27
namespace proto_fuzzer {
28
29
namespace {
30
31
// fd_set can only represent file descriptors < FD_SETSIZE. Reject any pair that couldn't participate in select()
32
// without memory corruption.
33
17.9k
bool FdFitsInFdSet(int fd) { return fd >= 0 && fd < FD_SETSIZE; }
34
35
// Cap per-scenario response chunks so a mutator that creates thousands of
36
// tiny on_readable entries can't dominate runtime.
37
constexpr std::size_t kMaxResponseChunks = 16;
38
39
/// Combine the scenario's raw on_readable strings with any serialised
40
/// WebSocket frames into a single ordered chunk list, capped at
41
/// kMaxResponseChunks. Historical behaviour: HTTP scenarios can carry
42
/// server_frames too; the fuzzer just feeds those bytes to curl.
43
7.44k
std::vector<std::string> BuildChunkList(const curl::fuzzer::proto::Connection& conn) {
44
7.44k
  std::vector<std::string> chunks;
45
7.44k
  chunks.reserve(kMaxResponseChunks);
46
7.44k
  const std::size_t raw_budget = std::min<std::size_t>(kMaxResponseChunks, conn.on_readable_size());
47
19.1k
  for (std::size_t i = 0; i < raw_budget; ++i) {
48
11.6k
    chunks.emplace_back(conn.on_readable(i));
49
11.6k
  }
50
7.44k
  const std::size_t frame_budget = kMaxResponseChunks - chunks.size();
51
7.44k
  const std::size_t frame_count = std::min<std::size_t>(frame_budget, conn.server_frames_size());
52
13.2k
  for (std::size_t i = 0; i < frame_count; ++i) {
53
5.83k
    chunks.emplace_back(SerializeWebSocketFrame(conn.server_frames(static_cast<int>(i))));
54
5.83k
  }
55
7.44k
  return chunks;
56
7.44k
}
57
58
}  // namespace
59
60
/// @class proto_fuzzer::MockConnection
61
/// @brief Owns one half of a socketpair used to feed canned responses to libcurl. The destructor closes the server-side
62
/// fd; the client-side fd is handed to libcurl via CURLOPT_OPENSOCKETFUNCTION and becomes curl's to close.
63
64
/// Construct a non-blocking AF_UNIX/SOCK_STREAM socketpair. Both fds are validated to fit inside FD_SETSIZE; on any
65
/// failure ok() returns false and the instance is unusable.
66
8.99k
MockConnection::MockConnection() : server_fd_(-1), client_fd_(-1), drain_limit_(0) {
67
8.99k
  int fds[2];
68
69
8.99k
  if (socketpair(AF_UNIX, SOCK_STREAM, 0, fds) != 0) {
70
0
    return;
71
0
  }
72
73
  // The fds must be small enough to fit in an fd_set for select(). If not, close them and fail the constructor.
74
8.99k
  if (!FdFitsInFdSet(fds[0]) || !FdFitsInFdSet(fds[1])) {
75
0
    close(fds[0]);
76
0
    close(fds[1]);
77
0
    return;
78
0
  }
79
80
  // Set the server-side fd non-blocking so we can write to it without risk of hanging the fuzzer.
81
8.99k
  int flags = fcntl(fds[0], F_GETFL, 0);
82
8.99k
  if (flags < 0 || fcntl(fds[0], F_SETFL, flags | O_NONBLOCK) < 0) {
83
0
    close(fds[0]);
84
0
    close(fds[1]);
85
0
    return;
86
0
  }
87
88
  // Success: store the file descriptors.
89
8.99k
  server_fd_ = fds[0];
90
8.99k
  client_fd_ = fds[1];
91
8.99k
}
92
93
/// Close the server-side fd (and the client-side fd if it was never handed off via take_client_fd()).
94
8.99k
MockConnection::~MockConnection() {
95
8.99k
  if (server_fd_ >= 0) {
96
8.99k
    close(server_fd_);
97
8.99k
  }
98
8.99k
  if (client_fd_ >= 0) {
99
0
    close(client_fd_);
100
0
  }
101
8.99k
}
102
103
/// @return true if the underlying socketpair was set up successfully.
104
8.99k
bool MockConnection::ok() const { return server_fd_ >= 0; }
105
106
/// @return the server-side fd (still owned by this MockConnection).
107
0
int MockConnection::server_fd() const { return server_fd_; }
108
109
/// Hand the client-side fd to libcurl. After this call the caller owns the fd and the MockConnection will not close it
110
/// on destruction.
111
/// @return the client-side socket fd as a curl_socket_t.
112
8.99k
curl_socket_t MockConnection::take_client_fd() {
113
8.99k
  int fd = client_fd_;
114
8.99k
  client_fd_ = -1;
115
8.99k
  return static_cast<curl_socket_t>(fd);
116
8.99k
}
117
118
/// Write 'size' bytes from 'data' to the server fd, looping until the whole buffer is sent or a short/failed write
119
/// occurs.
120
/// @param data Buffer to send.
121
/// @param size Number of bytes in 'data'.
122
/// @return false on short or failed write (treat the connection as lost).
123
25.2k
bool MockConnection::WriteAll(const unsigned char* data, std::size_t size) {
124
25.2k
  if (server_fd_ < 0) {
125
0
    return false;
126
0
  }
127
25.2k
  std::size_t written = 0;
128
50.4k
  while (written < size) {
129
25.2k
    ssize_t n = ::write(server_fd_, data + written, size - written);
130
25.2k
    if (n <= 0) {
131
0
      return false;
132
0
    }
133
25.2k
    written += static_cast<std::size_t>(n);
134
25.2k
  }
135
25.2k
  return true;
136
25.2k
}
137
138
/// Drain bytes curl has written. When a backpressure drain limit has been
139
/// applied (see ApplyBackpressure), stops after drain_limit_ bytes so the
140
/// kernel recv buffer stays near-full and curl keeps seeing short writes.
141
/// Otherwise drains until read() returns 0/EAGAIN, matching legacy behaviour.
142
45.9k
void MockConnection::DrainIncoming() {
143
45.9k
  if (server_fd_ < 0) {
144
0
    return;
145
0
  }
146
45.9k
  unsigned char scratch[4096];
147
45.9k
  std::size_t drained = 0;
148
64.6k
  while (drain_limit_ == 0 || drained < drain_limit_) {
149
55.6k
    std::size_t want = sizeof(scratch);
150
55.6k
    if (drain_limit_ != 0) {
151
14.5k
      const std::size_t remaining = drain_limit_ - drained;
152
14.5k
      if (remaining < want) {
153
11.8k
        want = remaining;
154
11.8k
      }
155
14.5k
    }
156
55.6k
    ssize_t n = ::read(server_fd_, scratch, want);
157
55.6k
    if (n <= 0) {
158
36.9k
      break;
159
36.9k
    }
160
18.7k
    drained += static_cast<std::size_t>(n);
161
18.7k
  }
162
45.9k
}
163
164
/// Tighten both halves of the socketpair buffer and/or cap DrainIncoming's
165
/// per-call byte budget. SO_RCVBUF on the server fd caps how much curl can
166
/// push into the pipe; SO_SNDBUF on the client fd (which curl will soon own
167
/// but hasn't yet, so we can still tune it) caps how much curl's send() can
168
/// buffer before short-writing. Linux socketpairs effectively use max(SNDBUF,
169
/// RCVBUF*2) as pipe capacity, so we need both to see short writes reliably.
170
/// See header docs.
171
8.99k
void MockConnection::ApplyBackpressure(int recv_buf_bytes, std::size_t drain_limit) {
172
8.99k
  if (recv_buf_bytes > 0) {
173
627
    if (server_fd_ >= 0) {
174
627
      (void)setsockopt(server_fd_, SOL_SOCKET, SO_RCVBUF, &recv_buf_bytes, sizeof(recv_buf_bytes));
175
627
    }
176
627
    if (client_fd_ >= 0) {
177
627
      (void)setsockopt(client_fd_, SOL_SOCKET, SO_SNDBUF, &recv_buf_bytes, sizeof(recv_buf_bytes));
178
627
    }
179
627
  }
180
8.99k
  drain_limit_ = drain_limit;
181
8.99k
}
182
183
/// Non-blocking read: append whatever bytes are currently available on the
184
/// server fd to 'out'. Used by the WS handshake path to collect curl's HTTP
185
/// Upgrade request without losing any bytes.
186
/// @param out Destination buffer; unchanged if no bytes are pending.
187
8.59k
void MockConnection::ReadAvailable(std::string* out) {
188
8.59k
  if (server_fd_ < 0 || out == nullptr) {
189
0
    return;
190
0
  }
191
8.59k
  unsigned char scratch[4096];
192
10.9k
  while (true) {
193
10.9k
    ssize_t n = ::read(server_fd_, scratch, sizeof(scratch));
194
10.9k
    if (n <= 0) {
195
8.59k
      break;
196
8.59k
    }
197
2.40k
    out->append(reinterpret_cast<const char*>(scratch), static_cast<std::size_t>(n));
198
2.40k
  }
199
8.59k
}
200
201
/// Signal end-of-response to libcurl by half-closing the write side.
202
6.97k
void MockConnection::ShutdownWrite() {
203
6.97k
  if (server_fd_ < 0) {
204
0
    return;
205
0
  }
206
6.97k
  ::shutdown(server_fd_, SHUT_WR);
207
6.97k
}
208
209
/// @class proto_fuzzer::MockServer
210
/// @brief Orchestrates a single mock HTTP exchange: installs the socket callbacks on an easy handle, then feeds queued
211
/// responses as libcurl reads them.
212
213
/// Construct an idle MockServer with no scripted responses. Install() on the
214
/// base class and DriveScenario() configure it from a Scenario proto.
215
7.44k
MockServer::MockServer() : next_chunk_(0), initial_sent_(false) {}
216
217
/// Default destructor; the owned MockConnection (if any) cleans up its socketpair.
218
7.44k
MockServer::~MockServer() = default;
219
220
/// Queue bytes to emit. initial_response is written synchronously in the
221
/// OPENSOCKETFUNCTION callback (HandleOpenSocket); on_readable entries are
222
/// written one-at-a-time when libcurl makes the fd readable.
223
/// @param initial_response Bytes written immediately on connection open.
224
/// @param on_readable      Additional chunks delivered one per iteration.
225
7.44k
void MockServer::SetScript(std::string initial_response, std::vector<std::string> on_readable) {
226
7.44k
  initial_response_ = std::move(initial_response);
227
7.44k
  on_readable_ = std::move(on_readable);
228
7.44k
  next_chunk_ = 0;
229
7.44k
  initial_sent_ = false;
230
7.44k
}
231
232
/// @return true if at least one on_readable chunk has not yet been sent.
233
24.5k
bool MockServer::has_more_chunks() const { return next_chunk_ < on_readable_.size(); }
234
235
/// Called by the OPENSOCKETFUNCTION trampoline in the base class. Creates the
236
/// MockConnection, writes initial_response into it, and returns the
237
/// client-side fd to hand to libcurl.
238
/// @return the client-side fd to hand to libcurl, or CURL_SOCKET_BAD on
239
///         failure.
240
7.75k
curl_socket_t MockServer::HandleOpenSocket() {
241
7.75k
  if (connection_) {
242
    // This mock supports exactly one connection per scenario.
243
670
    return CURL_SOCKET_BAD;
244
670
  }
245
7.08k
  connection_ = std::make_unique<MockConnection>();
246
7.08k
  if (!connection_->ok()) {
247
0
    connection_.reset();
248
0
    return CURL_SOCKET_BAD;
249
0
  }
250
7.08k
  ApplyPendingBackpressure();
251
7.08k
  if (!initial_response_.empty()) {
252
4.75k
    if (!connection_->WriteAll(reinterpret_cast<const unsigned char*>(initial_response_.data()),
253
4.75k
                               initial_response_.size())) {
254
0
      connection_.reset();
255
0
      return CURL_SOCKET_BAD;
256
0
    }
257
4.75k
  }
258
7.08k
  initial_sent_ = true;
259
7.08k
  if (on_readable_.empty()) {
260
2.94k
    connection_->ShutdownWrite();
261
2.94k
  }
262
7.08k
  return connection_->take_client_fd();
263
7.08k
}
264
265
/// Push the next queued chunk. Called by the drive loop when curl is ready
266
/// for more data. No-op if the queue is empty or no connection is open.
267
15.8k
void MockServer::DeliverNextChunk() {
268
15.8k
  if (!connection_ || next_chunk_ >= on_readable_.size()) {
269
0
    return;
270
0
  }
271
15.8k
  connection_->DrainIncoming();
272
15.8k
  const std::string& chunk = on_readable_[next_chunk_++];
273
15.8k
  if (!chunk.empty()) {
274
15.0k
    connection_->WriteAll(reinterpret_cast<const unsigned char*>(chunk.data()), chunk.size());
275
15.0k
  }
276
15.8k
  if (next_chunk_ >= on_readable_.size()) {
277
3.47k
    connection_->ShutdownWrite();
278
3.47k
  }
279
15.8k
}
280
281
/// Seed the mock from the scenario, then drive the perform loop until curl is
282
/// done or the idle-iteration cap is hit. Bounded by select() timeouts so a
283
/// misbehaving scenario cannot spin forever.
284
/// @param multi    caller-owned multi; 'easy' is already added.
285
/// @param easy     the curl easy handle attached to this mock.
286
/// @param scenario source of the initial_response and on_readable chunks.
287
7.44k
void MockServer::RunLoop(CURLM* multi, CURL* easy, const curl::fuzzer::proto::Scenario& scenario) {
288
7.44k
  (void)easy;
289
7.44k
  const auto& conn = scenario.connection();
290
7.44k
  SetScript(conn.initial_response(), BuildChunkList(conn));
291
292
7.44k
  int still_running = 1;
293
7.44k
  int idle_iterations = 0;
294
7.44k
  CURLMcode rc = CURLM_OK;
295
296
31.9k
  while (still_running && idle_iterations < kMaxIdleIterations) {
297
31.9k
    rc = curl_multi_perform(multi, &still_running);
298
31.9k
    if (rc != CURLM_OK) {
299
0
      break;
300
0
    }
301
31.9k
    if (!still_running) {
302
7.44k
      break;
303
7.44k
    }
304
305
24.5k
    int ready = WaitOnMultiFdset(multi, &rc);
306
24.5k
    if (rc != CURLM_OK) {
307
0
      break;
308
0
    }
309
310
    // Always drain whatever curl has written. Under backpressure the kernel
311
    // recv buffer would otherwise stay full — curl short-writes, the mock
312
    // never consumes, and the transfer wedges until kMaxIdleIterations. With
313
    // drain_limit set this still honours the per-tick byte budget.
314
24.5k
    if (connection_) {
315
24.5k
      connection_->DrainIncoming();
316
24.5k
    }
317
24.5k
    if (has_more_chunks()) {
318
15.8k
      DeliverNextChunk();
319
15.8k
      idle_iterations = 0;
320
15.8k
    } else if (ready == 0) {
321
8.64k
      ++idle_iterations;
322
8.64k
    } else {
323
13
      idle_iterations = 0;
324
13
    }
325
24.5k
  }
326
7.44k
}
327
328
}  // namespace proto_fuzzer