Coverage Report

Created: 2026-10-15 06:25

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.90k
    : scenario_(nullptr),
38
8.90k
      next_data_script_(0),
39
8.90k
      active_listener_fd_(-1),
40
8.90k
      active_listener_client_fd_(CURL_SOCKET_BAD),
41
8.90k
      pending_active_channel_(kMaxDataChannels),
42
8.90k
      control_reply_count_(0),
43
8.90k
      next_control_reply_(0),
44
8.90k
      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.90k
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.83k
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.83k
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.83k
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.79k
void FtpMockServer::ResetForScenario(const curl::fuzzer::proto::Scenario& scenario) {
73
8.79k
  CloseActiveDescriptors();
74
8.79k
  control_connection_.reset();
75
26.3k
  for (DataChannel& channel : data_channels_) {
76
26.3k
    channel.connection.reset();
77
26.3k
    channel.active_fd = -1;
78
26.3k
    channel.script = nullptr;
79
26.3k
    channel.transfer_started = false;
80
26.3k
    channel.upload = false;
81
26.3k
  }
82
83
8.79k
  scenario_ = &scenario;
84
8.79k
  next_data_script_ = 0;
85
8.79k
  pending_active_channel_ = kMaxDataChannels;
86
8.79k
  control_reply_count_ = std::min<std::size_t>(scenario_limits::kMaxResponseChunks,
87
8.79k
                                               static_cast<std::size_t>(scenario.connection().on_readable_size()));
88
8.79k
  next_control_reply_ = 0;
89
8.79k
  pending_control_bytes_.clear();
90
8.79k
  control_transcript_.clear();
91
8.79k
  uploaded_data_.clear();
92
8.79k
  control_opened_ = false;
93
8.79k
}
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
53.0k
  for (DataChannel& channel : data_channels_) {
105
53.0k
    if (channel.active_fd >= 0) {
106
97
      close(channel.active_fd);
107
97
      channel.active_fd = -1;
108
97
    }
109
53.0k
  }
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.8k
void FtpMockServer::ApplyScriptBackpressure(MockConnection* connection, const curl::fuzzer::proto::Connection& script) {
118
12.8k
  if (connection == nullptr) {
119
0
    return;
120
0
  }
121
122
12.8k
  const std::uint32_t int_max = static_cast<std::uint32_t>(std::numeric_limits<int>::max());
123
12.8k
  const auto& backpressure = script.backpressure();
124
12.8k
  const int receive_buffer = static_cast<int>(std::min(backpressure.recv_buf_bytes(), int_max));
125
12.8k
  connection->ApplyBackpressure(receive_buffer, static_cast<std::size_t>(backpressure.drain_limit()));
126
12.8k
}
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.63k
bool FtpMockServer::UsesActiveMode() const {
131
4.63k
  if (scenario_ == nullptr) {
132
0
    return false;
133
0
  }
134
10.8k
  for (int index = scenario_->options_size() - 1; index >= 0; --index) {
135
6.38k
    const auto& option = scenario_->options(index);
136
6.38k
    if (option.option_id() == curl::fuzzer::proto::CURLOPT_FTPPORT) {
137
206
      return option.value_case() == curl::fuzzer::proto::SetOption::kStringValue && !option.string_value().empty();
138
206
    }
139
6.38k
  }
140
4.42k
  return false;
141
4.63k
}
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.9k
SocketSetupDisposition FtpMockServer::GetSocketSetupDisposition(curl_socket_t curlfd, curlsocktype purpose) const {
151
12.9k
  if (purpose == CURLSOCKTYPE_ACCEPT || curlfd == active_listener_client_fd_) {
152
95
    return SocketSetupDisposition::kNeedsSetup;
153
95
  }
154
12.8k
  return SocketSetupDisposition::kAlreadyConnected;
155
12.9k
}
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
206
curl_socket_t FtpMockServer::OpenActiveListener(struct curl_sockaddr* address) {
161
206
  if (address == nullptr || (address->family != AF_INET && address->family != AF_INET6)) {
162
0
    return CURL_SOCKET_BAD;
163
0
  }
164
165
206
  if (pending_active_channel_ == kMaxDataChannels) {
166
180
    const std::size_t available_scripts =
167
180
        std::min<std::size_t>(kMaxDataChannels, static_cast<std::size_t>(scenario_->subsequent_connections_size()));
168
180
    if (next_data_script_ >= available_scripts) {
169
9
      return CURL_SOCKET_BAD;
170
9
    }
171
171
    pending_active_channel_ = next_data_script_++;
172
171
    DataChannel& channel = data_channels_[pending_active_channel_];
173
171
    channel.script = &scenario_->subsequent_connections(static_cast<int>(pending_active_channel_));
174
171
  }
175
176
197
  if (active_listener_fd_ >= 0) {
177
26
    close(active_listener_fd_);
178
26
    active_listener_fd_ = -1;
179
26
  }
180
197
  active_listener_client_fd_ = CURL_SOCKET_BAD;
181
182
197
  const int listener = socket(address->family, address->socktype, address->protocol);
183
197
  if (listener < 0) {
184
0
    return CURL_SOCKET_BAD;
185
0
  }
186
197
  active_listener_fd_ = dup(listener);
187
197
  if (active_listener_fd_ < 0) {
188
0
    close(listener);
189
0
    return CURL_SOCKET_BAD;
190
0
  }
191
197
  active_listener_client_fd_ = listener;
192
197
  (void)fcntl(active_listener_fd_, F_SETFD, FD_CLOEXEC);
193
197
  return listener;
194
197
}
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.1k
curl_socket_t FtpMockServer::HandleOpenSocket(curlsocktype purpose, struct curl_sockaddr* address) {
205
13.1k
  if (scenario_ == nullptr || purpose != CURLSOCKTYPE_IPCXN) {
206
0
    return CURL_SOCKET_BAD;
207
0
  }
208
209
13.1k
  if (!control_opened_) {
210
8.47k
    control_connection_ = std::make_unique<MockConnection>();
211
8.47k
    if (!control_connection_->ok()) {
212
0
      control_connection_.reset();
213
0
      return CURL_SOCKET_BAD;
214
0
    }
215
216
8.47k
    ApplyScriptBackpressure(control_connection_.get(), scenario_->connection());
217
8.47k
    const std::string& greeting = scenario_->connection().initial_response();
218
8.47k
    if (!greeting.empty() &&
219
6.10k
        !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.47k
    control_opened_ = true;
227
8.47k
    return control_connection_->take_client_fd();
228
8.47k
  }
229
230
4.63k
  if (UsesActiveMode()) {
231
206
    return OpenActiveListener(address);
232
206
  }
233
234
4.42k
  const std::size_t available_scripts =
235
4.42k
      std::min<std::size_t>(kMaxDataChannels, static_cast<std::size_t>(scenario_->subsequent_connections_size()));
236
4.42k
  if (next_data_script_ >= available_scripts) {
237
81
    return CURL_SOCKET_BAD;
238
81
  }
239
240
4.34k
  DataChannel& channel = data_channels_[next_data_script_];
241
4.34k
  channel.script = &scenario_->subsequent_connections(static_cast<int>(next_data_script_));
242
4.34k
  channel.connection = std::make_unique<MockConnection>();
243
4.34k
  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.34k
  ApplyScriptBackpressure(channel.connection.get(), *channel.script);
250
4.34k
  ++next_data_script_;
251
4.34k
  return channel.connection->take_client_fd();
252
4.34k
}
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
12
bool FtpMockServer::WriteActiveBytes(int fd, const unsigned char* data, std::size_t size) {
258
12
  std::size_t offset = 0;
259
24
  while (offset < size) {
260
12
    const ssize_t written = send(fd, data + offset, size - offset, MSG_NOSIGNAL | MSG_DONTWAIT);
261
12
    if (written > 0) {
262
12
      offset += static_cast<std::size_t>(written);
263
12
      continue;
264
12
    }
265
0
    if (written < 0 && errno == EINTR) {
266
0
      continue;
267
0
    }
268
0
    return false;
269
0
  }
270
12
  return true;
271
12
}
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
99
bool FtpMockServer::ReadActiveBytes(int fd, std::string* output) {
276
99
  bool made_progress = false;
277
99
  char buffer[16 * 1024];
278
114
  for (;;) {
279
114
    const ssize_t amount = recv(fd, buffer, sizeof(buffer), MSG_DONTWAIT);
280
114
    if (amount > 0) {
281
15
      output->append(buffer, static_cast<std::size_t>(amount));
282
15
      made_progress = true;
283
15
      continue;
284
15
    }
285
99
    if (amount < 0 && errno == EINTR) {
286
0
      continue;
287
0
    }
288
99
    return made_progress;
289
99
  }
290
99
}
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
99
void FtpMockServer::ConnectActiveDataChannel() {
296
99
  if (active_listener_fd_ < 0 || pending_active_channel_ >= kMaxDataChannels) {
297
2
    return;
298
2
  }
299
300
97
  struct sockaddr_storage address;
301
97
  std::memset(&address, 0, sizeof(address));
302
97
  socklen_t address_length = sizeof(address);
303
97
  if (getsockname(active_listener_fd_, reinterpret_cast<struct sockaddr*>(&address), &address_length) != 0) {
304
0
    return;
305
0
  }
306
307
97
  const int fd = socket(address.ss_family, SOCK_STREAM, IPPROTO_TCP);
308
97
  if (fd < 0) {
309
0
    return;
310
0
  }
311
97
  if (connect(fd, reinterpret_cast<struct sockaddr*>(&address), address_length) != 0) {
312
0
    close(fd);
313
0
    return;
314
0
  }
315
97
  const int flags = fcntl(fd, F_GETFL, 0);
316
97
  if (flags >= 0) {
317
97
    (void)fcntl(fd, F_SETFL, flags | O_NONBLOCK);
318
97
  }
319
97
  (void)fcntl(fd, F_SETFD, FD_CLOEXEC);
320
321
97
  DataChannel& channel = data_channels_[pending_active_channel_];
322
97
  channel.active_fd = fd;
323
97
  close(active_listener_fd_);
324
97
  active_listener_fd_ = -1;
325
97
  active_listener_client_fd_ = CURL_SOCKET_BAD;
326
97
  pending_active_channel_ = kMaxDataChannels;
327
97
}
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
46.5k
void FtpMockServer::CapturePrefix(std::string_view source, std::size_t limit, std::string* destination) {
336
46.5k
  if (destination == nullptr || destination->size() >= limit) {
337
15
    return;
338
15
  }
339
46.5k
  const std::size_t available = limit - destination->size();
340
46.5k
  destination->append(source.data(), std::min(source.size(), available));
341
46.5k
}
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.9k
std::string_view FtpMockServer::CommandVerb(std::string_view command) {
349
21.9k
  std::size_t begin = 0;
350
24.2k
  while (begin < command.size() && (command[begin] == ' ' || command[begin] == '\t')) {
351
2.33k
    ++begin;
352
2.33k
  }
353
354
21.9k
  std::size_t end = begin;
355
227k
  while (end < command.size() && command[end] != ' ' && command[end] != '\t' && command[end] != '\r' &&
356
205k
         command[end] != '\n') {
357
205k
    ++end;
358
205k
  }
359
21.9k
  return command.substr(begin, end - begin);
360
21.9k
}
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
158k
bool FtpMockServer::VerbEquals(std::string_view verb, std::string_view expected_uppercase) {
369
158k
  if (verb.size() != expected_uppercase.size()) {
370
61.4k
    return false;
371
61.4k
  }
372
121k
  for (std::size_t index = 0; index < verb.size(); ++index) {
373
118k
    unsigned char actual = static_cast<unsigned char>(verb[index]);
374
118k
    if (actual >= 'a' && actual <= 'z') {
375
531
      actual = static_cast<unsigned char>(actual - 'a' + 'A');
376
531
    }
377
118k
    if (actual != static_cast<unsigned char>(expected_uppercase[index])) {
378
94.3k
      return false;
379
94.3k
    }
380
118k
  }
381
3.16k
  return true;
382
97.5k
}
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.5k
FtpMockServer::TransferDirection FtpMockServer::DirectionForVerb(std::string_view verb) {
391
20.5k
  if (VerbEquals(verb, "STOR") || VerbEquals(verb, "APPE")) {
392
262
    return TransferDirection::kUpload;
393
262
  }
394
20.2k
  if (VerbEquals(verb, "RETR") || VerbEquals(verb, "LIST") || VerbEquals(verb, "NLST") || VerbEquals(verb, "MLSD")) {
395
2.49k
    return TransferDirection::kDownload;
396
2.49k
  }
397
17.7k
  return TransferDirection::kNone;
398
20.2k
}
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.7k
bool FtpMockServer::IsConfiguredCustomDownload(std::string_view verb) const {
409
17.7k
  if (scenario_ == nullptr || verb.empty()) {
410
111
    return false;
411
111
  }
412
413
38.2k
  for (int index = scenario_->options_size() - 1; index >= 0; --index) {
414
22.0k
    const auto& option = scenario_->options(index);
415
22.0k
    if (option.option_id() != curl::fuzzer::proto::CURLOPT_CUSTOMREQUEST) {
416
20.6k
      continue;
417
20.6k
    }
418
1.41k
    if (option.value_case() != curl::fuzzer::proto::SetOption::kStringValue) {
419
0
      return false;
420
0
    }
421
1.41k
    return VerbEquals(verb, CommandVerb(option.string_value()));
422
1.41k
  }
423
16.2k
  return false;
424
17.6k
}
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.4k
int FtpMockServer::FirstReplyCode(std::string_view response) {
432
21.4k
  std::size_t line_start = 0;
433
140k
  while (line_start < response.size()) {
434
139k
    const std::size_t newline = response.find('\n', line_start);
435
139k
    if (newline == std::string_view::npos) {
436
908
      return -1;
437
908
    }
438
138k
    const std::size_t line_size = newline - line_start + 1;
439
138k
    if (line_size > 3) {
440
30.1k
      const unsigned char first = static_cast<unsigned char>(response[line_start]);
441
30.1k
      const unsigned char second = static_cast<unsigned char>(response[line_start + 1]);
442
30.1k
      const unsigned char third = static_cast<unsigned char>(response[line_start + 2]);
443
30.1k
      if (first >= '0' && first <= '9' && second >= '0' && second <= '9' && third >= '0' && third <= '9' &&
444
20.1k
          response[line_start + 3] == ' ') {
445
19.7k
        return 100 * (first - '0') + 10 * (second - '0') + (third - '0');
446
19.7k
      }
447
30.1k
    }
448
119k
    line_start = newline + 1;
449
119k
  }
450
815
  return -1;
451
21.4k
}
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
796
bool FtpMockServer::TailNeedsOnlyNewline(std::string_view response) {
459
796
  const std::size_t last_newline = response.rfind('\n');
460
796
  const std::size_t tail = last_newline == std::string_view::npos ? 0 : last_newline + 1;
461
796
  if (response.size() - tail <= 3) {
462
631
    return false;
463
631
  }
464
165
  const unsigned char first = static_cast<unsigned char>(response[tail]);
465
165
  const unsigned char second = static_cast<unsigned char>(response[tail + 1]);
466
165
  const unsigned char third = static_cast<unsigned char>(response[tail + 2]);
467
165
  return first >= '0' && first <= '9' && second >= '0' && second <= '9' && third >= '0' && third <= '9' &&
468
36
         response[tail + 3] == ' ';
469
796
}
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
47.7k
const std::string* FtpMockServer::NextControlReply() {
477
47.7k
  if (scenario_ == nullptr || next_control_reply_ >= control_reply_count_) {
478
26.2k
    return nullptr;
479
26.2k
  }
480
21.4k
  return &scenario_->connection().on_readable(static_cast<int>(next_control_reply_++));
481
47.7k
}
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.6k
bool FtpMockServer::WriteControlBytes(std::string_view bytes) {
489
28.6k
  return bytes.empty() ||
490
28.1k
         (control_connection_ != nullptr &&
491
28.1k
          control_connection_->WriteAll(reinterpret_cast<const unsigned char*>(bytes.data()), bytes.size()));
492
28.6k
}
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.88k
void FtpMockServer::QueueTransferCompletion() {
502
2.88k
  const std::string* response = NextControlReply();
503
2.88k
  if (response != nullptr && response->empty()) {
504
51
    if (control_connection_ != nullptr) {
505
51
      control_connection_->ShutdownWrite();
506
51
    }
507
51
    return;
508
51
  }
509
2.83k
  if (response != nullptr && !response->empty()) {
510
893
    (void)WriteControlBytes(*response);
511
893
    if (FirstReplyCode(*response) >= 0) {
512
97
      return;
513
97
    }
514
796
    if (TailNeedsOnlyNewline(*response)) {
515
11
      (void)WriteControlBytes("\r\n");
516
11
      return;
517
11
    }
518
785
    if (response->back() != '\n') {
519
661
      (void)WriteControlBytes("\r\n");
520
661
    }
521
785
  }
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.72k
  (void)WriteControlBytes("226 mock transfer complete\r\n");
526
2.72k
}
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.52k
void FtpMockServer::PreloadDownload(DataChannel* channel) {
535
2.52k
  if (channel == nullptr || channel->script == nullptr || (channel->connection == nullptr && channel->active_fd < 0)) {
536
0
    return;
537
0
  }
538
539
2.52k
  const std::string& initial = channel->script->initial_response();
540
6.11k
  const auto write_bytes = [channel](const std::string& bytes) {
541
6.11k
    if (bytes.empty()) {
542
1.16k
      return true;
543
1.16k
    }
544
4.95k
    if (channel->connection != nullptr) {
545
4.93k
      return channel->connection->WriteAll(reinterpret_cast<const unsigned char*>(bytes.data()), bytes.size());
546
4.93k
    }
547
12
    return WriteActiveBytes(channel->active_fd, reinterpret_cast<const unsigned char*>(bytes.data()), bytes.size());
548
4.95k
  };
549
2.52k
  bool complete = write_bytes(initial);
550
2.52k
  const std::size_t chunk_count = std::min<std::size_t>(scenario_limits::kMaxResponseChunks,
551
2.52k
                                                        static_cast<std::size_t>(channel->script->on_readable_size()));
552
6.11k
  for (std::size_t index = 0; complete && index < chunk_count; ++index) {
553
3.59k
    const std::string& chunk = channel->script->on_readable(static_cast<int>(index));
554
3.59k
    complete = write_bytes(chunk);
555
3.59k
  }
556
2.52k
  (void)complete;
557
2.52k
  if (channel->connection != nullptr) {
558
2.48k
    channel->connection->ShutdownWrite();
559
2.48k
  } else {
560
37
    (void)shutdown(channel->active_fd, SHUT_WR);
561
37
  }
562
2.52k
}
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.88k
void FtpMockServer::StartNextTransfer(TransferDirection direction) {
569
3.43k
  for (std::size_t index = 0; index < next_data_script_; ++index) {
570
3.32k
    DataChannel& channel = data_channels_[index];
571
3.32k
    if ((channel.connection == nullptr && channel.active_fd < 0) || channel.transfer_started) {
572
557
      continue;
573
557
    }
574
575
2.77k
    channel.transfer_started = true;
576
2.77k
    channel.upload = direction == TransferDirection::kUpload;
577
2.77k
    if (!channel.upload) {
578
2.52k
      PreloadDownload(&channel);
579
2.52k
    }
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.77k
    return;
585
3.32k
  }
586
2.88k
}
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
44.8k
void FtpMockServer::HandleControlCommand(std::string_view command) {
593
44.8k
  const std::string* response = NextControlReply();
594
44.8k
  if (response == nullptr) {
595
24.3k
    return;
596
24.3k
  }
597
598
20.5k
  const int response_code = FirstReplyCode(*response);
599
20.5k
  (void)WriteControlBytes(*response);
600
601
20.5k
  const std::string_view verb = CommandVerb(command);
602
20.5k
  if ((VerbEquals(verb, "EPRT") || VerbEquals(verb, "PORT")) && response_code >= 200 && response_code < 300) {
603
99
    ConnectActiveDataChannel();
604
99
  }
605
20.5k
  TransferDirection direction = DirectionForVerb(verb);
606
20.5k
  if (direction == TransferDirection::kNone && IsConfiguredCustomDownload(verb)) {
607
267
    direction = TransferDirection::kDownload;
608
267
  }
609
20.5k
  const bool starts_download =
610
20.5k
      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.5k
  const bool starts_upload = direction == TransferDirection::kUpload && response_code >= 100 && response_code < 400;
614
20.5k
  if (starts_download || starts_upload) {
615
2.88k
    StartNextTransfer(direction);
616
2.88k
    QueueTransferCompletion();
617
2.88k
  }
618
20.5k
}
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
81.9k
bool FtpMockServer::ServiceControlConnection() {
625
81.9k
  if (control_connection_ == nullptr) {
626
1.16k
    return false;
627
1.16k
  }
628
629
80.7k
  const std::size_t size_before_read = pending_control_bytes_.size();
630
80.7k
  control_connection_->ReadAvailable(&pending_control_bytes_);
631
80.7k
  bool made_progress = pending_control_bytes_.size() != size_before_read;
632
633
80.7k
  std::size_t consumed = 0;
634
125k
  while (consumed < pending_control_bytes_.size()) {
635
44.8k
    const std::size_t newline = pending_control_bytes_.find('\n', consumed);
636
44.8k
    if (newline == std::string::npos) {
637
0
      break;
638
0
    }
639
44.8k
    const std::size_t command_size = newline - consumed + 1;
640
44.8k
    const std::string_view command(pending_control_bytes_.data() + consumed, command_size);
641
44.8k
    CapturePrefix(command, kMaxCapturedControlBytes, &control_transcript_);
642
44.8k
    HandleControlCommand(command);
643
44.8k
    consumed += command_size;
644
44.8k
    made_progress = true;
645
44.8k
  }
646
647
80.7k
  if (consumed != 0) {
648
29.4k
    pending_control_bytes_.erase(0, consumed);
649
29.4k
  }
650
80.7k
  return made_progress;
651
81.9k
}
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
81.9k
bool FtpMockServer::ServiceUploadConnections() {
658
81.9k
  bool made_progress = false;
659
110k
  for (std::size_t index = 0; index < next_data_script_; ++index) {
660
28.9k
    DataChannel& channel = data_channels_[index];
661
28.9k
    if (!channel.upload || (channel.connection == nullptr && channel.active_fd < 0)) {
662
26.5k
      continue;
663
26.5k
    }
664
665
2.46k
    std::string bytes;
666
2.46k
    if (channel.connection != nullptr) {
667
2.36k
      channel.connection->ReadAvailable(&bytes);
668
2.36k
    } else {
669
99
      (void)ReadActiveBytes(channel.active_fd, &bytes);
670
99
    }
671
2.46k
    if (!bytes.empty()) {
672
1.69k
      CapturePrefix(bytes, kMaxCapturedUploadBytes, &uploaded_data_);
673
1.69k
      made_progress = true;
674
1.69k
    }
675
2.46k
  }
676
81.9k
  return made_progress;
677
81.9k
}
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.79k
void FtpMockServer::FinishConnections(bool completed) {
685
8.79k
  if (control_connection_ != nullptr) {
686
8.47k
    if (completed) {
687
3.86k
      (void)WriteControlBytes("221 mock closing control connection\r\n");
688
3.86k
    }
689
8.47k
    control_connection_->ShutdownWrite();
690
8.47k
  }
691
13.3k
  for (std::size_t index = 0; index < next_data_script_; ++index) {
692
4.51k
    if (data_channels_[index].connection != nullptr) {
693
4.34k
      data_channels_[index].connection->ShutdownWrite();
694
4.34k
    }
695
4.51k
    if (data_channels_[index].active_fd >= 0) {
696
97
      (void)shutdown(data_channels_[index].active_fd, SHUT_WR);
697
97
    }
698
4.51k
  }
699
8.79k
  if (active_listener_fd_ >= 0) {
700
74
    close(active_listener_fd_);
701
74
    active_listener_fd_ = -1;
702
74
  }
703
8.79k
  active_listener_client_fd_ = CURL_SOCKET_BAD;
704
8.79k
}
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.79k
void FtpMockServer::RunLoop(CURLM* multi, CURL* easy, const curl::fuzzer::proto::Scenario& scenario) {
714
8.79k
  (void)easy;
715
8.79k
  ResetForScenario(scenario);
716
717
8.79k
  int still_running = 1;
718
8.79k
  int idle_iterations = 0;
719
8.79k
  int drive_iterations = 0;
720
8.79k
  CURLMcode result = CURLM_OK;
721
81.9k
  while (still_running && idle_iterations < kMaxIdleIterations && drive_iterations++ < kMaxDriveIterations) {
722
73.1k
    const int running_before = still_running;
723
73.1k
    result = curl_multi_perform(multi, &still_running);
724
73.1k
    if (result != CURLM_OK) {
725
0
      break;
726
0
    }
727
728
73.1k
    bool made_progress = still_running != running_before;
729
73.1k
    made_progress = ServiceControlConnection() || made_progress;
730
73.1k
    made_progress = ServiceUploadConnections() || made_progress;
731
73.1k
    if (made_progress) {
732
34.6k
      idle_iterations = 0;
733
38.4k
    } else {
734
38.4k
      ++idle_iterations;
735
38.4k
    }
736
73.1k
  }
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.79k
  (void)ServiceControlConnection();
741
8.79k
  (void)ServiceUploadConnections();
742
8.79k
  FinishConnections(still_running == 0 && result == CURLM_OK);
743
8.79k
  scenario_ = nullptr;
744
8.79k
}
745
746
}  // namespace proto_fuzzer