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