/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 |