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/target_policy.cc
Line
Count
Source
1
/*
2
 * Copyright (C) Max Dymond, <cmeister2@gmail.com>, et al.
3
 *
4
 * SPDX-License-Identifier: curl
5
 */
6
7
/// @file
8
/// @brief Implementation of the per-binary proto mutation policies.
9
10
#include "proto_fuzzer/target_policy.h"
11
12
#include <algorithm>
13
#include <cstdint>
14
#include <string>
15
16
#include "proto_fuzzer/scenario_limits.h"
17
18
namespace proto_fuzzer {
19
20
namespace {
21
22
// Linux raises smaller socket-buffer requests to an implementation minimum,
23
// so 2048 is both cheap and reliably small enough to exercise short writes.
24
constexpr std::uint32_t kDefaultBackpressureBufferBytes = 2048;
25
26
// Values outside these ranges do not create useful new socket behavior for
27
// the harness's bounded 4-16 KiB writes. Keeping them small also prevents a
28
// mutated uint32 recv size from overflowing the int accepted by setsockopt.
29
constexpr std::uint32_t kMinBackpressureBufferBytes = 2048;
30
constexpr std::uint32_t kMaxBackpressureBufferBytes = 4096;
31
constexpr std::uint32_t kMaxDrainBytesPerIteration = 1024;
32
33
/// Remove a repeated-field suffix that the runtime would ignore. Doing this
34
/// in LPM's postprocessor matters for speed as well as memory: otherwise later
35
/// mutations keep rediscovering and editing objects that cannot reach curl.
36
template <typename RepeatedField>
37
0
void TrimRepeated(RepeatedField* field, std::size_t limit) {
38
0
  const std::size_t size = static_cast<std::size_t>(field->size());
39
0
  if (size > limit) {
40
0
    field->DeleteSubrange(static_cast<int>(limit), static_cast<int>(size - limit));
41
0
  }
42
0
}
Unexecuted instantiation: target_policy.cc:void proto_fuzzer::(anonymous namespace)::TrimRepeated<google::protobuf::RepeatedPtrField<curl::fuzzer::proto::SetOption> >(google::protobuf::RepeatedPtrField<curl::fuzzer::proto::SetOption>*, unsigned long)
Unexecuted instantiation: target_policy.cc:void proto_fuzzer::(anonymous namespace)::TrimRepeated<google::protobuf::RepeatedPtrField<std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > > >(google::protobuf::RepeatedPtrField<std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > >*, unsigned long)
Unexecuted instantiation: target_policy.cc:void proto_fuzzer::(anonymous namespace)::TrimRepeated<google::protobuf::RepeatedPtrField<curl::fuzzer::proto::MimePart> >(google::protobuf::RepeatedPtrField<curl::fuzzer::proto::MimePart>*, unsigned long)
Unexecuted instantiation: target_policy.cc:void proto_fuzzer::(anonymous namespace)::TrimRepeated<google::protobuf::RepeatedPtrField<curl::fuzzer::proto::MimeDataPart> >(google::protobuf::RepeatedPtrField<curl::fuzzer::proto::MimeDataPart>*, unsigned long)
Unexecuted instantiation: target_policy.cc:void proto_fuzzer::(anonymous namespace)::TrimRepeated<google::protobuf::RepeatedPtrField<curl::fuzzer::proto::WebSocketFrame> >(google::protobuf::RepeatedPtrField<curl::fuzzer::proto::WebSocketFrame>*, unsigned long)
Unexecuted instantiation: target_policy.cc:void proto_fuzzer::(anonymous namespace)::TrimRepeated<google::protobuf::RepeatedPtrField<curl::fuzzer::proto::Connection> >(google::protobuf::RepeatedPtrField<curl::fuzzer::proto::Connection>*, unsigned long)
43
44
/// Bound strings passed to NUL-terminated metadata APIs. The runtime applies
45
/// the same prefix, so deleting the invisible suffix increases useful
46
/// mutation density without removing any behavior curl could observe.
47
0
void TrimMetadata(std::string* value) {
48
0
  if (value->size() > scenario_limits::kMaxMetadataBytes) {
49
0
    value->resize(scenario_limits::kMaxMetadataBytes);
50
0
  }
51
0
}
52
53
template <typename RepeatedBytes>
54
0
void BoundHeaderValues(RepeatedBytes* headers, std::size_t limit) {
55
0
  TrimRepeated(headers, limit);
56
0
  for (std::string& header : *headers) {
57
0
    TrimMetadata(&header);
58
0
  }
59
0
}
60
61
/// Keep one response script identical to the prefix MockServer and
62
/// WebSocketMockServer can deliver. Raw chunks take precedence over structured
63
/// frames, matching both runtime serializers.
64
0
void BoundConnectionShape(curl::fuzzer::proto::Connection* connection) {
65
0
  TrimRepeated(connection->mutable_on_readable(), scenario_limits::kMaxResponseChunks);
66
0
  const std::size_t raw_count = static_cast<std::size_t>(connection->on_readable_size());
67
0
  TrimRepeated(connection->mutable_server_frames(), scenario_limits::kMaxResponseChunks - raw_count);
68
0
}
69
70
/// Apply the metadata/header limits shared by both MIME part message types.
71
template <typename Part>
72
0
void BoundMimePartMetadata(Part* part) {
73
0
  TrimMetadata(part->mutable_name());
74
0
  TrimMetadata(part->mutable_filename());
75
0
  TrimMetadata(part->mutable_content_type());
76
0
  BoundHeaderValues(part->mutable_headers(), scenario_limits::kMaxMimeHeadersPerPart);
77
0
}
Unexecuted instantiation: target_policy.cc:void proto_fuzzer::(anonymous namespace)::BoundMimePartMetadata<curl::fuzzer::proto::MimePart>(curl::fuzzer::proto::MimePart*)
Unexecuted instantiation: target_policy.cc:void proto_fuzzer::(anonymous namespace)::BoundMimePartMetadata<curl::fuzzer::proto::MimeDataPart>(curl::fuzzer::proto::MimeDataPart*)
78
79
0
void BoundMimeLeaf(curl::fuzzer::proto::MimeDataPart* part) {
80
0
  BoundMimePartMetadata(part);
81
0
  if (part->data().size() > scenario_limits::kMaxMimeDataBytes) {
82
0
    part->mutable_data()->resize(scenario_limits::kMaxMimeDataBytes);
83
0
  }
84
0
}
85
86
/// Mirror the runtime's shared top-level/nested part budget in the protobuf
87
/// itself. A simple per-list cap is insufficient because many bounded child
88
/// lists could still leave most of the message semantically dead.
89
0
void BoundMimeShape(curl::fuzzer::proto::MimePost* post) {
90
0
  TrimRepeated(post->mutable_parts(), scenario_limits::kMaxTopLevelMimeParts);
91
0
  std::size_t remaining = scenario_limits::kMaxTotalMimeParts;
92
0
  std::size_t retained_top_parts = 0;
93
94
0
  while (retained_top_parts < static_cast<std::size_t>(post->parts_size()) && remaining != 0) {
95
0
    auto* part = post->mutable_parts(static_cast<int>(retained_top_parts));
96
0
    ++retained_top_parts;
97
0
    --remaining;
98
0
    BoundMimePartMetadata(part);
99
100
0
    if (part->content_case() == curl::fuzzer::proto::MimePart::kData) {
101
0
      if (part->data().size() > scenario_limits::kMaxMimeDataBytes) {
102
0
        part->mutable_data()->resize(scenario_limits::kMaxMimeDataBytes);
103
0
      }
104
0
      continue;
105
0
    }
106
0
    if (part->content_case() != curl::fuzzer::proto::MimePart::kSubparts) {
107
0
      continue;
108
0
    }
109
110
0
    auto* children = part->mutable_subparts()->mutable_parts();
111
0
    TrimRepeated(children, std::min(scenario_limits::kMaxNestedMimeParts, remaining));
112
0
    for (auto& child : *children) {
113
0
      BoundMimeLeaf(&child);
114
0
      --remaining;
115
0
    }
116
0
  }
117
118
0
  TrimRepeated(post->mutable_parts(), retained_top_parts);
119
0
}
120
121
/// Remove upload bytes and read steps the callback cannot observe. Clamping
122
/// individual limits also keeps mutations concentrated on short reads instead
123
/// of many distinct uint32 values that all collapse to the same 16 KiB cap.
124
0
void BoundUploadShape(curl::fuzzer::proto::UploadScript* upload) {
125
0
  if (upload->data().size() > scenario_limits::kMaxUploadBytes) {
126
0
    upload->mutable_data()->resize(scenario_limits::kMaxUploadBytes);
127
0
  }
128
  // RepeatedField<uint32_t> lacks RepeatedPtrField's DeleteSubrange helper;
129
  // removing the ignored suffix from the end is constant-time per element and
130
  // preserves the mutation-significant prefix exactly.
131
0
  while (static_cast<std::size_t>(upload->read_sizes_size()) > scenario_limits::kMaxUploadReadSteps) {
132
0
    upload->mutable_read_sizes()->RemoveLast();
133
0
  }
134
0
  for (int i = 0; i < upload->read_sizes_size(); ++i) {
135
0
    if (upload->read_sizes(i) > scenario_limits::kMaxUploadReadSize) {
136
0
      upload->set_read_sizes(i, scenario_limits::kMaxUploadReadSize);
137
0
    }
138
0
  }
139
0
}
140
141
/// Canonicalize all shape limits enforced by the runtime. This runs only in
142
/// fixed policy targets; the compatibility binary deliberately retains its
143
/// historical no-postprocessor semantics for existing OSS-Fuzz reproducers.
144
0
void BoundScenarioShape(curl::fuzzer::proto::Scenario* scenario) {
145
0
  TrimRepeated(scenario->mutable_options(), scenario_limits::kMaxOptions);
146
0
  for (auto& option : *scenario->mutable_options()) {
147
0
    if (option.value_case() == curl::fuzzer::proto::SetOption::kStringValue) {
148
0
      TrimMetadata(option.mutable_string_value());
149
0
    }
150
0
  }
151
152
0
  BoundHeaderValues(scenario->mutable_request_headers(), scenario_limits::kMaxRequestHeaders);
153
0
  if (scenario->has_mime_post()) {
154
0
    BoundMimeShape(scenario->mutable_mime_post());
155
0
  }
156
0
  if (scenario->has_upload()) {
157
0
    BoundUploadShape(scenario->mutable_upload());
158
0
  }
159
160
0
  BoundConnectionShape(scenario->mutable_connection());
161
0
  TrimRepeated(scenario->mutable_subsequent_connections(), scenario_limits::kMaxConnections - 1);
162
0
  for (auto& connection : *scenario->mutable_subsequent_connections()) {
163
0
    BoundConnectionShape(&connection);
164
0
  }
165
0
}
166
167
/// Return whether an option belongs in the high-throughput HTTP lane. This is
168
/// deliberately an allowlist rather than a denylist: adding a new structured
169
/// option should expand deep coverage first, not silently make the fast lane
170
/// slower before its cost has been measured.
171
0
bool IsCheapHttpOption(curl::fuzzer::proto::CurlOptionId option_id) {
172
0
  switch (option_id) {
173
0
    case curl::fuzzer::proto::CURLOPT_ACCEPT_ENCODING:
174
0
    case curl::fuzzer::proto::CURLOPT_BUFFERSIZE:
175
0
    case curl::fuzzer::proto::CURLOPT_CUSTOMREQUEST:
176
0
    case curl::fuzzer::proto::CURLOPT_DISALLOW_USERNAME_IN_URL:
177
0
    case curl::fuzzer::proto::CURLOPT_FAILONERROR:
178
0
    case curl::fuzzer::proto::CURLOPT_FILETIME:
179
0
    case curl::fuzzer::proto::CURLOPT_HEADER:
180
0
    case curl::fuzzer::proto::CURLOPT_HTTP09_ALLOWED:
181
0
    case curl::fuzzer::proto::CURLOPT_HTTP_CONTENT_DECODING:
182
0
    case curl::fuzzer::proto::CURLOPT_HTTP_TRANSFER_DECODING:
183
0
    case curl::fuzzer::proto::CURLOPT_HTTP_VERSION:
184
0
    case curl::fuzzer::proto::CURLOPT_HTTPGET:
185
0
    case curl::fuzzer::proto::CURLOPT_IGNORE_CONTENT_LENGTH:
186
0
    case curl::fuzzer::proto::CURLOPT_MAXFILESIZE_LARGE:
187
0
    case curl::fuzzer::proto::CURLOPT_NOBODY:
188
0
    case curl::fuzzer::proto::CURLOPT_PATH_AS_IS:
189
0
    case curl::fuzzer::proto::CURLOPT_RANGE:
190
0
    case curl::fuzzer::proto::CURLOPT_REQUEST_TARGET:
191
0
    case curl::fuzzer::proto::CURLOPT_RESUME_FROM_LARGE:
192
0
    case curl::fuzzer::proto::CURLOPT_TRANSFER_ENCODING:
193
0
    case curl::fuzzer::proto::CURLOPT_USERAGENT:
194
0
      return true;
195
196
0
    case curl::fuzzer::proto::CURL_OPTION_UNSPECIFIED:
197
0
    default:
198
0
      return false;
199
0
  }
200
0
}
201
202
/// Compact the option list before applying the general option-count bound.
203
/// Keeping an allowed option that appears after a long rejected prefix is
204
/// important for mutation density: bounding first would let expensive options
205
/// crowd cheap ones out of the only lane intended to approach legacy speed.
206
0
void RetainCheapHttpOptions(curl::fuzzer::proto::Scenario* scenario) {
207
0
  auto* options = scenario->mutable_options();
208
0
  int retained = 0;
209
0
  for (int index = 0; index < options->size(); ++index) {
210
0
    if (!IsCheapHttpOption(options->Get(index).option_id())) {
211
0
      continue;
212
0
    }
213
0
    if (retained != index) {
214
0
      options->SwapElements(retained, index);
215
0
    }
216
0
    ++retained;
217
0
  }
218
0
  options->DeleteSubrange(retained, options->size() - retained);
219
0
}
220
221
/// Remove the stateful shapes assigned to the deep HTTP target. This happens
222
/// before BoundScenarioShape so a fast iteration never walks or normalizes a
223
/// MIME tree, upload script, or follow-on connection that it will discard.
224
/// Raw response chunks and request headers stay intact because they reach the
225
/// core HTTP parser cheaply and provide much of the legacy fuzzer's coverage.
226
0
void RemoveDeepHttpShape(curl::fuzzer::proto::Scenario* scenario) {
227
0
  scenario->clear_mime_post();
228
0
  scenario->clear_upload();
229
0
  scenario->clear_subsequent_connections();
230
231
0
  auto* connection = scenario->mutable_connection();
232
0
  connection->clear_server_frames();
233
0
  connection->clear_manual_probes();
234
0
  connection->clear_backpressure();
235
0
}
236
237
/// Remove fields the single-socket WebSocket driver cannot consume. MIME also
238
/// changes the HTTP request away from a useful Upgrade handshake, so retaining
239
/// either shape in fixed WS lanes gives LPM mutation work with no WS coverage
240
/// payoff. The mixed compatibility target has no postprocessor and keeps its
241
/// historical behavior.
242
0
void RemoveIgnoredWebSocketShape(curl::fuzzer::proto::Scenario* scenario) {
243
0
  scenario->clear_subsequent_connections();
244
0
  scenario->clear_mime_post();
245
0
}
246
247
/// Preserve useful in-range mutations while folding ineffective extremes onto
248
/// meaningful boundaries. Zero remains special: it disables that individual
249
/// control and lets the other control provide the timing target's pressure.
250
0
std::uint32_t CanonicalizeNonZero(std::uint32_t value, std::uint32_t minimum, std::uint32_t maximum) {
251
0
  if (value == 0) {
252
0
    return 0;
253
0
  }
254
0
  return std::max(minimum, std::min(value, maximum));
255
0
}
256
257
/// Keep the timing target on the plaintext member of the protocol family.
258
/// TLS setup has its own cost profile and would obscure whether backpressure
259
/// mutations are exploring curl's send/receive state machines effectively.
260
0
curl::fuzzer::proto::Scheme PlaintextScheme(curl::fuzzer::proto::Scheme scheme) {
261
0
  switch (scheme) {
262
0
    case curl::fuzzer::proto::SCHEME_WS:
263
0
    case curl::fuzzer::proto::SCHEME_WSS:
264
0
      return curl::fuzzer::proto::SCHEME_WS;
265
0
    case curl::fuzzer::proto::SCHEME_HTTP:
266
0
    case curl::fuzzer::proto::SCHEME_HTTPS:
267
0
    case curl::fuzzer::proto::SCHEME_UNSPECIFIED:
268
0
    default:
269
0
      return curl::fuzzer::proto::SCHEME_HTTP;
270
0
  }
271
0
}
272
273
/// Remove timing controls from every connection the structured message can
274
/// carry. Clearing only the primary script would let a mutated redirect turn a
275
/// fixed fast lane into the timed drive loop after its second socket opens.
276
0
void ClearAllBackpressure(curl::fuzzer::proto::Scenario* scenario) {
277
0
  if (scenario->has_connection()) {
278
0
    scenario->mutable_connection()->clear_backpressure();
279
0
  }
280
0
  for (auto& connection : *scenario->mutable_subsequent_connections()) {
281
0
    connection.clear_backpressure();
282
0
  }
283
0
}
284
285
/// Clamp one explicitly pressure-bearing follow-on script to the same useful
286
/// ranges as the timing lane's primary connection. An absent configuration is
287
/// left absent so merely adding a redirect response does not add waits.
288
0
void CanonicalizeOptionalBackpressure(curl::fuzzer::proto::Connection* connection) {
289
0
  if (!connection->has_backpressure()) {
290
0
    return;
291
0
  }
292
0
  auto* backpressure = connection->mutable_backpressure();
293
0
  if (backpressure->recv_buf_bytes() == 0 && backpressure->drain_limit() != 0) {
294
0
    backpressure->set_recv_buf_bytes(kDefaultBackpressureBufferBytes);
295
0
  } else {
296
0
    backpressure->set_recv_buf_bytes(
297
0
        CanonicalizeNonZero(backpressure->recv_buf_bytes(), kMinBackpressureBufferBytes, kMaxBackpressureBufferBytes));
298
0
  }
299
0
  backpressure->set_drain_limit(CanonicalizeNonZero(backpressure->drain_limit(), 1, kMaxDrainBytesPerIteration));
300
0
}
301
302
}  // namespace
303
304
/// Canonicalize the fields that determine which server and drive-loop policy
305
/// execute. Fast targets discard backpressure because one mutated non-zero
306
/// scalar otherwise opts an ordinary input into hundreds of timed waits. The
307
/// timing target does the inverse: it guarantees a non-default buffer setting
308
/// so its CPU allocation remains focused on the intentionally slower paths.
309
0
void ApplyTargetPolicy(curl::fuzzer::proto::Scenario* scenario, TargetPolicy policy) {
310
0
  if (scenario == nullptr) {
311
0
    return;
312
0
  }
313
314
0
  if (policy == TargetPolicy::kFastHttp) {
315
0
    scenario->set_scheme(curl::fuzzer::proto::SCHEME_HTTP);
316
0
    RemoveDeepHttpShape(scenario);
317
0
    RetainCheapHttpOptions(scenario);
318
0
    BoundScenarioShape(scenario);
319
0
    return;
320
0
  }
321
322
0
  BoundScenarioShape(scenario);
323
324
0
  switch (policy) {
325
0
    case TargetPolicy::kFastHttp:
326
      // Handled before the general bounds so discarded deep shapes are never
327
      // traversed on the fast path.
328
0
      return;
329
330
0
    case TargetPolicy::kDeepHttp:
331
0
      scenario->set_scheme(curl::fuzzer::proto::SCHEME_HTTP);
332
0
      ClearAllBackpressure(scenario);
333
0
      return;
334
335
0
    case TargetPolicy::kFastHttps:
336
0
      scenario->set_scheme(curl::fuzzer::proto::SCHEME_HTTPS);
337
0
      ClearAllBackpressure(scenario);
338
0
      return;
339
340
0
    case TargetPolicy::kFastWebSocket:
341
0
      scenario->set_scheme(curl::fuzzer::proto::SCHEME_WS);
342
0
      ClearAllBackpressure(scenario);
343
0
      RemoveIgnoredWebSocketShape(scenario);
344
0
      return;
345
346
0
    case TargetPolicy::kFastSecureWebSocket:
347
0
      scenario->set_scheme(curl::fuzzer::proto::SCHEME_WSS);
348
0
      ClearAllBackpressure(scenario);
349
0
      RemoveIgnoredWebSocketShape(scenario);
350
0
      return;
351
352
0
    case TargetPolicy::kTiming: {
353
0
      scenario->set_scheme(PlaintextScheme(scenario->scheme()));
354
0
      if (scenario->scheme() == curl::fuzzer::proto::SCHEME_WS) {
355
0
        RemoveIgnoredWebSocketShape(scenario);
356
0
      }
357
0
      auto* backpressure = scenario->mutable_connection()->mutable_backpressure();
358
0
      if (backpressure->recv_buf_bytes() == 0) {
359
        // A drain limit alone cannot fill the default AF_UNIX buffer with the
360
        // harness's bounded upload. Always tighten the socket so this lane
361
        // represents real pressure, not merely selection of the timed loop.
362
0
        backpressure->set_recv_buf_bytes(kDefaultBackpressureBufferBytes);
363
0
      } else {
364
0
        backpressure->set_recv_buf_bytes(CanonicalizeNonZero(backpressure->recv_buf_bytes(),
365
0
                                                             kMinBackpressureBufferBytes, kMaxBackpressureBufferBytes));
366
0
      }
367
0
      backpressure->set_drain_limit(CanonicalizeNonZero(backpressure->drain_limit(), 1, kMaxDrainBytesPerIteration));
368
0
      for (auto& connection : *scenario->mutable_subsequent_connections()) {
369
0
        CanonicalizeOptionalBackpressure(&connection);
370
0
      }
371
0
      return;
372
0
    }
373
0
  }
374
0
}
375
376
}  // namespace proto_fuzzer