Coverage Report

Created: 2026-08-31 06:49

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
104
const char* BoundedCString(const std::string& value, std::string* truncated) {
33
104
  if (value.size() <= scenario_limits::kMaxMetadataBytes) {
34
104
    return value.c_str();
35
104
  }
36
0
  truncated->assign(value.data(), scenario_limits::kMaxMetadataBytes);
37
0
  return truncated->c_str();
38
104
}
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
10.1k
bool NeedsUploadCallbacks(const curl::fuzzer::proto::Scenario& scenario) {
47
10.1k
  if (scenario.has_upload()) {
48
57
    return true;
49
57
  }
50
10.0k
  const std::size_t option_count = RuntimeOptionCount(scenario);
51
25.1k
  for (std::size_t index = 0; index < option_count; ++index) {
52
16.3k
    const auto& option = scenario.options(static_cast<int>(index));
53
16.3k
    if (option.option_id() == curl::fuzzer::proto::CURLOPT_UPLOAD ||
54
15.5k
        option.option_id() == curl::fuzzer::proto::CURLOPT_POST) {
55
1.30k
      return true;
56
1.30k
    }
57
16.3k
  }
58
8.78k
  return false;
59
10.0k
}
60
61
/// Translate the mutation-friendly enum to curl's spelling. Restricting this
62
/// field to supported encoders spends cycles in encoder implementations rather
63
/// than repeatedly rediscovering the same invalid-string rejection.
64
0
const char* MimeEncoderName(curl::fuzzer::proto::MimeEncoder encoder) {
65
0
  switch (encoder) {
66
0
    case curl::fuzzer::proto::MIME_ENCODER_BINARY:
67
0
      return "binary";
68
0
    case curl::fuzzer::proto::MIME_ENCODER_8BIT:
69
0
      return "8bit";
70
0
    case curl::fuzzer::proto::MIME_ENCODER_7BIT:
71
0
      return "7bit";
72
0
    case curl::fuzzer::proto::MIME_ENCODER_BASE64:
73
0
      return "base64";
74
0
    case curl::fuzzer::proto::MIME_ENCODER_QUOTED_PRINTABLE:
75
0
      return "quoted-printable";
76
0
    case curl::fuzzer::proto::MIME_ENCODER_UNSPECIFIED:
77
0
    default:
78
0
      return nullptr;
79
0
  }
80
0
}
81
82
/// Append at most `limit` protobuf byte strings to a curl list. curl_slist_append
83
/// leaves the old head valid on allocation failure, so only replace the head
84
/// after a successful append and stop rather than burning the rest of the
85
/// iteration on allocations that are already failing.
86
template <typename RepeatedBytes>
87
10.1k
curl_slist* BuildHeaderList(const RepeatedBytes& values, std::size_t limit, std::size_t* applied) {
88
10.1k
  curl_slist* headers = nullptr;
89
10.1k
  std::string truncated;
90
10.1k
  const std::size_t count = std::min<std::size_t>(limit, values.size());
91
10.2k
  for (std::size_t i = 0; i < count; ++i) {
92
104
    const std::string& header = values.Get(static_cast<int>(i));
93
104
    curl_slist* appended = curl_slist_append(headers, BoundedCString(header, &truncated));
94
104
    if (appended == nullptr) {
95
0
      break;
96
0
    }
97
104
    headers = appended;
98
104
    ++*applied;
99
104
  }
100
10.1k
  return headers;
101
10.1k
}
102
103
/// Apply the metadata shared by top-level and nested protobuf part types. The
104
/// MIME API copies these strings, so temporary bounded buffers are sufficient;
105
/// only the MIME root itself needs to outlive the perform loop.
106
template <typename ProtoPart>
107
0
void ApplyPartMetadata(curl_mimepart* part, const ProtoPart& source, RequestBuildStats* stats) {
108
  // Reuse the rare compatibility-path allocation across all three fields;
109
  // ordinary postprocessed metadata never writes this scratch string.
110
0
  std::string truncated;
111
0
  if (!source.name().empty()) {
112
0
    (void)curl_mime_name(part, BoundedCString(source.name(), &truncated));
113
0
  }
114
0
  if (!source.filename().empty()) {
115
0
    (void)curl_mime_filename(part, BoundedCString(source.filename(), &truncated));
116
0
  }
117
0
  if (!source.content_type().empty()) {
118
0
    (void)curl_mime_type(part, BoundedCString(source.content_type(), &truncated));
119
0
  }
120
0
  if (const char* encoder = MimeEncoderName(source.encoder())) {
121
0
    (void)curl_mime_encoder(part, encoder);
122
0
  }
123
124
0
  std::size_t header_count = 0;
125
0
  curl_slist* headers = BuildHeaderList(source.headers(), scenario_limits::kMaxMimeHeadersPerPart, &header_count);
126
0
  if (headers != nullptr) {
127
    // take_ownership=1 is crucial: unlike the strings above, MIME retains the
128
    // list pointer. Once attached, the root curl_mime_free call recursively
129
    // releases it, including lists on nested parts.
130
0
    const CURLcode result = curl_mime_headers(part, headers, 1);
131
0
    if (result == CURLE_OK) {
132
0
      stats->mime_headers += header_count;
133
0
    } else {
134
0
      curl_slist_free_all(headers);
135
0
    }
136
0
  }
137
0
}
Unexecuted instantiation: request_data.cc:void proto_fuzzer::(anonymous namespace)::ApplyPartMetadata<curl::fuzzer::proto::MimePart>(curl_mimepart*, curl::fuzzer::proto::MimePart const&, proto_fuzzer::RequestBuildStats*)
Unexecuted instantiation: request_data.cc:void proto_fuzzer::(anonymous namespace)::ApplyPartMetadata<curl::fuzzer::proto::MimeDataPart>(curl_mimepart*, curl::fuzzer::proto::MimeDataPart const&, proto_fuzzer::RequestBuildStats*)
138
139
/// Copy bounded binary data into a MIME part. curl_mime_data accepts an
140
/// explicit size, so embedded NUL bytes remain fuzzable here unlike in the
141
/// metadata and header APIs.
142
0
void ApplyPartData(curl_mimepart* part, const std::string& data) {
143
0
  const std::size_t size = std::min(data.size(), scenario_limits::kMaxMimeDataBytes);
144
0
  const char* bytes = data.empty() ? "" : data.data();
145
0
  (void)curl_mime_data(part, bytes, size);
146
0
}
147
148
/// Populate the fixed-depth child body and debit the shared total-part budget.
149
/// Returning an empty MIME object when the child list is empty is intentional:
150
/// curl's empty multipart serialization is useful coverage and remains cheap.
151
void PopulateSubparts(curl_mime* mime, const curl::fuzzer::proto::MimeSubparts& source, std::size_t* remaining_parts,
152
0
                      RequestBuildStats* stats) {
153
0
  const std::size_t count = std::min<std::size_t>(scenario_limits::kMaxNestedMimeParts, source.parts_size());
154
0
  for (std::size_t i = 0; i < count && *remaining_parts != 0; ++i) {
155
0
    curl_mimepart* part = curl_mime_addpart(mime);
156
0
    if (part == nullptr) {
157
0
      break;
158
0
    }
159
0
    --*remaining_parts;
160
0
    ++stats->mime_parts;
161
0
    const auto& proto_part = source.parts(static_cast<int>(i));
162
0
    ApplyPartMetadata(part, proto_part, stats);
163
0
    ApplyPartData(part, proto_part.data());
164
0
  }
165
0
}
166
167
/// Construct the top MIME tree. curl_mime_subparts transfers ownership only
168
/// on success, so failed attachments are freed immediately while successful
169
/// ones are left for the top-level root to release recursively.
170
1
curl_mime* BuildMimePost(CURL* easy, const curl::fuzzer::proto::MimePost& source, RequestBuildStats* stats) {
171
1
  curl_mime* mime = curl_mime_init(easy);
172
1
  if (mime == nullptr) {
173
0
    return nullptr;
174
0
  }
175
176
1
  std::size_t remaining_parts = scenario_limits::kMaxTotalMimeParts;
177
1
  const std::size_t count = std::min<std::size_t>(scenario_limits::kMaxTopLevelMimeParts, source.parts_size());
178
1
  for (std::size_t i = 0; i < count && remaining_parts != 0; ++i) {
179
0
    curl_mimepart* part = curl_mime_addpart(mime);
180
0
    if (part == nullptr) {
181
0
      break;
182
0
    }
183
0
    --remaining_parts;
184
0
    ++stats->mime_parts;
185
0
    const auto& proto_part = source.parts(static_cast<int>(i));
186
0
    ApplyPartMetadata(part, proto_part, stats);
187
188
0
    switch (proto_part.content_case()) {
189
0
      case curl::fuzzer::proto::MimePart::kData:
190
0
        ApplyPartData(part, proto_part.data());
191
0
        break;
192
0
      case curl::fuzzer::proto::MimePart::kSubparts: {
193
0
        curl_mime* subparts = curl_mime_init(easy);
194
0
        if (subparts == nullptr) {
195
0
          break;
196
0
        }
197
0
        PopulateSubparts(subparts, proto_part.subparts(), &remaining_parts, stats);
198
0
        if (curl_mime_subparts(part, subparts) != CURLE_OK) {
199
0
          curl_mime_free(subparts);
200
0
        }
201
0
        break;
202
0
      }
203
0
      case curl::fuzzer::proto::MimePart::CONTENT_NOT_SET:
204
0
      default:
205
0
        break;
206
0
    }
207
0
  }
208
1
  return mime;
209
1
}
210
211
}  // namespace
212
213
/// Borrow and cap the immutable upload shape once, before libcurl receives a
214
/// userdata pointer. ScenarioRunner keeps the protobuf alive for the complete
215
/// drive, so a view removes a per-input body copy without weakening callback
216
/// lifetime. The absent-message fallback deliberately avoids a 16 KiB
217
/// allocation: Read() synthesizes the same `U` bytes the old global callback
218
/// produced while retaining per-run cursor isolation.
219
UploadScriptState::UploadScriptState(const curl::fuzzer::proto::Scenario& scenario)
220
10.1k
    : data_(),
221
10.1k
      read_step_count_(0),
222
10.1k
      total_size_(scenario_limits::kMaxUploadBytes),
223
10.1k
      offset_(0),
224
10.1k
      next_read_size_(0),
225
10.1k
      terminal_(curl::fuzzer::proto::UPLOAD_TERMINAL_EOF),
226
10.1k
      seek_result_(curl::fuzzer::proto::UPLOAD_SEEK_CANTSEEK),
227
10.1k
      scripted_(scenario.has_upload()) {
228
10.1k
  if (!scripted_) {
229
10.0k
    return;
230
10.0k
  }
231
232
57
  const auto& upload = scenario.upload();
233
57
  const std::size_t data_size = std::min(upload.data().size(), scenario_limits::kMaxUploadBytes);
234
57
  data_ = std::string_view(upload.data().data(), data_size);
235
57
  total_size_ = data_.size();
236
57
  terminal_ = upload.terminal();
237
57
  seek_result_ = upload.seek_result();
238
239
57
  read_step_count_ = std::min<std::size_t>(upload.read_sizes_size(), scenario_limits::kMaxUploadReadSteps);
240
57
  for (std::size_t i = 0; i < read_step_count_; ++i) {
241
0
    const std::size_t requested = upload.read_sizes(static_cast<int>(i));
242
    // Zero-as-one ensures every retained step makes progress; see the schema
243
    // comment for why zero is not treated as an early EOF sentinel.
244
0
    read_sizes_[i] = std::max<std::size_t>(1, std::min(requested, scenario_limits::kMaxUploadReadSize));
245
0
  }
246
57
}
247
248
/// Return bytes from either the explicit payload or the allocation-free
249
/// fallback. Terminal outcomes are emitted only after all data is consumed so
250
/// a mutation can independently control fragmentation and completion policy.
251
1.18k
std::size_t UploadScriptState::Read(char* buffer, std::size_t capacity) {
252
1.18k
  if (offset_ >= total_size_) {
253
487
    return terminal_ == curl::fuzzer::proto::UPLOAD_TERMINAL_ABORT ? CURL_READFUNC_ABORT : 0;
254
487
  }
255
693
  if (buffer == nullptr || capacity == 0) {
256
0
    return 0;
257
0
  }
258
259
693
  std::size_t chunk_limit = capacity;
260
693
  if (next_read_size_ < read_step_count_) {
261
0
    chunk_limit = std::min(chunk_limit, read_sizes_[next_read_size_++]);
262
0
  }
263
693
  const std::size_t count = std::min(chunk_limit, total_size_ - offset_);
264
693
  if (scripted_) {
265
4
    std::memcpy(buffer, data_.data() + offset_, count);
266
689
  } else {
267
689
    std::memset(buffer, 'U', count);
268
689
  }
269
693
  offset_ += count;
270
693
  return count;
271
693
}
272
273
/// Model only seeks curl can meaningfully request from a bounded memory
274
/// source. Explicit range checks avoid signed overflow and keep a bogus
275
/// mutation from wrapping into an in-bounds cursor.
276
26
int UploadScriptState::Seek(curl_off_t requested_offset, int origin) {
277
26
  switch (seek_result_) {
278
26
    case curl::fuzzer::proto::UPLOAD_SEEK_CANTSEEK:
279
26
      return CURL_SEEKFUNC_CANTSEEK;
280
0
    case curl::fuzzer::proto::UPLOAD_SEEK_FAIL:
281
0
      return CURL_SEEKFUNC_FAIL;
282
0
    case curl::fuzzer::proto::UPLOAD_SEEK_OK:
283
0
      break;
284
0
    default:
285
0
      return CURL_SEEKFUNC_CANTSEEK;
286
26
  }
287
288
0
  const curl_off_t current = static_cast<curl_off_t>(offset_);
289
0
  const curl_off_t end = static_cast<curl_off_t>(total_size_);
290
0
  curl_off_t base = 0;
291
0
  switch (origin) {
292
0
    case SEEK_SET:
293
0
      base = 0;
294
0
      break;
295
0
    case SEEK_CUR:
296
0
      base = current;
297
0
      break;
298
0
    case SEEK_END:
299
0
      base = end;
300
0
      break;
301
0
    default:
302
0
      return CURL_SEEKFUNC_FAIL;
303
0
  }
304
305
  // Both base and end are at most 16 KiB. Comparing the requested delta to
306
  // these small bounds before addition handles even CURL_OFF_T_MIN safely.
307
0
  if (requested_offset < -base || requested_offset > end - base) {
308
0
    return CURL_SEEKFUNC_FAIL;
309
0
  }
310
0
  offset_ = static_cast<std::size_t>(base + requested_offset);
311
0
  next_read_size_ = 0;
312
0
  return CURL_SEEKFUNC_OK;
313
0
}
314
315
/// Multiplication is normally benign because curl uses size=1, but callbacks
316
/// are an API boundary. Abort an impossible overflowing pair: saturating to
317
/// SIZE_MAX would let Read() copy into a buffer whose real extent is unknown.
318
1.18k
std::size_t UploadScriptState::ReadCallback(char* buffer, std::size_t size, std::size_t nitems, void* userdata) {
319
1.18k
  if (userdata == nullptr || size == 0 || nitems == 0) {
320
0
    return 0;
321
0
  }
322
1.18k
  const std::size_t max = std::numeric_limits<std::size_t>::max();
323
1.18k
  if (nitems > max / size) {
324
0
    return CURL_READFUNC_ABORT;
325
0
  }
326
1.18k
  return static_cast<UploadScriptState*>(userdata)->Read(buffer, size * nitems);
327
1.18k
}
328
329
/// Keep the C callback a one-line type bridge so all outcome/cursor behaviour
330
/// remains directly unit-testable in Seek().
331
26
int UploadScriptState::SeekCallback(void* userdata, curl_off_t offset, int origin) {
332
26
  if (userdata == nullptr) {
333
0
    return CURL_SEEKFUNC_FAIL;
334
0
  }
335
26
  return static_cast<UploadScriptState*>(userdata)->Seek(offset, origin);
336
26
}
337
338
0
std::size_t UploadScriptState::data_size() const { return total_size_; }
339
340
0
std::size_t UploadScriptState::read_step_count() const { return read_step_count_; }
341
342
0
std::size_t UploadScriptState::offset() const { return offset_; }
343
344
0
bool UploadScriptState::scripted() const { return scripted_; }
345
346
/// Build both pointer-valued request features and attach them to the easy
347
/// handle. Setup errors are deliberately non-fatal: malformed or partially
348
/// allocated scenarios should still exercise whatever curl state was built.
349
ScenarioRequestData::ScenarioRequestData(CURL* easy, const curl::fuzzer::proto::Scenario& scenario)
350
10.1k
    : easy_(easy),
351
10.1k
      request_headers_(nullptr),
352
10.1k
      mime_post_(nullptr),
353
10.1k
      upload_state_(scenario),
354
10.1k
      upload_callbacks_installed_(false) {
355
10.1k
  if (easy_ == nullptr) {
356
0
    return;
357
0
  }
358
359
10.1k
  if (NeedsUploadCallbacks(scenario)) {
360
    // Install a per-run memory source even when Scenario.upload is absent but
361
    // CURLOPT_UPLOAD may enable reads. Without the fallback, curl would read
362
    // stdin and could block OSS-Fuzz. The state remains scoped to the complete
363
    // multi-handle drive so retries cannot share a cursor across iterations.
364
1.36k
    upload_callbacks_installed_ = true;
365
1.36k
    (void)curl_easy_setopt(easy_, CURLOPT_READFUNCTION, &UploadScriptState::ReadCallback);
366
1.36k
    (void)curl_easy_setopt(easy_, CURLOPT_READDATA, &upload_state_);
367
1.36k
    (void)curl_easy_setopt(easy_, CURLOPT_SEEKFUNCTION, &UploadScriptState::SeekCallback);
368
1.36k
    (void)curl_easy_setopt(easy_, CURLOPT_SEEKDATA, &upload_state_);
369
1.36k
  }
370
371
10.1k
  request_headers_ =
372
10.1k
      BuildHeaderList(scenario.request_headers(), scenario_limits::kMaxRequestHeaders, &stats_.request_headers);
373
10.1k
  if (request_headers_ != nullptr) {
374
97
    (void)curl_easy_setopt(easy_, CURLOPT_HTTPHEADER, request_headers_);
375
97
  }
376
377
10.1k
  if (scenario.has_mime_post()) {
378
1
    mime_post_ = BuildMimePost(easy_, scenario.mime_post(), &stats_);
379
1
    if (mime_post_ != nullptr) {
380
1
      (void)curl_easy_setopt(easy_, CURLOPT_MIMEPOST, mime_post_);
381
1
    }
382
1
  }
383
10.1k
}
384
385
/// Detach resources while the easy handle is valid, then free them. libcurl
386
/// does not copy headers, MIME roots, or callback userdata, so releasing any
387
/// one before the mock drive ends would create a use-after-free; relying on
388
/// easy cleanup to own header/MIME allocations would instead leak iterations.
389
10.1k
ScenarioRequestData::~ScenarioRequestData() {
390
10.1k
  if (easy_ != nullptr) {
391
    // Clear callbacks before their userdata member is destroyed. There is no
392
    // perform in this destructor, but making the handle non-dangling keeps the
393
    // ownership rule robust if cleanup later gains diagnostics or getinfo.
394
10.1k
    if (upload_callbacks_installed_) {
395
1.36k
      (void)curl_easy_setopt(easy_, CURLOPT_SEEKFUNCTION, nullptr);
396
1.36k
      (void)curl_easy_setopt(easy_, CURLOPT_SEEKDATA, nullptr);
397
1.36k
      (void)curl_easy_setopt(easy_, CURLOPT_READFUNCTION, nullptr);
398
1.36k
      (void)curl_easy_setopt(easy_, CURLOPT_READDATA, nullptr);
399
1.36k
    }
400
10.1k
    if (mime_post_ != nullptr) {
401
1
      (void)curl_easy_setopt(easy_, CURLOPT_MIMEPOST, nullptr);
402
1
    }
403
10.1k
    if (request_headers_ != nullptr) {
404
97
      (void)curl_easy_setopt(easy_, CURLOPT_HTTPHEADER, nullptr);
405
97
    }
406
10.1k
  }
407
10.1k
  curl_mime_free(mime_post_);
408
10.1k
  curl_slist_free_all(request_headers_);
409
10.1k
}
410
411
/// Expose cap-aware counts without exposing or transferring the owned curl
412
/// pointers themselves.
413
0
const RequestBuildStats& ScenarioRequestData::stats() const { return stats_; }
414
415
0
const UploadScriptState& ScenarioRequestData::upload_state() const { return upload_state_; }
416
417
0
bool ScenarioRequestData::upload_callbacks_installed() const { return upload_callbacks_installed_; }
418
419
}  // namespace proto_fuzzer