Add fuzz tests for QUIC ACK receive timestamps

Covers:
- round-trip serialization for valid ACKs
- crash safety for arbitrary data

A new dedicated fuzzer is added, since the existing one only covers Q046.

PiperOrigin-RevId: 1019694850
diff --git a/build/source_list.bzl b/build/source_list.bzl
index 808b5cd..089fbba 100644
--- a/build/source_list.bzl
+++ b/build/source_list.bzl
@@ -1341,6 +1341,7 @@
     "quic/core/qpack/qpack_send_stream_test.cc",
     "quic/core/qpack/qpack_static_table_test.cc",
     "quic/core/qpack/value_splitting_header_list_test.cc",
+    "quic/core/quic_ack_timestamp_fuzz_test.cc",
     "quic/core/quic_ack_timestamp_list_test.cc",
     "quic/core/quic_alarm_test.cc",
     "quic/core/quic_arena_scoped_ptr_test.cc",
diff --git a/build/source_list.gni b/build/source_list.gni
index d87c967..bd4454d 100644
--- a/build/source_list.gni
+++ b/build/source_list.gni
@@ -1344,6 +1344,7 @@
     "src/quiche/quic/core/qpack/qpack_send_stream_test.cc",
     "src/quiche/quic/core/qpack/qpack_static_table_test.cc",
     "src/quiche/quic/core/qpack/value_splitting_header_list_test.cc",
+    "src/quiche/quic/core/quic_ack_timestamp_fuzz_test.cc",
     "src/quiche/quic/core/quic_ack_timestamp_list_test.cc",
     "src/quiche/quic/core/quic_alarm_test.cc",
     "src/quiche/quic/core/quic_arena_scoped_ptr_test.cc",
diff --git a/build/source_list.json b/build/source_list.json
index 9be762b..29e7642 100644
--- a/build/source_list.json
+++ b/build/source_list.json
@@ -1343,6 +1343,7 @@
     "quiche/quic/core/qpack/qpack_send_stream_test.cc",
     "quiche/quic/core/qpack/qpack_static_table_test.cc",
     "quiche/quic/core/qpack/value_splitting_header_list_test.cc",
+    "quiche/quic/core/quic_ack_timestamp_fuzz_test.cc",
     "quiche/quic/core/quic_ack_timestamp_list_test.cc",
     "quiche/quic/core/quic_alarm_test.cc",
     "quiche/quic/core/quic_arena_scoped_ptr_test.cc",
diff --git a/quiche/quic/core/quic_ack_timestamp_fuzz_test.cc b/quiche/quic/core/quic_ack_timestamp_fuzz_test.cc
new file mode 100644
index 0000000..b3bfa05
--- /dev/null
+++ b/quiche/quic/core/quic_ack_timestamp_fuzz_test.cc
@@ -0,0 +1,300 @@
+// Copyright 2026 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.
+
+// Fuzz tests for ACK receive timestamps (draft-ietf-quic-receive-ts): the
+// serialization in QuicAckTimestampList and the parsing in QuicFramer.
+
+#include <algorithm>
+#include <cstddef>
+#include <cstdint>
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "quiche/quic/core/crypto/null_decrypter.h"
+#include "quiche/quic/core/crypto/null_encrypter.h"
+#include "quiche/quic/core/frames/quic_ack_frame.h"
+#include "quiche/quic/core/frames/quic_frame.h"
+#include "quiche/quic/core/quic_constants.h"
+#include "quiche/quic/core/quic_data_writer.h"
+#include "quiche/quic/core/quic_error_codes.h"
+#include "quiche/quic/core/quic_framer.h"
+#include "quiche/quic/core/quic_packet_number.h"
+#include "quiche/quic/core/quic_packets.h"
+#include "quiche/quic/core/quic_receive_time.h"
+#include "quiche/quic/core/quic_time.h"
+#include "quiche/quic/core/quic_types.h"
+#include "quiche/quic/core/quic_versions.h"
+#include "quiche/quic/platform/api/quic_test.h"
+#include "quiche/quic/test_tools/quic_test_utils.h"
+#include "quiche/common/platform/api/quiche_fuzztest.h"
+#include "quiche/common/platform/api/quiche_logging.h"
+
+namespace quic {
+namespace test {
+namespace {
+
+using ::fuzztest::Arbitrary;
+using ::fuzztest::InRange;
+using ::fuzztest::OneOf;
+using ::fuzztest::StructOf;
+using ::fuzztest::VectorOf;
+
+// The largest packet number that can be encoded as a varint62.
+constexpr uint64_t kMaxPacketNumber = kMaxIetfVarInt;
+
+// Records the receive timestamps parsed by the framer.
+class TimestampRecordingVisitor : public NoOpFramerVisitor {
+ public:
+  bool OnAckTimestamp(QuicPacketNumber packet_number,
+                      QuicReceiveTime timestamp) override {
+    timestamps_.emplace_back(packet_number, timestamp);
+    return true;
+  }
+
+  const PacketTimeVector& timestamps() const { return timestamps_; }
+
+ private:
+  PacketTimeVector timestamps_;
+};
+
+// A pair of framers in which the client sends 1-RTT packets with null
+// encryption to the server, and both sides have agreed on the same receive
+// timestamp parameters.
+class FramerPair {
+ public:
+  FramerPair(uint32_t max_timestamps, uint32_t exponent)
+      : sender_({ParsedQuicVersion::RFCv1()}, QuicTime::Zero(),
+                Perspective::IS_CLIENT, kQuicDefaultConnectionIdLength),
+        receiver_({ParsedQuicVersion::RFCv1()}, QuicTime::Zero(),
+                  Perspective::IS_SERVER, kQuicDefaultConnectionIdLength) {
+    sender_.set_visitor(&sender_visitor_);
+    sender_.SetEncrypter(
+        ENCRYPTION_FORWARD_SECURE,
+        std::make_unique<NullEncrypter>(sender_.perspective()));
+    sender_.set_peer_max_receive_timestamps_per_ack(max_timestamps);
+    sender_.set_peer_receive_timestamps_exponent(exponent);
+
+    receiver_.set_visitor(&receiver_visitor_);
+    receiver_.InstallDecrypter(
+        ENCRYPTION_FORWARD_SECURE,
+        std::make_unique<NullDecrypter>(receiver_.perspective()));
+    receiver_.set_local_max_receive_timestamps_per_ack(max_timestamps);
+    receiver_.set_local_receive_timestamps_exponent(exponent);
+
+    header_.form = IETF_QUIC_SHORT_HEADER_PACKET;
+    header_.destination_connection_id = TestConnectionId();
+    header_.destination_connection_id_included = CONNECTION_ID_PRESENT;
+    header_.source_connection_id_included = CONNECTION_ID_ABSENT;
+    header_.version_flag = false;
+    header_.reset_flag = false;
+    header_.packet_number = QuicPacketNumber(1);
+    header_.packet_number_length = PACKET_4BYTE_PACKET_NUMBER;
+  }
+
+  QuicFramer& sender() { return sender_; }
+  QuicFramer& receiver() { return receiver_; }
+  const QuicPacketHeader& header() const { return header_; }
+  const PacketTimeVector& received_timestamps() const {
+    return receiver_visitor_.timestamps();
+  }
+
+  // Encrypts `packet` and passes it to the receiver.  Returns the result of
+  // QuicFramer::ProcessPacket().
+  bool EncryptAndProcess(const QuicPacket& packet) {
+    char buffer[kMaxOutgoingPacketSize];
+    const size_t encrypted_length =
+        sender_.EncryptPayload(ENCRYPTION_FORWARD_SECURE, header_.packet_number,
+                               packet, buffer, sizeof(buffer));
+    QUICHE_CHECK_NE(encrypted_length, 0u);
+    return receiver_.ProcessPacket(
+        QuicEncryptedPacket(buffer, encrypted_length));
+  }
+
+ private:
+  NoOpFramerVisitor sender_visitor_;
+  TimestampRecordingVisitor receiver_visitor_;
+  QuicFramer sender_;
+  QuicFramer receiver_;
+  QuicPacketHeader header_;
+};
+
+// A packet for which a receive timestamp has been recorded.
+struct ReceivedPacket {
+  uint64_t packet_number;
+  // Time elapsed since the previous packet in the list has been received.
+  uint64_t time_since_previous_us;
+};
+
+auto PacketNumberDomain() {
+  // Small packet numbers make contiguous ranges likely; large ones exercise
+  // the multi-byte varint encodings.
+  return OneOf(InRange<uint64_t>(1, 128),
+               InRange<uint64_t>(1, kMaxPacketNumber));
+}
+
+// Serializes a well-formed ACK frame with receive timestamps into a packet with
+// `extra_space` bytes available beyond the minimum ACK frame size, parses it
+// back, and checks that the timestamps received are the most recent ones sent,
+// up to the rounding error allowed by `exponent`.
+void TimestampsRoundTrip(const std::vector<ReceivedPacket>& received,
+                         const std::vector<uint64_t>& acked_without_timestamp,
+                         uint32_t max_timestamps, uint32_t exponent,
+                         uint32_t ack_delay_us, bool has_ecn,
+                         size_t extra_space) {
+  // Build the frame the same way QuicReceivedPacketManager does: timestamps
+  // are listed in the order of arrival, and every timestamped packet is
+  // acknowledged.
+  QuicAckFrame ack;
+  QuicTimeDelta receive_time = QuicTimeDelta::Zero();
+  for (const ReceivedPacket& packet : received) {
+    receive_time = receive_time + QuicTimeDelta::FromMicroseconds(
+                                      packet.time_since_previous_us);
+    const QuicPacketNumber packet_number(packet.packet_number);
+    if (ack.packets.Contains(packet_number)) {
+      continue;
+    }
+    ack.packets.Add(packet_number);
+    ack.received_packet_times.push_back(
+        {packet_number, *QuicReceiveTime::FromPeerTime(receive_time)});
+  }
+  for (const uint64_t packet_number : acked_without_timestamp) {
+    ack.packets.Add(QuicPacketNumber(packet_number));
+  }
+  // QuicFramer's ACK range truncation requires the ACK block count to fit in a
+  // single-byte varint (< 64 additional blocks, or <= 64 total intervals).
+  if (ack.packets.Empty() || ack.packets.NumIntervals() > 64) {
+    return;
+  }
+  ack.largest_acked = ack.packets.Max();
+  ack.ack_delay_time = QuicTimeDelta::FromMicroseconds(ack_delay_us);
+  if (has_ecn) {
+    ack.ecn_counters = QuicEcnCounts(1, 2, 3);
+  }
+  const PacketTimeVector& sent = ack.received_packet_times;
+
+  FramerPair framers(max_timestamps, exponent);
+  QuicFramer& sender = framers.sender();
+  const QuicFrames frames = {QuicFrame(&ack)};
+  const size_t header_size =
+      GetPacketHeaderSize(sender.transport_version(), framers.header());
+  const size_t min_frame_size = QuicFramer::GetMinAckFrameSize(
+      sender.transport_version(), ack, sender.local_ack_delay_exponent(),
+      /*use_ietf_ack_with_receive_timestamp=*/!sent.empty());
+  const size_t available_space = std::min(
+      min_frame_size + extra_space,
+      sender.GetMaxPlaintextSize(kMaxOutgoingPacketSize) - header_size);
+
+  // An ACK frame can always be serialized as long as the minimum ACK frame
+  // fits.
+  const size_t frame_size = sender.GetSerializedFrameLength(
+      frames[0], available_space, /*first_frame=*/true, /*last_frame=*/true,
+      framers.header().packet_number_length);
+  ASSERT_GT(frame_size, 0u);
+  ASSERT_LE(frame_size, available_space);
+  std::unique_ptr<QuicPacket> packet = BuildUnsizedDataPacket(
+      &sender, framers.header(), frames, header_size + frame_size);
+  ASSERT_NE(packet, nullptr) << sender.detailed_error();
+
+  ASSERT_TRUE(framers.EncryptAndProcess(*packet))
+      << framers.receiver().detailed_error();
+  ASSERT_EQ(framers.receiver().error(), QUIC_NO_ERROR);
+
+  // The receiver gets the newest timestamps first.  Truncation, whether due to
+  // `max_timestamps` or to the lack of space, drops the oldest ones.
+  const PacketTimeVector& parsed = framers.received_timestamps();
+  const size_t max_count = std::min<size_t>(sent.size(), max_timestamps);
+  ASSERT_LE(parsed.size(), max_count);
+  if (frame_size < available_space) {
+    // The frame fit without being truncated to the available space.
+    EXPECT_EQ(parsed.size(), max_count);
+  }
+  const QuicTimeDelta max_rounding_error =
+      QuicTimeDelta::FromMicroseconds(int64_t{1} << exponent);
+  for (size_t i = 0; i < parsed.size(); ++i) {
+    const auto& [sent_packet_number, sent_time] = sent[sent.size() - 1 - i];
+    const auto& [parsed_packet_number, parsed_time] = parsed[i];
+    EXPECT_EQ(parsed_packet_number, sent_packet_number) << "i = " << i;
+    // Timestamps are never rounded down, so that the receive time reported is
+    // never earlier than the actual one.
+    const QuicTimeDelta rounding_error = parsed_time - sent_time;
+    EXPECT_GE(rounding_error, QuicTimeDelta::Zero()) << "i = " << i;
+    EXPECT_LT(rounding_error, max_rounding_error) << "i = " << i;
+  }
+}
+FUZZ_TEST(QuicAckTimestampFuzzTest, TimestampsRoundTrip)
+    .WithDomains(
+        /*received=*/VectorOf(
+            StructOf<ReceivedPacket>(PacketNumberDomain(),
+                                     InRange<uint64_t>(0, uint64_t{1} << 50)))
+            .WithMaxSize(64),
+        /*acked_without_timestamp=*/
+        VectorOf(PacketNumberDomain()).WithMaxSize(32),
+        /*max_timestamps=*/InRange<uint32_t>(1, 64),
+        /*exponent=*/InRange<uint32_t>(0, kMaxReceiveTimestampsExponent),
+        /*ack_delay_us=*/Arbitrary<uint32_t>(),
+        /*has_ecn=*/Arbitrary<bool>(),
+        /*extra_space=*/InRange<size_t>(0, 2 * kMaxOutgoingPacketSize));
+
+// Three timestamp ranges with six timestamps in total, taken from
+// QuicFramerTest.AckFrameMultipleReceiveTimestampRanges.
+std::string ValidAckPayload() {
+  constexpr char kPayload[] =
+      "\x52\x34\x00\x00\x00"                  // ACK header
+      "\x03"                                  // range count
+      "\x02\x03\xa9\xff\xff\xff\x51\x11\x01"  // range 1
+      "\x0b\x01\x50\x00"                      // range 2
+      "\x15\x02\x10\x41\x00";                 // range 3
+  return std::string(kPayload, std::size(kPayload) - 1);
+}
+
+// Passes a packet containing an ACK_RECEIVE_TIMESTAMPS frame type followed by
+// `payload` to the receiver.  Returns the result of
+// QuicFramer::ProcessPacket().
+bool ProcessAckPayload(FramerPair& framers, const std::string& payload,
+                       bool has_ecn) {
+  char buffer[kMaxOutgoingPacketSize];
+  QuicDataWriter writer(sizeof(buffer), buffer);
+  size_t length_field_offset = 0;
+  QUICHE_CHECK(framers.sender().AppendIetfPacketHeader(
+      framers.header(), &writer, &length_field_offset));
+  QUICHE_CHECK(writer.WriteVarInt62(has_ecn ? IETF_ACK_RECEIVE_TIMESTAMPS_ECN
+                                            : IETF_ACK_RECEIVE_TIMESTAMPS));
+  QUICHE_CHECK(writer.WriteBytes(payload.data(), payload.size()));
+  const QuicPacket packet(framers.sender().transport_version(), buffer,
+                          writer.length(), /*owns_buffer=*/false,
+                          framers.header());
+  return framers.EncryptAndProcess(packet);
+}
+
+class QuicAckTimestampFuzzSeedTest : public QuicTest {};
+
+// Ensures that the packets built by ProcessAckPayload() reach the timestamp
+// parser, and that the seed used below is valid.
+TEST_F(QuicAckTimestampFuzzSeedTest, ValidPayloadIsParsed) {
+  FramerPair framers(/*max_timestamps=*/1000, /*exponent=*/0);
+  ASSERT_TRUE(ProcessAckPayload(framers, ValidAckPayload(), /*has_ecn=*/false))
+      << framers.receiver().detailed_error();
+  EXPECT_EQ(framers.received_timestamps().size(), 6u);
+}
+
+// Passes an ACK_RECEIVE_TIMESTAMPS frame with an arbitrary payload to the
+// framer.  The payload may contain other frames after the ACK frame.
+void ParserHandlesArbitraryInput(const std::string& payload, bool has_ecn,
+                                 uint32_t max_timestamps, uint32_t exponent) {
+  FramerPair framers(max_timestamps, exponent);
+  ProcessAckPayload(framers, payload, has_ecn);
+}
+FUZZ_TEST(QuicAckTimestampFuzzTest, ParserHandlesArbitraryInput)
+    .WithDomains(/*payload=*/Arbitrary<std::string>().WithMaxSize(1200),
+                 /*has_ecn=*/Arbitrary<bool>(),
+                 /*max_timestamps=*/InRange<uint32_t>(1, 1 << 16),
+                 /*exponent=*/
+                 InRange<uint32_t>(0, kMaxReceiveTimestampsExponent))
+    .WithSeeds({{ValidAckPayload(), false, 1000, 0}});
+
+}  // namespace
+}  // namespace test
+}  // namespace quic