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