Coverage Report

Created: 2026-05-30 06:25

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
14.0k
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
5.83k
std::vector<std::string> BuildChunkList(const curl::fuzzer::proto::Connection& conn) {
44
5.83k
  std::vector<std::string> chunks;
45
5.83k
  chunks.reserve(kMaxResponseChunks);
46
5.83k
  const std::size_t raw_budget = std::min<std::size_t>(kMaxResponseChunks, conn.on_readable_size());
47
14.7k
  for (std::size_t i = 0; i < raw_budget; ++i) {
48
8.96k
    chunks.emplace_back(conn.on_readable(i));
49
8.96k
  }
50
5.83k
  const std::size_t frame_budget = kMaxResponseChunks - chunks.size();
51
5.83k
  const std::size_t frame_count = std::min<std::size_t>(frame_budget, conn.server_frames_size());
52
9.96k
  for (std::size_t i = 0; i < frame_count; ++i) {
53
4.13k
    chunks.emplace_back(SerializeWebSocketFrame(conn.server_frames(static_cast<int>(i))));
54
4.13k
  }
55
5.83k
  return chunks;
56
5.83k
}
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
7.02k
MockConnection::MockConnection() : server_fd_(-1), client_fd_(-1), drain_limit_(0) {
67
7.02k
  int fds[2];
68
69
7.02k
  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
7.02k
  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
7.02k
  int flags = fcntl(fds[0], F_GETFL, 0);
82
7.02k
  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
7.02k
  server_fd_ = fds[0];
90
7.02k
  client_fd_ = fds[1];
91
7.02k
}
92
93
/// Close the server-side fd (and the client-side fd if it was never handed off via take_client_fd()).
94
7.02k
MockConnection::~MockConnection() {
95
7.02k
  if (server_fd_ >= 0) {
96
7.02k
    close(server_fd_);
97
7.02k
  }
98
7.02k
  if (client_fd_ >= 0) {
99
0
    close(client_fd_);
100
0
  }
101
7.02k
}
102
103
/// @return true if the underlying socketpair was set up successfully.
104
7.02k
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
7.02k
curl_socket_t MockConnection::take_client_fd() {
113
7.02k
  int fd = client_fd_;
114
7.02k
  client_fd_ = -1;
115
7.02k
  return static_cast<curl_socket_t>(fd);
116
7.02k
}
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
19.1k
bool MockConnection::WriteAll(const unsigned char* data, std::size_t size) {
124
19.1k
  if (server_fd_ < 0) {
125
0
    return false;
126
0
  }
127
19.1k
  std::size_t written = 0;
128
38.2k
  while (written < size) {
129
19.1k
    ssize_t n = ::write(server_fd_, data + written, size - written);
130
19.1k
    if (n <= 0) {
131
0
      return false;
132
0
    }
133
19.1k
    written += static_cast<std::size_t>(n);
134
19.1k
  }
135
19.1k
  return true;
136
19.1k
}
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
40.5k
void MockConnection::DrainIncoming() {
143
40.5k
  if (server_fd_ < 0) {
144
0
    return;
145
0
  }
146
40.5k
  unsigned char scratch[4096];
147
40.5k
  std::size_t drained = 0;
148
60.5k
  while (drain_limit_ == 0 || drained < drain_limit_) {
149
48.1k
    std::size_t want = sizeof(scratch);
150
48.1k
    if (drain_limit_ != 0) {
151
20.7k
      const std::size_t remaining = drain_limit_ - drained;
152
20.7k
      if (remaining < want) {
153
16.8k
        want = remaining;
154
16.8k
      }
155
20.7k
    }
156
48.1k
    ssize_t n = ::read(server_fd_, scratch, want);
157
48.1k
    if (n <= 0) {
158
28.0k
      break;
159
28.0k
    }
160
20.0k
    drained += static_cast<std::size_t>(n);
161
20.0k
  }
162
40.5k
}
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
7.02k
void MockConnection::ApplyBackpressure(int recv_buf_bytes, std::size_t drain_limit) {
172
7.02k
  if (recv_buf_bytes > 0) {
173
833
    if (server_fd_ >= 0) {
174
833
      (void)setsockopt(server_fd_, SOL_SOCKET, SO_RCVBUF, &recv_buf_bytes, sizeof(recv_buf_bytes));
175
833
    }
176
833
    if (client_fd_ >= 0) {
177
833
      (void)setsockopt(client_fd_, SOL_SOCKET, SO_SNDBUF, &recv_buf_bytes, sizeof(recv_buf_bytes));
178
833
    }
179
833
  }
180
7.02k
  drain_limit_ = drain_limit;
181
7.02k
}
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
5.74k
void MockConnection::ReadAvailable(std::string* out) {
188
5.74k
  if (server_fd_ < 0 || out == nullptr) {
189
0
    return;
190
0
  }
191
5.74k
  unsigned char scratch[4096];
192
7.41k
  while (true) {
193
7.41k
    ssize_t n = ::read(server_fd_, scratch, sizeof(scratch));
194
7.41k
    if (n <= 0) {
195
5.74k
      break;
196
5.74k
    }
197
1.66k
    out->append(reinterpret_cast<const char*>(scratch), static_cast<std::size_t>(n));
198
1.66k
  }
199
5.74k
}
200
201
/// Signal end-of-response to libcurl by half-closing the write side.
202
5.04k
void MockConnection::ShutdownWrite() {
203
5.04k
  if (server_fd_ < 0) {
204
0
    return;
205
0
  }
206
5.04k
  ::shutdown(server_fd_, SHUT_WR);
207
5.04k
}
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
5.83k
MockServer::MockServer() : next_chunk_(0), initial_sent_(false) {}
216
217
/// Default destructor; the owned MockConnection (if any) cleans up its socketpair.
218
5.83k
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
5.83k
void MockServer::SetScript(std::string initial_response, std::vector<std::string> on_readable) {
226
5.83k
  initial_response_ = std::move(initial_response);
227
5.83k
  on_readable_ = std::move(on_readable);
228
5.83k
  next_chunk_ = 0;
229
5.83k
  initial_sent_ = false;
230
5.83k
}
231
232
/// @return true if at least one on_readable chunk has not yet been sent.
233
23.0k
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
5.95k
curl_socket_t MockServer::HandleOpenSocket() {
241
5.95k
  if (connection_) {
242
    // This mock supports exactly one connection per scenario.
243
511
    return CURL_SOCKET_BAD;
244
511
  }
245
5.44k
  connection_ = std::make_unique<MockConnection>();
246
5.44k
  if (!connection_->ok()) {
247
0
    connection_.reset();
248
0
    return CURL_SOCKET_BAD;
249
0
  }
250
5.44k
  ApplyPendingBackpressure();
251
5.44k
  if (!initial_response_.empty()) {
252
3.63k
    if (!connection_->WriteAll(reinterpret_cast<const unsigned char*>(initial_response_.data()),
253
3.63k
                               initial_response_.size())) {
254
0
      connection_.reset();
255
0
      return CURL_SOCKET_BAD;
256
0
    }
257
3.63k
  }
258
5.44k
  initial_sent_ = true;
259
5.44k
  if (on_readable_.empty()) {
260
2.25k
    connection_->ShutdownWrite();
261
2.25k
  }
262
5.44k
  return connection_->take_client_fd();
263
5.44k
}
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
10.8k
void MockServer::DeliverNextChunk() {
268
10.8k
  if (!connection_ || next_chunk_ >= on_readable_.size()) {
269
0
    return;
270
0
  }
271
10.8k
  connection_->DrainIncoming();
272
10.8k
  const std::string& chunk = on_readable_[next_chunk_++];
273
10.8k
  if (!chunk.empty()) {
274
10.3k
    connection_->WriteAll(reinterpret_cast<const unsigned char*>(chunk.data()), chunk.size());
275
10.3k
  }
276
10.8k
  if (next_chunk_ >= on_readable_.size()) {
277
2.33k
    connection_->ShutdownWrite();
278
2.33k
  }
279
10.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
5.83k
void MockServer::RunLoop(CURLM* multi, CURL* easy, const curl::fuzzer::proto::Scenario& scenario) {
288
5.83k
  (void)easy;
289
5.83k
  const auto& conn = scenario.connection();
290
5.83k
  SetScript(conn.initial_response(), BuildChunkList(conn));
291
292
5.83k
  int still_running = 1;
293
5.83k
  int idle_iterations = 0;
294
5.83k
  CURLMcode rc = CURLM_OK;
295
296
28.9k
  while (still_running && idle_iterations < kMaxIdleIterations) {
297
28.9k
    rc = curl_multi_perform(multi, &still_running);
298
28.9k
    if (rc != CURLM_OK) {
299
0
      break;
300
0
    }
301
28.9k
    if (!still_running) {
302
5.81k
      break;
303
5.81k
    }
304
305
23.0k
    int ready = WaitOnMultiFdset(multi, &rc);
306
23.0k
    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
23.0k
    if (connection_) {
315
23.0k
      connection_->DrainIncoming();
316
23.0k
    }
317
23.0k
    if (has_more_chunks()) {
318
10.8k
      DeliverNextChunk();
319
10.8k
      idle_iterations = 0;
320
12.2k
    } else if (ready == 0) {
321
12.2k
      ++idle_iterations;
322
12.2k
    } else {
323
13
      idle_iterations = 0;
324
13
    }
325
23.0k
  }
326
5.83k
}
327
328
}  // namespace proto_fuzzer