Coverage Report

Created: 2026-10-28 07:17

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/src/curl_fuzzer/proto_fuzzer/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