Coverage Report

Created: 2026-10-15 06:25

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
295k
const char* BoundedCString(const std::string& value, std::size_t limit, std::string* truncated) {
33
295k
  if (value.size() <= limit) {
34
295k
    return value.c_str();
35
295k
  }
36
134
  truncated->assign(value.data(), limit);
37
134
  return truncated->c_str();
38
295k
}
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
393k
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
393k
  if (scenario.scheme() == curl::fuzzer::proto::SCHEME_TELNET || scenario.has_upload()) {
51
21.7k
    return true;
52
21.7k
  }
53
371k
  const std::size_t option_count = RuntimeOptionCount(scenario);
54
1.19M
  for (std::size_t index = 0; index < option_count; ++index) {
55
837k
    const auto& option = scenario.options(static_cast<int>(index));
56
837k
    if (option.option_id() == curl::fuzzer::proto::CURLOPT_UPLOAD ||
57
828k
        option.option_id() == curl::fuzzer::proto::CURLOPT_POST) {
58
11.6k
      return true;
59
11.6k
    }
60
837k
  }
61
360k
  return false;
62
371k
}
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
141k
const char* MimeEncoderName(curl::fuzzer::proto::MimeEncoder encoder) {
68
141k
  switch (encoder) {
69
6.79k
    case curl::fuzzer::proto::MIME_ENCODER_BINARY:
70
6.79k
      return "binary";
71
6.92k
    case curl::fuzzer::proto::MIME_ENCODER_8BIT:
72
6.92k
      return "8bit";
73
9.67k
    case curl::fuzzer::proto::MIME_ENCODER_7BIT:
74
9.67k
      return "7bit";
75
16.8k
    case curl::fuzzer::proto::MIME_ENCODER_BASE64:
76
16.8k
      return "base64";
77
21.5k
    case curl::fuzzer::proto::MIME_ENCODER_QUOTED_PRINTABLE:
78
21.5k
      return "quoted-printable";
79
79.2k
    case curl::fuzzer::proto::MIME_ENCODER_UNSPECIFIED:
80
79.4k
    default:
81
79.4k
      return nullptr;
82
141k
  }
83
141k
}
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
549k
                             std::size_t* applied) {
92
549k
  CurlSlistPtr list;
93
549k
  std::string truncated;
94
549k
  const std::size_t count = std::min<std::size_t>(count_limit, values.size());
95
747k
  for (std::size_t i = 0; i < count; ++i) {
96
197k
    const std::string& value = values.Get(static_cast<int>(i));
97
197k
    curl_slist* appended = curl_slist_append(list.get(), BoundedCString(value, value_limit, &truncated));
98
197k
    if (appended == nullptr) {
99
184
      break;
100
184
    }
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
197k
    (void)list.release();
104
197k
    list.reset(appended);
105
197k
    ++*applied;
106
197k
  }
107
549k
  return list;
108
549k
}
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
141k
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
141k
  std::string truncated;
118
141k
  if (!source.name().empty()) {
119
33.5k
    (void)curl_mime_name(part, BoundedCString(source.name(), scenario_limits::kMaxMetadataBytes, &truncated));
120
33.5k
  }
121
141k
  if (!source.filename().empty()) {
122
34.4k
    (void)curl_mime_filename(part, BoundedCString(source.filename(), scenario_limits::kMaxMetadataBytes, &truncated));
123
34.4k
  }
124
141k
  if (!source.content_type().empty()) {
125
30.1k
    (void)curl_mime_type(part, BoundedCString(source.content_type(), scenario_limits::kMaxMetadataBytes, &truncated));
126
30.1k
  }
127
141k
  if (const char* encoder = MimeEncoderName(source.encoder())) {
128
61.8k
    (void)curl_mime_encoder(part, encoder);
129
61.8k
  }
130
131
141k
  std::size_t header_count = 0;
132
141k
  CurlSlistPtr headers = BuildStringList(source.headers(), scenario_limits::kMaxMimeHeadersPerPart,
133
141k
                                         scenario_limits::kMaxMetadataBytes, &header_count);
134
141k
  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
26.1k
    const CURLcode result = curl_mime_headers(part, headers.get(), 1);
139
26.1k
    if (result == CURLE_OK) {
140
26.1k
      (void)headers.release();
141
26.1k
      stats->mime_headers += header_count;
142
26.1k
    }
143
26.1k
  }
144
141k
}
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
76.1k
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
76.1k
  std::string truncated;
118
76.1k
  if (!source.name().empty()) {
119
15.6k
    (void)curl_mime_name(part, BoundedCString(source.name(), scenario_limits::kMaxMetadataBytes, &truncated));
120
15.6k
  }
121
76.1k
  if (!source.filename().empty()) {
122
15.0k
    (void)curl_mime_filename(part, BoundedCString(source.filename(), scenario_limits::kMaxMetadataBytes, &truncated));
123
15.0k
  }
124
76.1k
  if (!source.content_type().empty()) {
125
14.2k
    (void)curl_mime_type(part, BoundedCString(source.content_type(), scenario_limits::kMaxMetadataBytes, &truncated));
126
14.2k
  }
127
76.1k
  if (const char* encoder = MimeEncoderName(source.encoder())) {
128
34.0k
    (void)curl_mime_encoder(part, encoder);
129
34.0k
  }
130
131
76.1k
  std::size_t header_count = 0;
132
76.1k
  CurlSlistPtr headers = BuildStringList(source.headers(), scenario_limits::kMaxMimeHeadersPerPart,
133
76.1k
                                         scenario_limits::kMaxMetadataBytes, &header_count);
134
76.1k
  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
11.3k
    const CURLcode result = curl_mime_headers(part, headers.get(), 1);
139
11.3k
    if (result == CURLE_OK) {
140
11.3k
      (void)headers.release();
141
11.3k
      stats->mime_headers += header_count;
142
11.3k
    }
143
11.3k
  }
144
76.1k
}
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
65.1k
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
65.1k
  std::string truncated;
118
65.1k
  if (!source.name().empty()) {
119
17.9k
    (void)curl_mime_name(part, BoundedCString(source.name(), scenario_limits::kMaxMetadataBytes, &truncated));
120
17.9k
  }
121
65.1k
  if (!source.filename().empty()) {
122
19.3k
    (void)curl_mime_filename(part, BoundedCString(source.filename(), scenario_limits::kMaxMetadataBytes, &truncated));
123
19.3k
  }
124
65.1k
  if (!source.content_type().empty()) {
125
15.9k
    (void)curl_mime_type(part, BoundedCString(source.content_type(), scenario_limits::kMaxMetadataBytes, &truncated));
126
15.9k
  }
127
65.1k
  if (const char* encoder = MimeEncoderName(source.encoder())) {
128
27.8k
    (void)curl_mime_encoder(part, encoder);
129
27.8k
  }
130
131
65.1k
  std::size_t header_count = 0;
132
65.1k
  CurlSlistPtr headers = BuildStringList(source.headers(), scenario_limits::kMaxMimeHeadersPerPart,
133
65.1k
                                         scenario_limits::kMaxMetadataBytes, &header_count);
134
65.1k
  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
14.7k
    const CURLcode result = curl_mime_headers(part, headers.get(), 1);
139
14.7k
    if (result == CURLE_OK) {
140
14.7k
      (void)headers.release();
141
14.7k
      stats->mime_headers += header_count;
142
14.7k
    }
143
14.7k
  }
144
65.1k
}
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
60.8k
void ApplyPartData(curl_mimepart* part, const std::string& data) {
150
60.8k
  const std::size_t size = std::min(data.size(), scenario_limits::kMaxMimeDataBytes);
151
60.8k
  const char* bytes = data.empty() ? "" : data.data();
152
60.8k
  (void)curl_mime_data(part, bytes, size);
153
60.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
22.9k
                            std::size_t* remaining_generated_bytes, RequestBuildStats* stats) {
161
22.9k
  const std::size_t pattern_size = std::min(source.pattern().size(), scenario_limits::kMaxGeneratedMimePatternBytes);
162
22.9k
  if (pattern_size == 0 || *remaining_generated_bytes == 0) {
163
7.04k
    (void)curl_mime_data(part, "", 0);
164
7.04k
    return;
165
7.04k
  }
166
167
15.9k
  const std::size_t max_repeat_count = *remaining_generated_bytes / pattern_size;
168
15.9k
  const std::size_t repeat_count =
169
15.9k
      std::min<std::size_t>(static_cast<std::size_t>(source.repeat_count()), max_repeat_count);
170
15.9k
  const std::size_t materialized_size = repeat_count * pattern_size;
171
172
15.9k
  std::string materialized(materialized_size, '\0');
173
325M
  for (std::size_t offset = 0; offset < materialized_size; offset += pattern_size) {
174
325M
    std::copy_n(source.pattern().data(), pattern_size, materialized.data() + offset);
175
325M
  }
176
15.9k
  const char* bytes = materialized.empty() ? "" : materialized.data();
177
15.9k
  if (curl_mime_data(part, bytes, materialized.size()) == CURLE_OK) {
178
15.9k
    stats->generated_mime_bytes += materialized.size();
179
15.9k
  }
180
15.9k
  *remaining_generated_bytes -= materialized.size();
181
15.9k
}
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
34.3k
                      std::size_t* remaining_generated_bytes, RequestBuildStats* stats) {
188
34.3k
  const std::size_t count = std::min<std::size_t>(scenario_limits::kMaxNestedMimeParts, source.parts_size());
189
99.4k
  for (std::size_t i = 0; i < count && *remaining_parts != 0; ++i) {
190
65.1k
    curl_mimepart* part = curl_mime_addpart(mime);
191
65.1k
    if (part == nullptr) {
192
71
      break;
193
71
    }
194
65.1k
    --*remaining_parts;
195
65.1k
    ++stats->mime_parts;
196
65.1k
    const auto& proto_part = source.parts(static_cast<int>(i));
197
65.1k
    ApplyPartMetadata(part, proto_part, stats);
198
65.1k
    if (proto_part.content_case() == curl::fuzzer::proto::MimeDataPart::kGeneratedData) {
199
10.0k
      ApplyGeneratedPartData(part, proto_part.generated_data(), remaining_generated_bytes, stats);
200
55.0k
    } else {
201
      // Preserve the historical leaf behavior: an absent source is applied as
202
      // empty data just like an explicitly empty field 2.
203
55.0k
      ApplyPartData(part, proto_part.data());
204
55.0k
    }
205
65.1k
  }
206
34.3k
}
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
47.8k
CurlMimePtr BuildMimePost(CURL* easy, const curl::fuzzer::proto::MimePost& source, RequestBuildStats* stats) {
212
47.8k
  CurlMimePtr mime(curl_mime_init(easy));
213
47.8k
  if (mime == nullptr) {
214
39
    return nullptr;
215
39
  }
216
217
47.8k
  std::size_t remaining_parts = scenario_limits::kMaxTotalMimeParts;
218
47.8k
  std::size_t remaining_generated_bytes = scenario_limits::kMaxGeneratedMimeDataBytes;
219
47.8k
  const std::size_t count = std::min<std::size_t>(scenario_limits::kMaxTopLevelMimeParts, source.parts_size());
220
124k
  for (std::size_t i = 0; i < count && remaining_parts != 0; ++i) {
221
76.2k
    curl_mimepart* part = curl_mime_addpart(mime.get());
222
76.2k
    if (part == nullptr) {
223
44
      break;
224
44
    }
225
76.1k
    --remaining_parts;
226
76.1k
    ++stats->mime_parts;
227
76.1k
    const auto& proto_part = source.parts(static_cast<int>(i));
228
76.1k
    ApplyPartMetadata(part, proto_part, stats);
229
230
76.1k
    switch (proto_part.content_case()) {
231
5.75k
      case curl::fuzzer::proto::MimePart::kData:
232
5.75k
        ApplyPartData(part, proto_part.data());
233
5.75k
        break;
234
12.9k
      case curl::fuzzer::proto::MimePart::kGeneratedData:
235
12.9k
        ApplyGeneratedPartData(part, proto_part.generated_data(), &remaining_generated_bytes, stats);
236
12.9k
        break;
237
34.3k
      case curl::fuzzer::proto::MimePart::kSubparts: {
238
34.3k
        CurlMimePtr subparts(curl_mime_init(easy));
239
34.3k
        if (subparts == nullptr) {
240
41
          break;
241
41
        }
242
34.3k
        PopulateSubparts(subparts.get(), proto_part.subparts(), &remaining_parts, &remaining_generated_bytes, stats);
243
34.3k
        if (curl_mime_subparts(part, subparts.get()) == CURLE_OK) {
244
34.3k
          (void)subparts.release();
245
34.3k
        }
246
34.3k
        break;
247
34.3k
      }
248
23.1k
      case curl::fuzzer::proto::MimePart::CONTENT_NOT_SET:
249
23.1k
      default:
250
23.1k
        break;
251
76.1k
    }
252
76.1k
  }
253
47.8k
  return mime;
254
47.8k
}
255
256
}  // namespace
257
258
/// Borrow and cap a scripted upload source before libcurl receives a userdata
259
/// pointer. When Scenario.upload is absent, non-TELNET schemes synthesize the
260
/// historical 16 KiB `U` stream without allocating it, while TELNET reports
261
/// immediate EOF so merely selecting the protocol cannot inject unsolicited
262
/// terminal input. Borrowing avoids duplicating every fuzzed body before curl
263
/// can read it; `scenario` must remain alive and unmodified until this state is
264
/// destroyed.
265
/// @param scenario Source and lifetime owner of callback bytes and outcomes.
266
UploadScriptState::UploadScriptState(const curl::fuzzer::proto::Scenario& scenario)
267
393k
    : data_(),
268
393k
      read_step_count_(0),
269
393k
      total_size_(scenario.scheme() == curl::fuzzer::proto::SCHEME_TELNET ? 0 : scenario_limits::kMaxUploadBytes),
270
393k
      max_read_size_(scenario.scheme() == curl::fuzzer::proto::SCHEME_TELNET ? scenario_limits::kMaxTelnetUploadReadSize
271
393k
                                                                             : scenario_limits::kMaxUploadReadSize),
272
393k
      offset_(0),
273
393k
      next_read_size_(0),
274
393k
      terminal_(curl::fuzzer::proto::UPLOAD_TERMINAL_EOF),
275
393k
      seek_result_(curl::fuzzer::proto::UPLOAD_SEEK_CANTSEEK),
276
393k
      before_read_callback_(nullptr),
277
393k
      before_read_userdata_(nullptr),
278
393k
      scripted_(scenario.has_upload()) {
279
393k
  if (!scripted_) {
280
376k
    return;
281
376k
  }
282
283
16.5k
  const auto& upload = scenario.upload();
284
16.5k
  const std::size_t data_limit = scenario.scheme() == curl::fuzzer::proto::SCHEME_TELNET
285
16.5k
                                     ? scenario_limits::kMaxTelnetUploadBytes
286
16.5k
                                     : scenario_limits::kMaxUploadBytes;
287
16.5k
  const std::size_t data_size = std::min(upload.data().size(), data_limit);
288
16.5k
  data_ = std::string_view(upload.data().data(), data_size);
289
16.5k
  total_size_ = data_.size();
290
16.5k
  terminal_ = upload.terminal();
291
16.5k
  if (scenario.scheme() != curl::fuzzer::proto::SCHEME_TELNET &&
292
16.3k
      terminal_ == curl::fuzzer::proto::UPLOAD_TERMINAL_PAUSE) {
293
    // Event-driven protocols need an external resume source. Interpret this
294
    // TELNET-specific outcome as EOF before curl can retain a paused transfer.
295
41
    terminal_ = curl::fuzzer::proto::UPLOAD_TERMINAL_EOF;
296
41
  }
297
16.5k
  seek_result_ = upload.seek_result();
298
299
16.5k
  const std::size_t read_step_limit = scenario.scheme() == curl::fuzzer::proto::SCHEME_TELNET
300
16.5k
                                          ? scenario_limits::kMaxTelnetUploadReadSteps
301
16.5k
                                          : scenario_limits::kMaxUploadReadSteps;
302
16.5k
  read_step_count_ = std::min<std::size_t>(upload.read_sizes_size(), read_step_limit);
303
72.6k
  for (std::size_t i = 0; i < read_step_count_; ++i) {
304
56.0k
    const std::size_t requested = upload.read_sizes(static_cast<int>(i));
305
    // Zero-as-one ensures every retained step makes progress; see the schema
306
    // comment for why zero is not treated as an early EOF sentinel.
307
56.0k
    read_sizes_[i] = std::max<std::size_t>(1, std::min(requested, max_read_size_));
308
56.0k
  }
309
16.5k
}
310
311
/// Copy the next bounded chunk and advance the cursor, using either the explicit
312
/// payload or the allocation-free fallback. Terminal outcomes are emitted only
313
/// after all data is consumed so a mutation can independently control
314
/// fragmentation and completion policy. Public so focused tests can validate
315
/// callback semantics without relying on HTTP timing.
316
/// @param buffer Destination supplied by curl.
317
/// @param capacity Writable bytes in `buffer`.
318
/// @return Bytes copied, zero for EOF, CURL_READFUNC_ABORT, or the TELNET-
319
///         only CURL_READFUNC_PAUSE outcome.
320
152k
std::size_t UploadScriptState::Read(char* buffer, std::size_t capacity) {
321
  // TELNET can produce negotiation replies while one curl_multi_perform call
322
  // owns the thread. Empty them before returning more callback bytes;
323
  // otherwise send_telnet_data() can consume the whole bounded transfer
324
  // timeout while the synchronous path waits for socket capacity.
325
152k
  if (before_read_callback_ != nullptr) {
326
1.75k
    before_read_callback_(before_read_userdata_);
327
1.75k
  }
328
152k
  if (offset_ >= total_size_) {
329
12.1k
    switch (terminal_) {
330
366
      case curl::fuzzer::proto::UPLOAD_TERMINAL_ABORT:
331
366
        return CURL_READFUNC_ABORT;
332
22
      case curl::fuzzer::proto::UPLOAD_TERMINAL_PAUSE:
333
22
        return CURL_READFUNC_PAUSE;
334
11.7k
      case curl::fuzzer::proto::UPLOAD_TERMINAL_EOF:
335
11.7k
      default:
336
11.7k
        return 0;
337
12.1k
    }
338
12.1k
  }
339
140k
  if (buffer == nullptr || capacity == 0) {
340
0
    return 0;
341
0
  }
342
343
140k
  std::size_t chunk_limit = std::min(capacity, max_read_size_);
344
140k
  if (next_read_size_ < read_step_count_) {
345
128k
    chunk_limit = std::min(chunk_limit, read_sizes_[next_read_size_++]);
346
128k
  }
347
140k
  const std::size_t count = std::min(chunk_limit, total_size_ - offset_);
348
140k
  if (scripted_) {
349
135k
    std::memcpy(buffer, data_.data() + offset_, count);
350
135k
  } else {
351
4.88k
    std::memset(buffer, 'U', count);
352
4.88k
  }
353
140k
  offset_ += count;
354
140k
  return count;
355
140k
}
356
357
/// Apply the configured seek outcome and, for OK, move the bounded cursor.
358
/// Successful seeks restart the short-read sequence so retries replay the same
359
/// callback fragmentation deterministically. Explicit range checks model only
360
/// seeks curl can meaningfully request from a bounded memory source, avoiding
361
/// signed overflow or a mutation wrapping into an in-bounds cursor.
362
/// @param requested_offset Offset interpreted relative to `origin`.
363
/// @param origin One of SEEK_SET, SEEK_CUR, or SEEK_END.
364
/// @return A CURL_SEEKFUNC_* result.
365
16.2k
int UploadScriptState::Seek(curl_off_t requested_offset, int origin) {
366
16.2k
  switch (seek_result_) {
367
470
    case curl::fuzzer::proto::UPLOAD_SEEK_CANTSEEK:
368
470
      return CURL_SEEKFUNC_CANTSEEK;
369
34
    case curl::fuzzer::proto::UPLOAD_SEEK_FAIL:
370
34
      return CURL_SEEKFUNC_FAIL;
371
15.7k
    case curl::fuzzer::proto::UPLOAD_SEEK_OK:
372
15.7k
      break;
373
0
    default:
374
0
      return CURL_SEEKFUNC_CANTSEEK;
375
16.2k
  }
376
377
15.7k
  const curl_off_t current = static_cast<curl_off_t>(offset_);
378
15.7k
  const curl_off_t end = static_cast<curl_off_t>(total_size_);
379
15.7k
  curl_off_t base = 0;
380
15.7k
  switch (origin) {
381
15.7k
    case SEEK_SET:
382
15.7k
      base = 0;
383
15.7k
      break;
384
0
    case SEEK_CUR:
385
0
      base = current;
386
0
      break;
387
0
    case SEEK_END:
388
0
      base = end;
389
0
      break;
390
0
    default:
391
0
      return CURL_SEEKFUNC_FAIL;
392
15.7k
  }
393
394
  // Both base and end are at most 16 KiB. Comparing the requested delta to
395
  // these small bounds before addition handles even CURL_OFF_T_MIN safely.
396
15.7k
  if (requested_offset < -base || requested_offset > end - base) {
397
96
    return CURL_SEEKFUNC_FAIL;
398
96
  }
399
15.6k
  offset_ = static_cast<std::size_t>(base + requested_offset);
400
15.6k
  next_read_size_ = 0;
401
15.6k
  return CURL_SEEKFUNC_OK;
402
15.7k
}
403
404
/// libcurl trampoline that protects multiplication of its two buffer-size
405
/// arguments before forwarding to Read(). Multiplication is normally benign
406
/// because curl uses size=1, but callbacks are an API boundary. Abort an
407
/// impossible overflowing pair: saturating to SIZE_MAX would let Read() copy
408
/// into a buffer whose real extent is unknown.
409
152k
std::size_t UploadScriptState::ReadCallback(char* buffer, std::size_t size, std::size_t nitems, void* userdata) {
410
152k
  if (userdata == nullptr || size == 0 || nitems == 0) {
411
0
    return 0;
412
0
  }
413
152k
  const std::size_t max = std::numeric_limits<std::size_t>::max();
414
152k
  if (nitems > max / size) {
415
0
    return CURL_READFUNC_ABORT;
416
0
  }
417
152k
  return static_cast<UploadScriptState*>(userdata)->Read(buffer, size * nitems);
418
152k
}
419
420
/// libcurl trampoline for the userdata-first seek callback signature. Keep the
421
/// C callback a type bridge so all outcome/cursor behaviour remains directly
422
/// unit-testable in Seek().
423
16.2k
int UploadScriptState::SeekCallback(void* userdata, curl_off_t offset, int origin) {
424
16.2k
  if (userdata == nullptr) {
425
0
    return CURL_SEEKFUNC_FAIL;
426
0
  }
427
16.2k
  return static_cast<UploadScriptState*>(userdata)->Seek(offset, origin);
428
16.2k
}
429
430
/// @return Number of bytes visible to the callback after caps.
431
324k
std::size_t UploadScriptState::data_size() const { return total_size_; }
432
433
/// @return Number of retained per-read limits after caps.
434
324k
std::size_t UploadScriptState::read_step_count() const { return read_step_count_; }
435
436
/// @return Current byte cursor, exposed for deterministic unit tests.
437
324k
std::size_t UploadScriptState::offset() const { return offset_; }
438
439
/// @return Whether Scenario.upload was present instead of using fallback.
440
324k
bool UploadScriptState::scripted() const { return scripted_; }
441
442
/// Install a protocol hook without exposing the mutable cursor or curl's
443
/// retained callback userdata outside its owner.
444
5.42k
void UploadScriptState::SetBeforeReadCallback(BeforeReadCallback callback, void* userdata) {
445
5.42k
  before_read_callback_ = callback;
446
5.42k
  before_read_userdata_ = userdata;
447
5.42k
}
448
449
/// Construct and apply the protocol-specific pointer-valued fields in `scenario`
450
/// to `easy`. Setup errors are deliberately non-fatal: malformed or partially
451
/// allocated scenarios should still exercise whatever curl state was built.
452
/// `easy` must remain alive until this object is destroyed, because cleanup
453
/// first detaches the pointers from the handle and only then frees them.
454
/// @param easy Easy handle that will perform this scenario.
455
/// @param scenario Source headers/TELNET options, optional MIME body, and
456
///                 upload script.
457
/// @param apply_resolve_entries Whether to install the scenario's bounded
458
///        resolver mappings plus the mandatory loopback mapping.
459
ScenarioRequestData::ScenarioRequestData(CURL* easy, const curl::fuzzer::proto::Scenario& scenario,
460
                                         bool apply_resolve_entries)
461
393k
    : easy_(easy),
462
393k
      request_headers_(nullptr),
463
393k
      resolve_entries_(nullptr),
464
393k
      telnet_options_(nullptr),
465
393k
      mime_post_(nullptr),
466
393k
      upload_state_(scenario),
467
393k
      upload_callbacks_installed_(false),
468
393k
      resolve_entries_ready_(!apply_resolve_entries) {
469
393k
  if (easy_ == nullptr) {
470
0
    return;
471
0
  }
472
473
  // Complete all allocating work before installing retained pointers. If a
474
  // C++ allocation throws, scoped owners unwind without leaving the easy
475
  // handle pointing into a partially constructed request-data object.
476
393k
  bool resolve_entries_built = false;
477
393k
  if (apply_resolve_entries) {
478
15.0k
    resolve_entries_ = BuildStringList(scenario.resolve_entries(), scenario_limits::kMaxResolveEntries,
479
15.0k
                                       scenario_limits::kMaxResolveEntryBytes, &stats_.resolve_entries);
480
    // This mapping is deliberately last: preceding wildcard or removal
481
    // mutations may exercise host-cache parsing, but cannot redirect the
482
    // canonical resolver-lane origin away from the in-process mock.
483
15.0k
    curl_slist* appended = curl_slist_append(resolve_entries_.get(), "resolve.test:80:127.0.0.1");
484
15.0k
    if (appended != nullptr) {
485
15.0k
      (void)resolve_entries_.release();
486
15.0k
      resolve_entries_.reset(appended);
487
15.0k
      resolve_entries_built = true;
488
15.0k
    }
489
15.0k
  }
490
491
  // HTTP headers/MIME and TELNET options are mutually exclusive because only
492
  // the selected protocol can observe them. Avoid allocating protocol-inert
493
  // lists and trees in compatibility inputs that bypass target policy.
494
393k
  if (scenario.scheme() == curl::fuzzer::proto::SCHEME_TELNET) {
495
5.42k
    telnet_options_ = BuildStringList(scenario.telnet_options(), scenario_limits::kMaxTelnetOptions,
496
5.42k
                                      scenario_limits::kMaxTelnetOptionBytes, &stats_.telnet_options);
497
387k
  } else {
498
387k
    request_headers_ = BuildStringList(scenario.request_headers(), scenario_limits::kMaxRequestHeaders,
499
387k
                                       scenario_limits::kMaxMetadataBytes, &stats_.request_headers);
500
387k
    if (scenario.has_mime_post()) {
501
47.8k
      mime_post_ = BuildMimePost(easy_, scenario.mime_post(), &stats_);
502
47.8k
    }
503
387k
  }
504
505
393k
  if (resolve_entries_built) {
506
15.0k
    resolve_entries_ready_ = curl_easy_setopt(easy_, CURLOPT_RESOLVE, resolve_entries_.get()) == CURLE_OK;
507
15.0k
  }
508
509
393k
  if (NeedsUploadCallbacks(scenario)) {
510
    // Install a per-run memory source even when Scenario.upload is absent but
511
    // CURLOPT_UPLOAD or TELNET may request caller input. Non-TELNET schemes
512
    // retain the historical fallback bytes; TELNET returns EOF. Either result
513
    // replaces stdin and cannot block OSS-Fuzz. The state remains scoped to
514
    // the complete drive so retries cannot share a cursor across iterations.
515
33.3k
    upload_callbacks_installed_ = true;
516
33.3k
    (void)curl_easy_setopt(easy_, CURLOPT_READFUNCTION, &UploadScriptState::ReadCallback);
517
33.3k
    (void)curl_easy_setopt(easy_, CURLOPT_READDATA, &upload_state_);
518
33.3k
    (void)curl_easy_setopt(easy_, CURLOPT_SEEKFUNCTION, &UploadScriptState::SeekCallback);
519
33.3k
    (void)curl_easy_setopt(easy_, CURLOPT_SEEKDATA, &upload_state_);
520
33.3k
  }
521
522
393k
  if (telnet_options_ != nullptr) {
523
497
    (void)curl_easy_setopt(easy_, CURLOPT_TELNETOPTIONS, telnet_options_.get());
524
497
  }
525
393k
  if (request_headers_ != nullptr) {
526
44.7k
    (void)curl_easy_setopt(easy_, CURLOPT_HTTPHEADER, request_headers_.get());
527
44.7k
  }
528
393k
  if (mime_post_ != nullptr) {
529
47.8k
    (void)curl_easy_setopt(easy_, CURLOPT_MIMEPOST, mime_post_.get());
530
47.8k
  }
531
393k
}
532
533
/// Detach pointer options/callbacks while the easy handle is valid, then release
534
/// request allocations. libcurl does not copy headers, MIME roots, or callback
535
/// userdata, so releasing any one before the mock drive ends would create a
536
/// use-after-free; relying on easy cleanup to own header/MIME allocations would
537
/// instead leak iterations.
538
393k
ScenarioRequestData::~ScenarioRequestData() {
539
393k
  if (easy_ != nullptr) {
540
    // Clear callbacks before their userdata member is destroyed. There is no
541
    // perform in this destructor, but making the handle non-dangling keeps the
542
    // ownership rule robust if cleanup later gains diagnostics or getinfo.
543
393k
    if (upload_callbacks_installed_) {
544
33.3k
      (void)curl_easy_setopt(easy_, CURLOPT_SEEKFUNCTION, nullptr);
545
33.3k
      (void)curl_easy_setopt(easy_, CURLOPT_SEEKDATA, nullptr);
546
33.3k
      (void)curl_easy_setopt(easy_, CURLOPT_READFUNCTION, nullptr);
547
33.3k
      (void)curl_easy_setopt(easy_, CURLOPT_READDATA, nullptr);
548
33.3k
    }
549
393k
    if (mime_post_ != nullptr) {
550
47.8k
      (void)curl_easy_setopt(easy_, CURLOPT_MIMEPOST, nullptr);
551
47.8k
    }
552
393k
    if (request_headers_ != nullptr) {
553
44.7k
      (void)curl_easy_setopt(easy_, CURLOPT_HTTPHEADER, nullptr);
554
44.7k
    }
555
393k
    if (resolve_entries_ != nullptr) {
556
15.0k
      (void)curl_easy_setopt(easy_, CURLOPT_RESOLVE, nullptr);
557
15.0k
    }
558
393k
    if (telnet_options_ != nullptr) {
559
497
      (void)curl_easy_setopt(easy_, CURLOPT_TELNETOPTIONS, nullptr);
560
497
    }
561
393k
  }
562
393k
}
563
564
/// Return resource counts after caps and allocation failures were applied,
565
/// without exposing or transferring the owned curl pointers themselves.
566
/// @return Immutable construction statistics.
567
324k
const RequestBuildStats& ScenarioRequestData::stats() const { return stats_; }
568
569
/// Return the callback state retained for the complete drive. This is mainly
570
/// useful to ownership tests; the runner itself lets libcurl mutate it.
571
/// @return Immutable upload state.
572
324k
const UploadScriptState& ScenarioRequestData::upload_state() const { return upload_state_; }
573
574
/// Return whether this scenario needed raw upload callbacks. Exposing the
575
/// decision lets ownership tests ensure ordinary requests avoid redundant
576
/// setopt/teardown work without exposing either userdata pointer.
577
/// @return True when read and seek callbacks were attached to the handle.
578
324k
bool ScenarioRequestData::upload_callbacks_installed() const { return upload_callbacks_installed_; }
579
580
/// Return whether resolver-only slist construction installed its mandatory
581
/// final loopback mapping. Ordinary lanes always report true.
582
/// @return True when the resolver mapping is ready for the transfer.
583
15.0k
bool ScenarioRequestData::resolve_entries_ready() const { return resolve_entries_ready_; }
584
585
/// Arrange protocol-specific work immediately before every upload read.
586
/// This is intentionally a narrow callback seam rather than exposing curl's
587
/// retained READFUNCTION userdata or the mutable upload cursor.
588
/// @param callback Function to invoke, or nullptr to clear the hook.
589
/// @param userdata Borrowed pointer passed to `callback`.
590
5.42k
void ScenarioRequestData::SetBeforeUploadReadCallback(UploadScriptState::BeforeReadCallback callback, void* userdata) {
591
5.42k
  upload_state_.SetBeforeReadCallback(callback, userdata);
592
5.42k
}
593
594
}  // namespace proto_fuzzer