Add a fuzz test for BalsaFrame

This new fuzzer configures BalsaFrame with arbitrary parameters and then asks it to process an arbitrary input. Besides checking whether the code crashes, it also checks that serializing and parsing are idempotent.

PiperOrigin-RevId: 840808259
diff --git a/build/source_list.bzl b/build/source_list.bzl
index 5f823f3..265fdd9 100644
--- a/build/source_list.bzl
+++ b/build/source_list.bzl
@@ -985,6 +985,7 @@
 balsa_hdrs = [
     "balsa/balsa_enums.h",
     "balsa/balsa_frame.h",
+    "balsa/balsa_fuzz_util.h",
     "balsa/balsa_headers.h",
     "balsa/balsa_headers_sequence.h",
     "balsa/balsa_visitor_interface.h",
@@ -999,6 +1000,7 @@
 balsa_srcs = [
     "balsa/balsa_enums.cc",
     "balsa/balsa_frame.cc",
+    "balsa/balsa_fuzz_util.cc",
     "balsa/balsa_headers.cc",
     "balsa/balsa_headers_sequence.cc",
     "balsa/header_properties.cc",
@@ -1096,6 +1098,7 @@
 quiche_tests_hdrs = [
 ]
 quiche_tests_srcs = [
+    "balsa/balsa_frame_fuzz_test.cc",
     "balsa/balsa_frame_test.cc",
     "balsa/balsa_headers_sequence_test.cc",
     "balsa/balsa_headers_test.cc",
diff --git a/build/source_list.gni b/build/source_list.gni
index a9cd5c0..5ba19e6 100644
--- a/build/source_list.gni
+++ b/build/source_list.gni
@@ -985,6 +985,7 @@
 balsa_hdrs = [
     "src/quiche/balsa/balsa_enums.h",
     "src/quiche/balsa/balsa_frame.h",
+    "src/quiche/balsa/balsa_fuzz_util.h",
     "src/quiche/balsa/balsa_headers.h",
     "src/quiche/balsa/balsa_headers_sequence.h",
     "src/quiche/balsa/balsa_visitor_interface.h",
@@ -999,6 +1000,7 @@
 balsa_srcs = [
     "src/quiche/balsa/balsa_enums.cc",
     "src/quiche/balsa/balsa_frame.cc",
+    "src/quiche/balsa/balsa_fuzz_util.cc",
     "src/quiche/balsa/balsa_headers.cc",
     "src/quiche/balsa/balsa_headers_sequence.cc",
     "src/quiche/balsa/header_properties.cc",
@@ -1097,6 +1099,7 @@
 
 ]
 quiche_tests_srcs = [
+    "src/quiche/balsa/balsa_frame_fuzz_test.cc",
     "src/quiche/balsa/balsa_frame_test.cc",
     "src/quiche/balsa/balsa_headers_sequence_test.cc",
     "src/quiche/balsa/balsa_headers_test.cc",
diff --git a/build/source_list.json b/build/source_list.json
index 8f11f30..264d690 100644
--- a/build/source_list.json
+++ b/build/source_list.json
@@ -984,6 +984,7 @@
   "balsa_hdrs": [
     "quiche/balsa/balsa_enums.h",
     "quiche/balsa/balsa_frame.h",
+    "quiche/balsa/balsa_fuzz_util.h",
     "quiche/balsa/balsa_headers.h",
     "quiche/balsa/balsa_headers_sequence.h",
     "quiche/balsa/balsa_visitor_interface.h",
@@ -998,6 +999,7 @@
   "balsa_srcs": [
     "quiche/balsa/balsa_enums.cc",
     "quiche/balsa/balsa_frame.cc",
+    "quiche/balsa/balsa_fuzz_util.cc",
     "quiche/balsa/balsa_headers.cc",
     "quiche/balsa/balsa_headers_sequence.cc",
     "quiche/balsa/header_properties.cc",
@@ -1096,6 +1098,7 @@
 
   ],
   "quiche_tests_srcs": [
+    "quiche/balsa/balsa_frame_fuzz_test.cc",
     "quiche/balsa/balsa_frame_test.cc",
     "quiche/balsa/balsa_headers_sequence_test.cc",
     "quiche/balsa/balsa_headers_test.cc",
diff --git a/quiche/balsa/balsa_frame_fuzz_test.cc b/quiche/balsa/balsa_frame_fuzz_test.cc
new file mode 100644
index 0000000..8add7bc
--- /dev/null
+++ b/quiche/balsa/balsa_frame_fuzz_test.cc
@@ -0,0 +1,189 @@
+// Copyright 2025 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include <cstddef>
+#include <string>
+
+#include "absl/strings/escaping.h"
+#include "absl/strings/str_format.h"
+#include "absl/strings/string_view.h"
+#include "quiche/balsa/balsa_enums.h"
+#include "quiche/balsa/balsa_frame.h"
+#include "quiche/balsa/balsa_fuzz_util.h"
+#include "quiche/balsa/balsa_headers.h"
+#include "quiche/balsa/http_validation_policy.h"
+#include "quiche/balsa/simple_buffer.h"
+#include "quiche/common/platform/api/quiche_fuzztest.h"
+#include "quiche/common/platform/api/quiche_logging.h"
+#include "quiche/common/platform/api/quiche_test.h"
+
+namespace quiche {
+namespace {
+
+struct FuzzParams {
+  // This string is the input to `BalsaFrame::ProcessInput()`.
+  std::string input_to_parse;
+  HttpValidationPolicy http_validation_policy;
+  size_t max_header_length = 0;
+  bool is_request = false;
+  bool request_was_head = false;
+  bool allow_arbitrary_body = false;
+  bool allow_reading_until_close_for_request = false;
+  bool parse_truncated_headers_even_when_headers_too_long = false;
+
+  // Used by fuzztest as the "human-readable printer".
+  template <typename Sink>
+  friend void AbslStringify(Sink& sink, const FuzzParams& p) {
+    absl::Format(&sink,
+                 "(\"%s\", http_validation_policy=%v, max_header_length=%v, "
+                 "is_request=%v, request_was_head=%v, allow_arbitrary_body=%v,"
+                 "allow_reading_until_close_for_request=%v, "
+                 "parse_truncated_headers_even_when_headers_too_long=%v)",
+                 absl::CHexEscape(p.input_to_parse), p.http_validation_policy,
+                 p.max_header_length, p.is_request, p.request_was_head,
+                 p.allow_arbitrary_body,
+                 p.allow_reading_until_close_for_request,
+                 p.parse_truncated_headers_even_when_headers_too_long);
+  }
+};
+
+void ConfigureBalsaFrame(const FuzzParams& params, BalsaFrame& out) {
+  out.set_http_validation_policy(params.http_validation_policy);
+  out.set_is_request(params.is_request);
+  out.set_request_was_head(params.request_was_head);
+  if (params.allow_arbitrary_body) {
+    out.AllowArbitraryBody();
+  }
+  out.set_max_header_length(params.max_header_length);
+  out.set_allow_reading_until_close_for_request(
+      params.allow_reading_until_close_for_request);
+  out.set_parse_truncated_headers_even_when_headers_too_long(
+      params.parse_truncated_headers_even_when_headers_too_long);
+}
+
+// This property test configures `BalsaFrame` with arbitrary parameters before
+// asking it to parse an arbitrary input.
+//
+// Besides testing for crashes, this test also checks idempotency properties of
+// header serialization and parsing.
+//
+// Graphically:
+//
+//    Bytes   BalsaFrame   BalsaHeaders   SimpleBuffer
+//      │         │             │              │
+//      a ──────> b ──────────> c1 ──────────> d1
+//      │         │             c2 <───────────┤
+//      │         │             ├────────────> d2
+//
+// The following properties should be true:
+//   1. BalsaHeaders c1 and c2 contain the same headers.
+//   2. SimpleBuffer d1 and d2 contain the same bytes.
+void BalsaFrameParsesArbitraryInput(const FuzzParams& params) {
+  QUICHE_DVLOG(1) << "Input to parse: "
+                  << absl::CHexEscape(params.input_to_parse);
+  BalsaFrame framer;
+  ConfigureBalsaFrame(params, /*out=*/framer);
+  BalsaHeaders headers;
+  framer.set_balsa_headers(&headers);
+  const size_t num_bytes_consumed = framer.ProcessInput(
+      params.input_to_parse.data(), params.input_to_parse.size());
+
+  const std::string headers_debug_string = headers.DebugString();
+  QUICHE_DVLOG(1) << "Parsed headers: " << headers_debug_string;
+  EXPECT_LE(num_bytes_consumed, params.input_to_parse.size());
+
+  if (framer.Error() || !framer.MessageFullyRead() || headers.IsEmpty()) {
+    return;
+  }
+
+  // Serialize `headers` into `simple_buffer`.
+  SimpleBuffer simple_buffer;
+  size_t expected_write_buffer_size = headers.GetSizeForWriteBuffer();
+  headers.WriteHeaderAndEndingToBuffer(&simple_buffer);
+
+  absl::string_view readable_region = simple_buffer.GetReadableRegion();
+  EXPECT_EQ(expected_write_buffer_size,
+            static_cast<size_t>(simple_buffer.ReadableBytes()));
+  QUICHE_DVLOG(1) << "Serialized headers: "
+                  << absl::CHexEscape(readable_region);
+
+  // Parse `simple_buffer` into `headers2`.
+  framer.Reset();
+  ConfigureBalsaFrame(params, /*out=*/framer);
+  BalsaHeaders headers2;
+  framer.set_balsa_headers(&headers2);
+  const size_t num_bytes_consumed2 =
+      framer.ProcessInput(readable_region.data(), readable_region.size());
+  EXPECT_LE(num_bytes_consumed2,
+            static_cast<size_t>(simple_buffer.ReadableBytes()))
+      << "Parsing should not consume more bytes than were serialized.";
+
+  // Usually, we should be able to parse our own serialization. One exception to
+  // the rule is that serializing a header can make it longer, so we will fail
+  // to parse headers that become longer than `params.max_header_length`.
+  if (framer.Error()) {
+    EXPECT_EQ(framer.ErrorCode(), BalsaFrameEnums::HEADERS_TOO_LONG)
+        << "Unexpectedly failed to parse our own serialization. Parse state: "
+        << BalsaFrameEnums::ParseStateToString(framer.ParseState())
+        << ", error code: "
+        << BalsaFrameEnums::ErrorCodeToString(framer.ErrorCode())
+        << ", readable_region.size(): " << readable_region.size()
+        << ", max_header_length: " << params.max_header_length
+        << ", original input: \"" << absl::CHexEscape(params.input_to_parse)
+        << "\", serialization: \"" << absl::CHexEscape(readable_region) << "\"";
+    return;
+  }
+
+  const std::string headers_debug_string2 = headers2.DebugString();
+  QUICHE_DVLOG(1) << "Re-parsed headers: " << headers_debug_string2;
+  EXPECT_STREQ(headers_debug_string.c_str(), headers_debug_string2.c_str());
+  EXPECT_TRUE(framer.MessageFullyRead());
+
+  // Serialize `headers2` into `simple_buffer2`.
+  SimpleBuffer simple_buffer2;
+  headers2.WriteHeaderAndEndingToBuffer(&simple_buffer2);
+  QUICHE_DVLOG(1) << "Re-serialized headers: "
+                  << absl::CHexEscape(simple_buffer2.GetReadableRegion());
+  EXPECT_EQ(simple_buffer.GetReadableRegion(),
+            simple_buffer2.GetReadableRegion());
+}
+
+FUZZ_TEST(BalsaFrameTest, BalsaFrameParsesArbitraryInput)
+    .WithDomains(fuzztest::StructOf<FuzzParams>(
+        fuzztest::Arbitrary<std::string>(), ArbitraryHttpValidationPolicy(),
+        // When `max_header_length` is zero, `BalsaBuffer::StartOfFirstBlock()`
+        // hits the QUICHE_BUG named `bug_if_1182_1`. TBD whether this is a real
+        // bug or whether `max_header_length` should never be zero.
+        /*max_header_length=*/fuzztest::NonZero<size_t>(),
+        fuzztest::Arbitrary<bool>(), fuzztest::Arbitrary<bool>(),
+        fuzztest::Arbitrary<bool>(), fuzztest::Arbitrary<bool>(),
+        fuzztest::Arbitrary<bool>()));
+
+// An earlier version of `BalsaFrameParsesArbitraryInput()` believed that the
+// number of bytes returned from `frame.ProcessInput()` would be equal to the
+// size of the input when `frame.MessageFullyRead()` is true. Now, it only
+// checks that the number of bytes is <= the size of the input.
+TEST(BalsaFrameTest, RegressionTestFuzzerBugParsingFewerBytesThanSerialized) {
+  FuzzParams params;
+  params.input_to_parse = "!\n";
+  params.max_header_length = 1024;
+  params.is_request = true;
+  BalsaFrameParsesArbitraryInput(params);
+}
+
+// An earlier version of `BalsaFrameParsesArbitraryInput()` believed that any
+// serialization we produced was guaranteed to be parseable. However, it's
+// possible for serialization to make the header longer than the max header
+// length. In this case, "X\n" serializes to "X\r\n\r\n", which exceeds the max
+// length of 2.
+TEST(BalsaFrameTest, RegressionTestFuzzerBugHeaderTooLong) {
+  FuzzParams params;
+  params.input_to_parse = "X\n";
+  params.max_header_length = 2;
+  params.is_request = true;
+  BalsaFrameParsesArbitraryInput(params);
+}
+
+}  // namespace
+}  // namespace quiche
diff --git a/quiche/balsa/balsa_fuzz_util.cc b/quiche/balsa/balsa_fuzz_util.cc
new file mode 100644
index 0000000..fc4ed71
--- /dev/null
+++ b/quiche/balsa/balsa_fuzz_util.cc
@@ -0,0 +1,48 @@
+// Copyright 2025 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+#include "quiche/balsa/balsa_fuzz_util.h"
+
+#include <optional>
+#include <tuple>
+#include <type_traits>
+
+#include "quiche/balsa/http_validation_policy.h"
+#include "quiche/common/platform/api/quiche_fuzztest.h"
+
+namespace quiche {
+
+fuzztest::Domain<HttpValidationPolicy> ArbitraryHttpValidationPolicy() {
+  return fuzztest::StructOf<HttpValidationPolicy>(
+      fuzztest::Arbitrary<bool>(), fuzztest::Arbitrary<bool>(),
+      fuzztest::Arbitrary<bool>(), fuzztest::Arbitrary<bool>(),
+      fuzztest::Arbitrary<bool>(), fuzztest::Arbitrary<bool>(),
+      fuzztest::Arbitrary<bool>(), fuzztest::Arbitrary<bool>(),
+      fuzztest::Arbitrary<bool>(), fuzztest::Arbitrary<bool>(),
+      fuzztest::Arbitrary<bool>(), ArbitraryFirstLineValidationOption(),
+      fuzztest::Arbitrary<bool>(), fuzztest::Arbitrary<bool>(),
+      fuzztest::Arbitrary<bool>(), ArbitraryFirstLineValidationOption(),
+      fuzztest::Arbitrary<bool>(), fuzztest::Arbitrary<bool>(),
+      fuzztest::Arbitrary<bool>());
+}
+
+fuzztest::Domain<HttpValidationPolicy::FirstLineValidationOption>
+ArbitraryFirstLineValidationOption() {
+  using EnumType = HttpValidationPolicy::FirstLineValidationOption;
+  using UnderlyingType =
+      std::underlying_type_t<HttpValidationPolicy::FirstLineValidationOption>;
+  return fuzztest::ReversibleMap(
+      [](UnderlyingType x) -> EnumType {
+        return static_cast<HttpValidationPolicy::FirstLineValidationOption>(x);
+      },
+      [](EnumType x) -> std::optional<std::tuple<UnderlyingType>> {
+        return {static_cast<UnderlyingType>(x)};
+      },
+      fuzztest::InRange<UnderlyingType>(
+          static_cast<UnderlyingType>(
+              HttpValidationPolicy::FirstLineValidationOption::kMinValue),
+          static_cast<UnderlyingType>(
+              HttpValidationPolicy::FirstLineValidationOption::kMaxValue)));
+}
+
+}  // namespace quiche
diff --git a/quiche/balsa/balsa_fuzz_util.h b/quiche/balsa/balsa_fuzz_util.h
new file mode 100644
index 0000000..2ff51ee
--- /dev/null
+++ b/quiche/balsa/balsa_fuzz_util.h
@@ -0,0 +1,19 @@
+// Copyright 2025 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#ifndef QUICHE_BALSA_BALSA_FUZZ_UTIL_H_
+#define QUICHE_BALSA_BALSA_FUZZ_UTIL_H_
+
+#include "quiche/balsa/http_validation_policy.h"
+#include "quiche/common/platform/api/quiche_fuzztest.h"
+
+namespace quiche {
+
+fuzztest::Domain<HttpValidationPolicy> ArbitraryHttpValidationPolicy();
+fuzztest::Domain<HttpValidationPolicy::FirstLineValidationOption>
+ArbitraryFirstLineValidationOption();
+
+}  // namespace quiche
+
+#endif  // QUICHE_BALSA_BALSA_FUZZ_UTIL_H_
diff --git a/quiche/balsa/http_validation_policy.h b/quiche/balsa/http_validation_policy.h
index 054ffbf..cb3c39f 100644
--- a/quiche/balsa/http_validation_policy.h
+++ b/quiche/balsa/http_validation_policy.h
@@ -6,8 +6,8 @@
 #define QUICHE_BALSA_HTTP_VALIDATION_POLICY_H_
 
 #include <cstdint>
-#include <ostream>
 
+#include "absl/strings/str_format.h"
 #include "quiche/common/platform/api/quiche_export.h"
 
 namespace quiche {
@@ -16,6 +16,9 @@
 // requests.  It offers individual Boolean members to be consulted during the
 // parsing of an HTTP request.  For historical reasons, every member is set up
 // such that `true` means more strict validation.
+//
+// NOTE: When modifying this struct's members, please update `AbslStringify()`
+// below and `ArbitraryHttpValidationPolicy()` in balsa_fuzz_util.h.
 struct QUICHE_EXPORT HttpValidationPolicy {
   // https://tools.ietf.org/html/rfc7230#section-3.2.4 deprecates "folding"
   // of long header lines onto continuation lines.
@@ -76,6 +79,8 @@
     NONE,
     SANITIZE,
     REJECT,
+    kMinValue = NONE,
+    kMaxValue = REJECT,
   };
   FirstLineValidationOption sanitize_cr_tab_in_first_line =
       FirstLineValidationOption::NONE;
@@ -106,6 +111,65 @@
   // 9110, Section 5.6.2.
   // https://datatracker.ietf.org/doc/html/rfc9110#section-5.6.2
   bool disallow_invalid_request_methods = false;
+
+  template <typename Sink>
+  friend void AbslStringify(Sink& sink, FirstLineValidationOption option) {
+    switch (option) {
+      case FirstLineValidationOption::NONE:
+        sink.Append("NONE");
+        return;
+      case FirstLineValidationOption::SANITIZE:
+        sink.Append("SANITIZE");
+        return;
+      case FirstLineValidationOption::REJECT:
+        sink.Append("REJECT");
+        return;
+    }
+    sink.Append("UNKNOWN");
+  }
+
+  template <typename Sink>
+  friend void AbslStringify(Sink& sink, const HttpValidationPolicy& policy) {
+    absl::Format(&sink,
+                 "{disallow_header_continuation_lines=%v, "
+                 "require_header_colon=%v, "
+                 "disallow_multiple_content_length=%v, "
+                 "disallow_transfer_encoding_with_content_length=%v, "
+                 "validate_transfer_encoding=%v, "
+                 "require_content_length_if_body_required=%v, "
+                 "disallow_double_quote_in_header_name=%v, "
+                 "disallow_invalid_header_characters_in_response=%v, "
+                 "disallow_lone_cr_in_request_headers=%v, "
+                 "disallow_lone_cr_in_chunk_extension=%v, "
+                 "disallow_invalid_target_uris=%v, "
+                 "sanitize_cr_tab_in_first_line=%v, "
+                 "disallow_obs_text_in_field_names=%v, "
+                 "disallow_lone_lf_in_chunk_extension=%v, "
+                 "require_chunked_body_end_with_crlf_crlf=%v, "
+                 "sanitize_firstline_spaces=%v, "
+                 "sanitize_obs_fold_in_header_values=%v, "
+                 "disallow_stray_data_after_chunk=%v, "
+                 "disallow_invalid_request_methods=%v}",
+                 policy.disallow_header_continuation_lines,
+                 policy.require_header_colon,
+                 policy.disallow_multiple_content_length,
+                 policy.disallow_transfer_encoding_with_content_length,
+                 policy.validate_transfer_encoding,
+                 policy.require_content_length_if_body_required,
+                 policy.disallow_double_quote_in_header_name,
+                 policy.disallow_invalid_header_characters_in_response,
+                 policy.disallow_lone_cr_in_request_headers,
+                 policy.disallow_lone_cr_in_chunk_extension,
+                 policy.disallow_invalid_target_uris,
+                 policy.sanitize_cr_tab_in_first_line,
+                 policy.disallow_obs_text_in_field_names,
+                 policy.disallow_lone_lf_in_chunk_extension,
+                 policy.require_chunked_body_end_with_crlf_crlf,
+                 policy.sanitize_firstline_spaces,
+                 policy.sanitize_obs_fold_in_header_values,
+                 policy.disallow_stray_data_after_chunk,
+                 policy.disallow_invalid_request_methods);
+  }
 };
 
 }  // namespace quiche