Coverage Report

Created: 2026-09-14 07:12

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 <fcntl.h>
13
#include <netinet/in.h>
14
#include <sys/socket.h>
15
#include <unistd.h>
16
17
#include <algorithm>
18
#include <cerrno>
19
#include <cstddef>
20
#include <cstdint>
21
#include <cstring>
22
#include <limits>
23
#include <memory>
24
#include <string>
25
#include <string_view>
26
27
#include "proto_fuzzer/mock_server.h"
28
#include "proto_fuzzer/scenario_limits.h"
29
30
namespace proto_fuzzer {
31
32
/// Begin without a borrowed protobuf or any open socketpairs. Keeping all
33
/// session state in the object avoids process globals that could leak one
34
/// libFuzzer iteration's protocol position into the next.
35
FtpMockServer::FtpMockServer()
36
9.03k
    : scenario_(nullptr),
37
9.03k
      next_data_script_(0),
38
9.03k
      active_listener_fd_(-1),
39
9.03k
      active_listener_client_fd_(CURL_SOCKET_BAD),
40
9.03k
      pending_active_channel_(kMaxDataChannels),
41
9.03k
      control_reply_count_(0),
42
9.03k
      next_control_reply_(0),
43
9.03k
      control_opened_(false) {}
44
45
/// Loopback peers are ordinary unique_ptr-owned transports; the out-of-line
46
/// destructor also permits MockConnection to remain forward-declared
47
/// in the header.
48
9.03k
FtpMockServer::~FtpMockServer() { CloseActiveDescriptors(); }
49
50
/// Return the bounded command capture accumulated by the most recent drive.
51
0
const std::string& FtpMockServer::control_transcript() const { return control_transcript_; }
52
53
/// Return the bounded upload capture accumulated by the most recent drive.
54
0
const std::string& FtpMockServer::uploaded_data() const { return uploaded_data_; }
55
56
/// Report how many passive peers curl actually requested, rather than how many
57
/// scripts happened to be present in the protobuf.
58
0
std::size_t FtpMockServer::opened_data_connection_count() const { return next_data_script_; }
59
60
/// Clear descriptor and parser state before borrowing the next Scenario. A
61
/// FtpMockServer can be reused serially, but no connection or response cursor
62
/// is meaningful across easy-handle drives.
63
9.03k
void FtpMockServer::ResetForScenario(const curl::fuzzer::proto::Scenario& scenario) {
64
9.03k
  CloseActiveDescriptors();
65
9.03k
  control_connection_.reset();
66
27.0k
  for (DataChannel& channel : data_channels_) {
67
27.0k
    channel.connection.reset();
68
27.0k
    channel.active_fd = -1;
69
27.0k
    channel.script = nullptr;
70
27.0k
    channel.transfer_started = false;
71
27.0k
    channel.upload = false;
72
27.0k
  }
73
74
9.03k
  scenario_ = &scenario;
75
9.03k
  next_data_script_ = 0;
76
9.03k
  pending_active_channel_ = kMaxDataChannels;
77
9.03k
  control_reply_count_ = std::min<std::size_t>(scenario_limits::kMaxResponseChunks,
78
9.03k
                                               static_cast<std::size_t>(scenario.connection().on_readable_size()));
79
9.03k
  next_control_reply_ = 0;
80
9.03k
  pending_control_bytes_.clear();
81
9.03k
  control_transcript_.clear();
82
9.03k
  uploaded_data_.clear();
83
9.03k
  control_opened_ = false;
84
9.03k
}
85
86
/// Close only descriptors owned by the peer. Curl owns every listener fd
87
/// returned by HandleOpenSocket; active_listener_fd_ is a private duplicate.
88
18.0k
void FtpMockServer::CloseActiveDescriptors() {
89
18.0k
  active_listener_client_fd_ = CURL_SOCKET_BAD;
90
18.0k
  if (active_listener_fd_ >= 0) {
91
0
    close(active_listener_fd_);
92
0
    active_listener_fd_ = -1;
93
0
  }
94
54.1k
  for (DataChannel& channel : data_channels_) {
95
54.1k
    if (channel.active_fd >= 0) {
96
69
      close(channel.active_fd);
97
69
      channel.active_fd = -1;
98
69
    }
99
54.1k
  }
100
18.0k
  pending_active_channel_ = kMaxDataChannels;
101
18.0k
}
102
103
/// Clamp a compatibility input's raw protobuf socket size before crossing the
104
/// platform int boundary. Target policies normally clear FTP backpressure,
105
/// but direct corpus replay must retain deterministic, well-defined behavior.
106
13.6k
void FtpMockServer::ApplyScriptBackpressure(MockConnection* connection, const curl::fuzzer::proto::Connection& script) {
107
13.6k
  if (connection == nullptr) {
108
0
    return;
109
0
  }
110
111
13.6k
  const std::uint32_t int_max = static_cast<std::uint32_t>(std::numeric_limits<int>::max());
112
13.6k
  const auto& backpressure = script.backpressure();
113
13.6k
  const int receive_buffer = static_cast<int>(std::min(backpressure.recv_buf_bytes(), int_max));
114
13.6k
  connection->ApplyBackpressure(receive_buffer, static_cast<std::size_t>(backpressure.drain_limit()));
115
13.6k
}
116
117
/// Return whether the final FTPPORT setopt retained by the Scenario selected
118
/// active mode. Target policy constrains the actual value to loopback.
119
5.01k
bool FtpMockServer::UsesActiveMode() const {
120
5.01k
  if (scenario_ == nullptr) {
121
0
    return false;
122
0
  }
123
11.7k
  for (int index = scenario_->options_size() - 1; index >= 0; --index) {
124
6.81k
    const auto& option = scenario_->options(index);
125
6.81k
    if (option.option_id() == curl::fuzzer::proto::CURLOPT_FTPPORT) {
126
130
      return option.value_case() == curl::fuzzer::proto::SetOption::kStringValue && !option.string_value().empty();
127
130
    }
128
6.81k
  }
129
4.88k
  return false;
130
5.01k
}
131
132
/// Control/passive sockets are connected AF_UNIX pairs, while active FTP
133
/// needs curl to bind and listen on the real TCP descriptor returned by
134
/// OpenActiveListener. The descriptor identity is recorded when opened, so
135
/// this decision never depends on a sandbox-sensitive getsockopt call.
136
13.7k
SocketSetupDisposition FtpMockServer::GetSocketSetupDisposition(curl_socket_t curlfd, curlsocktype purpose) const {
137
13.7k
  if (purpose == CURLSOCKTYPE_ACCEPT || curlfd == active_listener_client_fd_) {
138
67
    return SocketSetupDisposition::kNeedsSetup;
139
67
  }
140
13.6k
  return SocketSetupDisposition::kAlreadyConnected;
141
13.7k
}
142
143
/// Give curl a genuine INET socket for bind/listen while retaining a duplicate
144
/// that can discover the ephemeral loopback port after those operations.
145
130
curl_socket_t FtpMockServer::OpenActiveListener(struct curl_sockaddr* address) {
146
130
  if (address == nullptr || (address->family != AF_INET && address->family != AF_INET6)) {
147
0
    return CURL_SOCKET_BAD;
148
0
  }
149
150
130
  if (pending_active_channel_ == kMaxDataChannels) {
151
111
    const std::size_t available_scripts =
152
111
        std::min<std::size_t>(kMaxDataChannels, static_cast<std::size_t>(scenario_->subsequent_connections_size()));
153
111
    if (next_data_script_ >= available_scripts) {
154
9
      return CURL_SOCKET_BAD;
155
9
    }
156
102
    pending_active_channel_ = next_data_script_++;
157
102
    DataChannel& channel = data_channels_[pending_active_channel_];
158
102
    channel.script = &scenario_->subsequent_connections(static_cast<int>(pending_active_channel_));
159
102
  }
160
161
121
  if (active_listener_fd_ >= 0) {
162
19
    close(active_listener_fd_);
163
19
    active_listener_fd_ = -1;
164
19
  }
165
121
  active_listener_client_fd_ = CURL_SOCKET_BAD;
166
167
121
  const int listener = socket(address->family, address->socktype, address->protocol);
168
121
  if (listener < 0) {
169
0
    return CURL_SOCKET_BAD;
170
0
  }
171
121
  active_listener_fd_ = dup(listener);
172
121
  if (active_listener_fd_ < 0) {
173
0
    close(listener);
174
0
    return CURL_SOCKET_BAD;
175
0
  }
176
121
  active_listener_client_fd_ = listener;
177
121
  (void)fcntl(active_listener_fd_, F_SETFD, FD_CLOEXEC);
178
121
  return listener;
179
121
}
180
181
/// Allocate one control socket followed by a bounded sequence of passive data
182
/// sockets, or a real listener when CURLOPT_FTPPORT selected active mode.
183
/// Data is intentionally not preloaded here: curl has not yet sent the command
184
/// that determines whether the stream is a listing, download, or upload.
185
13.9k
curl_socket_t FtpMockServer::HandleOpenSocket(curlsocktype purpose, struct curl_sockaddr* address) {
186
13.9k
  if (scenario_ == nullptr || purpose != CURLSOCKTYPE_IPCXN) {
187
0
    return CURL_SOCKET_BAD;
188
0
  }
189
190
13.9k
  if (!control_opened_) {
191
8.92k
    control_connection_ = std::make_unique<MockConnection>();
192
8.92k
    if (!control_connection_->ok()) {
193
0
      control_connection_.reset();
194
0
      return CURL_SOCKET_BAD;
195
0
    }
196
197
8.92k
    ApplyScriptBackpressure(control_connection_.get(), scenario_->connection());
198
8.92k
    const std::string& greeting = scenario_->connection().initial_response();
199
8.92k
    if (!greeting.empty() &&
200
6.51k
        !control_connection_->WriteAll(reinterpret_cast<const unsigned char*>(greeting.data()), greeting.size())) {
201
0
      control_connection_.reset();
202
0
      return CURL_SOCKET_BAD;
203
0
    }
204
    // Do not consume the unique control slot until its peer is usable. Curl
205
    // may retry a failed application socket callback, and that retry must not
206
    // be mistaken for the first passive data connection.
207
8.92k
    control_opened_ = true;
208
8.92k
    return control_connection_->take_client_fd();
209
8.92k
  }
210
211
5.01k
  if (UsesActiveMode()) {
212
130
    return OpenActiveListener(address);
213
130
  }
214
215
4.88k
  const std::size_t available_scripts =
216
4.88k
      std::min<std::size_t>(kMaxDataChannels, static_cast<std::size_t>(scenario_->subsequent_connections_size()));
217
4.88k
  if (next_data_script_ >= available_scripts) {
218
133
    return CURL_SOCKET_BAD;
219
133
  }
220
221
4.75k
  DataChannel& channel = data_channels_[next_data_script_];
222
4.75k
  channel.script = &scenario_->subsequent_connections(static_cast<int>(next_data_script_));
223
4.75k
  channel.connection = std::make_unique<MockConnection>();
224
4.75k
  if (!channel.connection->ok()) {
225
0
    channel.connection.reset();
226
0
    channel.script = nullptr;
227
0
    return CURL_SOCKET_BAD;
228
0
  }
229
230
4.75k
  ApplyScriptBackpressure(channel.connection.get(), *channel.script);
231
4.75k
  ++next_data_script_;
232
4.75k
  return channel.connection->take_client_fd();
233
4.75k
}
234
235
/// Retry writes only while they can make immediate progress. Scenario bytes
236
/// are bounded, and dropping a suffix on EAGAIN is preferable to introducing
237
/// a wall-clock wait into one fuzz iteration.
238
37
bool FtpMockServer::WriteActiveBytes(int fd, const unsigned char* data, std::size_t size) {
239
37
  std::size_t offset = 0;
240
74
  while (offset < size) {
241
37
    const ssize_t written = send(fd, data + offset, size - offset, MSG_NOSIGNAL | MSG_DONTWAIT);
242
37
    if (written > 0) {
243
37
      offset += static_cast<std::size_t>(written);
244
37
      continue;
245
37
    }
246
0
    if (written < 0 && errno == EINTR) {
247
0
      continue;
248
0
    }
249
0
    return false;
250
0
  }
251
37
  return true;
252
37
}
253
254
/// Drain an active upload without waiting for curl. The accepted peer is
255
/// nonblocking and the outer perform loop calls this again after more output.
256
33
bool FtpMockServer::ReadActiveBytes(int fd, std::string* output) {
257
33
  bool made_progress = false;
258
33
  char buffer[16 * 1024];
259
42
  for (;;) {
260
42
    const ssize_t amount = recv(fd, buffer, sizeof(buffer), MSG_DONTWAIT);
261
42
    if (amount > 0) {
262
9
      output->append(buffer, static_cast<std::size_t>(amount));
263
9
      made_progress = true;
264
9
      continue;
265
9
    }
266
33
    if (amount < 0 && errno == EINTR) {
267
0
      continue;
268
0
    }
269
33
    return made_progress;
270
33
  }
271
33
}
272
273
/// Connect to the exact listener curl bound. Since active_listener_fd_ is a
274
/// duplicate of that socket, getsockname observes the assigned port without
275
/// trusting or reparsing bytes from the control transcript.
276
69
void FtpMockServer::ConnectActiveDataChannel() {
277
69
  if (active_listener_fd_ < 0 || pending_active_channel_ >= kMaxDataChannels) {
278
0
    return;
279
0
  }
280
281
69
  struct sockaddr_storage address;
282
69
  std::memset(&address, 0, sizeof(address));
283
69
  socklen_t address_length = sizeof(address);
284
69
  if (getsockname(active_listener_fd_, reinterpret_cast<struct sockaddr*>(&address), &address_length) != 0) {
285
0
    return;
286
0
  }
287
288
69
  const int fd = socket(address.ss_family, SOCK_STREAM, IPPROTO_TCP);
289
69
  if (fd < 0) {
290
0
    return;
291
0
  }
292
69
  if (connect(fd, reinterpret_cast<struct sockaddr*>(&address), address_length) != 0) {
293
0
    close(fd);
294
0
    return;
295
0
  }
296
69
  const int flags = fcntl(fd, F_GETFL, 0);
297
69
  if (flags >= 0) {
298
69
    (void)fcntl(fd, F_SETFL, flags | O_NONBLOCK);
299
69
  }
300
69
  (void)fcntl(fd, F_SETFD, FD_CLOEXEC);
301
302
69
  DataChannel& channel = data_channels_[pending_active_channel_];
303
69
  channel.active_fd = fd;
304
69
  close(active_listener_fd_);
305
69
  active_listener_fd_ = -1;
306
69
  active_listener_client_fd_ = CURL_SOCKET_BAD;
307
69
  pending_active_channel_ = kMaxDataChannels;
308
69
}
309
310
/// Retain only a bounded prefix for assertions. Parsing and draining continue
311
/// against the full transport bytes, so reaching the cap cannot change curl's
312
/// behavior or manufacture socket backpressure.
313
58.6k
void FtpMockServer::CapturePrefix(std::string_view source, std::size_t limit, std::string* destination) {
314
58.6k
  if (destination == nullptr || destination->size() >= limit) {
315
10
    return;
316
10
  }
317
58.6k
  const std::size_t available = limit - destination->size();
318
58.6k
  destination->append(source.data(), std::min(source.size(), available));
319
58.6k
}
320
321
/// Remove FTP's optional leading horizontal whitespace and return just the
322
/// verb. Arguments remain untouched because only curl, not the mock harness,
323
/// should interpret fuzzed paths and offsets.
324
25.0k
std::string_view FtpMockServer::CommandVerb(std::string_view command) {
325
25.0k
  std::size_t begin = 0;
326
26.9k
  while (begin < command.size() && (command[begin] == ' ' || command[begin] == '\t')) {
327
1.98k
    ++begin;
328
1.98k
  }
329
330
25.0k
  std::size_t end = begin;
331
209k
  while (end < command.size() && command[end] != ' ' && command[end] != '\t' && command[end] != '\r' &&
332
185k
         command[end] != '\n') {
333
184k
    ++end;
334
184k
  }
335
25.0k
  return command.substr(begin, end - begin);
336
25.0k
}
337
338
/// Fold only ASCII lowercase command letters. FTP verbs are ASCII tokens, so
339
/// locale-aware case conversion would add state and undefined signed-char
340
/// behavior without accepting anything curl can legitimately send.
341
183k
bool FtpMockServer::VerbEquals(std::string_view verb, std::string_view expected_uppercase) {
342
183k
  if (verb.size() != expected_uppercase.size()) {
343
67.5k
    return false;
344
67.5k
  }
345
143k
  for (std::size_t index = 0; index < verb.size(); ++index) {
346
139k
    unsigned char actual = static_cast<unsigned char>(verb[index]);
347
139k
    if (actual >= 'a' && actual <= 'z') {
348
478
      actual = static_cast<unsigned char>(actual - 'a' + 'A');
349
478
    }
350
139k
    if (actual != static_cast<unsigned char>(expected_uppercase[index])) {
351
112k
      return false;
352
112k
    }
353
139k
  }
354
3.50k
  return true;
355
116k
}
356
357
/// Recognize curl's built-in data commands plus MLSD, the useful structured
358
/// listing custom request. PRET deliberately remains control-only even though
359
/// its argument can contain RETR/STOR: no data transfer has begun at that
360
/// point.
361
23.8k
FtpMockServer::TransferDirection FtpMockServer::DirectionForVerb(std::string_view verb) {
362
23.8k
  if (VerbEquals(verb, "STOR") || VerbEquals(verb, "APPE")) {
363
330
    return TransferDirection::kUpload;
364
330
  }
365
23.4k
  if (VerbEquals(verb, "RETR") || VerbEquals(verb, "LIST") || VerbEquals(verb, "NLST") || VerbEquals(verb, "MLSD")) {
366
2.89k
    return TransferDirection::kDownload;
367
2.89k
  }
368
20.6k
  return TransferDirection::kNone;
369
23.4k
}
370
371
/// Scan backwards because repeated setopt calls are legal and libcurl keeps
372
/// the last value. Comparing only the verb preserves arbitrary custom
373
/// arguments while still avoiding false positives on preparatory FTP commands
374
/// that happen to receive a fuzzed 125/150 reply after EPSV opened a socket.
375
20.6k
bool FtpMockServer::IsConfiguredCustomDownload(std::string_view verb) const {
376
20.6k
  if (scenario_ == nullptr || verb.empty()) {
377
108
    return false;
378
108
  }
379
380
44.5k
  for (int index = scenario_->options_size() - 1; index >= 0; --index) {
381
25.2k
    const auto& option = scenario_->options(index);
382
25.2k
    if (option.option_id() != curl::fuzzer::proto::CURLOPT_CUSTOMREQUEST) {
383
24.0k
      continue;
384
24.0k
    }
385
1.17k
    if (option.value_case() != curl::fuzzer::proto::SetOption::kStringValue) {
386
0
      return false;
387
0
    }
388
1.17k
    return VerbEquals(verb, CommandVerb(option.string_value()));
389
1.17k
  }
390
19.3k
  return false;
391
20.4k
}
392
393
/// Scan complete lines because a legal FTP response fragment may start with
394
/// one or more informational lines. curl ends a response at the first line
395
/// whose first three bytes are digits and whose fourth byte is a space.
396
24.8k
int FtpMockServer::FirstReplyCode(std::string_view response) {
397
24.8k
  std::size_t line_start = 0;
398
130k
  while (line_start < response.size()) {
399
129k
    const std::size_t newline = response.find('\n', line_start);
400
129k
    if (newline == std::string_view::npos) {
401
914
      return -1;
402
914
    }
403
128k
    const std::size_t line_size = newline - line_start + 1;
404
128k
    if (line_size > 3) {
405
39.0k
      const unsigned char first = static_cast<unsigned char>(response[line_start]);
406
39.0k
      const unsigned char second = static_cast<unsigned char>(response[line_start + 1]);
407
39.0k
      const unsigned char third = static_cast<unsigned char>(response[line_start + 2]);
408
39.0k
      if (first >= '0' && first <= '9' && second >= '0' && second <= '9' && third >= '0' && third <= '9' &&
409
23.7k
          response[line_start + 3] == ' ') {
410
23.1k
        return 100 * (first - '0') + 10 * (second - '0') + (third - '0');
411
23.1k
      }
412
39.0k
    }
413
105k
    line_start = newline + 1;
414
105k
  }
415
789
  return -1;
416
24.8k
}
417
418
/// A raw completion such as "226 done" needs only its missing newline. In
419
/// that common mutation, appending a second synthetic reply would leave bytes
420
/// in curl's control cache and misalign a later wildcard transfer.
421
821
bool FtpMockServer::TailNeedsOnlyNewline(std::string_view response) {
422
821
  const std::size_t last_newline = response.rfind('\n');
423
821
  const std::size_t tail = last_newline == std::string_view::npos ? 0 : last_newline + 1;
424
821
  if (response.size() - tail <= 3) {
425
539
    return false;
426
539
  }
427
282
  const unsigned char first = static_cast<unsigned char>(response[tail]);
428
282
  const unsigned char second = static_cast<unsigned char>(response[tail + 1]);
429
282
  const unsigned char third = static_cast<unsigned char>(response[tail + 2]);
430
282
  return first >= '0' && first <= '9' && second >= '0' && second <= '9' && third >= '0' && third <= '9' &&
431
46
         response[tail + 3] == ' ';
432
821
}
433
434
/// Return the next bounded primary response. Empty strings still advance the
435
/// cursor: they are meaningful truncation mutations for ordinary command
436
/// states and explicitly select control EOF at transfer completion.
437
60.0k
const std::string* FtpMockServer::NextControlReply() {
438
60.0k
  if (scenario_ == nullptr || next_control_reply_ >= control_reply_count_) {
439
35.2k
    return nullptr;
440
35.2k
  }
441
24.8k
  return &scenario_->connection().on_readable(static_cast<int>(next_control_reply_++));
442
60.0k
}
443
444
/// Keep failed peer writes local to the mock. Response cursors must still
445
/// advance after curl closes early, otherwise a replacement socket could see
446
/// a reply intended for an earlier command.
447
32.5k
bool FtpMockServer::WriteControlBytes(std::string_view bytes) {
448
32.5k
  return bytes.empty() ||
449
32.1k
         (control_connection_ != nullptr &&
450
32.1k
          control_connection_->WriteAll(reinterpret_cast<const unsigned char*>(bytes.data()), bytes.size()));
451
32.5k
}
452
453
/// Queue a scenario-provided final reply and make it guaranteed non-blocking.
454
/// ftp_done_control_reply() calls getftpresponse() synchronously after data
455
/// EOF; if the fuzzed fragment lacks a terminating numeric line, append the
456
/// smallest completion needed so curl returns to the harness immediately. An
457
/// explicitly empty repeated value instead half-closes the peer: this retains
458
/// deterministic liveness while making curl's missing-completion error path
459
/// directly seedable.
460
3.27k
void FtpMockServer::QueueTransferCompletion() {
461
3.27k
  const std::string* response = NextControlReply();
462
3.27k
  if (response != nullptr && response->empty()) {
463
30
    if (control_connection_ != nullptr) {
464
30
      control_connection_->ShutdownWrite();
465
30
    }
466
30
    return;
467
30
  }
468
3.24k
  if (response != nullptr && !response->empty()) {
469
1.01k
    (void)WriteControlBytes(*response);
470
1.01k
    if (FirstReplyCode(*response) >= 0) {
471
196
      return;
472
196
    }
473
821
    if (TailNeedsOnlyNewline(*response)) {
474
30
      (void)WriteControlBytes("\r\n");
475
30
      return;
476
30
    }
477
791
    if (response->back() != '\n') {
478
630
      (void)WriteControlBytes("\r\n");
479
630
    }
480
791
  }
481
482
  // This line is a liveness guard, not a forced successful outcome: any
483
  // complete fuzzed final response above remains the reply curl consumes.
484
3.02k
  (void)WriteControlBytes("226 mock transfer complete\r\n");
485
3.02k
}
486
487
/// Send every runtime-visible data fragment before half-closing the peer. The
488
/// serialized fuzz input is already length-bounded; sending the complete
489
/// prefix lets curl drain its data state without event-loop sleeps while each
490
/// protobuf fragment still affects byte content and parser behavior.
491
2.89k
void FtpMockServer::PreloadDownload(DataChannel* channel) {
492
2.89k
  if (channel == nullptr || channel->script == nullptr || (channel->connection == nullptr && channel->active_fd < 0)) {
493
0
    return;
494
0
  }
495
496
2.89k
  const std::string& initial = channel->script->initial_response();
497
6.74k
  const auto write_bytes = [channel](const std::string& bytes) {
498
6.74k
    if (bytes.empty()) {
499
1.26k
      return true;
500
1.26k
    }
501
5.48k
    if (channel->connection != nullptr) {
502
5.44k
      return channel->connection->WriteAll(reinterpret_cast<const unsigned char*>(bytes.data()), bytes.size());
503
5.44k
    }
504
37
    return WriteActiveBytes(channel->active_fd, reinterpret_cast<const unsigned char*>(bytes.data()), bytes.size());
505
5.48k
  };
506
2.89k
  bool complete = write_bytes(initial);
507
2.89k
  const std::size_t chunk_count = std::min<std::size_t>(scenario_limits::kMaxResponseChunks,
508
2.89k
                                                        static_cast<std::size_t>(channel->script->on_readable_size()));
509
6.74k
  for (std::size_t index = 0; complete && index < chunk_count; ++index) {
510
3.84k
    const std::string& chunk = channel->script->on_readable(static_cast<int>(index));
511
3.84k
    complete = write_bytes(chunk);
512
3.84k
  }
513
2.89k
  (void)complete;
514
2.89k
  if (channel->connection != nullptr) {
515
2.86k
    channel->connection->ShutdownWrite();
516
2.86k
  } else {
517
34
    (void)shutdown(channel->active_fd, SHUT_WR);
518
34
  }
519
2.89k
}
520
521
/// Pair the next transfer command with the oldest passive socket that EPSV or
522
/// PASV opened but no command has used. FTP serializes data transfers, so this
523
/// simple cursor is sufficient and avoids interpreting fuzzed port numbers.
524
3.27k
void FtpMockServer::StartNextTransfer(TransferDirection direction) {
525
3.91k
  for (std::size_t index = 0; index < next_data_script_; ++index) {
526
3.84k
    DataChannel& channel = data_channels_[index];
527
3.84k
    if ((channel.connection == nullptr && channel.active_fd < 0) || channel.transfer_started) {
528
632
      continue;
529
632
    }
530
531
3.20k
    channel.transfer_started = true;
532
3.20k
    channel.upload = direction == TransferDirection::kUpload;
533
3.20k
    if (!channel.upload) {
534
2.89k
      PreloadDownload(&channel);
535
2.89k
    }
536
    // Upload peers keep their write half open until cleanup. A real FTP server
537
    // does not send an early FIN while receiving a file, and a readiness
538
    // backend could otherwise report that FIN alongside POLLOUT before curl
539
    // has delivered the upload callback's bytes.
540
3.20k
    return;
541
3.84k
  }
542
3.27k
}
543
544
/// Advance one reply for every complete command. Accepted transfer commands
545
/// additionally consume their final reply now, because waiting until data EOF
546
/// would be too late to escape curl's blocking completion read.
547
56.8k
void FtpMockServer::HandleControlCommand(std::string_view command) {
548
56.8k
  const std::string* response = NextControlReply();
549
56.8k
  if (response == nullptr) {
550
32.9k
    return;
551
32.9k
  }
552
553
23.8k
  const int response_code = FirstReplyCode(*response);
554
23.8k
  (void)WriteControlBytes(*response);
555
556
23.8k
  const std::string_view verb = CommandVerb(command);
557
23.8k
  if ((VerbEquals(verb, "EPRT") || VerbEquals(verb, "PORT")) && response_code >= 200 && response_code < 300) {
558
69
    ConnectActiveDataChannel();
559
69
  }
560
23.8k
  TransferDirection direction = DirectionForVerb(verb);
561
23.8k
  if (direction == TransferDirection::kNone && IsConfiguredCustomDownload(verb)) {
562
184
    direction = TransferDirection::kDownload;
563
184
  }
564
23.8k
  const bool starts_download =
565
23.8k
      direction == TransferDirection::kDownload && (response_code == 125 || response_code == 150);
566
  // curl's STOR handler accepts every response below 400 before initiating
567
  // the upload, even though 125/150 are the conventional successful replies.
568
23.8k
  const bool starts_upload = direction == TransferDirection::kUpload && response_code >= 100 && response_code < 400;
569
23.8k
  if (starts_download || starts_upload) {
570
3.27k
    StartNextTransfer(direction);
571
3.27k
    QueueTransferCompletion();
572
3.27k
  }
573
23.8k
}
574
575
/// Read without waiting, retain partial final commands, and process all full
576
/// lines already emitted by curl. The number of replies is bounded even if a
577
/// malformed command stream contains many newlines.
578
90.5k
bool FtpMockServer::ServiceControlConnection() {
579
90.5k
  if (control_connection_ == nullptr) {
580
212
    return false;
581
212
  }
582
583
90.3k
  const std::size_t size_before_read = pending_control_bytes_.size();
584
90.3k
  control_connection_->ReadAvailable(&pending_control_bytes_);
585
90.3k
  bool made_progress = pending_control_bytes_.size() != size_before_read;
586
587
90.3k
  std::size_t consumed = 0;
588
147k
  while (consumed < pending_control_bytes_.size()) {
589
56.8k
    const std::size_t newline = pending_control_bytes_.find('\n', consumed);
590
56.8k
    if (newline == std::string::npos) {
591
0
      break;
592
0
    }
593
56.8k
    const std::size_t command_size = newline - consumed + 1;
594
56.8k
    const std::string_view command(pending_control_bytes_.data() + consumed, command_size);
595
56.8k
    CapturePrefix(command, kMaxCapturedControlBytes, &control_transcript_);
596
56.8k
    HandleControlCommand(command);
597
56.8k
    consumed += command_size;
598
56.8k
    made_progress = true;
599
56.8k
  }
600
601
90.3k
  if (consumed != 0) {
602
35.9k
    pending_control_bytes_.erase(0, consumed);
603
35.9k
  }
604
90.3k
  return made_progress;
605
90.5k
}
606
607
/// Drain uploads into a temporary buffer so data beyond the observable cap is
608
/// still removed from the socket. This keeps curl's progress independent of
609
/// how much a unit test chooses to retain.
610
90.5k
bool FtpMockServer::ServiceUploadConnections() {
611
90.5k
  bool made_progress = false;
612
123k
  for (std::size_t index = 0; index < next_data_script_; ++index) {
613
32.6k
    DataChannel& channel = data_channels_[index];
614
32.6k
    if (!channel.upload || (channel.connection == nullptr && channel.active_fd < 0)) {
615
29.8k
      continue;
616
29.8k
    }
617
618
2.81k
    std::string bytes;
619
2.81k
    if (channel.connection != nullptr) {
620
2.78k
      channel.connection->ReadAvailable(&bytes);
621
2.78k
    } else {
622
33
      (void)ReadActiveBytes(channel.active_fd, &bytes);
623
33
    }
624
2.81k
    if (!bytes.empty()) {
625
1.88k
      CapturePrefix(bytes, kMaxCapturedUploadBytes, &uploaded_data_);
626
1.88k
      made_progress = true;
627
1.88k
    }
628
2.81k
  }
629
90.5k
  return made_progress;
630
90.5k
}
631
632
/// Prepare cleanup while the mock can still write to curl's control socket.
633
/// A completed easy remains in multi's connection cache until cleanup, where
634
/// FTP sends QUIT and performs a blocking read. Preloading 221 handles that
635
/// path; an unfinished drive instead gets EOF so cleanup fails fast.
636
9.03k
void FtpMockServer::FinishConnections(bool completed) {
637
9.03k
  if (control_connection_ != nullptr) {
638
8.92k
    if (completed) {
639
4.05k
      (void)WriteControlBytes("221 mock closing control connection\r\n");
640
4.05k
    }
641
8.92k
    control_connection_->ShutdownWrite();
642
8.92k
  }
643
13.8k
  for (std::size_t index = 0; index < next_data_script_; ++index) {
644
4.85k
    if (data_channels_[index].connection != nullptr) {
645
4.75k
      data_channels_[index].connection->ShutdownWrite();
646
4.75k
    }
647
4.85k
    if (data_channels_[index].active_fd >= 0) {
648
69
      (void)shutdown(data_channels_[index].active_fd, SHUT_WR);
649
69
    }
650
4.85k
  }
651
9.03k
  if (active_listener_fd_ >= 0) {
652
33
    close(active_listener_fd_);
653
33
    active_listener_fd_ = -1;
654
33
  }
655
9.03k
  active_listener_client_fd_ = CURL_SOCKET_BAD;
656
9.03k
}
657
658
/// Alternate curl transitions with immediate peer service until the transfer
659
/// completes or deterministic idle/operation caps win. No select(), poll(),
660
/// or sleep is needed: every transport is a local socketpair, and malformed
661
/// scripts are terminated by half-close after the bounded loop.
662
9.03k
void FtpMockServer::RunLoop(CURLM* multi, CURL* easy, const curl::fuzzer::proto::Scenario& scenario) {
663
9.03k
  (void)easy;
664
9.03k
  ResetForScenario(scenario);
665
666
9.03k
  int still_running = 1;
667
9.03k
  int idle_iterations = 0;
668
9.03k
  int drive_iterations = 0;
669
9.03k
  CURLMcode result = CURLM_OK;
670
90.5k
  while (still_running && idle_iterations < kMaxIdleIterations && drive_iterations++ < kMaxDriveIterations) {
671
81.5k
    const int running_before = still_running;
672
81.5k
    result = curl_multi_perform(multi, &still_running);
673
81.5k
    if (result != CURLM_OK) {
674
0
      break;
675
0
    }
676
677
81.5k
    bool made_progress = still_running != running_before;
678
81.5k
    made_progress = ServiceControlConnection() || made_progress;
679
81.5k
    made_progress = ServiceUploadConnections() || made_progress;
680
81.5k
    if (made_progress) {
681
41.5k
      idle_iterations = 0;
682
41.5k
    } else {
683
39.9k
      ++idle_iterations;
684
39.9k
    }
685
81.5k
  }
686
687
  // The final perform may have emitted upload or control bytes immediately
688
  // before marking the transfer done. Drain/capture them before cleanup.
689
9.03k
  (void)ServiceControlConnection();
690
9.03k
  (void)ServiceUploadConnections();
691
9.03k
  FinishConnections(still_running == 0 && result == CURLM_OK);
692
9.03k
  scenario_ = nullptr;
693
9.03k
}
694
695
}  // namespace proto_fuzzer