Coverage Report

Created: 2026-10-28 07:17

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