/src/curl_fuzzer/proto_fuzzer/imap_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 IMAP peer. |
9 | | |
10 | | #include "proto_fuzzer/imap_mock_server.h" |
11 | | |
12 | | #include <algorithm> |
13 | | #include <cstddef> |
14 | | #include <cstdint> |
15 | | #include <limits> |
16 | | #include <string> |
17 | | #include <string_view> |
18 | | |
19 | | namespace proto_fuzzer { |
20 | | |
21 | | namespace { |
22 | | |
23 | | /// Fold only ASCII lowercase command bytes. IMAP status keywords are ASCII |
24 | | /// tokens, so locale-aware conversion would add state without accepting |
25 | | /// anything curl can legitimately send. |
26 | 153k | bool AsciiEqualsIgnoreCase(std::string_view value, std::string_view expected_lowercase) { |
27 | 153k | if (value.size() != expected_lowercase.size()) { |
28 | 133k | return false; |
29 | 133k | } |
30 | 39.4k | for (std::size_t index = 0; index < value.size(); ++index) { |
31 | 30.0k | unsigned char actual = static_cast<unsigned char>(value[index]); |
32 | 30.0k | if (actual >= 'A' && actual <= 'Z') { |
33 | 20.2k | actual = static_cast<unsigned char>(actual - 'A' + 'a'); |
34 | 20.2k | } |
35 | 30.0k | if (actual != static_cast<unsigned char>(expected_lowercase[index])) { |
36 | 10.7k | return false; |
37 | 10.7k | } |
38 | 30.0k | } |
39 | 9.38k | return true; |
40 | 20.1k | } |
41 | | |
42 | | } // namespace |
43 | | |
44 | | /// Begin without a borrowed protobuf or open socketpair. RunLoop borrows a |
45 | | /// Scenario only for the synchronous DriveScenario call in which socket |
46 | | /// callbacks can run. |
47 | 5.14k | ImapMockServer::ImapMockServer() = default; |
48 | | |
49 | | /// Release the server-owned socketpair half after curl has returned its client |
50 | | /// descriptor. The out-of-line destructor keeps MockConnection's complete type |
51 | | /// local to this translation unit. |
52 | 5.14k | ImapMockServer::~ImapMockServer() = default; |
53 | | |
54 | | /// Clear the tag remembered from the previous drive. The shared base has |
55 | | /// already reset the transport, reply cursor, transcript, upload capture, and |
56 | | /// body phase. |
57 | 5.05k | void ImapMockServer::ResetProtocolState() { |
58 | 5.05k | current_tag_.clear(); |
59 | 5.05k | authentication_continuation_active_ = false; |
60 | 5.05k | } |
61 | | |
62 | | /// Return whether a scripted line is a tagged status response. Untagged (`*`) |
63 | | /// and continuation (`+`) lines are left alone, as are already-tagged or |
64 | | /// arbitrary bytes that curl should observe verbatim. |
65 | | /// @param line One complete response line, including its line ending. |
66 | | /// @return true when the current command tag must be prepended. |
67 | 66.0k | bool ImapMockServer::StatusLineNeedsTag(std::string_view line) { |
68 | 66.0k | const std::size_t space = line.find_first_of(" \r\n"); |
69 | 66.0k | const std::string_view keyword = line.substr(0, space); |
70 | 66.0k | if (keyword.empty()) { |
71 | 20.7k | return false; |
72 | 20.7k | } |
73 | 45.3k | if (!AsciiEqualsIgnoreCase(keyword, "ok") && !AsciiEqualsIgnoreCase(keyword, "no") && |
74 | 35.9k | !AsciiEqualsIgnoreCase(keyword, "bad") && !AsciiEqualsIgnoreCase(keyword, "preauth")) { |
75 | 35.9k | return false; |
76 | 35.9k | } |
77 | 9.38k | return space != std::string_view::npos; |
78 | 45.3k | } |
79 | | |
80 | | /// Return whether a scripted response contains an IMAP continuation line. |
81 | | /// Literal bodies are skipped with the same rules as RewriteReply so their |
82 | | /// arbitrary bytes cannot start a continuation exchange. |
83 | | /// @param response Scenario-owned reply fragment. |
84 | | /// @return true when a non-literal response line begins with `+`. |
85 | 7.59k | bool ImapMockServer::ResponseHasContinuation(std::string_view response) { |
86 | 7.59k | std::size_t position = 0; |
87 | 39.0k | while (position < response.size()) { |
88 | 33.7k | const std::size_t newline = response.find('\n', position); |
89 | 33.7k | const std::size_t line_end = newline == std::string_view::npos ? response.size() : newline + 1; |
90 | 33.7k | const std::string_view line = response.substr(position, line_end - position); |
91 | 33.7k | if (!line.empty() && line.front() == '+') { |
92 | 2.20k | return true; |
93 | 2.20k | } |
94 | | |
95 | 31.5k | const std::size_t literal = LiteralSize(line); |
96 | 31.5k | const std::size_t available = response.size() - line_end; |
97 | 31.5k | position = line_end + std::min(literal, available); |
98 | 31.5k | } |
99 | 5.38k | return false; |
100 | 7.59k | } |
101 | | |
102 | | /// Return whether a scripted response contains a terminal status for the |
103 | | /// current tag. Literal bodies are skipped with the same rules as RewriteReply |
104 | | /// so body bytes that happen to begin with an IMAP status word cannot end |
105 | | /// authentication. Both tag-relative placeholders and an exact already-tagged |
106 | | /// line are accepted. |
107 | | /// @param response Scenario-owned reply fragment. |
108 | | /// @return true when the response completes the current tagged command. |
109 | 7.59k | bool ImapMockServer::ResponseHasTaggedCompletion(std::string_view response) const { |
110 | 7.59k | std::size_t position = 0; |
111 | 29.9k | while (position < response.size()) { |
112 | 26.5k | const std::size_t newline = response.find('\n', position); |
113 | 26.5k | const std::size_t line_end = newline == std::string_view::npos ? response.size() : newline + 1; |
114 | 26.5k | const std::string_view line = response.substr(position, line_end - position); |
115 | 26.5k | const bool tag_relative = StatusLineNeedsTag(line); |
116 | 26.5k | const bool has_current_tag = !current_tag_.empty() && line.size() > current_tag_.size() && |
117 | 13.7k | line.compare(0, current_tag_.size(), current_tag_) == 0 && |
118 | 162 | line[current_tag_.size()] == ' ' && |
119 | 92 | StatusLineNeedsTag(line.substr(current_tag_.size() + 1)); |
120 | 26.5k | if (tag_relative || has_current_tag) { |
121 | 4.24k | return true; |
122 | 4.24k | } |
123 | | |
124 | 22.3k | const std::size_t literal = LiteralSize(line); |
125 | 22.3k | const std::size_t available = response.size() - line_end; |
126 | 22.3k | position = line_end + std::min(literal, available); |
127 | 22.3k | } |
128 | 3.34k | return false; |
129 | 7.59k | } |
130 | | |
131 | | /// Parse a trailing IMAP literal marker such as `{42}` or `{42+}`. |
132 | | /// @param line One response or command line. |
133 | | /// @return Literal byte count, or 0 when no well-formed marker is present. |
134 | 93.9k | std::size_t ImapMockServer::LiteralSize(std::string_view line) { |
135 | 214k | while (!line.empty() && (line.back() == '\r' || line.back() == '\n')) { |
136 | 120k | line.remove_suffix(1); |
137 | 120k | } |
138 | 93.9k | if (line.empty() || line.back() != '}') { |
139 | 92.6k | return 0; |
140 | 92.6k | } |
141 | 1.28k | const std::size_t open = line.rfind('{'); |
142 | 1.28k | if (open == std::string_view::npos || open + 2 > line.size()) { |
143 | 202 | return 0; |
144 | 202 | } |
145 | | |
146 | 1.08k | std::size_t position = open + 1; |
147 | 1.08k | std::size_t value = 0; |
148 | 1.08k | bool digits = false; |
149 | 2.85k | while (position < line.size() && line[position] >= '0' && line[position] <= '9') { |
150 | 1.78k | const std::size_t digit = static_cast<std::size_t>(line[position] - '0'); |
151 | 1.78k | if (value > (std::numeric_limits<std::size_t>::max() - digit) / 10) { |
152 | 11 | return 0; |
153 | 11 | } |
154 | 1.76k | value = value * 10 + digit; |
155 | 1.76k | digits = true; |
156 | 1.76k | ++position; |
157 | 1.76k | } |
158 | 1.07k | if (!digits) { |
159 | 230 | return 0; |
160 | 230 | } |
161 | 844 | if (position < line.size() && line[position] == '+') { |
162 | 32 | ++position; |
163 | 32 | } |
164 | 844 | return position + 1 == line.size() ? value : 0; |
165 | 1.07k | } |
166 | | |
167 | | /// Parse the literal size from an APPEND command. Only the command's final |
168 | | /// marker matters; earlier braces in a mailbox name are ignored. |
169 | | /// @param command Complete APPEND command line. |
170 | | /// @return Announced literal size, or the maximum size_t when the command has |
171 | | /// no well-formed marker. Zero is a valid literal length. |
172 | 842 | std::size_t ImapMockServer::AppendLiteralSize(std::string_view command) { |
173 | 2.52k | while (!command.empty() && (command.back() == '\r' || command.back() == '\n')) { |
174 | 1.68k | command.remove_suffix(1); |
175 | 1.68k | } |
176 | 842 | if (command.empty() || command.back() != '}') { |
177 | 2 | return std::numeric_limits<std::size_t>::max(); |
178 | 2 | } |
179 | 840 | const std::size_t open = command.rfind('{'); |
180 | 840 | if (open == std::string_view::npos) { |
181 | 0 | return std::numeric_limits<std::size_t>::max(); |
182 | 0 | } |
183 | | |
184 | 840 | std::size_t position = open + 1; |
185 | 840 | std::size_t value = 0; |
186 | 840 | bool digits = false; |
187 | 5.70k | while (position < command.size() && command[position] >= '0' && command[position] <= '9') { |
188 | 4.86k | const std::size_t digit = static_cast<std::size_t>(command[position] - '0'); |
189 | 4.86k | if (value > (std::numeric_limits<std::size_t>::max() - digit) / 10) { |
190 | 0 | return std::numeric_limits<std::size_t>::max(); |
191 | 0 | } |
192 | 4.86k | value = value * 10 + digit; |
193 | 4.86k | digits = true; |
194 | 4.86k | ++position; |
195 | 4.86k | } |
196 | 840 | if (!digits) { |
197 | 0 | return std::numeric_limits<std::size_t>::max(); |
198 | 0 | } |
199 | 840 | if (position < command.size() && command[position] == '+') { |
200 | 0 | ++position; |
201 | 0 | } |
202 | 840 | return position + 1 == command.size() ? value : std::numeric_limits<std::size_t>::max(); |
203 | 840 | } |
204 | | |
205 | | /// Resolve one scripted reply to wire bytes. A tagged status line gains the |
206 | | /// tag of the command currently being answered. Literal bodies are copied |
207 | | /// verbatim so fuzzed body bytes cannot be mistaken for status lines. |
208 | | /// @param response Scenario-owned reply fragment. |
209 | | /// @return Bytes to queue on the control socket. |
210 | 8.18k | std::string ImapMockServer::RewriteReply(std::string_view response) const { |
211 | 8.18k | std::string output; |
212 | 8.18k | output.reserve(response.size() + 16); |
213 | | |
214 | 8.18k | std::size_t position = 0; |
215 | 48.3k | while (position < response.size()) { |
216 | 40.1k | const std::size_t newline = response.find('\n', position); |
217 | 40.1k | const std::size_t line_end = newline == std::string_view::npos ? response.size() : newline + 1; |
218 | 40.1k | const std::string_view line = response.substr(position, line_end - position); |
219 | | |
220 | 40.1k | if (!current_tag_.empty() && StatusLineNeedsTag(line)) { |
221 | 5.12k | output.append(current_tag_); |
222 | 5.12k | output.push_back(' '); |
223 | 5.12k | } |
224 | 40.1k | output.append(line); |
225 | | |
226 | 40.1k | const std::size_t literal = LiteralSize(line); |
227 | 40.1k | if (literal == 0) { |
228 | 39.8k | position = line_end; |
229 | 39.8k | continue; |
230 | 39.8k | } |
231 | | |
232 | 274 | const std::size_t body_start = line_end; |
233 | 274 | const std::size_t available = response.size() - body_start; |
234 | 274 | const std::size_t body_size = std::min(literal, available); |
235 | 274 | output.append(response.substr(body_start, body_size)); |
236 | 274 | position = body_start + body_size; |
237 | 274 | } |
238 | 8.18k | return output; |
239 | 8.18k | } |
240 | | |
241 | | /// Derive the tag curl will assign to its next command. Curl formats the tag |
242 | | /// as `<letter><3-digit connection-local command id>`; the letter is stable |
243 | | /// for a connection and the id increments once per command. The predicted tag |
244 | | /// only drives a teardown liveness reply, so a malformed previous tag simply |
245 | | /// falls back to an untagged BYE and the final half-close. |
246 | | /// @return Predicted tag, or empty when the previous tag was not numeric. |
247 | 4.65k | std::string ImapMockServer::NextCommandTag() const { |
248 | 4.65k | std::size_t digits_start = current_tag_.size(); |
249 | 12.1k | while (digits_start > 0 && current_tag_[digits_start - 1] >= '0' && current_tag_[digits_start - 1] <= '9') { |
250 | 7.47k | --digits_start; |
251 | 7.47k | } |
252 | 4.65k | if (digits_start == current_tag_.size() || digits_start == 0) { |
253 | 2.15k | return std::string(); |
254 | 2.15k | } |
255 | | |
256 | 2.49k | std::uint64_t number = 0; |
257 | 9.96k | for (std::size_t index = digits_start; index < current_tag_.size(); ++index) { |
258 | 7.47k | number = number * 10 + static_cast<std::uint64_t>(current_tag_[index] - '0'); |
259 | 7.47k | if (number > 999999) { |
260 | 0 | return std::string(); |
261 | 0 | } |
262 | 7.47k | } |
263 | 2.49k | ++number; |
264 | | |
265 | 2.49k | std::string digits = std::to_string(number); |
266 | 2.49k | const std::size_t width = current_tag_.size() - digits_start; |
267 | 2.49k | if (digits.size() < width) { |
268 | 2.48k | digits.insert(0, width - digits.size(), '0'); |
269 | 2.48k | } |
270 | 2.49k | return current_tag_.substr(0, digits_start) + digits; |
271 | 2.49k | } |
272 | | |
273 | | /// Advance one reply for every complete command or AUTHENTICATE continuation. |
274 | | /// Continuation data keeps the initiating command tag until a scripted tagged |
275 | | /// status ends the exchange. A `+` reply to APPEND enters literal-draining mode |
276 | | /// and pre-queues the next scripted reply as the final tagged completion. |
277 | | /// Curl's imap_done() waits for that completion inside curl_multi_perform, so |
278 | | /// the outer peer loop cannot provide it later. |
279 | | /// @param command One newline-terminated client line from curl. |
280 | 87.5k | void ImapMockServer::HandleLine(std::string_view command) { |
281 | 87.5k | if (scenario() == nullptr) { |
282 | 826 | return; |
283 | 826 | } |
284 | | |
285 | 86.7k | const bool authentication_payload = authentication_continuation_active_; |
286 | 86.7k | if (!authentication_payload) { |
287 | 85.6k | current_tag_ = std::string(FirstToken(command)); |
288 | 85.6k | } |
289 | | |
290 | 86.7k | const std::string* response = NextControlReply(); |
291 | 86.7k | if (response == nullptr) { |
292 | 79.1k | return; |
293 | 79.1k | } |
294 | | |
295 | 7.59k | const bool continuation = ResponseHasContinuation(*response); |
296 | 7.59k | const bool tagged_completion = ResponseHasTaggedCompletion(*response); |
297 | 7.59k | (void)WriteControlBytes(RewriteReply(*response)); |
298 | | |
299 | 7.59k | if (authentication_payload) { |
300 | 333 | if (tagged_completion) { |
301 | 159 | authentication_continuation_active_ = false; |
302 | 159 | } |
303 | 333 | return; |
304 | 333 | } |
305 | | |
306 | 7.26k | const std::string_view verb = SecondToken(command); |
307 | 7.26k | if (continuation && TokenEquals(verb, "AUTHENTICATE")) { |
308 | 482 | authentication_continuation_active_ = !tagged_completion; |
309 | 482 | return; |
310 | 482 | } |
311 | | |
312 | 6.77k | if (!continuation || !TokenEquals(verb, "APPEND")) { |
313 | 5.93k | return; |
314 | 5.93k | } |
315 | | |
316 | 842 | const std::size_t literal = AppendLiteralSize(command); |
317 | 842 | if (literal == std::numeric_limits<std::size_t>::max()) { |
318 | | // Curl always announces the literal size here. Without one, leave the |
319 | | // transfer to the bounded idle budget and final half-close instead of |
320 | | // guessing where an arbitrary body ends. |
321 | 2 | return; |
322 | 2 | } |
323 | | |
324 | 840 | BeginLiteralBody(literal); |
325 | | |
326 | 840 | const std::string* final_response = NextControlReply(); |
327 | 840 | if (final_response != nullptr) { |
328 | 593 | (void)WriteControlBytes(RewriteReply(*final_response)); |
329 | 593 | } |
330 | 840 | } |
331 | | |
332 | | /// Queue a tagged completion for curl's blocking LOGOUT during teardown. Any |
333 | | /// real scripted response already consumed by curl still determines the |
334 | | /// transfer result; this line only prevents a teardown timeout. |
335 | 4.65k | void ImapMockServer::QueueShutdownReply() { |
336 | 4.65k | const std::string next_tag = NextCommandTag(); |
337 | 4.65k | if (!next_tag.empty()) { |
338 | 2.49k | (void)WriteControlBytes(next_tag + std::string(kShutdownTail)); |
339 | 2.49k | } else { |
340 | 2.15k | (void)WriteControlBytes("* BYE imap mock closing\r\n"); |
341 | 2.15k | } |
342 | 4.65k | } |
343 | | |
344 | | } // namespace proto_fuzzer |