Coverage Report

Created: 2026-10-22 06:56

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