Coverage Report

Created: 2026-10-28 07:17

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/src/curl_fuzzer/proto_fuzzer/mqtt_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 the packet-aware in-process MQTT peer.
9
10
#include "proto_fuzzer/mqtt_mock_server.h"
11
12
#include <algorithm>
13
#include <cstddef>
14
#include <cstdint>
15
#include <limits>
16
#include <memory>
17
#include <string>
18
#include <string_view>
19
20
#include "proto_fuzzer/mock_server.h"
21
#include "proto_fuzzer/scenario_limits.h"
22
23
namespace proto_fuzzer {
24
25
namespace {
26
27
// MQTT 3.1.1 control packet types, already shifted into the fixed header's
28
// high nibble. The low nibble carries type-specific flags such as PUBLISH QoS.
29
constexpr unsigned char kMqttConnect = 0x10;
30
constexpr unsigned char kMqttPublish = 0x30;
31
constexpr unsigned char kMqttPuback = 0x40;
32
constexpr unsigned char kMqttSubscribe = 0x80;
33
constexpr unsigned char kMqttSuback = 0x90;
34
constexpr unsigned char kMqttPingreq = 0xc0;
35
constexpr unsigned char kMqttPingresp = 0xd0;
36
constexpr unsigned char kMqttDisconnect = 0xe0;
37
38
12.7k
unsigned char ByteAt(std::string_view bytes, std::size_t index) { return static_cast<unsigned char>(bytes[index]); }
39
40
}  // namespace
41
42
/// Begin without a borrowed protobuf or open socketpair. RunLoop borrows a
43
/// Scenario only for the synchronous DriveScenario call in which socket
44
/// callbacks can run.
45
MqttMockServer::MqttMockServer()
46
4.04k
    : scenario_(nullptr),
47
4.04k
      server_chunk_count_(0),
48
4.04k
      next_server_chunk_(0),
49
4.04k
      packet_count_(0),
50
4.04k
      connect_count_(0),
51
4.04k
      publish_count_(0),
52
4.04k
      subscribe_count_(0),
53
4.04k
      qos1_publish_count_(0),
54
4.04k
      active_pollset_probe_count_(0),
55
4.04k
      release_script_chunks_(false),
56
4.04k
      protocol_error_(false),
57
4.04k
      socket_opened_(false) {}
58
59
/// Release the server-owned socketpair half after curl has returned its client
60
/// descriptor. The out-of-line destructor keeps MockConnection's complete type
61
/// local to this translation unit.
62
4.04k
MqttMockServer::~MqttMockServer() = default;
63
64
/// @return A prefix of complete client packets from the most recent drive.
65
4.02k
const std::string& MqttMockServer::packet_transcript() const { return packet_transcript_; }
66
67
/// @return A prefix of the first PUBLISH packet's topic.
68
0
const std::string& MqttMockServer::published_topic() const { return published_topic_; }
69
70
/// @return A prefix of the first PUBLISH packet's application payload.
71
4.02k
const std::string& MqttMockServer::published_payload() const { return published_payload_; }
72
73
/// @return Number of complete client packets observed in the most recent drive.
74
7.03k
std::size_t MqttMockServer::packet_count() const { return packet_count_; }
75
76
/// @return Number of CONNECT packets observed.
77
0
std::size_t MqttMockServer::connect_count() const { return connect_count_; }
78
79
/// @return Number of PUBLISH packets observed.
80
4.02k
std::size_t MqttMockServer::publish_count() const { return publish_count_; }
81
82
/// @return Number of SUBSCRIBE packets observed.
83
4.02k
std::size_t MqttMockServer::subscribe_count() const { return subscribe_count_; }
84
85
/// @return Number of PUBLISH packets that requested QoS 1.
86
0
std::size_t MqttMockServer::qos1_publish_count() const { return qos1_publish_count_; }
87
88
/// Report how many requested zero-timeout multi-poll probes ran during the
89
/// most recent drive. RunLoop increments this only after a successful perform
90
/// turn while curl still owns an active transfer and the MQTT socket is open.
91
/// @return Number of active, connected pollset probes in the previous drive.
92
0
std::size_t MqttMockServer::active_pollset_probe_count() const { return active_pollset_probe_count_; }
93
94
/// Reset every borrowed pointer, parser cursor, and observable capture before
95
/// borrowing the next Scenario.
96
/// @param scenario Scenario whose Connection supplies the CONNACK and any
97
///                 server-initiated packet chunks.
98
3.88k
void MqttMockServer::ResetForScenario(const curl::fuzzer::proto::Scenario& scenario) {
99
3.88k
  connection_.reset();
100
3.88k
  scenario_ = &scenario;
101
3.88k
  server_chunk_count_ = std::min<std::size_t>(scenario_limits::kMaxResponseChunks,
102
3.88k
                                              static_cast<std::size_t>(scenario.connection().on_readable_size()));
103
3.88k
  next_server_chunk_ = 0;
104
3.88k
  packet_count_ = 0;
105
3.88k
  connect_count_ = 0;
106
3.88k
  publish_count_ = 0;
107
3.88k
  subscribe_count_ = 0;
108
3.88k
  qos1_publish_count_ = 0;
109
3.88k
  active_pollset_probe_count_ = 0;
110
3.88k
  pending_client_bytes_.clear();
111
3.88k
  packet_transcript_.clear();
112
3.88k
  published_topic_.clear();
113
3.88k
  published_payload_.clear();
114
3.88k
  release_script_chunks_ = false;
115
3.88k
  protocol_error_ = false;
116
3.88k
  socket_opened_ = false;
117
3.88k
}
118
119
/// Apply bounded transport knobs when creating the scripted socket. Target
120
/// policies normally clear MQTT backpressure, but direct corpus replay must
121
/// retain deterministic, well-defined behavior.
122
/// @param connection Peer receiving the socket settings.
123
/// @param script Connection script carrying optional backpressure values.
124
void MqttMockServer::ApplyScriptBackpressure(MockConnection* connection,
125
3.52k
                                             const curl::fuzzer::proto::Connection& script) {
126
3.52k
  if (connection == nullptr) {
127
0
    return;
128
0
  }
129
130
3.52k
  const std::uint32_t int_max = static_cast<std::uint32_t>(std::numeric_limits<int>::max());
131
3.52k
  const auto& backpressure = script.backpressure();
132
3.52k
  const int receive_buffer = static_cast<int>(std::min(backpressure.recv_buf_bytes(), int_max));
133
3.52k
  connection->ApplyBackpressure(receive_buffer, static_cast<std::size_t>(backpressure.drain_limit()));
134
3.52k
}
135
136
/// Create the default plaintext transport for the MQTT packet exchange. The
137
/// MQTTS subclass overrides this factory while retaining the binary parser.
138
/// @return A new socketpair-backed connection.
139
826
std::unique_ptr<MockConnection> MqttMockServer::CreateConnection() { return std::make_unique<MockConnection>(); }
140
141
/// Append decoded application bytes to the pending parser buffer. The outer
142
/// service loop drains this buffer after every transport call, so it remains
143
/// bounded by the scenario's packet budget.
144
/// @param data First decoded byte.
145
/// @param size Number of bytes available at `data`.
146
6.46k
void MqttMockServer::ObserveIncomingData(const unsigned char* data, std::size_t size) {
147
6.46k
  if (data == nullptr || size == 0) {
148
0
    return;
149
0
  }
150
6.46k
  pending_client_bytes_.append(reinterpret_cast<const char*>(data), size);
151
6.46k
}
152
153
/// Create the single MQTT socketpair and queue the scenario's first server
154
/// bytes. curl sends CONNECT immediately, so a normal scenario preloads the
155
/// CONNACK here; an empty or malformed greeting remains a reachable error path.
156
/// @param purpose Socket role requested by curl.
157
/// @param address Unused stream destination metadata required by the callback
158
///                signature.
159
/// @return Client descriptor for the MQTT channel, or CURL_SOCKET_BAD.
160
3.52k
curl_socket_t MqttMockServer::HandleOpenSocket(curlsocktype purpose, struct curl_sockaddr* /*address*/) {
161
3.52k
  if (scenario_ == nullptr || purpose != CURLSOCKTYPE_IPCXN || socket_opened_) {
162
0
    return CURL_SOCKET_BAD;
163
0
  }
164
165
3.52k
  connection_ = CreateConnection();
166
3.52k
  if (!connection_ || !connection_->ok()) {
167
0
    connection_.reset();
168
0
    return CURL_SOCKET_BAD;
169
0
  }
170
3.52k
  connection_->SetIncomingDataObserver(this);
171
172
3.52k
  ApplyScriptBackpressure(connection_.get(), scenario_->connection());
173
3.52k
  const std::string& greeting = scenario_->connection().initial_response();
174
3.52k
  if (!greeting.empty() &&
175
1.62k
      !connection_->WriteAll(reinterpret_cast<const unsigned char*>(greeting.data()), greeting.size())) {
176
2
    connection_.reset();
177
2
    return CURL_SOCKET_BAD;
178
2
  }
179
180
3.52k
  socket_opened_ = true;
181
3.52k
  return connection_->take_client_fd();
182
3.52k
}
183
184
/// Decode MQTT's base-128 remaining-length field. MQTT 3.1.1 permits at most
185
/// four length bytes; a fifth continuation is a malformed fixed header rather
186
/// than an invitation to wait for more input.
187
/// @param bytes Fixed header and whatever length bytes have arrived.
188
/// @param header_size Receives the fixed-header size when complete.
189
/// @param remaining_length Receives the declared body size when complete.
190
/// @return kComplete, kNeedMore, or kInvalid.
191
MqttMockServer::ParseResult MqttMockServer::ParseRemainingLength(std::string_view bytes, std::size_t* header_size,
192
4.90k
                                                                 std::size_t* remaining_length) {
193
4.90k
  std::size_t value = 0;
194
4.90k
  std::size_t multiplier = 1;
195
5.40k
  for (std::size_t index = 1; index <= 4; ++index) {
196
5.40k
    if (bytes.size() <= index) {
197
0
      return ParseResult::kNeedMore;
198
0
    }
199
5.40k
    const unsigned char encoded = ByteAt(bytes, index);
200
5.40k
    value += static_cast<std::size_t>(encoded & 0x7fU) * multiplier;
201
5.40k
    if ((encoded & 0x80U) == 0) {
202
4.90k
      *header_size = index + 1;
203
4.90k
      *remaining_length = value;
204
4.90k
      return ParseResult::kComplete;
205
4.90k
    }
206
496
    multiplier *= 128;
207
496
  }
208
0
  return ParseResult::kInvalid;
209
4.90k
}
210
211
/// Retain only a bounded prefix for assertions. Parsing and draining continue
212
/// against the full transport bytes, so reaching the cap cannot change curl's
213
/// behavior or manufacture socket backpressure.
214
/// @param source Newly observed bytes.
215
/// @param limit Maximum retained destination size.
216
/// @param destination Capture buffer receiving the available prefix.
217
5.27k
void MqttMockServer::CapturePrefix(std::string_view source, std::size_t limit, std::string* destination) {
218
5.27k
  if (destination == nullptr || destination->size() >= limit) {
219
8
    return;
220
8
  }
221
5.26k
  const std::size_t available = limit - destination->size();
222
5.26k
  destination->append(source.data(), std::min(source.size(), available));
223
5.26k
}
224
225
/// Keep failed peer writes local to the mock. Response cursors must still
226
/// advance after curl closes early, otherwise a replacement socket could see a
227
/// reply intended for an earlier packet.
228
/// @param bytes Response bytes to queue.
229
/// @return true when the complete fragment was queued.
230
6.39k
bool MqttMockServer::WriteServerBytes(std::string_view bytes) {
231
6.39k
  return bytes.empty() || (connection_ != nullptr &&
232
6.39k
                           connection_->WriteAll(reinterpret_cast<const unsigned char*>(bytes.data()), bytes.size()));
233
6.39k
}
234
235
/// Interpret one complete client packet. Packet classification uses only the
236
/// fixed header, so a malformed variable header cannot desynchronize the
237
/// remaining-length parser that already found the next boundary.
238
/// @param first_byte Fixed header byte carrying the type and flags.
239
/// @param body Variable header plus payload, exactly `remaining_length` bytes.
240
4.89k
void MqttMockServer::HandleClientPacket(unsigned char first_byte, std::string_view body) {
241
4.89k
  const unsigned char type = static_cast<unsigned char>(first_byte & 0xf0U);
242
4.89k
  switch (type) {
243
3.46k
    case kMqttConnect:
244
3.46k
      ++connect_count_;
245
3.46k
      return;
246
247
186
    case kMqttPublish: {
248
186
      ++publish_count_;
249
186
      if (body.size() < 2) {
250
0
        return;
251
0
      }
252
186
      const std::size_t topic_length =
253
186
          (static_cast<std::size_t>(ByteAt(body, 0)) << 8U) | static_cast<std::size_t>(ByteAt(body, 1));
254
186
      std::size_t cursor = 2;
255
186
      const std::size_t available = body.size() - cursor;
256
186
      const std::size_t captured_topic = std::min(topic_length, available);
257
186
      CapturePrefix(body.substr(cursor, captured_topic), scenario_limits::kMaxMetadataBytes, &published_topic_);
258
186
      cursor += captured_topic;
259
186
      if (captured_topic < topic_length) {
260
0
        return;
261
0
      }
262
263
186
      const unsigned qos = (static_cast<unsigned>(first_byte) >> 1U) & 0x03U;
264
186
      if (qos == 1U) {
265
0
        if (body.size() - cursor < 2) {
266
0
          return;
267
0
        }
268
0
        const unsigned char packet_id_high = ByteAt(body, cursor);
269
0
        const unsigned char packet_id_low = ByteAt(body, cursor + 1);
270
0
        cursor += 2;
271
        // PUBACK has no flags and a two-byte remaining length followed by the
272
        // packet identifier it acknowledges. Current curl always publishes at
273
        // QoS 0; this branch keeps the acknowledgement path correct if a
274
        // future revision makes QoS configurable.
275
0
        const unsigned char puback[] = {kMqttPuback, 0x02, packet_id_high, packet_id_low};
276
0
        (void)WriteServerBytes(std::string_view(reinterpret_cast<const char*>(puback), sizeof(puback)));
277
0
        ++qos1_publish_count_;
278
0
      }
279
280
186
      CapturePrefix(body.substr(cursor), kMaxCapturedPayloadBytes, &published_payload_);
281
186
      return;
282
186
    }
283
284
1.06k
    case kMqttSubscribe: {
285
1.06k
      ++subscribe_count_;
286
1.06k
      const unsigned char packet_id_high = body.empty() ? 0 : ByteAt(body, 0);
287
1.06k
      const unsigned char packet_id_low = body.size() < 2 ? 0 : ByteAt(body, 1);
288
      // SUBACK echoes the SUBSCRIBE packet id and reports success for the
289
      // single requested topic filter. curl's MQTT state machine compares both
290
      // identifier bytes and the return code before accepting the packet.
291
1.06k
      const unsigned char suback[] = {kMqttSuback, 0x03, packet_id_high, packet_id_low, 0x00};
292
1.06k
      (void)WriteServerBytes(std::string_view(reinterpret_cast<const char*>(suback), sizeof(suback)));
293
      // Only the subscribe flow remains readable after its acknowledgement.
294
      // on_readable chunks carry server-initiated packets such as PUBLISH.
295
1.06k
      release_script_chunks_ = true;
296
1.06k
      return;
297
186
    }
298
299
0
    case kMqttPingreq: {
300
0
      const unsigned char pingresp[] = {kMqttPingresp, 0x00};
301
0
      (void)WriteServerBytes(std::string_view(reinterpret_cast<const char*>(pingresp), sizeof(pingresp)));
302
0
      return;
303
186
    }
304
305
186
    case kMqttDisconnect:
306
      // curl sends DISCONNECT after PUBLISH. No reply is defined, and the
307
      // cleanup half-close below still terminates the socket.
308
186
      return;
309
310
0
    default:
311
      // CONNACK/SUBACK/PUBACK and unknown types are server-to-client packets.
312
      // Recording them in the transcript is enough; inventing a reply would
313
      // only add bytes curl never reads in this direction.
314
0
      return;
315
4.89k
  }
316
4.89k
}
317
318
/// Release one scenario-provided server chunk after SUBACK. Chunks are opaque
319
/// bytes, not newline commands: each one is expected to be a complete MQTT
320
/// packet (for example PUBLISH followed by DISCONNECT). An explicitly empty
321
/// chunk half-closes the peer, retaining a scripted truncation/EOF path.
322
/// @return true when a script slot was consumed.
323
40.3k
bool MqttMockServer::ReleaseNextServerChunk() {
324
40.3k
  if (!release_script_chunks_ || scenario_ == nullptr || connection_ == nullptr ||
325
34.9k
      next_server_chunk_ >= server_chunk_count_) {
326
34.9k
    return false;
327
34.9k
  }
328
329
5.40k
  const std::string& chunk = scenario_->connection().on_readable(static_cast<int>(next_server_chunk_++));
330
5.40k
  if (chunk.empty()) {
331
68
    connection_->ShutdownWrite();
332
68
    release_script_chunks_ = false;
333
68
    return true;
334
68
  }
335
336
5.33k
  (void)WriteServerBytes(chunk);
337
5.33k
  if (next_server_chunk_ >= server_chunk_count_) {
338
659
    connection_->ShutdownWrite();
339
659
  }
340
5.33k
  return true;
341
5.40k
}
342
343
/// Advance the carrier without waiting, then process every complete packet
344
/// already decoded into the pending buffer. A plaintext connection yields raw
345
/// socket bytes; a TLS carrier completes its handshake and yields decrypted
346
/// application bytes through ObserveIncomingData. A packet is delivered only
347
/// after its fixed header and declared body have both arrived, so fragmentation
348
/// cannot shift the parser.
349
/// @return true when bytes, transport state, a packet, or a response cursor
350
///         advanced.
351
40.3k
bool MqttMockServer::ServiceClientConnection() {
352
40.3k
  if (connection_ == nullptr || protocol_error_) {
353
1.28k
    return false;
354
1.28k
  }
355
356
39.1k
  const std::size_t size_before_read = pending_client_bytes_.size();
357
39.1k
  const std::size_t transport_progress = connection_->DrainIncoming();
358
39.1k
  bool made_progress = pending_client_bytes_.size() != size_before_read || transport_progress != 0;
359
360
39.1k
  std::size_t consumed = 0;
361
39.1k
  int packets = 0;
362
44.0k
  while (consumed < pending_client_bytes_.size() && packets < kMaxPacketsPerService) {
363
4.90k
    const std::string_view available(pending_client_bytes_.data() + consumed, pending_client_bytes_.size() - consumed);
364
4.90k
    if (available.size() < 2) {
365
0
      break;
366
0
    }
367
368
4.90k
    std::size_t header_size = 0;
369
4.90k
    std::size_t remaining_length = 0;
370
4.90k
    const ParseResult parsed = ParseRemainingLength(available, &header_size, &remaining_length);
371
4.90k
    if (parsed == ParseResult::kNeedMore) {
372
0
      break;
373
0
    }
374
4.90k
    if (parsed == ParseResult::kInvalid || remaining_length > kMaxPacketBytes) {
375
7
      protocol_error_ = true;
376
7
      break;
377
7
    }
378
379
4.89k
    const std::size_t packet_size = header_size + remaining_length;
380
4.89k
    if (available.size() < packet_size) {
381
0
      break;
382
0
    }
383
384
4.89k
    const std::string_view packet = available.substr(0, packet_size);
385
4.89k
    CapturePrefix(packet, kMaxCapturedPacketBytes, &packet_transcript_);
386
4.89k
    ++packet_count_;
387
4.89k
    HandleClientPacket(ByteAt(packet, 0), packet.substr(header_size));
388
4.89k
    consumed += packet_size;
389
4.89k
    ++packets;
390
4.89k
    made_progress = true;
391
4.89k
  }
392
393
39.1k
  if (protocol_error_) {
394
7
    pending_client_bytes_.clear();
395
    // curl's malformed-packet paths are bounded, but closing the write half
396
    // immediately keeps a bogus remaining length from waiting on the idle
397
    // budget for an impossible body.
398
7
    connection_->ShutdownWrite();
399
7
    release_script_chunks_ = false;
400
39.0k
  } else if (consumed != 0) {
401
4.71k
    pending_client_bytes_.erase(0, consumed);
402
4.71k
  }
403
39.1k
  return made_progress;
404
40.3k
}
405
406
/// Half-close the peer so curl observes a deterministic end of server packets
407
/// after the transfer has stopped or the idle budget expired.
408
3.88k
void MqttMockServer::FinishConnections() {
409
3.88k
  if (connection_ != nullptr) {
410
3.52k
    connection_->ShutdownWrite();
411
3.52k
  }
412
3.88k
}
413
414
/// Alternate curl transitions with immediate peer service until the transfer
415
/// completes or deterministic idle/operation caps win. No select(), poll(), or
416
/// sleep is needed: the CONNACK is preloaded, synthesized acknowledgements are
417
/// written from the same thread, and scripted server packets are released one
418
/// per turn so curl consumes the preceding acknowledgement first.
419
/// @param multi Multi handle containing `easy`.
420
/// @param easy Easy handle already installed on this mock.
421
/// @param scenario Connection script to borrow.
422
3.88k
void MqttMockServer::RunLoop(CURLM* multi, CURL* easy, const curl::fuzzer::proto::Scenario& scenario) {
423
3.88k
  (void)easy;
424
3.88k
  ResetForScenario(scenario);
425
426
3.88k
  int still_running = 1;
427
3.88k
  int idle_iterations = 0;
428
3.88k
  int drive_iterations = 0;
429
3.88k
  CURLMcode result = CURLM_OK;
430
40.3k
  while (still_running && idle_iterations < kMaxIdleIterations && drive_iterations++ < kMaxDriveIterations) {
431
36.5k
    const int running_before = still_running;
432
36.5k
    result = curl_multi_perform(multi, &still_running);
433
36.5k
    if (result != CURLM_OK) {
434
0
      break;
435
0
    }
436
437
    // Pollset construction is opt-in coverage work. Waiting until the socket
438
    // callback has run and the transfer remains active keeps the default hot
439
    // loop syscall-free and asks curl for a live MQTT protocol pollset.
440
36.5k
    if (scenario.probe_multi_pollset() && still_running && socket_opened_) {
441
2.34k
      ProbeMultiPollset(multi);
442
2.34k
      ++active_pollset_probe_count_;
443
2.34k
    }
444
445
36.5k
    bool made_progress = still_running != running_before;
446
36.5k
    made_progress = ServiceClientConnection() || made_progress;
447
36.5k
    made_progress = ReleaseNextServerChunk() || made_progress;
448
36.5k
    if (made_progress) {
449
13.5k
      idle_iterations = 0;
450
22.9k
    } else {
451
22.9k
      ++idle_iterations;
452
22.9k
    }
453
36.5k
  }
454
455
  // The final perform may have emitted CONNECT/PUBLISH/DISCONNECT immediately
456
  // before marking the transfer done. Drain those bytes before cleanup.
457
3.88k
  (void)ServiceClientConnection();
458
3.88k
  (void)ReleaseNextServerChunk();
459
3.88k
  FinishConnections();
460
3.88k
  scenario_ = nullptr;
461
3.88k
}
462
463
}  // namespace proto_fuzzer