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/request_data.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 Builds bounded curl_slist and curl_mime state from a Scenario.
9
10
#include "proto_fuzzer/request_data.h"
11
12
#include <algorithm>
13
#include <cstddef>
14
#include <cstdio>
15
#include <cstring>
16
#include <limits>
17
#include <string>
18
19
#include "proto_fuzzer/option_apply.h"
20
#include "proto_fuzzer/scenario_limits.h"
21
22
namespace proto_fuzzer {
23
24
namespace {
25
26
/// Borrow the protobuf string when it already fits curl's NUL-terminated API,
27
/// allocating `truncated` only for compatibility inputs that bypass the fixed
28
/// target postprocessor. The callers below all synchronously copy this value,
29
/// so neither pointer escapes the call. Embedded NUL bytes deliberately remain:
30
/// curl observes the same prefix as before while an oversized invisible suffix
31
/// cannot dominate allocation.
32
389k
const char* BoundedCString(const std::string& value, std::size_t limit, std::string* truncated) {
33
389k
  if (value.size() <= limit) {
34
388k
    return value.c_str();
35
388k
  }
36
127
  truncated->assign(value.data(), limit);
37
127
  return truncated->c_str();
38
389k
}
39
40
/// Raw read/seek callbacks are useful only when a script exists or a supported
41
/// SetOption can make curl request caller-provided body bytes. CURLOPT_POST is
42
/// included because POST without POSTFIELDS/MIME also falls back to the read
43
/// callback. Avoiding callbacks for ordinary requests removes eight setopt
44
/// calls across setup and teardown, while any potentially body-reading option
45
/// retains the non-blocking fallback regardless of its mutated value.
46
264k
bool NeedsUploadCallbacks(const curl::fuzzer::proto::Scenario& scenario) {
47
  // curl's TELNET implementation polls stdin unless a READFUNCTION was
48
  // explicitly installed. Always provide the bounded per-scenario source,
49
  // even when there is no upload payload to send.
50
264k
  if (scenario.scheme() == curl::fuzzer::proto::SCHEME_TELNET || scenario.has_upload()) {
51
30.8k
    return true;
52
30.8k
  }
53
233k
  const std::size_t option_count = RuntimeOptionCount(scenario);
54
780k
  for (std::size_t index = 0; index < option_count; ++index) {
55
551k
    const auto& option = scenario.options(static_cast<int>(index));
56
551k
    if (option.option_id() == curl::fuzzer::proto::CURLOPT_UPLOAD ||
57
548k
        option.option_id() == curl::fuzzer::proto::CURLOPT_POST) {
58
4.94k
      return true;
59
4.94k
    }
60
551k
  }
61
228k
  return false;
62
233k
}
63
64
/// Translate the mutation-friendly enum to curl's spelling. Restricting this
65
/// field to supported encoders spends cycles in encoder implementations rather
66
/// than repeatedly rediscovering the same invalid-string rejection.
67
175k
const char* MimeEncoderName(curl::fuzzer::proto::MimeEncoder encoder) {
68
175k
  switch (encoder) {
69
10.0k
    case curl::fuzzer::proto::MIME_ENCODER_BINARY:
70
10.0k
      return "binary";
71
8.94k
    case curl::fuzzer::proto::MIME_ENCODER_8BIT:
72
8.94k
      return "8bit";
73
11.7k
    case curl::fuzzer::proto::MIME_ENCODER_7BIT:
74
11.7k
      return "7bit";
75
22.6k
    case curl::fuzzer::proto::MIME_ENCODER_BASE64:
76
22.6k
      return "base64";
77
24.2k
    case curl::fuzzer::proto::MIME_ENCODER_QUOTED_PRINTABLE:
78
24.2k
      return "quoted-printable";
79
97.0k
    case curl::fuzzer::proto::MIME_ENCODER_UNSPECIFIED:
80
97.6k
    default:
81
97.6k
      return nullptr;
82
175k
  }
83
175k
}
84
85
/// Append at most `limit` protobuf byte strings to a curl list. curl_slist_append
86
/// leaves the old head valid on allocation failure, so only replace the head
87
/// after a successful append and stop rather than burning the rest of the
88
/// iteration on allocations that are already failing.
89
template <typename RepeatedBytes>
90
CurlSlistPtr BuildStringList(const RepeatedBytes& values, std::size_t count_limit, std::size_t value_limit,
91
450k
                             std::size_t* applied) {
92
450k
  CurlSlistPtr list;
93
450k
  std::string truncated;
94
450k
  const std::size_t count = std::min<std::size_t>(count_limit, values.size());
95
701k
  for (std::size_t i = 0; i < count; ++i) {
96
251k
    const std::string& value = values.Get(static_cast<int>(i));
97
251k
    curl_slist* appended = curl_slist_append(list.get(), BoundedCString(value, value_limit, &truncated));
98
251k
    if (appended == nullptr) {
99
968
      break;
100
968
    }
101
    // append retains the existing nodes and may return the same head. Release
102
    // before resetting so adopting the successful result cannot free them.
103
250k
    (void)list.release();
104
250k
    list.reset(appended);
105
250k
    ++*applied;
106
250k
  }
107
450k
  return list;
108
450k
}
109
110
/// Apply the metadata shared by top-level and nested protobuf part types. The
111
/// MIME API copies these strings, so temporary bounded buffers are sufficient;
112
/// only the MIME root itself needs to outlive the perform loop.
113
template <typename ProtoPart>
114
175k
void ApplyPartMetadata(curl_mimepart* part, const ProtoPart& source, RequestBuildStats* stats) {
115
  // Reuse the rare compatibility-path allocation across all three fields;
116
  // ordinary postprocessed metadata never writes this scratch string.
117
175k
  std::string truncated;
118
175k
  if (!source.name().empty()) {
119
48.5k
    (void)curl_mime_name(part, BoundedCString(source.name(), scenario_limits::kMaxMetadataBytes, &truncated));
120
48.5k
  }
121
175k
  if (!source.filename().empty()) {
122
45.2k
    (void)curl_mime_filename(part, BoundedCString(source.filename(), scenario_limits::kMaxMetadataBytes, &truncated));
123
45.2k
  }
124
175k
  if (!source.content_type().empty()) {
125
43.6k
    (void)curl_mime_type(part, BoundedCString(source.content_type(), scenario_limits::kMaxMetadataBytes, &truncated));
126
43.6k
  }
127
175k
  if (const char* encoder = MimeEncoderName(source.encoder())) {
128
77.6k
    (void)curl_mime_encoder(part, encoder);
129
77.6k
  }
130
131
175k
  std::size_t header_count = 0;
132
175k
  CurlSlistPtr headers = BuildStringList(source.headers(), scenario_limits::kMaxMimeHeadersPerPart,
133
175k
                                         scenario_limits::kMaxMetadataBytes, &header_count);
134
175k
  if (headers != nullptr) {
135
    // take_ownership=1 is crucial: unlike the strings above, MIME retains the
136
    // list pointer. Once attached, the root curl_mime_free call recursively
137
    // releases it, including lists on nested parts.
138
42.4k
    const CURLcode result = curl_mime_headers(part, headers.get(), 1);
139
42.4k
    if (result == CURLE_OK) {
140
42.4k
      (void)headers.release();
141
42.4k
      stats->mime_headers += header_count;
142
42.4k
    }
143
42.4k
  }
144
175k
}
request_data.cc:void proto_fuzzer::(anonymous namespace)::ApplyPartMetadata<curl::fuzzer::proto::MimePart>(curl_mimepart*, curl::fuzzer::proto::MimePart const&, proto_fuzzer::RequestBuildStats*)
Line
Count
Source
114
100k
void ApplyPartMetadata(curl_mimepart* part, const ProtoPart& source, RequestBuildStats* stats) {
115
  // Reuse the rare compatibility-path allocation across all three fields;
116
  // ordinary postprocessed metadata never writes this scratch string.
117
100k
  std::string truncated;
118
100k
  if (!source.name().empty()) {
119
27.3k
    (void)curl_mime_name(part, BoundedCString(source.name(), scenario_limits::kMaxMetadataBytes, &truncated));
120
27.3k
  }
121
100k
  if (!source.filename().empty()) {
122
23.8k
    (void)curl_mime_filename(part, BoundedCString(source.filename(), scenario_limits::kMaxMetadataBytes, &truncated));
123
23.8k
  }
124
100k
  if (!source.content_type().empty()) {
125
22.9k
    (void)curl_mime_type(part, BoundedCString(source.content_type(), scenario_limits::kMaxMetadataBytes, &truncated));
126
22.9k
  }
127
100k
  if (const char* encoder = MimeEncoderName(source.encoder())) {
128
44.2k
    (void)curl_mime_encoder(part, encoder);
129
44.2k
  }
130
131
100k
  std::size_t header_count = 0;
132
100k
  CurlSlistPtr headers = BuildStringList(source.headers(), scenario_limits::kMaxMimeHeadersPerPart,
133
100k
                                         scenario_limits::kMaxMetadataBytes, &header_count);
134
100k
  if (headers != nullptr) {
135
    // take_ownership=1 is crucial: unlike the strings above, MIME retains the
136
    // list pointer. Once attached, the root curl_mime_free call recursively
137
    // releases it, including lists on nested parts.
138
21.6k
    const CURLcode result = curl_mime_headers(part, headers.get(), 1);
139
21.6k
    if (result == CURLE_OK) {
140
21.6k
      (void)headers.release();
141
21.6k
      stats->mime_headers += header_count;
142
21.6k
    }
143
21.6k
  }
144
100k
}
request_data.cc:void proto_fuzzer::(anonymous namespace)::ApplyPartMetadata<curl::fuzzer::proto::MimeDataPart>(curl_mimepart*, curl::fuzzer::proto::MimeDataPart const&, proto_fuzzer::RequestBuildStats*)
Line
Count
Source
114
74.8k
void ApplyPartMetadata(curl_mimepart* part, const ProtoPart& source, RequestBuildStats* stats) {
115
  // Reuse the rare compatibility-path allocation across all three fields;
116
  // ordinary postprocessed metadata never writes this scratch string.
117
74.8k
  std::string truncated;
118
74.8k
  if (!source.name().empty()) {
119
21.2k
    (void)curl_mime_name(part, BoundedCString(source.name(), scenario_limits::kMaxMetadataBytes, &truncated));
120
21.2k
  }
121
74.8k
  if (!source.filename().empty()) {
122
21.3k
    (void)curl_mime_filename(part, BoundedCString(source.filename(), scenario_limits::kMaxMetadataBytes, &truncated));
123
21.3k
  }
124
74.8k
  if (!source.content_type().empty()) {
125
20.6k
    (void)curl_mime_type(part, BoundedCString(source.content_type(), scenario_limits::kMaxMetadataBytes, &truncated));
126
20.6k
  }
127
74.8k
  if (const char* encoder = MimeEncoderName(source.encoder())) {
128
33.3k
    (void)curl_mime_encoder(part, encoder);
129
33.3k
  }
130
131
74.8k
  std::size_t header_count = 0;
132
74.8k
  CurlSlistPtr headers = BuildStringList(source.headers(), scenario_limits::kMaxMimeHeadersPerPart,
133
74.8k
                                         scenario_limits::kMaxMetadataBytes, &header_count);
134
74.8k
  if (headers != nullptr) {
135
    // take_ownership=1 is crucial: unlike the strings above, MIME retains the
136
    // list pointer. Once attached, the root curl_mime_free call recursively
137
    // releases it, including lists on nested parts.
138
20.7k
    const CURLcode result = curl_mime_headers(part, headers.get(), 1);
139
20.7k
    if (result == CURLE_OK) {
140
20.7k
      (void)headers.release();
141
20.7k
      stats->mime_headers += header_count;
142
20.7k
    }
143
20.7k
  }
144
74.8k
}
145
146
/// Copy bounded binary data into a MIME part. curl_mime_data accepts an
147
/// explicit size, so embedded NUL bytes remain fuzzable here unlike in the
148
/// metadata and header APIs.
149
67.8k
void ApplyPartData(curl_mimepart* part, const std::string& data) {
150
67.8k
  const std::size_t size = std::min(data.size(), scenario_limits::kMaxMimeDataBytes);
151
67.8k
  const char* bytes = data.empty() ? "" : data.data();
152
67.8k
  (void)curl_mime_data(part, bytes, size);
153
67.8k
}
154
155
/// Materialize one compact repeated source without allowing the protobuf's
156
/// uint32 count or a collection of MIME parts to amplify into unbounded work.
157
/// Division establishes the maximum count before multiplication, so this is
158
/// safe even when size_t is narrower than uint32_t.
159
void ApplyGeneratedPartData(curl_mimepart* part, const curl::fuzzer::proto::GeneratedBytes& source,
160
26.8k
                            std::size_t* remaining_generated_bytes, RequestBuildStats* stats) {
161
26.8k
  const std::size_t pattern_size = std::min(source.pattern().size(), scenario_limits::kMaxGeneratedMimePatternBytes);
162
26.8k
  if (pattern_size == 0 || *remaining_generated_bytes == 0) {
163
9.51k
    (void)curl_mime_data(part, "", 0);
164
9.51k
    return;
165
9.51k
  }
166
167
17.3k
  const std::size_t max_repeat_count = *remaining_generated_bytes / pattern_size;
168
17.3k
  const std::size_t repeat_count =
169
17.3k
      std::min<std::size_t>(static_cast<std::size_t>(source.repeat_count()), max_repeat_count);
170
17.3k
  const std::size_t materialized_size = repeat_count * pattern_size;
171
172
17.3k
  std::string materialized(materialized_size, '\0');
173
217M
  for (std::size_t offset = 0; offset < materialized_size; offset += pattern_size) {
174
217M
    std::copy_n(source.pattern().data(), pattern_size, materialized.data() + offset);
175
217M
  }
176
17.3k
  const char* bytes = materialized.empty() ? "" : materialized.data();
177
17.3k
  if (curl_mime_data(part, bytes, materialized.size()) == CURLE_OK) {
178
17.2k
    stats->generated_mime_bytes += materialized.size();
179
17.2k
  }
180
17.3k
  *remaining_generated_bytes -= materialized.size();
181
17.3k
}
182
183
/// Populate the fixed-depth child body and debit the shared total-part budget.
184
/// Returning an empty MIME object when the child list is empty is intentional:
185
/// curl's empty multipart serialization is useful coverage and remains cheap.
186
void PopulateSubparts(curl_mime* mime, const curl::fuzzer::proto::MimeSubparts& source, std::size_t* remaining_parts,
187
35.2k
                      std::size_t* remaining_generated_bytes, RequestBuildStats* stats) {
188
35.2k
  const std::size_t count = std::min<std::size_t>(scenario_limits::kMaxNestedMimeParts, source.parts_size());
189
110k
  for (std::size_t i = 0; i < count && *remaining_parts != 0; ++i) {
190
74.9k
    curl_mimepart* part = curl_mime_addpart(mime);
191
74.9k
    if (part == nullptr) {
192
153
      break;
193
153
    }
194
74.8k
    --*remaining_parts;
195
74.8k
    ++stats->mime_parts;
196
74.8k
    const auto& proto_part = source.parts(static_cast<int>(i));
197
74.8k
    ApplyPartMetadata(part, proto_part, stats);
198
74.8k
    if (proto_part.content_case() == curl::fuzzer::proto::MimeDataPart::kGeneratedData) {
199
13.4k
      ApplyGeneratedPartData(part, proto_part.generated_data(), remaining_generated_bytes, stats);
200
61.3k
    } else {
201
      // Preserve the historical leaf behavior: an absent source is applied as
202
      // empty data just like an explicitly empty field 2.
203
61.3k
      ApplyPartData(part, proto_part.data());
204
61.3k
    }
205
74.8k
  }
206
35.2k
}
207
208
/// Construct the top MIME tree. curl_mime_subparts transfers ownership only
209
/// on success, so failed attachments are freed immediately while successful
210
/// ones are left for the top-level root to release recursively.
211
CurlMimePtr BuildMimePost(CURL* easy, const curl::fuzzer::proto::MimePost& source, UploadScriptState* upload_state,
212
48.5k
                          RequestBuildStats* stats) {
213
48.5k
  CurlMimePtr mime(curl_mime_init(easy));
214
48.5k
  if (mime == nullptr) {
215
81
    return nullptr;
216
81
  }
217
218
48.4k
  std::size_t remaining_parts = scenario_limits::kMaxTotalMimeParts;
219
48.4k
  std::size_t remaining_generated_bytes = scenario_limits::kMaxGeneratedMimeDataBytes;
220
48.4k
  const std::size_t count = std::min<std::size_t>(scenario_limits::kMaxTopLevelMimeParts, source.parts_size());
221
148k
  for (std::size_t i = 0; i < count && remaining_parts != 0; ++i) {
222
100k
    curl_mimepart* part = curl_mime_addpart(mime.get());
223
100k
    if (part == nullptr) {
224
139
      break;
225
139
    }
226
100k
    --remaining_parts;
227
100k
    ++stats->mime_parts;
228
100k
    const auto& proto_part = source.parts(static_cast<int>(i));
229
100k
    ApplyPartMetadata(part, proto_part, stats);
230
231
100k
    switch (proto_part.content_case()) {
232
6.54k
      case curl::fuzzer::proto::MimePart::kData:
233
6.54k
        ApplyPartData(part, proto_part.data());
234
6.54k
        break;
235
13.3k
      case curl::fuzzer::proto::MimePart::kGeneratedData:
236
13.3k
        ApplyGeneratedPartData(part, proto_part.generated_data(), &remaining_generated_bytes, stats);
237
13.3k
        break;
238
12.2k
      case curl::fuzzer::proto::MimePart::kUploadCallback:
239
12.2k
        if (upload_state != nullptr && upload_state->InstallAsMimePart(part) == CURLE_OK) {
240
12.2k
          ++stats->mime_callback_parts;
241
12.2k
        }
242
12.2k
        break;
243
35.3k
      case curl::fuzzer::proto::MimePart::kSubparts: {
244
35.3k
        CurlMimePtr subparts(curl_mime_init(easy));
245
35.3k
        if (subparts == nullptr) {
246
128
          break;
247
128
        }
248
35.2k
        PopulateSubparts(subparts.get(), proto_part.subparts(), &remaining_parts, &remaining_generated_bytes, stats);
249
35.2k
        if (curl_mime_subparts(part, subparts.get()) == CURLE_OK) {
250
35.2k
          (void)subparts.release();
251
35.2k
        }
252
35.2k
        break;
253
35.3k
      }
254
32.8k
      case curl::fuzzer::proto::MimePart::CONTENT_NOT_SET:
255
32.8k
      default:
256
32.8k
        break;
257
100k
    }
258
100k
  }
259
48.4k
  return mime;
260
48.4k
}
261
262
}  // namespace
263
264
/// Borrow and cap a scripted upload source before libcurl receives a userdata
265
/// pointer. When Scenario.upload is absent, non-TELNET schemes synthesize the
266
/// historical 16 KiB `U` stream without allocating it, while TELNET reports
267
/// immediate EOF so merely selecting the protocol cannot inject unsolicited
268
/// terminal input. Borrowing avoids duplicating every fuzzed body before curl
269
/// can read it; `scenario` must remain alive and unmodified until this state is
270
/// destroyed.
271
/// @param scenario Source and lifetime owner of callback bytes and outcomes.
272
UploadScriptState::UploadScriptState(const curl::fuzzer::proto::Scenario& scenario)
273
264k
    : data_(),
274
264k
      read_step_count_(0),
275
264k
      total_size_(scenario.scheme() == curl::fuzzer::proto::SCHEME_TELNET ? 0 : scenario_limits::kMaxUploadBytes),
276
264k
      max_read_size_(scenario.scheme() == curl::fuzzer::proto::SCHEME_TELNET ? scenario_limits::kMaxTelnetUploadReadSize
277
264k
                                                                             : scenario_limits::kMaxUploadReadSize),
278
264k
      offset_(0),
279
264k
      next_read_size_(0),
280
264k
      seek_calls_(0),
281
264k
      terminal_(curl::fuzzer::proto::UPLOAD_TERMINAL_EOF),
282
264k
      seek_result_(curl::fuzzer::proto::UPLOAD_SEEK_CANTSEEK),
283
264k
      before_read_callback_(nullptr),
284
264k
      before_read_userdata_(nullptr),
285
264k
      scripted_(scenario.has_upload()) {
286
264k
  if (!scripted_) {
287
237k
    return;
288
237k
  }
289
290
26.0k
  const auto& upload = scenario.upload();
291
26.0k
  const std::size_t data_limit = scenario.scheme() == curl::fuzzer::proto::SCHEME_TELNET
292
26.0k
                                     ? scenario_limits::kMaxTelnetUploadBytes
293
26.0k
                                     : scenario_limits::kMaxUploadBytes;
294
26.0k
  const std::size_t data_size = std::min(upload.data().size(), data_limit);
295
26.0k
  data_ = std::string_view(upload.data().data(), data_size);
296
26.0k
  total_size_ = data_.size();
297
26.0k
  terminal_ = upload.terminal();
298
26.0k
  if (scenario.scheme() != curl::fuzzer::proto::SCHEME_TELNET &&
299
25.7k
      terminal_ == curl::fuzzer::proto::UPLOAD_TERMINAL_PAUSE) {
300
    // Event-driven protocols need an external resume source. Interpret this
301
    // TELNET-specific outcome as EOF before curl can retain a paused transfer.
302
92
    terminal_ = curl::fuzzer::proto::UPLOAD_TERMINAL_EOF;
303
92
  }
304
26.0k
  seek_result_ = upload.seek_result();
305
306
26.0k
  const std::size_t read_step_limit = scenario.scheme() == curl::fuzzer::proto::SCHEME_TELNET
307
26.0k
                                          ? scenario_limits::kMaxTelnetUploadReadSteps
308
26.0k
                                          : scenario_limits::kMaxUploadReadSteps;
309
26.0k
  read_step_count_ = std::min<std::size_t>(upload.read_sizes_size(), read_step_limit);
310
78.8k
  for (std::size_t i = 0; i < read_step_count_; ++i) {
311
52.7k
    const std::size_t requested = upload.read_sizes(static_cast<int>(i));
312
    // Zero-as-one ensures every retained step makes progress; see the schema
313
    // comment for why zero is not treated as an early EOF sentinel.
314
52.7k
    read_sizes_[i] = std::max<std::size_t>(1, std::min(requested, max_read_size_));
315
52.7k
  }
316
26.0k
}
317
318
/// Copy the next bounded chunk and advance the cursor, using either the explicit
319
/// payload or the allocation-free fallback. Terminal outcomes are emitted only
320
/// after all data is consumed so a mutation can independently control
321
/// fragmentation and completion policy. Public so focused tests can validate
322
/// callback semantics without relying on HTTP timing.
323
/// @param buffer Destination supplied by curl.
324
/// @param capacity Writable bytes in `buffer`.
325
/// @return Bytes copied, zero for EOF, CURL_READFUNC_ABORT, or the TELNET-
326
///         only CURL_READFUNC_PAUSE outcome.
327
137k
std::size_t UploadScriptState::Read(char* buffer, std::size_t capacity) {
328
  // TELNET can produce negotiation replies while one curl_multi_perform call
329
  // owns the thread. Empty them before returning more callback bytes;
330
  // otherwise send_telnet_data() can consume the whole bounded transfer
331
  // timeout while the synchronous path waits for socket capacity.
332
137k
  if (before_read_callback_ != nullptr) {
333
1.64k
    before_read_callback_(before_read_userdata_);
334
1.64k
  }
335
137k
  if (offset_ >= total_size_) {
336
8.39k
    switch (terminal_) {
337
312
      case curl::fuzzer::proto::UPLOAD_TERMINAL_ABORT:
338
312
        return CURL_READFUNC_ABORT;
339
17
      case curl::fuzzer::proto::UPLOAD_TERMINAL_PAUSE:
340
17
        return CURL_READFUNC_PAUSE;
341
8.06k
      case curl::fuzzer::proto::UPLOAD_TERMINAL_EOF:
342
8.06k
      default:
343
8.06k
        return 0;
344
8.39k
    }
345
8.39k
  }
346
129k
  if (buffer == nullptr || capacity == 0) {
347
0
    return 0;
348
0
  }
349
350
129k
  std::size_t chunk_limit = std::min(capacity, max_read_size_);
351
129k
  if (next_read_size_ < read_step_count_) {
352
15.2k
    chunk_limit = std::min(chunk_limit, read_sizes_[next_read_size_++]);
353
15.2k
  }
354
129k
  const std::size_t count = std::min(chunk_limit, total_size_ - offset_);
355
129k
  if (scripted_) {
356
19.2k
    std::memcpy(buffer, data_.data() + offset_, count);
357
110k
  } else {
358
110k
    std::memset(buffer, 'U', count);
359
110k
  }
360
129k
  offset_ += count;
361
129k
  return count;
362
129k
}
363
364
/// Apply the configured seek outcome and, for OK, move the bounded cursor.
365
/// Successful seeks restart the short-read sequence so retries replay the same
366
/// callback fragmentation deterministically. Explicit range checks model only
367
/// seeks curl can meaningfully request from a bounded memory source, avoiding
368
/// signed overflow or a mutation wrapping into an in-bounds cursor.
369
/// @param requested_offset Offset interpreted relative to `origin`.
370
/// @param origin One of SEEK_SET, SEEK_CUR, or SEEK_END.
371
/// @return A CURL_SEEKFUNC_* result.
372
5.62k
int UploadScriptState::Seek(curl_off_t requested_offset, int origin) {
373
5.62k
  ++seek_calls_;
374
5.62k
  switch (seek_result_) {
375
552
    case curl::fuzzer::proto::UPLOAD_SEEK_CANTSEEK:
376
552
      return CURL_SEEKFUNC_CANTSEEK;
377
163
    case curl::fuzzer::proto::UPLOAD_SEEK_FAIL:
378
163
      return CURL_SEEKFUNC_FAIL;
379
4.90k
    case curl::fuzzer::proto::UPLOAD_SEEK_OK:
380
4.90k
      break;
381
4
    default:
382
4
      return CURL_SEEKFUNC_CANTSEEK;
383
5.62k
  }
384
385
4.90k
  const curl_off_t current = static_cast<curl_off_t>(offset_);
386
4.90k
  const curl_off_t end = static_cast<curl_off_t>(total_size_);
387
4.90k
  curl_off_t base = 0;
388
4.90k
  switch (origin) {
389
4.90k
    case SEEK_SET:
390
4.90k
      base = 0;
391
4.90k
      break;
392
0
    case SEEK_CUR:
393
0
      base = current;
394
0
      break;
395
0
    case SEEK_END:
396
0
      base = end;
397
0
      break;
398
0
    default:
399
0
      return CURL_SEEKFUNC_FAIL;
400
4.90k
  }
401
402
  // Both base and end are at most 16 KiB. Comparing the requested delta to
403
  // these small bounds before addition handles even CURL_OFF_T_MIN safely.
404
4.90k
  if (requested_offset < -base || requested_offset > end - base) {
405
98
    return CURL_SEEKFUNC_FAIL;
406
98
  }
407
4.80k
  offset_ = static_cast<std::size_t>(base + requested_offset);
408
4.80k
  next_read_size_ = 0;
409
4.80k
  return CURL_SEEKFUNC_OK;
410
4.90k
}
411
412
/// libcurl trampoline that protects multiplication of its two buffer-size
413
/// arguments before forwarding to Read(). Multiplication is normally benign
414
/// because curl uses size=1, but callbacks are an API boundary. Abort an
415
/// impossible overflowing pair: saturating to SIZE_MAX would let Read() copy
416
/// into a buffer whose real extent is unknown.
417
137k
std::size_t UploadScriptState::ReadCallback(char* buffer, std::size_t size, std::size_t nitems, void* userdata) {
418
137k
  if (userdata == nullptr || size == 0 || nitems == 0) {
419
0
    return 0;
420
0
  }
421
137k
  const std::size_t max = std::numeric_limits<std::size_t>::max();
422
137k
  if (nitems > max / size) {
423
0
    return CURL_READFUNC_ABORT;
424
0
  }
425
137k
  return static_cast<UploadScriptState*>(userdata)->Read(buffer, size * nitems);
426
137k
}
427
428
/// libcurl trampoline for the userdata-first seek callback signature. Keep the
429
/// C callback a type bridge so all outcome/cursor behaviour remains directly
430
/// unit-testable in Seek().
431
5.62k
int UploadScriptState::SeekCallback(void* userdata, curl_off_t offset, int origin) {
432
5.62k
  if (userdata == nullptr) {
433
0
    return CURL_SEEKFUNC_FAIL;
434
0
  }
435
5.62k
  return static_cast<UploadScriptState*>(userdata)->Seek(offset, origin);
436
5.62k
}
437
438
/// @return Number of bytes visible to the callback after caps.
439
222k
std::size_t UploadScriptState::data_size() const { return total_size_; }
440
441
/// @return Number of retained per-read limits after caps.
442
222k
std::size_t UploadScriptState::read_step_count() const { return read_step_count_; }
443
444
/// @return Current byte cursor, exposed for deterministic unit tests.
445
222k
std::size_t UploadScriptState::offset() const { return offset_; }
446
447
/// @return Number of times libcurl asked the upload source to seek.
448
222k
std::size_t UploadScriptState::seek_calls() const { return seek_calls_; }
449
450
/// @return Whether Scenario.upload was present instead of using fallback.
451
222k
bool UploadScriptState::scripted() const { return scripted_; }
452
453
/// Install the same bounded read/seek cursor behind a public MIME callback
454
/// part. curl retains `this` until the MIME root is freed, so ScenarioRequestData
455
/// declares the root after the state and detaches it before either is released.
456
/// @param part MIME part owned by the caller's live root.
457
/// @return libcurl's setup result.
458
12.2k
CURLcode UploadScriptState::InstallAsMimePart(curl_mimepart* part) {
459
12.2k
  if (part == nullptr) {
460
0
    return CURLE_BAD_FUNCTION_ARGUMENT;
461
0
  }
462
12.2k
  return curl_mime_data_cb(part, static_cast<curl_off_t>(total_size_), &UploadScriptState::ReadCallback,
463
12.2k
                           &UploadScriptState::SeekCallback, nullptr, this);
464
12.2k
}
465
466
/// Install a protocol hook without exposing the mutable cursor or curl's
467
/// retained callback userdata outside its owner.
468
5.05k
void UploadScriptState::SetBeforeReadCallback(BeforeReadCallback callback, void* userdata) {
469
5.05k
  before_read_callback_ = callback;
470
5.05k
  before_read_userdata_ = userdata;
471
5.05k
}
472
473
/// Construct and apply the protocol-specific pointer-valued fields in `scenario`
474
/// to `easy`. Setup errors are deliberately non-fatal: malformed or partially
475
/// allocated scenarios should still exercise whatever curl state was built.
476
/// `easy` must remain alive until this object is destroyed, because cleanup
477
/// first detaches the pointers from the handle and only then frees them.
478
/// @param easy Easy handle that will perform this scenario.
479
/// @param scenario Source headers/TELNET options, optional MIME body, and
480
///                 upload script.
481
/// @param apply_resolve_entries Whether to install the scenario's bounded
482
///        resolver mappings plus the mandatory loopback mapping.
483
ScenarioRequestData::ScenarioRequestData(CURL* easy, const curl::fuzzer::proto::Scenario& scenario,
484
                                         bool apply_resolve_entries)
485
264k
    : easy_(easy),
486
264k
      request_headers_(nullptr),
487
264k
      resolve_entries_(nullptr),
488
264k
      telnet_options_(nullptr),
489
264k
      mail_rcpt_(nullptr),
490
264k
      upload_state_(scenario),
491
264k
      mime_post_(nullptr),
492
264k
      upload_callbacks_installed_(false),
493
264k
      resolve_entries_ready_(!apply_resolve_entries) {
494
264k
  if (easy_ == nullptr) {
495
0
    return;
496
0
  }
497
498
  // Complete all allocating work before installing retained pointers. If a
499
  // C++ allocation throws, scoped owners unwind without leaving the easy
500
  // handle pointing into a partially constructed request-data object.
501
264k
  bool resolve_entries_built = false;
502
264k
  if (apply_resolve_entries) {
503
5.88k
    resolve_entries_ = BuildStringList(scenario.resolve_entries(), scenario_limits::kMaxResolveEntries,
504
5.88k
                                       scenario_limits::kMaxResolveEntryBytes, &stats_.resolve_entries);
505
    // This mapping is deliberately last: preceding wildcard or removal
506
    // mutations may exercise host-cache parsing, but cannot redirect the
507
    // canonical resolver-lane origin away from the in-process mock.
508
5.88k
    curl_slist* appended = curl_slist_append(resolve_entries_.get(), "resolve.test:80:127.0.0.1");
509
5.88k
    if (appended != nullptr) {
510
5.86k
      (void)resolve_entries_.release();
511
5.86k
      resolve_entries_.reset(appended);
512
5.86k
      resolve_entries_built = true;
513
5.86k
    }
514
5.88k
  }
515
516
  // HTTP headers/MIME and TELNET options are mutually exclusive because only
517
  // the selected protocol can observe them. Avoid allocating protocol-inert
518
  // lists and trees in compatibility inputs that bypass target policy.
519
264k
  if (scenario.scheme() == curl::fuzzer::proto::SCHEME_TELNET) {
520
5.05k
    telnet_options_ = BuildStringList(scenario.telnet_options(), scenario_limits::kMaxTelnetOptions,
521
5.05k
                                      scenario_limits::kMaxTelnetOptionBytes, &stats_.telnet_options);
522
259k
  } else {
523
259k
    request_headers_ = BuildStringList(scenario.request_headers(), scenario_limits::kMaxRequestHeaders,
524
259k
                                       scenario_limits::kMaxMetadataBytes, &stats_.request_headers);
525
259k
    if (scenario.has_mime_post()) {
526
48.5k
      mime_post_ = BuildMimePost(easy_, scenario.mime_post(), &upload_state_, &stats_);
527
48.5k
    }
528
259k
  }
529
530
264k
  if (scenario.scheme() == curl::fuzzer::proto::SCHEME_SMTP || scenario.scheme() == curl::fuzzer::proto::SCHEME_SMTPS ||
531
259k
      !scenario.mail_rcpt().empty()) {
532
    // Recipient list entries borrow protobuf-owned storage only for the
533
    // duration of BuildStringList; curl_slist_append copies each address.
534
5.40k
    mail_rcpt_ = BuildStringList(scenario.mail_rcpt(), scenario_limits::kMaxMailRecipients,
535
5.40k
                                 scenario_limits::kMaxMailRecipientBytes, &stats_.mail_rcpt);
536
5.40k
  }
537
538
264k
  if (resolve_entries_built) {
539
5.86k
    resolve_entries_ready_ = curl_easy_setopt(easy_, CURLOPT_RESOLVE, resolve_entries_.get()) == CURLE_OK;
540
5.86k
  }
541
542
264k
  if (NeedsUploadCallbacks(scenario)) {
543
    // Install a per-run memory source even when Scenario.upload is absent but
544
    // CURLOPT_UPLOAD or TELNET may request caller input. Non-TELNET schemes
545
    // retain the historical fallback bytes; TELNET returns EOF. Either result
546
    // replaces stdin and cannot block OSS-Fuzz. The state remains scoped to
547
    // the complete drive so retries cannot share a cursor across iterations.
548
35.7k
    upload_callbacks_installed_ = true;
549
35.7k
    (void)curl_easy_setopt(easy_, CURLOPT_READFUNCTION, &UploadScriptState::ReadCallback);
550
35.7k
    (void)curl_easy_setopt(easy_, CURLOPT_READDATA, &upload_state_);
551
35.7k
    (void)curl_easy_setopt(easy_, CURLOPT_SEEKFUNCTION, &UploadScriptState::SeekCallback);
552
35.7k
    (void)curl_easy_setopt(easy_, CURLOPT_SEEKDATA, &upload_state_);
553
35.7k
  }
554
555
264k
  if (telnet_options_ != nullptr) {
556
566
    (void)curl_easy_setopt(easy_, CURLOPT_TELNETOPTIONS, telnet_options_.get());
557
566
  }
558
264k
  if (mail_rcpt_ != nullptr) {
559
2.77k
    (void)curl_easy_setopt(easy_, CURLOPT_MAIL_RCPT, mail_rcpt_.get());
560
2.77k
  }
561
264k
  if (request_headers_ != nullptr) {
562
49.2k
    (void)curl_easy_setopt(easy_, CURLOPT_HTTPHEADER, request_headers_.get());
563
49.2k
  }
564
264k
  if (mime_post_ != nullptr) {
565
48.4k
    (void)curl_easy_setopt(easy_, CURLOPT_MIMEPOST, mime_post_.get());
566
48.4k
  }
567
264k
}
568
569
/// Detach pointer options/callbacks while the easy handle is valid, then release
570
/// request allocations. libcurl does not copy headers, MIME roots, or callback
571
/// userdata, so releasing any one before the mock drive ends would create a
572
/// use-after-free; relying on easy cleanup to own header/MIME allocations would
573
/// instead leak iterations.
574
264k
ScenarioRequestData::~ScenarioRequestData() {
575
264k
  if (easy_ != nullptr) {
576
    // Clear callbacks before their userdata member is destroyed. There is no
577
    // perform in this destructor, but making the handle non-dangling keeps the
578
    // ownership rule robust if cleanup later gains diagnostics or getinfo.
579
264k
    if (upload_callbacks_installed_) {
580
35.7k
      (void)curl_easy_setopt(easy_, CURLOPT_SEEKFUNCTION, nullptr);
581
35.7k
      (void)curl_easy_setopt(easy_, CURLOPT_SEEKDATA, nullptr);
582
35.7k
      (void)curl_easy_setopt(easy_, CURLOPT_READFUNCTION, nullptr);
583
35.7k
      (void)curl_easy_setopt(easy_, CURLOPT_READDATA, nullptr);
584
35.7k
    }
585
264k
    if (mime_post_ != nullptr) {
586
48.4k
      (void)curl_easy_setopt(easy_, CURLOPT_MIMEPOST, nullptr);
587
48.4k
    }
588
264k
    if (request_headers_ != nullptr) {
589
49.2k
      (void)curl_easy_setopt(easy_, CURLOPT_HTTPHEADER, nullptr);
590
49.2k
    }
591
264k
    if (resolve_entries_ != nullptr) {
592
5.87k
      (void)curl_easy_setopt(easy_, CURLOPT_RESOLVE, nullptr);
593
5.87k
    }
594
264k
    if (telnet_options_ != nullptr) {
595
566
      (void)curl_easy_setopt(easy_, CURLOPT_TELNETOPTIONS, nullptr);
596
566
    }
597
264k
    if (mail_rcpt_ != nullptr) {
598
2.77k
      (void)curl_easy_setopt(easy_, CURLOPT_MAIL_RCPT, nullptr);
599
2.77k
    }
600
264k
  }
601
  // Callback MIME parts retain upload_state_ as userdata. Release the root
602
  // explicitly while that state is still live instead of relying only on
603
  // reverse member destruction order.
604
264k
  mime_post_.reset();
605
264k
}
606
607
/// Return resource counts after caps and allocation failures were applied,
608
/// without exposing or transferring the owned curl pointers themselves.
609
/// @return Immutable construction statistics.
610
222k
const RequestBuildStats& ScenarioRequestData::stats() const { return stats_; }
611
612
/// Return the callback state retained for the complete drive. This is mainly
613
/// useful to ownership tests; the runner itself lets libcurl mutate it.
614
/// @return Immutable upload state.
615
222k
const UploadScriptState& ScenarioRequestData::upload_state() const { return upload_state_; }
616
617
/// Return whether this scenario needed raw upload callbacks. Exposing the
618
/// decision lets ownership tests ensure ordinary requests avoid redundant
619
/// setopt/teardown work without exposing either userdata pointer.
620
/// @return True when read and seek callbacks were attached to the handle.
621
222k
bool ScenarioRequestData::upload_callbacks_installed() const { return upload_callbacks_installed_; }
622
623
/// Return whether resolver-only slist construction installed its mandatory
624
/// final loopback mapping. Ordinary lanes always report true.
625
/// @return True when the resolver mapping is ready for the transfer.
626
7.81k
bool ScenarioRequestData::resolve_entries_ready() const { return resolve_entries_ready_; }
627
628
/// Arrange protocol-specific work immediately before every upload read.
629
/// This is intentionally a narrow callback seam rather than exposing curl's
630
/// retained READFUNCTION userdata or the mutable upload cursor.
631
/// @param callback Function to invoke, or nullptr to clear the hook.
632
/// @param userdata Borrowed pointer passed to `callback`.
633
5.05k
void ScenarioRequestData::SetBeforeUploadReadCallback(UploadScriptState::BeforeReadCallback callback, void* userdata) {
634
5.05k
  upload_state_.SetBeforeReadCallback(callback, userdata);
635
5.05k
}
636
637
}  // namespace proto_fuzzer