Coverage Report

Created: 2026-09-01 07:00

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/src/curl_fuzzer/proto_fuzzer/ftp_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 command-aware in-process FTP peer.
9
10
#include "proto_fuzzer/ftp_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
/// Begin without a borrowed protobuf or any open socketpairs. Keeping all
26
/// session state in the object avoids process globals that could leak one
27
/// libFuzzer iteration's protocol position into the next.
28
FtpMockServer::FtpMockServer()
29
0
    : scenario_(nullptr),
30
0
      next_data_script_(0),
31
0
      control_reply_count_(0),
32
0
      next_control_reply_(0),
33
0
      control_opened_(false) {}
34
35
/// Loopback peers are ordinary unique_ptr-owned transports; the out-of-line
36
/// destructor also permits MockConnection to remain forward-declared
37
/// in the header.
38
0
FtpMockServer::~FtpMockServer() = default;
39
40
/// Return the bounded command capture accumulated by the most recent drive.
41
0
const std::string& FtpMockServer::control_transcript() const { return control_transcript_; }
42
43
/// Return the bounded upload capture accumulated by the most recent drive.
44
0
const std::string& FtpMockServer::uploaded_data() const { return uploaded_data_; }
45
46
/// Report how many passive peers curl actually requested, rather than how many
47
/// scripts happened to be present in the protobuf.
48
0
std::size_t FtpMockServer::opened_data_connection_count() const { return next_data_script_; }
49
50
/// Clear descriptor and parser state before borrowing the next Scenario. A
51
/// FtpMockServer can be reused serially, but no connection or response cursor
52
/// is meaningful across easy-handle drives.
53
0
void FtpMockServer::ResetForScenario(const curl::fuzzer::proto::Scenario& scenario) {
54
0
  control_connection_.reset();
55
0
  for (DataChannel& channel : data_channels_) {
56
0
    channel.connection.reset();
57
0
    channel.script = nullptr;
58
0
    channel.transfer_started = false;
59
0
    channel.upload = false;
60
0
  }
61
62
0
  scenario_ = &scenario;
63
0
  next_data_script_ = 0;
64
0
  control_reply_count_ = std::min<std::size_t>(scenario_limits::kMaxResponseChunks,
65
0
                                               static_cast<std::size_t>(scenario.connection().on_readable_size()));
66
0
  next_control_reply_ = 0;
67
0
  pending_control_bytes_.clear();
68
0
  control_transcript_.clear();
69
0
  uploaded_data_.clear();
70
0
  control_opened_ = false;
71
0
}
72
73
/// Clamp a compatibility input's raw protobuf socket size before crossing the
74
/// platform int boundary. Target policies normally clear FTP backpressure,
75
/// but direct corpus replay must retain deterministic, well-defined behavior.
76
0
void FtpMockServer::ApplyScriptBackpressure(MockConnection* connection, const curl::fuzzer::proto::Connection& script) {
77
0
  if (connection == nullptr) {
78
0
    return;
79
0
  }
80
81
0
  const std::uint32_t int_max = static_cast<std::uint32_t>(std::numeric_limits<int>::max());
82
0
  const auto& backpressure = script.backpressure();
83
0
  const int receive_buffer = static_cast<int>(std::min(backpressure.recv_buf_bytes(), int_max));
84
0
  connection->ApplyBackpressure(receive_buffer, static_cast<std::size_t>(backpressure.drain_limit()));
85
0
}
86
87
/// Allocate one control socket followed by a bounded sequence of passive data
88
/// sockets. Data is intentionally not preloaded here: curl opens EPSV/PASV's
89
/// socket before it has sent the command that determines whether the stream is
90
/// a listing, download, or upload.
91
/// @param purpose Socket role requested by curl; active-mode accepts are
92
///                deliberately unsupported.
93
/// @param address Original IP destination retained by curl for FTP's passive
94
///                host selection; the connected socketpair need not alter it.
95
/// @return An already-connected client fd, or CURL_SOCKET_BAD when the bounded
96
///         control/data script has no matching peer.
97
0
curl_socket_t FtpMockServer::HandleOpenSocket(curlsocktype purpose, struct curl_sockaddr* address) {
98
0
  (void)address;
99
  // Passive control/data sockets are ordinary outbound connections. Reject an
100
  // active-mode accept request rather than handing curl a connected socketpair
101
  // whose semantics cannot model listen/accept or a server callback.
102
0
  if (scenario_ == nullptr || purpose != CURLSOCKTYPE_IPCXN) {
103
0
    return CURL_SOCKET_BAD;
104
0
  }
105
106
0
  if (!control_opened_) {
107
0
    control_connection_ = std::make_unique<MockConnection>();
108
0
    if (!control_connection_->ok()) {
109
0
      control_connection_.reset();
110
0
      return CURL_SOCKET_BAD;
111
0
    }
112
113
0
    ApplyScriptBackpressure(control_connection_.get(), scenario_->connection());
114
0
    const std::string& greeting = scenario_->connection().initial_response();
115
0
    if (!greeting.empty() &&
116
0
        !control_connection_->WriteAll(reinterpret_cast<const unsigned char*>(greeting.data()), greeting.size())) {
117
0
      control_connection_.reset();
118
0
      return CURL_SOCKET_BAD;
119
0
    }
120
    // Do not consume the unique control slot until its peer is usable. Curl
121
    // may retry a failed application socket callback, and that retry must not
122
    // be mistaken for the first passive data connection.
123
0
    control_opened_ = true;
124
0
    return control_connection_->take_client_fd();
125
0
  }
126
127
0
  const std::size_t available_scripts =
128
0
      std::min<std::size_t>(kMaxDataChannels, static_cast<std::size_t>(scenario_->subsequent_connections_size()));
129
0
  if (next_data_script_ >= available_scripts) {
130
0
    return CURL_SOCKET_BAD;
131
0
  }
132
133
0
  DataChannel& channel = data_channels_[next_data_script_];
134
0
  channel.script = &scenario_->subsequent_connections(static_cast<int>(next_data_script_));
135
0
  channel.connection = std::make_unique<MockConnection>();
136
0
  if (!channel.connection->ok()) {
137
0
    channel.connection.reset();
138
0
    channel.script = nullptr;
139
0
    return CURL_SOCKET_BAD;
140
0
  }
141
142
0
  ApplyScriptBackpressure(channel.connection.get(), *channel.script);
143
0
  ++next_data_script_;
144
0
  return channel.connection->take_client_fd();
145
0
}
146
147
/// Retain only a bounded prefix for assertions. Parsing and draining continue
148
/// against the full transport bytes, so reaching the cap cannot change curl's
149
/// behavior or manufacture socket backpressure.
150
0
void FtpMockServer::CapturePrefix(std::string_view source, std::size_t limit, std::string* destination) {
151
0
  if (destination == nullptr || destination->size() >= limit) {
152
0
    return;
153
0
  }
154
0
  const std::size_t available = limit - destination->size();
155
0
  destination->append(source.data(), std::min(source.size(), available));
156
0
}
157
158
/// Remove FTP's optional leading horizontal whitespace and return just the
159
/// verb. Arguments remain untouched because only curl, not the mock harness,
160
/// should interpret fuzzed paths and offsets.
161
0
std::string_view FtpMockServer::CommandVerb(std::string_view command) {
162
0
  std::size_t begin = 0;
163
0
  while (begin < command.size() && (command[begin] == ' ' || command[begin] == '\t')) {
164
0
    ++begin;
165
0
  }
166
167
0
  std::size_t end = begin;
168
0
  while (end < command.size() && command[end] != ' ' && command[end] != '\t' && command[end] != '\r' &&
169
0
         command[end] != '\n') {
170
0
    ++end;
171
0
  }
172
0
  return command.substr(begin, end - begin);
173
0
}
174
175
/// Fold only ASCII lowercase command letters. FTP verbs are ASCII tokens, so
176
/// locale-aware case conversion would add state and undefined signed-char
177
/// behavior without accepting anything curl can legitimately send.
178
0
bool FtpMockServer::VerbEquals(std::string_view verb, std::string_view expected_uppercase) {
179
0
  if (verb.size() != expected_uppercase.size()) {
180
0
    return false;
181
0
  }
182
0
  for (std::size_t index = 0; index < verb.size(); ++index) {
183
0
    unsigned char actual = static_cast<unsigned char>(verb[index]);
184
0
    if (actual >= 'a' && actual <= 'z') {
185
0
      actual = static_cast<unsigned char>(actual - 'a' + 'A');
186
0
    }
187
0
    if (actual != static_cast<unsigned char>(expected_uppercase[index])) {
188
0
      return false;
189
0
    }
190
0
  }
191
0
  return true;
192
0
}
193
194
/// Recognize curl's built-in data commands plus MLSD, the useful structured
195
/// listing custom request. PRET deliberately remains control-only even though
196
/// its argument can contain RETR/STOR: no data transfer has begun at that
197
/// point.
198
0
FtpMockServer::TransferDirection FtpMockServer::DirectionForVerb(std::string_view verb) {
199
0
  if (VerbEquals(verb, "STOR") || VerbEquals(verb, "APPE")) {
200
0
    return TransferDirection::kUpload;
201
0
  }
202
0
  if (VerbEquals(verb, "RETR") || VerbEquals(verb, "LIST") || VerbEquals(verb, "NLST") || VerbEquals(verb, "MLSD")) {
203
0
    return TransferDirection::kDownload;
204
0
  }
205
0
  return TransferDirection::kNone;
206
0
}
207
208
/// Scan backwards because repeated setopt calls are legal and libcurl keeps
209
/// the last value. Comparing only the verb preserves arbitrary custom
210
/// arguments while still avoiding false positives on preparatory FTP commands
211
/// that happen to receive a fuzzed 125/150 reply after EPSV opened a socket.
212
0
bool FtpMockServer::IsConfiguredCustomDownload(std::string_view verb) const {
213
0
  if (scenario_ == nullptr || verb.empty()) {
214
0
    return false;
215
0
  }
216
217
0
  for (int index = scenario_->options_size() - 1; index >= 0; --index) {
218
0
    const auto& option = scenario_->options(index);
219
0
    if (option.option_id() != curl::fuzzer::proto::CURLOPT_CUSTOMREQUEST) {
220
0
      continue;
221
0
    }
222
0
    if (option.value_case() != curl::fuzzer::proto::SetOption::kStringValue) {
223
0
      return false;
224
0
    }
225
0
    return VerbEquals(verb, CommandVerb(option.string_value()));
226
0
  }
227
0
  return false;
228
0
}
229
230
/// Scan complete lines because a legal FTP response fragment may start with
231
/// one or more informational lines. curl ends a response at the first line
232
/// whose first three bytes are digits and whose fourth byte is a space.
233
0
int FtpMockServer::FirstReplyCode(std::string_view response) {
234
0
  std::size_t line_start = 0;
235
0
  while (line_start < response.size()) {
236
0
    const std::size_t newline = response.find('\n', line_start);
237
0
    if (newline == std::string_view::npos) {
238
0
      return -1;
239
0
    }
240
0
    const std::size_t line_size = newline - line_start + 1;
241
0
    if (line_size > 3) {
242
0
      const unsigned char first = static_cast<unsigned char>(response[line_start]);
243
0
      const unsigned char second = static_cast<unsigned char>(response[line_start + 1]);
244
0
      const unsigned char third = static_cast<unsigned char>(response[line_start + 2]);
245
0
      if (first >= '0' && first <= '9' && second >= '0' && second <= '9' && third >= '0' && third <= '9' &&
246
0
          response[line_start + 3] == ' ') {
247
0
        return 100 * (first - '0') + 10 * (second - '0') + (third - '0');
248
0
      }
249
0
    }
250
0
    line_start = newline + 1;
251
0
  }
252
0
  return -1;
253
0
}
254
255
/// A raw completion such as "226 done" needs only its missing newline. In
256
/// that common mutation, appending a second synthetic reply would leave bytes
257
/// in curl's control cache and misalign a later wildcard transfer.
258
0
bool FtpMockServer::TailNeedsOnlyNewline(std::string_view response) {
259
0
  const std::size_t last_newline = response.rfind('\n');
260
0
  const std::size_t tail = last_newline == std::string_view::npos ? 0 : last_newline + 1;
261
0
  if (response.size() - tail <= 3) {
262
0
    return false;
263
0
  }
264
0
  const unsigned char first = static_cast<unsigned char>(response[tail]);
265
0
  const unsigned char second = static_cast<unsigned char>(response[tail + 1]);
266
0
  const unsigned char third = static_cast<unsigned char>(response[tail + 2]);
267
0
  return first >= '0' && first <= '9' && second >= '0' && second <= '9' && third >= '0' && third <= '9' &&
268
0
         response[tail + 3] == ' ';
269
0
}
270
271
/// Return the next bounded primary response. Empty strings still advance the
272
/// cursor: they are meaningful truncation mutations for ordinary command
273
/// states and explicitly select control EOF at transfer completion.
274
0
const std::string* FtpMockServer::NextControlReply() {
275
0
  if (scenario_ == nullptr || next_control_reply_ >= control_reply_count_) {
276
0
    return nullptr;
277
0
  }
278
0
  return &scenario_->connection().on_readable(static_cast<int>(next_control_reply_++));
279
0
}
280
281
/// Keep failed peer writes local to the mock. Response cursors must still
282
/// advance after curl closes early, otherwise a replacement socket could see
283
/// a reply intended for an earlier command.
284
0
bool FtpMockServer::WriteControlBytes(std::string_view bytes) {
285
0
  return bytes.empty() ||
286
0
         (control_connection_ != nullptr &&
287
0
          control_connection_->WriteAll(reinterpret_cast<const unsigned char*>(bytes.data()), bytes.size()));
288
0
}
289
290
/// Queue a scenario-provided final reply and make it guaranteed non-blocking.
291
/// ftp_done_control_reply() calls getftpresponse() synchronously after data
292
/// EOF; if the fuzzed fragment lacks a terminating numeric line, append the
293
/// smallest completion needed so curl returns to the harness immediately. An
294
/// explicitly empty repeated value instead half-closes the peer: this retains
295
/// deterministic liveness while making curl's missing-completion error path
296
/// directly seedable.
297
0
void FtpMockServer::QueueTransferCompletion() {
298
0
  const std::string* response = NextControlReply();
299
0
  if (response != nullptr && response->empty()) {
300
0
    if (control_connection_ != nullptr) {
301
0
      control_connection_->ShutdownWrite();
302
0
    }
303
0
    return;
304
0
  }
305
0
  if (response != nullptr && !response->empty()) {
306
0
    (void)WriteControlBytes(*response);
307
0
    if (FirstReplyCode(*response) >= 0) {
308
0
      return;
309
0
    }
310
0
    if (TailNeedsOnlyNewline(*response)) {
311
0
      (void)WriteControlBytes("\r\n");
312
0
      return;
313
0
    }
314
0
    if (response->back() != '\n') {
315
0
      (void)WriteControlBytes("\r\n");
316
0
    }
317
0
  }
318
319
  // This line is a liveness guard, not a forced successful outcome: any
320
  // complete fuzzed final response above remains the reply curl consumes.
321
0
  (void)WriteControlBytes("226 mock transfer complete\r\n");
322
0
}
323
324
/// Send every runtime-visible data fragment before half-closing the peer. The
325
/// serialized fuzz input is already length-bounded; sending the complete
326
/// prefix lets curl drain its data state without event-loop sleeps while each
327
/// protobuf fragment still affects byte content and parser behavior.
328
0
void FtpMockServer::PreloadDownload(DataChannel* channel) {
329
0
  if (channel == nullptr || channel->connection == nullptr || channel->script == nullptr) {
330
0
    return;
331
0
  }
332
333
0
  const std::string& initial = channel->script->initial_response();
334
0
  bool complete = initial.empty() ||
335
0
                  channel->connection->WriteAll(reinterpret_cast<const unsigned char*>(initial.data()), initial.size());
336
0
  const std::size_t chunk_count = std::min<std::size_t>(scenario_limits::kMaxResponseChunks,
337
0
                                                        static_cast<std::size_t>(channel->script->on_readable_size()));
338
0
  for (std::size_t index = 0; complete && index < chunk_count; ++index) {
339
0
    const std::string& chunk = channel->script->on_readable(static_cast<int>(index));
340
0
    complete = chunk.empty() ||
341
0
               channel->connection->WriteAll(reinterpret_cast<const unsigned char*>(chunk.data()), chunk.size());
342
0
  }
343
0
  (void)complete;
344
0
  channel->connection->ShutdownWrite();
345
0
}
346
347
/// Pair the next transfer command with the oldest passive socket that EPSV or
348
/// PASV opened but no command has used. FTP serializes data transfers, so this
349
/// simple cursor is sufficient and avoids interpreting fuzzed port numbers.
350
0
void FtpMockServer::StartNextTransfer(TransferDirection direction) {
351
0
  for (std::size_t index = 0; index < next_data_script_; ++index) {
352
0
    DataChannel& channel = data_channels_[index];
353
0
    if (channel.connection == nullptr || channel.transfer_started) {
354
0
      continue;
355
0
    }
356
357
0
    channel.transfer_started = true;
358
0
    channel.upload = direction == TransferDirection::kUpload;
359
0
    if (!channel.upload) {
360
0
      PreloadDownload(&channel);
361
0
    }
362
    // Upload peers keep their write half open until cleanup. A real FTP server
363
    // does not send an early FIN while receiving a file, and a readiness
364
    // backend could otherwise report that FIN alongside POLLOUT before curl
365
    // has delivered the upload callback's bytes.
366
0
    return;
367
0
  }
368
0
}
369
370
/// Advance one reply for every complete command. Accepted transfer commands
371
/// additionally consume their final reply now, because waiting until data EOF
372
/// would be too late to escape curl's blocking completion read.
373
0
void FtpMockServer::HandleControlCommand(std::string_view command) {
374
0
  const std::string* response = NextControlReply();
375
0
  if (response == nullptr) {
376
0
    return;
377
0
  }
378
379
0
  const int response_code = FirstReplyCode(*response);
380
0
  (void)WriteControlBytes(*response);
381
382
0
  const std::string_view verb = CommandVerb(command);
383
0
  TransferDirection direction = DirectionForVerb(verb);
384
0
  if (direction == TransferDirection::kNone && IsConfiguredCustomDownload(verb)) {
385
0
    direction = TransferDirection::kDownload;
386
0
  }
387
0
  const bool starts_download =
388
0
      direction == TransferDirection::kDownload && (response_code == 125 || response_code == 150);
389
  // curl's STOR handler accepts every response below 400 before initiating
390
  // the upload, even though 125/150 are the conventional successful replies.
391
0
  const bool starts_upload = direction == TransferDirection::kUpload && response_code >= 100 && response_code < 400;
392
0
  if (starts_download || starts_upload) {
393
0
    StartNextTransfer(direction);
394
0
    QueueTransferCompletion();
395
0
  }
396
0
}
397
398
/// Read without waiting, retain partial final commands, and process all full
399
/// lines already emitted by curl. The number of replies is bounded even if a
400
/// malformed command stream contains many newlines.
401
0
bool FtpMockServer::ServiceControlConnection() {
402
0
  if (control_connection_ == nullptr) {
403
0
    return false;
404
0
  }
405
406
0
  const std::size_t size_before_read = pending_control_bytes_.size();
407
0
  control_connection_->ReadAvailable(&pending_control_bytes_);
408
0
  bool made_progress = pending_control_bytes_.size() != size_before_read;
409
410
0
  std::size_t consumed = 0;
411
0
  while (consumed < pending_control_bytes_.size()) {
412
0
    const std::size_t newline = pending_control_bytes_.find('\n', consumed);
413
0
    if (newline == std::string::npos) {
414
0
      break;
415
0
    }
416
0
    const std::size_t command_size = newline - consumed + 1;
417
0
    const std::string_view command(pending_control_bytes_.data() + consumed, command_size);
418
0
    CapturePrefix(command, kMaxCapturedControlBytes, &control_transcript_);
419
0
    HandleControlCommand(command);
420
0
    consumed += command_size;
421
0
    made_progress = true;
422
0
  }
423
424
0
  if (consumed != 0) {
425
0
    pending_control_bytes_.erase(0, consumed);
426
0
  }
427
0
  return made_progress;
428
0
}
429
430
/// Drain uploads into a temporary buffer so data beyond the observable cap is
431
/// still removed from the socket. This keeps curl's progress independent of
432
/// how much a unit test chooses to retain.
433
0
bool FtpMockServer::ServiceUploadConnections() {
434
0
  bool made_progress = false;
435
0
  for (std::size_t index = 0; index < next_data_script_; ++index) {
436
0
    DataChannel& channel = data_channels_[index];
437
0
    if (!channel.upload || channel.connection == nullptr) {
438
0
      continue;
439
0
    }
440
441
0
    std::string bytes;
442
0
    channel.connection->ReadAvailable(&bytes);
443
0
    if (!bytes.empty()) {
444
0
      CapturePrefix(bytes, kMaxCapturedUploadBytes, &uploaded_data_);
445
0
      made_progress = true;
446
0
    }
447
0
  }
448
0
  return made_progress;
449
0
}
450
451
/// Prepare cleanup while the mock can still write to curl's control socket.
452
/// A completed easy remains in multi's connection cache until cleanup, where
453
/// FTP sends QUIT and performs a blocking read. Preloading 221 handles that
454
/// path; an unfinished drive instead gets EOF so cleanup fails fast.
455
0
void FtpMockServer::FinishConnections(bool completed) {
456
0
  if (control_connection_ != nullptr) {
457
0
    if (completed) {
458
0
      (void)WriteControlBytes("221 mock closing control connection\r\n");
459
0
    }
460
0
    control_connection_->ShutdownWrite();
461
0
  }
462
0
  for (std::size_t index = 0; index < next_data_script_; ++index) {
463
0
    if (data_channels_[index].connection != nullptr) {
464
0
      data_channels_[index].connection->ShutdownWrite();
465
0
    }
466
0
  }
467
0
}
468
469
/// Alternate curl transitions with immediate peer service until the transfer
470
/// completes or deterministic idle/operation caps win. No select(), poll(),
471
/// or sleep is needed: every transport is a local socketpair, and malformed
472
/// scripts are terminated by half-close after the bounded loop.
473
0
void FtpMockServer::RunLoop(CURLM* multi, CURL* easy, const curl::fuzzer::proto::Scenario& scenario) {
474
0
  (void)easy;
475
0
  ResetForScenario(scenario);
476
477
0
  int still_running = 1;
478
0
  int idle_iterations = 0;
479
0
  int drive_iterations = 0;
480
0
  CURLMcode result = CURLM_OK;
481
0
  while (still_running && idle_iterations < kMaxIdleIterations && drive_iterations++ < kMaxDriveIterations) {
482
0
    const int running_before = still_running;
483
0
    result = curl_multi_perform(multi, &still_running);
484
0
    if (result != CURLM_OK) {
485
0
      break;
486
0
    }
487
488
0
    bool made_progress = still_running != running_before;
489
0
    made_progress = ServiceControlConnection() || made_progress;
490
0
    made_progress = ServiceUploadConnections() || made_progress;
491
0
    if (made_progress) {
492
0
      idle_iterations = 0;
493
0
    } else {
494
0
      ++idle_iterations;
495
0
    }
496
0
  }
497
498
  // The final perform may have emitted upload or control bytes immediately
499
  // before marking the transfer done. Drain/capture them before cleanup.
500
0
  (void)ServiceControlConnection();
501
0
  (void)ServiceUploadConnections();
502
0
  FinishConnections(still_running == 0 && result == CURLM_OK);
503
0
  scenario_ = nullptr;
504
0
}
505
506
}  // namespace proto_fuzzer