diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 7116652e1..2bd5fb76f 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -135,8 +135,9 @@ "https://bcr.bazel.build/modules/grpc/1.66.0.bcr.3/MODULE.bazel": "f6047e89faf488f5e3e65cb2594c6f5e86992abec7487163ff6b623526e543b0", "https://bcr.bazel.build/modules/grpc/1.70.1/MODULE.bazel": "b800cd8e3e7555c1e61cba2e02d3a2fcf0e91f66e800db286d965d3b7a6a721a", "https://bcr.bazel.build/modules/grpc/1.70.1/source.json": "e2977ea6cf9f2755418934d4ae134a6569713dd200fd7aded86a4b7f1b86efc9", + "https://bcr.bazel.build/modules/highwayhash/0.0.0-20240305-5ad3bf8.bcr.1/MODULE.bazel": "ddac4832716c287d1c4f3bc05fb91ee684950bfcd8f05a965697838006b6217a", + "https://bcr.bazel.build/modules/highwayhash/0.0.0-20240305-5ad3bf8.bcr.1/source.json": "4c0c79319b20975c9f3cfbb3d2d5d6c3b51226915c5baa7b9d0759dcac0e67a4", "https://bcr.bazel.build/modules/highwayhash/0.0.0-20240305-5ad3bf8/MODULE.bazel": "5c7f29d5bd70feff14b0f65b39584957e18e4a8d555e5a29a4c36019afbb44b9", - "https://bcr.bazel.build/modules/highwayhash/0.0.0-20240305-5ad3bf8/source.json": "211c0937ef5f537da6c3c135d12e60927c71b380642e207e4a02b86d29c55e85", "https://bcr.bazel.build/modules/jsoncpp/1.9.5/MODULE.bazel": "31271aedc59e815656f5736f282bb7509a97c7ecb43e927ac1a37966e0578075", "https://bcr.bazel.build/modules/jsoncpp/1.9.6/MODULE.bazel": "2f8d20d3b7d54143213c4dfc3d98225c42de7d666011528dc8fe91591e2e17b0", "https://bcr.bazel.build/modules/jsoncpp/1.9.6/source.json": "a04756d367a2126c3541682864ecec52f92cdee80a35735a3cb249ce015ca000", diff --git a/build/source_list.bzl b/build/source_list.bzl index 63110c255..8f89280e5 100644 --- a/build/source_list.bzl +++ b/build/source_list.bzl @@ -895,9 +895,12 @@ quiche_test_support_hdrs = [ "quic/test_tools/test_certificates.h", "quic/test_tools/test_ip_packets.h", "quic/test_tools/test_ticket_crypter.h", + "quic/test_tools/quic_spdy_session_test_utils.h", "quic/test_tools/web_transport_resets_backend.h", "quic/test_tools/web_transport_test_tools.h", + "quic/core/http/web_transport_draft15_test_utils.h", "web_transport/test_tools/in_memory_stream.h", + "web_transport/test_tools/draft15_constants.h", "web_transport/test_tools/mock_web_transport.h", ] quiche_test_support_srcs = [ @@ -1402,6 +1405,17 @@ quiche_tests_srcs = [ "web_transport/test_tools/in_memory_stream_test.cc", "web_transport/web_transport_headers_test.cc", "web_transport/web_transport_priority_scheduler_test.cc", + "common/capsule_draft15_test.cc", + "quic/core/http/web_transport_buffering_draft15_test.cc", + "quic/core/http/web_transport_capsule_dispatch_draft15_test.cc", + "quic/core/http/web_transport_error_codes_draft15_test.cc", + "quic/core/http/web_transport_flow_control_draft15_test.cc", + "quic/core/http/web_transport_keying_material_draft15_test.cc", + "quic/core/http/web_transport_session_establishment_draft15_test.cc", + "quic/core/http/web_transport_session_limiting_draft15_test.cc", + "quic/core/http/web_transport_streams_draft15_test.cc", + "quic/core/http/web_transport_version_negotiation_draft15_test.cc", + "web_transport/web_transport_headers_draft15_test.cc", ] io_tests_hdrs = [ ] diff --git a/quiche/common/capsule.cc b/quiche/common/capsule.cc index 62fdde645..11f0207f1 100644 --- a/quiche/common/capsule.cc +++ b/quiche/common/capsule.cc @@ -67,6 +67,16 @@ std::string CapsuleTypeToString(CapsuleType capsule_type) { return "WT_MAX_STREAMS_BIDI"; case CapsuleType::WT_MAX_STREAMS_UNIDI: return "WT_MAX_STREAMS_UNIDI"; + case CapsuleType::WT_MAX_DATA: + return "WT_MAX_DATA"; + case CapsuleType::WT_DATA_BLOCKED: + return "WT_DATA_BLOCKED"; + case CapsuleType::WT_STREAM_DATA_BLOCKED: + return "WT_STREAM_DATA_BLOCKED"; + case CapsuleType::WT_STREAMS_BLOCKED_BIDI: + return "WT_STREAMS_BLOCKED_BIDI"; + case CapsuleType::WT_STREAMS_BLOCKED_UNIDI: + return "WT_STREAMS_BLOCKED_UNIDI"; case CapsuleType::COMPRESSION_ASSIGN: return "COMPRESSION_ASSIGN"; case CapsuleType::COMPRESSION_CLOSE: @@ -229,6 +239,24 @@ std::string WebTransportMaxStreamsCapsule::ToString() const { " (max_streams=", max_stream_count, ")"); } +std::string WebTransportMaxDataCapsule::ToString() const { + return absl::StrCat("WT_MAX_DATA (max_data=", max_data, ")"); +} + +std::string WebTransportDataBlockedCapsule::ToString() const { + return absl::StrCat("WT_DATA_BLOCKED (data_limit=", data_limit, ")"); +} + +std::string WebTransportStreamDataBlockedCapsule::ToString() const { + return absl::StrCat("WT_STREAM_DATA_BLOCKED (stream_id=", stream_id, + ", stream_data_limit=", stream_data_limit, ")"); +} + +std::string WebTransportStreamsBlockedCapsule::ToString() const { + return absl::StrCat(CapsuleTypeToString(capsule_type()), + " (stream_limit=", stream_limit, ")"); +} + std::string Capsule::ToString() const { return std::visit([](const auto& capsule) { return capsule.ToString(); }, capsule_); @@ -375,6 +403,27 @@ absl::StatusOr SerializeCapsuleWithStatus( return SerializeCapsuleFields( capsule.capsule_type(), allocator, WireVarInt62(capsule.web_transport_max_streams().max_stream_count)); + case CapsuleType::WT_MAX_DATA: + return SerializeCapsuleFields( + capsule.capsule_type(), allocator, + WireVarInt62(capsule.web_transport_max_data().max_data)); + case CapsuleType::WT_DATA_BLOCKED: + return SerializeCapsuleFields( + capsule.capsule_type(), allocator, + WireVarInt62(capsule.web_transport_data_blocked().data_limit)); + case CapsuleType::WT_STREAM_DATA_BLOCKED: + return SerializeCapsuleFields( + capsule.capsule_type(), allocator, + WireVarInt62( + capsule.web_transport_stream_data_blocked().stream_id), + WireVarInt62( + capsule.web_transport_stream_data_blocked().stream_data_limit)); + case CapsuleType::WT_STREAMS_BLOCKED_BIDI: + case CapsuleType::WT_STREAMS_BLOCKED_UNIDI: + return SerializeCapsuleFields( + capsule.capsule_type(), allocator, + WireVarInt62( + capsule.web_transport_streams_blocked().stream_limit)); case CapsuleType::COMPRESSION_ASSIGN: QUICHE_DCHECK(capsule.compression_assign_capsule().ip_address_port == quiche::QuicheSocketAddress() || @@ -702,6 +751,44 @@ absl::StatusOr ParseCapsulePayload(QuicheDataReader& reader, } return Capsule(std::move(capsule)); } + case CapsuleType::WT_MAX_DATA: { + WebTransportMaxDataCapsule capsule; + if (!reader.ReadVarInt62(&capsule.max_data)) { + return absl::InvalidArgumentError( + "Failed to parse the max data field"); + } + return Capsule(std::move(capsule)); + } + case CapsuleType::WT_DATA_BLOCKED: { + WebTransportDataBlockedCapsule capsule; + if (!reader.ReadVarInt62(&capsule.data_limit)) { + return absl::InvalidArgumentError( + "Failed to parse the data blocked limit field"); + } + return Capsule(std::move(capsule)); + } + case CapsuleType::WT_STREAM_DATA_BLOCKED: { + WebTransportStreamDataBlockedCapsule capsule; + QUICHE_RETURN_IF_ERROR( + ReadWebTransportStreamId(reader, capsule.stream_id)); + if (!reader.ReadVarInt62(&capsule.stream_data_limit)) { + return absl::InvalidArgumentError( + "Failed to parse the stream data blocked limit field"); + } + return Capsule(std::move(capsule)); + } + case CapsuleType::WT_STREAMS_BLOCKED_UNIDI: + case CapsuleType::WT_STREAMS_BLOCKED_BIDI: { + WebTransportStreamsBlockedCapsule capsule; + capsule.stream_type = type == CapsuleType::WT_STREAMS_BLOCKED_UNIDI + ? webtransport::StreamType::kUnidirectional + : webtransport::StreamType::kBidirectional; + if (!reader.ReadVarInt62(&capsule.stream_limit)) { + return absl::InvalidArgumentError( + "Failed to parse the streams blocked limit field"); + } + return Capsule(std::move(capsule)); + } case CapsuleType::COMPRESSION_ASSIGN: { CompressionAssignCapsule capsule; if (!reader.ReadVarInt62(&capsule.context_id)) { @@ -879,4 +966,26 @@ bool WebTransportMaxStreamsCapsule::operator==( max_stream_count == other.max_stream_count; } +bool WebTransportMaxDataCapsule::operator==( + const WebTransportMaxDataCapsule& other) const { + return max_data == other.max_data; +} + +bool WebTransportDataBlockedCapsule::operator==( + const WebTransportDataBlockedCapsule& other) const { + return data_limit == other.data_limit; +} + +bool WebTransportStreamDataBlockedCapsule::operator==( + const WebTransportStreamDataBlockedCapsule& other) const { + return stream_id == other.stream_id && + stream_data_limit == other.stream_data_limit; +} + +bool WebTransportStreamsBlockedCapsule::operator==( + const WebTransportStreamsBlockedCapsule& other) const { + return stream_type == other.stream_type && + stream_limit == other.stream_limit; +} + } // namespace quiche diff --git a/quiche/common/capsule.h b/quiche/common/capsule.h index c7fbbb6f9..71e6c2c2a 100644 --- a/quiche/common/capsule.h +++ b/quiche/common/capsule.h @@ -49,19 +49,17 @@ enum class CapsuleType : uint64_t { WT_STOP_SENDING = 0x190b4d3a, WT_STREAM = 0x190b4d3b, WT_STREAM_WITH_FIN = 0x190b4d3c, - // Should be removed as a result of - // . - // WT_MAX_DATA = 0x190b4d3d, + WT_MAX_DATA = 0x190b4d3d, WT_MAX_STREAM_DATA = 0x190b4d3e, WT_MAX_STREAMS_BIDI = 0x190b4d3f, WT_MAX_STREAMS_UNIDI = 0x190b4d40, - // TODO(b/264263113): implement those. + // TODO(b/264263113): PADDING not yet implemented. // PADDING = 0x190b4d38, - // WT_DATA_BLOCKED = 0x190b4d41, - // WT_STREAM_DATA_BLOCKED = 0x190b4d42, - // WT_STREAMS_BLOCKED_BIDI = 0x190b4d43, - // WT_STREAMS_BLOCKED_UNIDI = 0x190b4d44, + WT_DATA_BLOCKED = 0x190b4d41, + WT_STREAM_DATA_BLOCKED = 0x190b4d42, + WT_STREAMS_BLOCKED_BIDI = 0x190b4d43, + WT_STREAMS_BLOCKED_UNIDI = 0x190b4d44, }; QUICHE_EXPORT std::string CapsuleTypeToString(CapsuleType capsule_type); @@ -215,6 +213,43 @@ struct QUICHE_EXPORT WebTransportMaxStreamsCapsule { } }; +struct QUICHE_EXPORT WebTransportMaxDataCapsule { + uint64_t max_data; + + bool operator==(const WebTransportMaxDataCapsule& other) const; + std::string ToString() const; + CapsuleType capsule_type() const { return CapsuleType::WT_MAX_DATA; } +}; +struct QUICHE_EXPORT WebTransportDataBlockedCapsule { + uint64_t data_limit; + + bool operator==(const WebTransportDataBlockedCapsule& other) const; + std::string ToString() const; + CapsuleType capsule_type() const { return CapsuleType::WT_DATA_BLOCKED; } +}; +struct QUICHE_EXPORT WebTransportStreamDataBlockedCapsule { + webtransport::StreamId stream_id; + uint64_t stream_data_limit; + + bool operator==(const WebTransportStreamDataBlockedCapsule& other) const; + std::string ToString() const; + CapsuleType capsule_type() const { + return CapsuleType::WT_STREAM_DATA_BLOCKED; + } +}; +struct QUICHE_EXPORT WebTransportStreamsBlockedCapsule { + webtransport::StreamType stream_type; + uint64_t stream_limit; + + bool operator==(const WebTransportStreamsBlockedCapsule& other) const; + std::string ToString() const; + CapsuleType capsule_type() const { + return stream_type == webtransport::StreamType::kBidirectional + ? CapsuleType::WT_STREAMS_BLOCKED_BIDI + : CapsuleType::WT_STREAMS_BLOCKED_UNIDI; + } +}; + struct QUICHE_EXPORT CompressionAssignCapsule { uint64_t context_id; QuicheSocketAddress ip_address_port; @@ -356,6 +391,32 @@ class QUICHE_EXPORT Capsule { const WebTransportMaxStreamsCapsule& web_transport_max_streams() const { return std::get(capsule_); } + WebTransportMaxDataCapsule& web_transport_max_data() { + return std::get(capsule_); + } + const WebTransportMaxDataCapsule& web_transport_max_data() const { + return std::get(capsule_); + } + WebTransportDataBlockedCapsule& web_transport_data_blocked() { + return std::get(capsule_); + } + const WebTransportDataBlockedCapsule& web_transport_data_blocked() const { + return std::get(capsule_); + } + WebTransportStreamDataBlockedCapsule& web_transport_stream_data_blocked() { + return std::get(capsule_); + } + const WebTransportStreamDataBlockedCapsule& + web_transport_stream_data_blocked() const { + return std::get(capsule_); + } + WebTransportStreamsBlockedCapsule& web_transport_streams_blocked() { + return std::get(capsule_); + } + const WebTransportStreamsBlockedCapsule& web_transport_streams_blocked() + const { + return std::get(capsule_); + } UnknownCapsule& unknown_capsule() { return std::get(capsule_); } @@ -371,8 +432,10 @@ class QUICHE_EXPORT Capsule { RouteAdvertisementCapsule, WebTransportStreamDataCapsule, WebTransportResetStreamCapsule, WebTransportStopSendingCapsule, WebTransportMaxStreamsCapsule, WebTransportMaxStreamDataCapsule, - UnknownCapsule, CompressionAssignCapsule, - CompressionCloseCapsule> + WebTransportMaxDataCapsule, WebTransportDataBlockedCapsule, + WebTransportStreamDataBlockedCapsule, + WebTransportStreamsBlockedCapsule, UnknownCapsule, + CompressionAssignCapsule, CompressionCloseCapsule> capsule_; }; diff --git a/quiche/common/capsule_draft15_test.cc b/quiche/common/capsule_draft15_test.cc new file mode 100644 index 000000000..fa2689b81 --- /dev/null +++ b/quiche/common/capsule_draft15_test.cc @@ -0,0 +1,252 @@ +// Copyright 2024 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. + +// Draft-15 acceptance tests for WebTransport capsule types (Section 6, 9.6). +// Pure capsule serialization/parsing tests — no QUIC session dependencies. +// Session-dependent tests live in +// quiche/quic/core/http/web_transport_capsule_dispatch_draft15_test.cc. + +#include +#include +#include + +#include "absl/strings/string_view.h" +#include "quiche/common/capsule.h" +#include "quiche/common/platform/api/quiche_test.h" +#include "quiche/common/quiche_buffer_allocator.h" +#include "quiche/common/simple_buffer_allocator.h" +#include "quiche/web_transport/test_tools/draft15_constants.h" +#include "quiche/web_transport/web_transport.h" + +namespace quiche { +namespace { + +// --- Capsule type codepoint assertions (Section 9.6) --- + +TEST(CapsuleDraft15, WtCloseSessionCapsuleType) { + EXPECT_EQ(static_cast(CapsuleType::CLOSE_WEBTRANSPORT_SESSION), + webtransport::draft15::kWtCloseSession); +} + +TEST(CapsuleDraft15, WtDrainSessionCapsuleType) { + EXPECT_EQ(static_cast(CapsuleType::DRAIN_WEBTRANSPORT_SESSION), + webtransport::draft15::kWtDrainSession); +} + +TEST(CapsuleDraft15, WtMaxStreamsBidiCapsuleParse) { + EXPECT_EQ(static_cast(CapsuleType::WT_MAX_STREAMS_BIDI), + webtransport::draft15::kWtMaxStreamsBidi); +} + +TEST(CapsuleDraft15, WtMaxStreamsUnidiCapsuleParse) { + EXPECT_EQ(static_cast(CapsuleType::WT_MAX_STREAMS_UNIDI), + webtransport::draft15::kWtMaxStreamsUnidi); +} + +TEST(CapsuleDraft15, WtMaxDataCapsuleParse) { + EXPECT_EQ(static_cast(CapsuleType::WT_MAX_DATA), + webtransport::draft15::kWtMaxData); +} + +TEST(CapsuleDraft15, WtDataBlockedCapsuleParse) { + EXPECT_EQ(static_cast(CapsuleType::WT_DATA_BLOCKED), + webtransport::draft15::kWtDataBlocked); +} + +TEST(CapsuleDraft15, WtStreamsBlockedBidiCapsuleParse) { + EXPECT_EQ(static_cast(CapsuleType::WT_STREAMS_BLOCKED_BIDI), + webtransport::draft15::kWtStreamsBlockedBidi); +} + +TEST(CapsuleDraft15, WtStreamsBlockedUnidiCapsuleParse) { + EXPECT_EQ(static_cast(CapsuleType::WT_STREAMS_BLOCKED_UNIDI), + webtransport::draft15::kWtStreamsBlockedUnidi); +} + +// --- Message length validation (Section 6) --- + +class TestCapsuleVisitor : public CapsuleParser::Visitor { + public: + bool OnCapsule(const Capsule& capsule) override { + last_capsule_type_ = capsule.capsule_type(); + if (capsule.capsule_type() == + CapsuleType::CLOSE_WEBTRANSPORT_SESSION) { + last_error_message_length_ = + capsule.close_web_transport_session_capsule() + .error_message.size(); + } + capsule_received_ = true; + last_capsule_ = capsule; + if (capsule.capsule_type() == + CapsuleType::CLOSE_WEBTRANSPORT_SESSION && + capsule.close_web_transport_session_capsule() + .error_message.size() > 1024) { + return false; + } + return true; + } + + void OnCapsuleParseFailure(absl::string_view error_message) override { + parse_failure_ = true; + failure_message_ = std::string(error_message); + } + + bool capsule_received_ = false; + bool parse_failure_ = false; + std::string failure_message_; + std::optional last_capsule_type_; + std::optional last_capsule_; + size_t last_error_message_length_ = 0; +}; + +TEST(CapsuleDraft15, WtCloseSessionMaxMessage1024) { + std::string long_message(1025, 'x'); + Capsule capsule = Capsule::CloseWebTransportSession(0, long_message); + + quiche::SimpleBufferAllocator allocator; + quiche::QuicheBuffer serialized = SerializeCapsule(capsule, &allocator); + ASSERT_FALSE(serialized.empty()); + + TestCapsuleVisitor visitor; + CapsuleParser parser(&visitor); + bool parse_ok = parser.IngestCapsuleFragment(serialized.AsStringView()); + + EXPECT_FALSE(parse_ok) + << "Parsing a CLOSE_WEBTRANSPORT_SESSION capsule with a 1025-byte " + "message should fail validation"; + EXPECT_TRUE(visitor.capsule_received_); + EXPECT_TRUE(visitor.parse_failure_); + + std::string ok_message(1024, 'y'); + Capsule ok_capsule = Capsule::CloseWebTransportSession(0, ok_message); + quiche::QuicheBuffer ok_serialized = + SerializeCapsule(ok_capsule, &allocator); + + TestCapsuleVisitor ok_visitor; + CapsuleParser ok_parser(&ok_visitor); + EXPECT_TRUE(ok_parser.IngestCapsuleFragment(ok_serialized.AsStringView())); + EXPECT_TRUE(ok_visitor.capsule_received_); + EXPECT_FALSE(ok_visitor.parse_failure_); +} + +// --- Round-trip serialization tests --- + +TEST(CapsuleDraft15, WtMaxDataSerializeRoundTrip) { + quiche::SimpleBufferAllocator allocator; + Capsule capsule(WebTransportMaxDataCapsule{65536}); + QuicheBuffer serialized = SerializeCapsule(capsule, &allocator); + ASSERT_FALSE(serialized.empty()); + + TestCapsuleVisitor visitor; + CapsuleParser parser(&visitor); + ASSERT_TRUE(parser.IngestCapsuleFragment(serialized.AsStringView())); + ASSERT_TRUE(visitor.capsule_received_); + EXPECT_EQ(visitor.last_capsule_type_, CapsuleType::WT_MAX_DATA); + ASSERT_TRUE(visitor.last_capsule_.has_value()); + EXPECT_EQ(visitor.last_capsule_->web_transport_max_data().max_data, 65536u); +} + +TEST(CapsuleDraft15, WtMaxStreamsBidiSerializeRoundTrip) { + quiche::SimpleBufferAllocator allocator; + Capsule capsule(WebTransportMaxStreamsCapsule{ + webtransport::StreamType::kBidirectional, 100}); + QuicheBuffer serialized = SerializeCapsule(capsule, &allocator); + ASSERT_FALSE(serialized.empty()); + + TestCapsuleVisitor visitor; + CapsuleParser parser(&visitor); + ASSERT_TRUE(parser.IngestCapsuleFragment(serialized.AsStringView())); + ASSERT_TRUE(visitor.capsule_received_); + EXPECT_EQ(visitor.last_capsule_type_, CapsuleType::WT_MAX_STREAMS_BIDI); + ASSERT_TRUE(visitor.last_capsule_.has_value()); + EXPECT_EQ( + visitor.last_capsule_->web_transport_max_streams().stream_type, + webtransport::StreamType::kBidirectional); + EXPECT_EQ( + visitor.last_capsule_->web_transport_max_streams().max_stream_count, + 100u); +} + +TEST(CapsuleDraft15, WtMaxStreamsUnidiSerializeRoundTrip) { + quiche::SimpleBufferAllocator allocator; + Capsule capsule(WebTransportMaxStreamsCapsule{ + webtransport::StreamType::kUnidirectional, 50}); + QuicheBuffer serialized = SerializeCapsule(capsule, &allocator); + ASSERT_FALSE(serialized.empty()); + + TestCapsuleVisitor visitor; + CapsuleParser parser(&visitor); + ASSERT_TRUE(parser.IngestCapsuleFragment(serialized.AsStringView())); + ASSERT_TRUE(visitor.capsule_received_); + EXPECT_EQ(visitor.last_capsule_type_, CapsuleType::WT_MAX_STREAMS_UNIDI); + ASSERT_TRUE(visitor.last_capsule_.has_value()); + EXPECT_EQ( + visitor.last_capsule_->web_transport_max_streams().stream_type, + webtransport::StreamType::kUnidirectional); + EXPECT_EQ( + visitor.last_capsule_->web_transport_max_streams().max_stream_count, + 50u); +} + +TEST(CapsuleDraft15, WtDataBlockedSerializeRoundTrip) { + quiche::SimpleBufferAllocator allocator; + Capsule capsule(WebTransportDataBlockedCapsule{1024}); + QuicheBuffer serialized = SerializeCapsule(capsule, &allocator); + ASSERT_FALSE(serialized.empty()); + + TestCapsuleVisitor visitor; + CapsuleParser parser(&visitor); + ASSERT_TRUE(parser.IngestCapsuleFragment(serialized.AsStringView())); + ASSERT_TRUE(visitor.capsule_received_); + EXPECT_EQ(visitor.last_capsule_type_, CapsuleType::WT_DATA_BLOCKED); + ASSERT_TRUE(visitor.last_capsule_.has_value()); + EXPECT_EQ(visitor.last_capsule_->web_transport_data_blocked().data_limit, + 1024u); +} + +TEST(CapsuleDraft15, WtStreamsBlockedBidiSerializeRoundTrip) { + quiche::SimpleBufferAllocator allocator; + Capsule capsule(WebTransportStreamsBlockedCapsule{ + webtransport::StreamType::kBidirectional, 50}); + QuicheBuffer serialized = SerializeCapsule(capsule, &allocator); + ASSERT_FALSE(serialized.empty()); + + TestCapsuleVisitor visitor; + CapsuleParser parser(&visitor); + ASSERT_TRUE(parser.IngestCapsuleFragment(serialized.AsStringView())); + ASSERT_TRUE(visitor.capsule_received_); + EXPECT_EQ(visitor.last_capsule_type_, CapsuleType::WT_STREAMS_BLOCKED_BIDI); + ASSERT_TRUE(visitor.last_capsule_.has_value()); + EXPECT_EQ( + visitor.last_capsule_->web_transport_streams_blocked().stream_type, + webtransport::StreamType::kBidirectional); + EXPECT_EQ( + visitor.last_capsule_->web_transport_streams_blocked().stream_limit, + 50u); +} + +TEST(CapsuleDraft15, WtStreamsBlockedUnidiSerializeRoundTrip) { + quiche::SimpleBufferAllocator allocator; + Capsule capsule(WebTransportStreamsBlockedCapsule{ + webtransport::StreamType::kUnidirectional, 25}); + QuicheBuffer serialized = SerializeCapsule(capsule, &allocator); + ASSERT_FALSE(serialized.empty()); + + TestCapsuleVisitor visitor; + CapsuleParser parser(&visitor); + ASSERT_TRUE(parser.IngestCapsuleFragment(serialized.AsStringView())); + ASSERT_TRUE(visitor.capsule_received_); + EXPECT_EQ(visitor.last_capsule_type_, CapsuleType::WT_STREAMS_BLOCKED_UNIDI); + ASSERT_TRUE(visitor.last_capsule_.has_value()); + EXPECT_EQ( + visitor.last_capsule_->web_transport_streams_blocked().stream_type, + webtransport::StreamType::kUnidirectional); + EXPECT_EQ( + visitor.last_capsule_->web_transport_streams_blocked().stream_limit, + 25u); +} + + +} // namespace +} // namespace quiche diff --git a/quiche/quic/core/http/end_to_end_test.cc b/quiche/quic/core/http/end_to_end_test.cc index 9035a26e1..48cfce6f5 100644 --- a/quiche/quic/core/http/end_to_end_test.cc +++ b/quiche/quic/core/http/end_to_end_test.cc @@ -351,7 +351,12 @@ class EndToEndTest : public QuicTestWithParam { override_client_connection_id_length_); } client->client()->set_connection_debug_visitor(connection_debug_visitor_); - client->client()->set_enable_web_transport(enable_web_transport_); + client->client()->set_supported_web_transport_versions(wt_versions_); + client->client()->set_wt_initial_max_streams_bidi( + client_wt_max_streams_bidi_); + client->client()->set_wt_initial_max_streams_uni( + client_wt_max_streams_uni_); + client->client()->set_wt_initial_max_data(client_wt_max_data_); if (connect) { client->Connect(); } @@ -509,8 +514,12 @@ class EndToEndTest : public QuicTestWithParam { } bool Initialize() { - if (enable_web_transport_) { - memory_cache_backend_.set_enable_webtransport(true); + if (wt_versions_.Any()) { + memory_cache_backend_.set_supported_web_transport_versions(wt_versions_); + if (wt_versions_.IsSet(WebTransportHttp3Version::kDraft15)) { + client_config_.SetReliableStreamReset(true); + server_config_.SetReliableStreamReset(true); + } } QuicTagVector copt; @@ -910,7 +919,12 @@ class EndToEndTest : public QuicTestWithParam { headers[":authority"] = "localhost"; headers[":path"] = path; headers[":method"] = "CONNECT"; - headers[":protocol"] = "webtransport"; + if (GetClientSession()->SupportedWebTransportVersion() == + WebTransportHttp3Version::kDraft15) { + headers[":protocol"] = "webtransport-h3"; + } else { + headers[":protocol"] = "webtransport"; + } for (const auto& [key, value] : extra_headers) { headers[key] = std::string(value); } @@ -1098,7 +1112,10 @@ class EndToEndTest : public QuicTestWithParam { int override_server_connection_id_length_; int override_client_connection_id_length_ = -1; uint8_t expected_server_connection_id_length_; - bool enable_web_transport_ = false; + WebTransportHttp3VersionSet wt_versions_; + uint64_t client_wt_max_streams_bidi_ = 0; + uint64_t client_wt_max_streams_uni_ = 0; + uint64_t client_wt_max_data_ = 0; bool enable_mlkem_in_client_ = false; std::vector received_webtransport_unidirectional_streams_; bool use_preferred_address_ = false; @@ -7529,7 +7546,8 @@ TEST_P(EndToEndTest, BlockServerUntilSettingsReceived) { } TEST_P(EndToEndTest, WebTransportSessionSetup) { - enable_web_transport_ = true; + wt_versions_ = WebTransportHttp3VersionSet( + {WebTransportHttp3Version::kDraft02, WebTransportHttp3Version::kDraft07}); ASSERT_TRUE(Initialize()); if (!version_.IsIetfQuic()) { @@ -7548,7 +7566,8 @@ TEST_P(EndToEndTest, WebTransportSessionSetup) { } TEST_P(EndToEndTest, WebTransportSessionProtocolNegotiation) { - enable_web_transport_ = true; + wt_versions_ = WebTransportHttp3VersionSet( + {WebTransportHttp3Version::kDraft02, WebTransportHttp3Version::kDraft07}); ASSERT_TRUE(Initialize()); if (!version_.IsIetfQuic()) { @@ -7582,7 +7601,8 @@ TEST_P(EndToEndTest, WebTransportSessionProtocolNegotiation) { } TEST_P(EndToEndTest, WebTransportSessionSetupWithEchoWithSuffix) { - enable_web_transport_ = true; + wt_versions_ = WebTransportHttp3VersionSet( + {WebTransportHttp3Version::kDraft02, WebTransportHttp3Version::kDraft07}); ASSERT_TRUE(Initialize()); if (!version_.IsIetfQuic()) { @@ -7606,7 +7626,8 @@ TEST_P(EndToEndTest, WebTransportSessionSetupWithEchoWithSuffix) { } TEST_P(EndToEndTest, WebTransportSessionWithLoss) { - enable_web_transport_ = true; + wt_versions_ = WebTransportHttp3VersionSet( + {WebTransportHttp3Version::kDraft02, WebTransportHttp3Version::kDraft07}); // Enable loss to verify all permutations of receiving SETTINGS and // request/response data. SetPacketLossPercentage(30); @@ -7628,7 +7649,8 @@ TEST_P(EndToEndTest, WebTransportSessionWithLoss) { } TEST_P(EndToEndTest, WebTransportSessionUnidirectionalStream) { - enable_web_transport_ = true; + wt_versions_ = WebTransportHttp3VersionSet( + {WebTransportHttp3Version::kDraft02, WebTransportHttp3Version::kDraft07}); ASSERT_TRUE(Initialize()); if (!version_.IsIetfQuic()) { @@ -7678,7 +7700,8 @@ TEST_P(EndToEndTest, WebTransportSessionUnidirectionalStream) { } TEST_P(EndToEndTest, WebTransportSessionUnidirectionalStreamSentEarly) { - enable_web_transport_ = true; + wt_versions_ = WebTransportHttp3VersionSet( + {WebTransportHttp3Version::kDraft02, WebTransportHttp3Version::kDraft07}); SetPacketLossPercentage(30); ASSERT_TRUE(Initialize()); @@ -7713,7 +7736,8 @@ TEST_P(EndToEndTest, WebTransportSessionUnidirectionalStreamSentEarly) { } TEST_P(EndToEndTest, WebTransportSessionBidirectionalStream) { - enable_web_transport_ = true; + wt_versions_ = WebTransportHttp3VersionSet( + {WebTransportHttp3Version::kDraft02, WebTransportHttp3Version::kDraft07}); ASSERT_TRUE(Initialize()); if (!version_.IsIetfQuic()) { @@ -7749,7 +7773,8 @@ TEST_P(EndToEndTest, WebTransportSessionBidirectionalStream) { } TEST_P(EndToEndTest, WebTransportSessionBidirectionalStreamWithBuffering) { - enable_web_transport_ = true; + wt_versions_ = WebTransportHttp3VersionSet( + {WebTransportHttp3Version::kDraft02, WebTransportHttp3Version::kDraft07}); SetPacketLossPercentage(30); ASSERT_TRUE(Initialize()); @@ -7771,7 +7796,8 @@ TEST_P(EndToEndTest, WebTransportSessionBidirectionalStreamWithBuffering) { } TEST_P(EndToEndTest, WebTransportSessionServerBidirectionalStream) { - enable_web_transport_ = true; + wt_versions_ = WebTransportHttp3VersionSet( + {WebTransportHttp3Version::kDraft02, WebTransportHttp3Version::kDraft07}); ASSERT_TRUE(Initialize()); if (!version_.IsIetfQuic()) { @@ -7807,7 +7833,8 @@ TEST_P(EndToEndTest, WebTransportSessionServerBidirectionalStream) { } TEST_P(EndToEndTest, WebTransportDatagrams) { - enable_web_transport_ = true; + wt_versions_ = WebTransportHttp3VersionSet( + {WebTransportHttp3Version::kDraft02, WebTransportHttp3Version::kDraft07}); ASSERT_TRUE(Initialize()); if (!version_.IsIetfQuic()) { @@ -7834,7 +7861,8 @@ TEST_P(EndToEndTest, WebTransportDatagrams) { } TEST_P(EndToEndTest, WebTransportSessionClose) { - enable_web_transport_ = true; + wt_versions_ = WebTransportHttp3VersionSet( + {WebTransportHttp3Version::kDraft02, WebTransportHttp3Version::kDraft07}); ASSERT_TRUE(Initialize()); if (!version_.IsIetfQuic()) { @@ -7866,7 +7894,8 @@ TEST_P(EndToEndTest, WebTransportSessionClose) { } TEST_P(EndToEndTest, WebTransportSessionCloseWithoutCapsule) { - enable_web_transport_ = true; + wt_versions_ = WebTransportHttp3VersionSet( + {WebTransportHttp3Version::kDraft02, WebTransportHttp3Version::kDraft07}); ASSERT_TRUE(Initialize()); if (!version_.IsIetfQuic()) { @@ -7898,7 +7927,8 @@ TEST_P(EndToEndTest, WebTransportSessionCloseWithoutCapsule) { } TEST_P(EndToEndTest, WebTransportSessionReceiveClose) { - enable_web_transport_ = true; + wt_versions_ = WebTransportHttp3VersionSet( + {WebTransportHttp3Version::kDraft02, WebTransportHttp3Version::kDraft07}); ASSERT_TRUE(Initialize()); if (!version_.IsIetfQuic()) { @@ -7933,7 +7963,8 @@ TEST_P(EndToEndTest, WebTransportSessionReceiveClose) { } TEST_P(EndToEndTest, WebTransportSessionReceiveDrain) { - enable_web_transport_ = true; + wt_versions_ = WebTransportHttp3VersionSet( + {WebTransportHttp3Version::kDraft02, WebTransportHttp3Version::kDraft07}); ASSERT_TRUE(Initialize()); if (!version_.IsIetfQuic()) { @@ -7956,7 +7987,8 @@ TEST_P(EndToEndTest, WebTransportSessionReceiveDrain) { } TEST_P(EndToEndTest, WebTransportSessionStreamTermination) { - enable_web_transport_ = true; + wt_versions_ = WebTransportHttp3VersionSet( + {WebTransportHttp3Version::kDraft02, WebTransportHttp3Version::kDraft07}); ASSERT_TRUE(Initialize()); if (!version_.IsIetfQuic()) { @@ -8016,7 +8048,8 @@ TEST_P(EndToEndTest, WebTransportSessionStreamTermination) { // https://datatracker.ietf.org/doc/draft-seemann-quic-reliable-stream-reset/ in // order to make this work. TEST_P(EndToEndTest, DISABLED_WebTransportSessionResetReliability) { - enable_web_transport_ = true; + wt_versions_ = WebTransportHttp3VersionSet( + {WebTransportHttp3Version::kDraft02, WebTransportHttp3Version::kDraft07}); ASSERT_TRUE(Initialize()); if (!version_.IsIetfQuic()) { @@ -8056,7 +8089,8 @@ TEST_P(EndToEndTest, DISABLED_WebTransportSessionResetReliability) { } TEST_P(EndToEndTest, WebTransportSession404) { - enable_web_transport_ = true; + wt_versions_ = WebTransportHttp3VersionSet( + {WebTransportHttp3Version::kDraft02, WebTransportHttp3Version::kDraft07}); ASSERT_TRUE(Initialize()); if (!version_.IsIetfQuic()) { @@ -8080,7 +8114,8 @@ TEST_P(EndToEndTest, WebTransportSession404) { })); } TEST_P(EndToEndTest, WebTransportSessionGoaway) { - enable_web_transport_ = true; + wt_versions_ = WebTransportHttp3VersionSet( + {WebTransportHttp3Version::kDraft02, WebTransportHttp3Version::kDraft07}); ASSERT_TRUE(Initialize()); if (!version_.IsIetfQuic()) { @@ -8141,6 +8176,215 @@ TEST_P(EndToEndTest, WebTransportSessionGoaway) { #endif } +TEST_P(EndToEndTest, WebTransportDraft15SessionEstablishment) { + // Verify draft-15 is negotiated when both sides support it. + wt_versions_ = WebTransportHttp3VersionSet( + {WebTransportHttp3Version::kDraft15}); + ASSERT_TRUE(Initialize()); + if (!version_.IsIetfQuic()) return; + + WebTransportHttp3* session = + CreateWebTransportSession("/echo", /*wait_for_server_response=*/true); + ASSERT_NE(session, nullptr); + + // The negotiated version must be draft-15. + EXPECT_EQ(GetClientSession()->SupportedWebTransportVersion(), + WebTransportHttp3Version::kDraft15); +} + +TEST_P(EndToEndTest, WebTransportDraft15SessionLimiting) { + // Section 5.1: Without FC, at most one session is allowed. + wt_versions_ = WebTransportHttp3VersionSet( + {WebTransportHttp3Version::kDraft15}); + ASSERT_TRUE(Initialize()); + if (!version_.IsIetfQuic()) return; + + WebTransportHttp3* session1 = + CreateWebTransportSession("/echo", /*wait_for_server_response=*/true); + ASSERT_NE(session1, nullptr); + + // Second session should fail — no FC means 1 session limit. + WebTransportHttp3* session2 = + CreateWebTransportSession("/echo", /*wait_for_server_response=*/false); + EXPECT_EQ(session2, nullptr) + << "Section 5.1: Without FC, client must not establish more than " + "one simultaneous WebTransport session"; +} + +TEST_P(EndToEndTest, WebTransportDraft15NoDatagramsAfterClose) { + // Section 6 MUST NOT: After session termination, no new datagrams + // may be sent. + wt_versions_ = WebTransportHttp3VersionSet( + {WebTransportHttp3Version::kDraft15}); + ASSERT_TRUE(Initialize()); + if (!version_.IsIetfQuic()) return; + + WebTransportHttp3* session = + CreateWebTransportSession("/echo", /*wait_for_server_response=*/true); + ASSERT_NE(session, nullptr); + + session->CloseSession(0, "done"); + + auto status = session->SendOrQueueDatagram("post-close datagram"); + EXPECT_NE(status.code, webtransport::DatagramStatusCode::kSuccess) + << "SendOrQueueDatagram must fail after session termination"; +} + +TEST_P(EndToEndTest, WebTransportDraft15FlowControlLimits) { + // Section 5.1 + 5.3: FC is enabled when both endpoints send at least + // one non-zero FC SETTING. With FC enabled, the server's default + // initial_max_streams_bidi=0 blocks the client from opening bidi + // streams (Section 5.5.2: default 0 means "must wait for capsule"). + wt_versions_ = WebTransportHttp3VersionSet( + {WebTransportHttp3Version::kDraft15}); + // Both sides declare FC intent with non-zero limits. + client_wt_max_streams_bidi_ = 10; + client_wt_max_streams_uni_ = 10; + client_wt_max_data_ = 65536; + // Server enables FC but keeps bidi=0 (the default). + memory_cache_backend_.set_wt_initial_max_streams_uni(10); + memory_cache_backend_.set_wt_initial_max_data(65536); + ASSERT_TRUE(Initialize()); + if (!version_.IsIetfQuic()) return; + + WebTransportHttp3* session = + CreateWebTransportSession("/echo", /*wait_for_server_response=*/true); + ASSERT_NE(session, nullptr); + + // Server's initial_max_streams_bidi defaults to 0 — no bidi streams + // allowed until the server sends non-zero limits or WT_MAX_STREAMS. + EXPECT_FALSE(session->CanOpenNextOutgoingBidirectionalStream()) + << "Section 5.3: With server's initial_max_streams_bidi=0, client " + "must not be able to open bidirectional streams"; + + webtransport::Stream* blocked = session->OpenOutgoingBidirectionalStream(); + EXPECT_EQ(blocked, nullptr) + << "Section 5.3: OpenOutgoingBidirectionalStream must return nullptr " + "when stream limit is 0"; +} + +TEST_P(EndToEndTest, WebTransportDraft15DataFlowControl) { + // Section 5.1 + 5.3: FC is enabled when both endpoints send at least + // one non-zero FC SETTING. With FC enabled, the server's default + // initial_max_streams_uni=0 blocks the client from opening uni + // streams (Section 5.5.1: default 0 means "must wait for capsule"). + wt_versions_ = WebTransportHttp3VersionSet( + {WebTransportHttp3Version::kDraft15}); + // Both sides declare FC intent with non-zero limits. + client_wt_max_streams_bidi_ = 10; + client_wt_max_streams_uni_ = 10; + client_wt_max_data_ = 65536; + // Server enables FC but keeps uni=0 (the default). + memory_cache_backend_.set_wt_initial_max_streams_bidi(10); + memory_cache_backend_.set_wt_initial_max_data(65536); + ASSERT_TRUE(Initialize()); + if (!version_.IsIetfQuic()) return; + + WebTransportHttp3* session = + CreateWebTransportSession("/echo", /*wait_for_server_response=*/true); + ASSERT_NE(session, nullptr); + + // Server's initial_max_data defaults to 0. Even if we could open a + // stream, we can't send data. + EXPECT_FALSE(session->CanOpenNextOutgoingUnidirectionalStream()) + << "Section 5.4: With server's initial_max_streams_uni=0, client " + "must not be able to open unidirectional streams"; +} + +TEST_P(EndToEndTest, WebTransportDraft15ResetStreamAt) { + // Section 4.4: Resetting a WT data stream must use RESET_STREAM_AT with + // reliable_offset >= stream header size, ensuring the peer can associate + // the stream with the correct session even after reset. + wt_versions_ = WebTransportHttp3VersionSet( + {WebTransportHttp3Version::kDraft15}); + client_wt_max_streams_bidi_ = 10; + client_wt_max_streams_uni_ = 10; + client_wt_max_data_ = 65536; + memory_cache_backend_.set_wt_initial_max_streams_bidi(10); + memory_cache_backend_.set_wt_initial_max_streams_uni(10); + memory_cache_backend_.set_wt_initial_max_data(65536); + + bool saw_reset_stream_at = false; + QuicStreamOffset observed_reliable_offset = 0; + NiceMock visitor; + connection_debug_visitor_ = &visitor; + ON_CALL(visitor, OnPacketSent(_, _, _, _, _, _, _, _, _)) + .WillByDefault( + [&](QuicPacketNumber, QuicPacketLength, bool, TransmissionType, + EncryptionLevel, const QuicFrames& retransmittable_frames, + const QuicFrames& /*nonretransmittable_frames*/, QuicTime, + uint32_t) { + for (const auto& frame : retransmittable_frames) { + if (frame.type == RESET_STREAM_AT_FRAME) { + saw_reset_stream_at = true; + observed_reliable_offset = + frame.reset_stream_at_frame->reliable_offset; + } + } + }); + + ASSERT_TRUE(Initialize()); + if (!version_.IsIetfQuic()) return; + + WebTransportHttp3* session = + CreateWebTransportSession("/echo", /*wait_for_server_response=*/true); + ASSERT_NE(session, nullptr); + + webtransport::Stream* stream = session->OpenOutgoingBidirectionalStream(); + ASSERT_NE(stream, nullptr); + + // Write some data so the stream header is sent. + EXPECT_TRUE(stream->Write("hello")); + + // Reset the stream — Section 4.4 requires RESET_STREAM_AT. + stream->ResetWithUserCode(0); + client_->WaitForWriteToFlush(); + + EXPECT_TRUE(saw_reset_stream_at) + << "Section 4.4: Draft-15 stream reset must use RESET_STREAM_AT, " + "not plain RST_STREAM"; + EXPECT_GT(observed_reliable_offset, 0u) + << "Section 4.4: reliable_offset must be >= stream header size so " + "the peer can associate the stream with its session"; +} + +TEST_P(EndToEndTest, WebTransportKeyingMaterial) { + // Section 4.8: GetKeyingMaterial with real BoringSSL TLS exporter. + wt_versions_ = WebTransportHttp3VersionSet( + {WebTransportHttp3Version::kDraft15}); + ASSERT_TRUE(Initialize()); + if (!version_.IsIetfQuic()) return; + + WebTransportHttp3* session = + CreateWebTransportSession("/echo", /*wait_for_server_response=*/true); + ASSERT_NE(session, nullptr); + + // Section 4.8 SHALL: export keying material of the requested length. + auto result = session->GetKeyingMaterial("test-label", "test-context", 32); + ASSERT_TRUE(result.ok()) << result.status(); + EXPECT_EQ(result->size(), 32u); + + // Deterministic: same inputs produce the same output. + auto result2 = session->GetKeyingMaterial("test-label", "test-context", 32); + ASSERT_TRUE(result2.ok()) << result2.status(); + EXPECT_EQ(*result, *result2); + + // Different label produces different output. + auto result3 = session->GetKeyingMaterial("other-label", "test-context", 32); + ASSERT_TRUE(result3.ok()) << result3.status(); + EXPECT_NE(*result, *result3); + + // Different context produces different output. + auto result4 = session->GetKeyingMaterial("test-label", "other-context", 32); + ASSERT_TRUE(result4.ok()) << result4.status(); + EXPECT_NE(*result, *result4); + + // Empty context works and produces different output from non-empty. + auto result5 = session->GetKeyingMaterial("test-label", "", 32); + ASSERT_TRUE(result5.ok()) << result5.status(); + EXPECT_NE(*result, *result5); +} + TEST_P(EndToEndTest, InvalidExtendedConnect) { SetQuicReloadableFlag(quic_act_upon_invalid_header, true); ASSERT_TRUE(Initialize()); diff --git a/quiche/quic/core/http/http_constants.cc b/quiche/quic/core/http/http_constants.cc index 7dbb58558..9f2186418 100644 --- a/quiche/quic/core/http/http_constants.cc +++ b/quiche/quic/core/http/http_constants.cc @@ -23,6 +23,10 @@ std::string H3SettingsToString(Http3AndQpackSettingsIdentifiers identifier) { RETURN_STRING_LITERAL(SETTINGS_H3_DATAGRAM); RETURN_STRING_LITERAL(SETTINGS_WEBTRANS_DRAFT00); RETURN_STRING_LITERAL(SETTINGS_WEBTRANS_MAX_SESSIONS_DRAFT07); + RETURN_STRING_LITERAL(SETTINGS_WT_ENABLED); + RETURN_STRING_LITERAL(SETTINGS_WT_INITIAL_MAX_STREAMS_UNI); + RETURN_STRING_LITERAL(SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI); + RETURN_STRING_LITERAL(SETTINGS_WT_INITIAL_MAX_DATA); RETURN_STRING_LITERAL(SETTINGS_ENABLE_CONNECT_PROTOCOL); RETURN_STRING_LITERAL(SETTINGS_ENABLE_METADATA); } diff --git a/quiche/quic/core/http/http_constants.h b/quiche/quic/core/http/http_constants.h index 210fb4d02..f6abf0465 100644 --- a/quiche/quic/core/http/http_constants.h +++ b/quiche/quic/core/http/http_constants.h @@ -45,6 +45,11 @@ enum Http3AndQpackSettingsIdentifiers : uint64_t { // draft-ietf-webtrans-http3 SETTINGS_WEBTRANS_DRAFT00 = 0x2b603742, SETTINGS_WEBTRANS_MAX_SESSIONS_DRAFT07 = 0xc671706a, + // draft-ietf-webtrans-http3-15 + SETTINGS_WT_ENABLED = 0x2c7cf000, + SETTINGS_WT_INITIAL_MAX_STREAMS_UNI = 0x2b64, + SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI = 0x2b65, + SETTINGS_WT_INITIAL_MAX_DATA = 0x2b61, // draft-ietf-httpbis-h3-websockets SETTINGS_ENABLE_CONNECT_PROTOCOL = 0x08, SETTINGS_ENABLE_METADATA = 0x4d44, @@ -73,6 +78,26 @@ enum : uint64_t { kDefaultMaximumBlockedStreams = 100 }; ABSL_CONST_INIT QUICHE_EXPORT extern const absl::string_view kUserAgentHeaderName; +// Draft-15 WebTransport HTTP/3 error codes (Section 9.5 of +// draft-ietf-webtrans-http3-15). + +// Session error codes — used in WT_CLOSE_SESSION capsule Application Error +// Code field (32-bit). +inline constexpr WebTransportSessionError kWtFlowControlError = + static_cast(0x045d4487); +inline constexpr WebTransportSessionError kWtAlpnError = + static_cast(0x0817b3dd); + +// Stream error codes — used in RST_STREAM / STOP_SENDING frames. +inline constexpr uint64_t kWtSessionGone = 0x170d7b68; +inline constexpr uint64_t kWtBufferedStreamRejected = 0x3994bd84; + +// Connection error code — used in CONNECTION_CLOSE. +inline constexpr uint64_t kWtRequirementsNotMet = 0x212c0d48; + +// H3_DATAGRAM_ERROR (RFC 9297 §9.1, referenced by draft-15 Section 5.6.2). +inline constexpr uint64_t kH3DatagramError = 0x33; + } // namespace quic #endif // QUICHE_QUIC_CORE_HTTP_HTTP_CONSTANTS_H_ diff --git a/quiche/quic/core/http/quic_spdy_session.cc b/quiche/quic/core/http/quic_spdy_session.cc index 23395ff26..7d035bce4 100644 --- a/quiche/quic/core/http/quic_spdy_session.cc +++ b/quiche/quic/core/http/quic_spdy_session.cc @@ -60,6 +60,7 @@ using spdy::SpdyStreamId; namespace quic { ABSL_CONST_INIT const size_t kMaxUnassociatedWebTransportStreams = 24; +ABSL_CONST_INIT const size_t kMaxBufferedWebTransportDatagrams = 32; namespace { @@ -652,6 +653,25 @@ void QuicSpdySession::FillSettingsFrame() { settings_.values[SETTINGS_WEBTRANS_MAX_SESSIONS_DRAFT07] = kDefaultMaxWebTransportSessions; } + if (versions.IsSet(WebTransportHttp3Version::kDraft15)) { + QUICHE_BUG_IF( + WT_draft15_enabled_extended_connect_disabled, + perspective() == Perspective::IS_SERVER && !allow_extended_connect()) + << "WebTransport draft-15 enabled, but extended CONNECT is not"; + settings_.values[SETTINGS_WT_ENABLED] = 1; + if (local_wt_initial_max_streams_bidi_ > 0) { + settings_.values[SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI] = + local_wt_initial_max_streams_bidi_; + } + if (local_wt_initial_max_streams_uni_ > 0) { + settings_.values[SETTINGS_WT_INITIAL_MAX_STREAMS_UNI] = + local_wt_initial_max_streams_uni_; + } + if (local_wt_initial_max_data_ > 0) { + settings_.values[SETTINGS_WT_INITIAL_MAX_DATA] = + local_wt_initial_max_data_; + } + } } if (allow_extended_connect()) { settings_.values[SETTINGS_ENABLE_CONNECT_PROTOCOL] = 1; @@ -1155,29 +1175,53 @@ bool QuicSpdySession::ValidateWebTransportSettingsConsistency() { return true; } + const bool is_draft15 = *version == WebTransportHttp3Version::kDraft15; + auto close_for_missing_setting = [&](const std::string& details) { + if (is_draft15) { + connection()->CloseConnection( + QUIC_HTTP_INVALID_SETTING_VALUE, + static_cast(kWtRequirementsNotMet), + details, ConnectionCloseBehavior::SEND_CONNECTION_CLOSE_PACKET); + } else { + CloseConnectionWithDetails(QUIC_HTTP_INVALID_SETTING_VALUE, details); + } + }; + if (!allow_extended_connect_) { - CloseConnectionWithDetails( - QUIC_HTTP_INVALID_SETTING_VALUE, - "Negotiated use of WebTransport over HTTP/3 (draft-07 or later), but " - "failed to negotiate extended CONNECT"); + close_for_missing_setting( + "Negotiated use of WebTransport over HTTP/3, but failed to negotiate " + "extended CONNECT"); return false; } if (http_datagram_support_ == HttpDatagramSupport::kDraft04) { - CloseConnectionWithDetails( - QUIC_HTTP_INVALID_SETTING_VALUE, - "WebTransport over HTTP/3 version draft-07 and beyond requires the " - "RFC version of HTTP datagrams"); + close_for_missing_setting( + "WebTransport over HTTP/3 requires the RFC version of HTTP datagrams"); return false; } if (http_datagram_support_ != HttpDatagramSupport::kRfc) { - CloseConnectionWithDetails( - QUIC_HTTP_INVALID_SETTING_VALUE, + close_for_missing_setting( "WebTransport over HTTP/3 requires HTTP datagrams support"); return false; } + if (is_draft15 && !connection()->reliable_stream_reset_enabled()) { + close_for_missing_setting( + "WebTransport over HTTP/3 (draft-15) requires the reset_stream_at " + "transport parameter"); + return false; + } + + if (is_draft15 && + (!GetSavedConfig().HasReceivedMaxDatagramFrameSize() || + GetSavedConfig().ReceivedMaxDatagramFrameSize() == 0)) { + close_for_missing_setting( + "WebTransport over HTTP/3 (draft-15) requires " + "max_datagram_frame_size > 0"); + return false; + } + return true; } @@ -1385,6 +1429,101 @@ bool QuicSpdySession::OnSetting(uint64_t id, uint64_t value) { } } break; + case SETTINGS_WT_ENABLED: + if (!WillNegotiateWebTransport()) { + break; + } + QUIC_DVLOG(1) << ENDPOINT + << "SETTINGS_WT_ENABLED received with value " << value; + if (!VerifySettingIsZeroOrOne(id, value)) { + return false; + } + if (value == 1) { + peer_web_transport_versions_.Set(WebTransportHttp3Version::kDraft15); + } else if (peer_web_transport_versions_.IsSet( + WebTransportHttp3Version::kDraft15)) { + // Section 3.2: Server must not disable WT after 0-RTT acceptance. + CloseConnectionWithDetails( + was_zero_rtt_rejected() + ? QUIC_HTTP_ZERO_RTT_REJECTION_SETTINGS_MISMATCH + : QUIC_HTTP_ZERO_RTT_RESUMPTION_SETTINGS_MISMATCH, + "Server sent SETTINGS_WT_ENABLED: 0 which reduces previously " + "negotiated value: 1"); + return false; + } + break; + case SETTINGS_WT_INITIAL_MAX_STREAMS_UNI: + QUIC_DVLOG(1) << ENDPOINT + << "SETTINGS_WT_INITIAL_MAX_STREAMS_UNI received with " + "value " + << value; + // Section 5.6.2: Maximum Streams cannot exceed 2^60. + if (value > (1ULL << 60)) { + CloseConnectionWithDetails( + QUIC_HTTP_INVALID_SETTING_VALUE, + "SETTINGS_WT_INITIAL_MAX_STREAMS_UNI exceeds 2^60"); + return false; + } + // Section 3.2: 0-RTT limits must not decrease. + if (peer_wt_initial_max_streams_uni_ != 0 && + peer_wt_initial_max_streams_uni_ > value) { + CloseConnectionWithDetails( + was_zero_rtt_rejected() + ? QUIC_HTTP_ZERO_RTT_REJECTION_SETTINGS_MISMATCH + : QUIC_HTTP_ZERO_RTT_RESUMPTION_SETTINGS_MISMATCH, + absl::StrCat( + "Server sent SETTINGS_WT_INITIAL_MAX_STREAMS_UNI: ", value, + " which reduces current value: ", + peer_wt_initial_max_streams_uni_)); + return false; + } + peer_wt_initial_max_streams_uni_ = value; + break; + case SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI: + QUIC_DVLOG(1) << ENDPOINT + << "SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI received with " + "value " + << value; + // Section 5.6.2: Maximum Streams cannot exceed 2^60. + if (value > (1ULL << 60)) { + CloseConnectionWithDetails( + QUIC_HTTP_INVALID_SETTING_VALUE, + "SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI exceeds 2^60"); + return false; + } + // Section 3.2: 0-RTT limits must not decrease. + if (peer_wt_initial_max_streams_bidi_ != 0 && + peer_wt_initial_max_streams_bidi_ > value) { + CloseConnectionWithDetails( + was_zero_rtt_rejected() + ? QUIC_HTTP_ZERO_RTT_REJECTION_SETTINGS_MISMATCH + : QUIC_HTTP_ZERO_RTT_RESUMPTION_SETTINGS_MISMATCH, + absl::StrCat( + "Server sent SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI: ", value, + " which reduces current value: ", + peer_wt_initial_max_streams_bidi_)); + return false; + } + peer_wt_initial_max_streams_bidi_ = value; + break; + case SETTINGS_WT_INITIAL_MAX_DATA: + QUIC_DVLOG(1) << ENDPOINT + << "SETTINGS_WT_INITIAL_MAX_DATA received with value " + << value; + // Section 3.2: 0-RTT limits must not decrease. + if (peer_wt_initial_max_data_ != 0 && + peer_wt_initial_max_data_ > value) { + CloseConnectionWithDetails( + was_zero_rtt_rejected() + ? QUIC_HTTP_ZERO_RTT_REJECTION_SETTINGS_MISMATCH + : QUIC_HTTP_ZERO_RTT_RESUMPTION_SETTINGS_MISMATCH, + absl::StrCat("Server sent SETTINGS_WT_INITIAL_MAX_DATA: ", value, + " which reduces current value: ", + peer_wt_initial_max_data_)); + return false; + } + peer_wt_initial_max_data_ = value; + break; default: QUIC_DVLOG(1) << ENDPOINT << "Unknown setting identifier " << id << " received with value " << value; @@ -1857,8 +1996,26 @@ void QuicSpdySession::OnDatagramReceived(absl::string_view datagram) { } stream_id64 *= kHttpDatagramStreamIdDivisor; QuicStreamId stream_id = static_cast(stream_id64); + QuicSpdyStream* stream = absl::down_cast(GetActiveStream(stream_id)); + + // Draft-15 Section 4.6: when enabled, buffer datagrams that arrive before + // their stream is ready for dispatch (stream missing, headers not received, + // or WebTransport session not yet created). + if (buffer_web_transport_datagrams_) { + bool ready = stream != nullptr && stream->headers_decompressed() && + GetWebTransportSession(stream_id) != nullptr; + if (!ready) { + if (buffered_datagrams_.size() >= kMaxBufferedWebTransportDatagrams) { + buffered_datagrams_.pop_front(); + } + buffered_datagrams_.push_back( + {stream_id, std::string(reader.ReadRemainingPayload())}); + return; + } + } + if (stream == nullptr) { QUIC_DLOG(INFO) << "Received HTTP/3 datagram for unknown stream ID " << stream_id; @@ -1882,6 +2039,20 @@ QuicSpdySession::SupportedWebTransportVersion() { return NegotiatedWebTransportVersion(); } +bool QuicSpdySession::CanCreateNewWebTransportSession() { + if (!SupportsWebTransport()) { + return false; + } + // Section 5.1: Without FC, at most one session is allowed. + if (NegotiatedWebTransportVersion() == WebTransportHttp3Version::kDraft15 && + !wt_flow_control_enabled()) { + if (active_web_transport_sessions_ >= 1) { + return false; + } + } + return true; +} + bool QuicSpdySession::SupportsH3Datagram() const { return http_datagram_support_ != HttpDatagramSupport::kNone; } @@ -1927,6 +2098,16 @@ void QuicSpdySession::AssociateIncomingWebTransportStreamWithSession( << stream_id; return; } + if (!IsValidWebTransportSessionId(session_id, version())) { + QUIC_DLOG(ERROR) << ENDPOINT << "Received WebTransport stream " + << stream_id << " with invalid session ID " + << session_id; + CloseConnectionWithDetails( + QUIC_HTTP_INVALID_SESSION_ID, + absl::StrCat("WebTransport stream ", stream_id, + " references invalid session ID ", session_id)); + return; + } WebTransportHttp3* session = GetWebTransportSession(session_id); if (session != nullptr) { QUIC_DVLOG(1) << ENDPOINT @@ -1938,11 +2119,18 @@ void QuicSpdySession::AssociateIncomingWebTransportStreamWithSession( } // Evict the oldest streams until we are under the limit. while (buffered_streams_.size() >= kMaxUnassociatedWebTransportStreams) { - QUIC_DVLOG(1) << ENDPOINT << "Removing stream " - << buffered_streams_.front().stream_id + QuicStreamId evict_id = buffered_streams_.front().stream_id; + QUIC_DVLOG(1) << ENDPOINT << "Removing stream " << evict_id << " from buffered streams as the queue is full."; - ResetStream(buffered_streams_.front().stream_id, - QUIC_STREAM_WEBTRANSPORT_BUFFERED_STREAMS_LIMIT_EXCEEDED); + if (SupportedWebTransportVersion() == + WebTransportHttp3Version::kDraft15) { + ResetStream(evict_id, QuicResetStreamError( + QUIC_STREAM_WEBTRANSPORT_BUFFERED_STREAMS_LIMIT_EXCEEDED, + kWtBufferedStreamRejected)); + } else { + ResetStream(evict_id, + QUIC_STREAM_WEBTRANSPORT_BUFFERED_STREAMS_LIMIT_EXCEEDED); + } buffered_streams_.pop_front(); } QUIC_DVLOG(1) << ENDPOINT << "Received a WebTransport stream " << stream_id @@ -1970,6 +2158,20 @@ void QuicSpdySession::ProcessBufferedWebTransportStreamsForSession( } } +void QuicSpdySession::FlushBufferedDatagramsForSession( + WebTransportHttp3* session) { + const QuicStreamId session_id = session->id(); + auto it = buffered_datagrams_.begin(); + while (it != buffered_datagrams_.end()) { + if (it->stream_id == session_id) { + session->OnHttp3Datagram(session_id, it->payload); + it = buffered_datagrams_.erase(it); + } else { + it++; + } + } +} + WebTransportHttp3UnidirectionalStream* QuicSpdySession::CreateOutgoingUnidirectionalWebTransportStream( WebTransportHttp3* session) { diff --git a/quiche/quic/core/http/quic_spdy_session.h b/quiche/quic/core/http/quic_spdy_session.h index acee8415e..49dd56a22 100644 --- a/quiche/quic/core/http/quic_spdy_session.h +++ b/quiche/quic/core/http/quic_spdy_session.h @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -45,6 +46,7 @@ class QuicSpdySessionPeer; class WebTransportHttp3UnidirectionalStream; QUICHE_EXPORT extern const size_t kMaxUnassociatedWebTransportStreams; +QUICHE_EXPORT extern const size_t kMaxBufferedWebTransportDatagrams; class QUICHE_EXPORT Http3DebugVisitor { public: @@ -143,6 +145,8 @@ enum class WebTransportHttp3Version : uint8_t { // See the changelog in the appendix for differences between draft-02 and // draft-07. kDraft07, + // + kDraft15, }; using WebTransportHttp3VersionSet = BitMask; @@ -151,7 +155,8 @@ using WebTransportHttp3VersionSet = BitMask; inline constexpr WebTransportHttp3VersionSet kDefaultSupportedWebTransportVersions = WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft02, - WebTransportHttp3Version::kDraft07}); + WebTransportHttp3Version::kDraft07, + WebTransportHttp3Version::kDraft15}); QUICHE_EXPORT std::string HttpDatagramSupportToString( HttpDatagramSupport http_datagram_support); @@ -415,6 +420,12 @@ class QUICHE_EXPORT QuicSpdySession // Override from QuicSession to support HTTP/3 datagrams. void OnDatagramReceived(absl::string_view datagram) override; + bool ExportKeyingMaterial(absl::string_view label, absl::string_view context, + size_t result_len, std::string* result) { + return GetMutableCryptoStream()->ExportKeyingMaterial(label, context, + result_len, result); + } + // Indicates whether the HTTP/3 session supports WebTransport. bool SupportsWebTransport(); @@ -422,6 +433,60 @@ class QUICHE_EXPORT QuicSpdySession // currently in use (which is the highest version supported by both peers). std::optional SupportedWebTransportVersion(); + // Section 5.1: Without session-level flow control, at most one session is + // allowed. + bool CanCreateNewWebTransportSession(); + void OnWebTransportSessionCreated() { ++active_web_transport_sessions_; } + void OnWebTransportSessionDestroyed() { + QUICHE_DCHECK_GT(active_web_transport_sessions_, 0u); + --active_web_transport_sessions_; + } + + // Must be called before Initialize() to take effect. + void set_wt_initial_max_streams_bidi(uint64_t value) { + local_wt_initial_max_streams_bidi_ = value; + } + void set_wt_initial_max_streams_uni(uint64_t value) { + local_wt_initial_max_streams_uni_ = value; + } + void set_wt_initial_max_data(uint64_t value) { + local_wt_initial_max_data_ = value; + } + + uint64_t local_wt_initial_max_streams_bidi() const { + return local_wt_initial_max_streams_bidi_; + } + uint64_t local_wt_initial_max_streams_uni() const { + return local_wt_initial_max_streams_uni_; + } + uint64_t local_wt_initial_max_data() const { + return local_wt_initial_max_data_; + } + + bool peer_wt_flow_control_enabled() const { + return peer_wt_initial_max_streams_bidi_ > 0 || + peer_wt_initial_max_streams_uni_ > 0 || + peer_wt_initial_max_data_ > 0; + } + bool local_wt_flow_control_enabled() const { + return local_wt_initial_max_streams_bidi_ > 0 || + local_wt_initial_max_streams_uni_ > 0 || + local_wt_initial_max_data_ > 0; + } + // Section 5.1: FC is enabled only when both sides advertise non-zero. + bool wt_flow_control_enabled() const { + return peer_wt_flow_control_enabled() && local_wt_flow_control_enabled(); + } + uint64_t peer_wt_initial_max_streams_bidi() const { + return peer_wt_initial_max_streams_bidi_; + } + uint64_t peer_wt_initial_max_streams_uni() const { + return peer_wt_initial_max_streams_uni_; + } + uint64_t peer_wt_initial_max_data() const { + return peer_wt_initial_max_data_; + } + // Indicates whether both the peer and us support HTTP/3 Datagrams. bool SupportsH3Datagram() const; @@ -459,6 +524,11 @@ class QUICHE_EXPORT QuicSpdySession WebTransportSessionId session_id, QuicStreamId stream_id); void ProcessBufferedWebTransportStreamsForSession(WebTransportHttp3* session); + void FlushBufferedDatagramsForSession(WebTransportHttp3* session); + + void set_buffer_web_transport_datagrams(bool v) { + buffer_web_transport_datagrams_ = v; + } bool CanOpenOutgoingUnidirectionalWebTransportStream( WebTransportSessionId /*id*/) { @@ -609,6 +679,11 @@ class QUICHE_EXPORT QuicSpdySession QuicStreamId stream_id; }; + struct QUICHE_EXPORT BufferedWebTransportDatagram { + QuicStreamId stream_id; + std::string payload; + }; + // The following methods are called by the SimpleVisitor. // Called when a HEADERS frame has been received. @@ -727,6 +802,11 @@ class QUICHE_EXPORT QuicSpdySession // oldest streams are evicated first. std::list buffered_streams_; + // WebTransport datagrams received before their session was established + // (draft-15 Section 4.6). Globally capped at kMaxBufferedWebTransportDatagrams. + bool buffer_web_transport_datagrams_ = false; + std::deque buffered_datagrams_; + spdy::SpdyFramer spdy_framer_; // Priority values received in PRIORITY_UPDATE frames for streams that are not @@ -746,6 +826,14 @@ class QUICHE_EXPORT QuicSpdySession // server cannot initiate WebTransport sessions. absl::flat_hash_map max_webtransport_sessions_; + + size_t active_web_transport_sessions_ = 0; + uint64_t peer_wt_initial_max_streams_bidi_ = 0; + uint64_t peer_wt_initial_max_streams_uni_ = 0; + uint64_t peer_wt_initial_max_data_ = 0; + uint64_t local_wt_initial_max_streams_bidi_ = 0; + uint64_t local_wt_initial_max_streams_uni_ = 0; + uint64_t local_wt_initial_max_data_ = 0; }; } // namespace quic diff --git a/quiche/quic/core/http/quic_spdy_session_test.cc b/quiche/quic/core/http/quic_spdy_session_test.cc index 6a39fe3e4..a33b2ea08 100644 --- a/quiche/quic/core/http/quic_spdy_session_test.cc +++ b/quiche/quic/core/http/quic_spdy_session_test.cc @@ -46,6 +46,7 @@ #include "quiche/quic/test_tools/quic_flow_controller_peer.h" #include "quiche/quic/test_tools/quic_session_peer.h" #include "quiche/quic/test_tools/quic_spdy_session_peer.h" +#include "quiche/quic/test_tools/quic_spdy_session_test_utils.h" #include "quiche/quic/test_tools/quic_stream_peer.h" #include "quiche/quic/test_tools/quic_test_utils.h" #include "quiche/common/quiche_endian.h" @@ -76,158 +77,6 @@ bool VerifyAndClearStopSendingFrame(const QuicFrame& frame) { return ClearControlFrame(frame); } -class TestCryptoStream : public QuicCryptoStream, public QuicCryptoHandshaker { - public: - explicit TestCryptoStream(QuicSession* session) - : QuicCryptoStream(session), - QuicCryptoHandshaker(this, session), - encryption_established_(false), - one_rtt_keys_available_(false), - params_(new QuicCryptoNegotiatedParameters) { - // Simulate a negotiated cipher_suite with a fake value. - params_->cipher_suite = 1; - } - - void EstablishZeroRttEncryption() { - encryption_established_ = true; - session()->connection()->SetEncrypter( - ENCRYPTION_ZERO_RTT, - std::make_unique(ENCRYPTION_ZERO_RTT)); - } - - void OnHandshakeMessage(const CryptoHandshakeMessage& /*message*/) override { - encryption_established_ = true; - one_rtt_keys_available_ = true; - QuicErrorCode error; - std::string error_details; - session()->config()->SetInitialStreamFlowControlWindowToSend( - kInitialStreamFlowControlWindowForTest); - session()->config()->SetInitialSessionFlowControlWindowToSend( - kInitialSessionFlowControlWindowForTest); - if (session()->version().IsIetfQuic()) { - if (session()->perspective() == Perspective::IS_CLIENT) { - session()->config()->SetOriginalConnectionIdToSend( - session()->connection()->connection_id()); - session()->config()->SetInitialSourceConnectionIdToSend( - session()->connection()->connection_id()); - } else { - session()->config()->SetInitialSourceConnectionIdToSend( - session()->connection()->client_connection_id()); - } - TransportParameters transport_parameters; - EXPECT_TRUE( - session()->config()->FillTransportParameters(&transport_parameters)); - error = session()->config()->ProcessTransportParameters( - transport_parameters, /* is_resumption = */ false, &error_details); - } else { - CryptoHandshakeMessage msg; - session()->config()->ToHandshakeMessage(&msg, transport_version()); - error = - session()->config()->ProcessPeerHello(msg, CLIENT, &error_details); - } - EXPECT_THAT(error, IsQuicNoError()); - session()->OnNewEncryptionKeyAvailable( - ENCRYPTION_FORWARD_SECURE, - std::make_unique(ENCRYPTION_FORWARD_SECURE)); - session()->OnConfigNegotiated(); - if (session()->connection()->version().IsIetfQuic()) { - session()->OnTlsHandshakeComplete(); - } else { - session()->SetDefaultEncryptionLevel(ENCRYPTION_FORWARD_SECURE); - } - session()->DiscardOldEncryptionKey(ENCRYPTION_INITIAL); - } - - // QuicCryptoStream implementation - ssl_early_data_reason_t EarlyDataReason() const override { - return ssl_early_data_unknown; - } - bool encryption_established() const override { - return encryption_established_; - } - bool one_rtt_keys_available() const override { - return one_rtt_keys_available_; - } - HandshakeState GetHandshakeState() const override { - return one_rtt_keys_available() ? HANDSHAKE_COMPLETE : HANDSHAKE_START; - } - void SetServerApplicationStateForResumption( - std::unique_ptr /*application_state*/) override {} - std::unique_ptr AdvanceKeysAndCreateCurrentOneRttDecrypter() - override { - return nullptr; - } - std::unique_ptr CreateCurrentOneRttEncrypter() override { - return nullptr; - } - const QuicCryptoNegotiatedParameters& crypto_negotiated_params() - const override { - return *params_; - } - CryptoMessageParser* crypto_message_parser() override { - return QuicCryptoHandshaker::crypto_message_parser(); - } - void OnPacketDecrypted(EncryptionLevel /*level*/) override {} - void OnOneRttPacketAcknowledged() override {} - void OnHandshakePacketSent() override {} - void OnHandshakeDoneReceived() override {} - void OnNewTokenReceived(absl::string_view /*token*/) override {} - std::string GetAddressToken( - const CachedNetworkParameters* /*cached_network_params*/) const override { - return ""; - } - bool ValidateAddressToken(absl::string_view /*token*/) const override { - return true; - } - const CachedNetworkParameters* PreviousCachedNetworkParams() const override { - return nullptr; - } - void SetPreviousCachedNetworkParams( - CachedNetworkParameters /*cached_network_params*/) override {} - - MOCK_METHOD(void, OnCanWrite, (), (override)); - - bool HasPendingCryptoRetransmission() const override { return false; } - - MOCK_METHOD(bool, HasPendingRetransmission, (), (const, override)); - - void OnConnectionClosed(const QuicConnectionCloseFrame& /*frame*/, - ConnectionCloseSource /*source*/) override {} - SSL* GetSsl() const override { return nullptr; } - bool IsCryptoFrameExpectedForEncryptionLevel( - EncryptionLevel level) const override { - return level != ENCRYPTION_ZERO_RTT; - } - EncryptionLevel GetEncryptionLevelToSendCryptoDataOfSpace( - PacketNumberSpace space) const override { - switch (space) { - case INITIAL_DATA: - return ENCRYPTION_INITIAL; - case HANDSHAKE_DATA: - return ENCRYPTION_HANDSHAKE; - case APPLICATION_DATA: - return ENCRYPTION_FORWARD_SECURE; - default: - QUICHE_DCHECK(false); - return NUM_ENCRYPTION_LEVELS; - } - } - - bool ExportKeyingMaterial(absl::string_view /*label*/, - absl::string_view /*context*/, - size_t /*result_len*/, std::string* - /*result*/) override { - return false; - } - - private: - using QuicCryptoStream::session; - - bool encryption_established_; - bool one_rtt_keys_available_; - quiche::QuicheReferenceCountedPointer params_; -}; - class TestHeadersStream : public QuicHeadersStream { public: explicit TestHeadersStream(QuicSpdySession* session) @@ -236,386 +85,6 @@ class TestHeadersStream : public QuicHeadersStream { MOCK_METHOD(void, OnCanWrite, (), (override)); }; -class TestStream : public QuicSpdyStream { - public: - TestStream(QuicStreamId id, QuicSpdySession* session, StreamType type) - : QuicSpdyStream(id, session, type) {} - - TestStream(PendingStream* pending, QuicSpdySession* session) - : QuicSpdyStream(pending, session) {} - - using QuicStream::CloseWriteSide; - - void OnBodyAvailable() override {} - - MOCK_METHOD(void, OnCanWrite, (), (override)); - MOCK_METHOD(bool, RetransmitStreamData, - (QuicStreamOffset, QuicByteCount, bool, TransmissionType), - (override)); - - MOCK_METHOD(bool, HasPendingRetransmission, (), (const, override)); - - protected: - bool ValidateReceivedHeaders(const QuicHeaderList& /*header_list*/) override { - return true; - } -}; - -class TestSession : public QuicSpdySession { - public: - explicit TestSession(QuicConnection* connection) - : QuicSpdySession(connection, nullptr, DefaultQuicConfig(), - CurrentSupportedVersions()), - crypto_stream_(this), - writev_consumes_all_data_(false) { - this->connection()->SetEncrypter( - ENCRYPTION_FORWARD_SECURE, - std::make_unique(ENCRYPTION_FORWARD_SECURE)); - if (this->connection()->version().IsIetfQuic()) { - QuicConnectionPeer::SetAddressValidated(this->connection()); - } - } - - ~TestSession() override { DeleteConnection(); } - - TestCryptoStream* GetMutableCryptoStream() override { - return &crypto_stream_; - } - - const TestCryptoStream* GetCryptoStream() const override { - return &crypto_stream_; - } - - TestStream* CreateOutgoingBidirectionalStream() override { - TestStream* stream = new TestStream(GetNextOutgoingBidirectionalStreamId(), - this, BIDIRECTIONAL); - ActivateStream(absl::WrapUnique(stream)); - return stream; - } - - TestStream* CreateIncomingStream(QuicStreamId id) override { - // Enforce the limit on the number of open streams. - if (!VersionIsIetfQuic(connection()->transport_version()) && - stream_id_manager().num_open_incoming_streams() + 1 > - max_open_incoming_bidirectional_streams()) { - connection()->CloseConnection( - QUIC_TOO_MANY_OPEN_STREAMS, "Too many streams!", - ConnectionCloseBehavior::SEND_CONNECTION_CLOSE_PACKET); - return nullptr; - } else { - TestStream* stream = new TestStream( - id, this, - DetermineStreamType(id, connection()->version(), perspective(), - /*is_incoming=*/true, BIDIRECTIONAL)); - ActivateStream(absl::WrapUnique(stream)); - return stream; - } - } - - TestStream* CreateIncomingStream(PendingStream* pending) override { - TestStream* stream = new TestStream(pending, this); - ActivateStream(absl::WrapUnique(stream)); - return stream; - } - - bool ShouldCreateIncomingStream(QuicStreamId /*id*/) override { return true; } - - bool ShouldCreateOutgoingBidirectionalStream() override { return true; } - - bool IsClosedStream(QuicStreamId id) { - return QuicSession::IsClosedStream(id); - } - - QuicStream* GetOrCreateStream(QuicStreamId stream_id) { - return QuicSpdySession::GetOrCreateStream(stream_id); - } - - QuicConsumedData WritevData(QuicStreamId id, size_t write_length, - QuicStreamOffset offset, StreamSendingState state, - TransmissionType type, - EncryptionLevel level) override { - bool fin = state != NO_FIN; - QuicConsumedData consumed(write_length, fin); - if (!writev_consumes_all_data_) { - consumed = - QuicSession::WritevData(id, write_length, offset, state, type, level); - } - QuicSessionPeer::GetWriteBlockedStreams(this)->UpdateBytesForStream( - id, consumed.bytes_consumed); - return consumed; - } - - void set_writev_consumes_all_data(bool val) { - writev_consumes_all_data_ = val; - } - - QuicConsumedData SendStreamData(QuicStream* stream) { - if (!QuicUtils::IsCryptoStreamId(connection()->transport_version(), - stream->id()) && - connection()->encryption_level() != ENCRYPTION_FORWARD_SECURE) { - this->connection()->SetDefaultEncryptionLevel(ENCRYPTION_FORWARD_SECURE); - } - QuicStreamPeer::SendBuffer(stream).SaveStreamData("not empty"); - QuicConsumedData consumed = - WritevData(stream->id(), 9, 0, FIN, NOT_RETRANSMISSION, - GetEncryptionLevelToSendApplicationData()); - QuicStreamPeer::SendBuffer(stream).OnStreamDataConsumed( - consumed.bytes_consumed); - return consumed; - } - - QuicConsumedData SendLargeFakeData(QuicStream* stream, int bytes) { - QUICHE_DCHECK(writev_consumes_all_data_); - return WritevData(stream->id(), bytes, 0, FIN, NOT_RETRANSMISSION, - GetEncryptionLevelToSendApplicationData()); - } - - WebTransportHttp3VersionSet LocallySupportedWebTransportVersions() - const override { - return locally_supported_web_transport_versions_; - } - void set_supports_webtransport(bool value) { - locally_supported_web_transport_versions_ = - value ? kDefaultSupportedWebTransportVersions - : WebTransportHttp3VersionSet(); - } - void set_locally_supported_web_transport_versions( - WebTransportHttp3VersionSet versions) { - locally_supported_web_transport_versions_ = std::move(versions); - } - - HttpDatagramSupport LocalHttpDatagramSupport() override { - return local_http_datagram_support_; - } - void set_local_http_datagram_support(HttpDatagramSupport value) { - local_http_datagram_support_ = value; - } - - MOCK_METHOD(void, OnAcceptChFrame, (const AcceptChFrame&), (override)); - - using QuicSession::closed_streams; - using QuicSession::pending_streams_size; - using QuicSession::ShouldKeepConnectionAlive; - using QuicSpdySession::settings; - using QuicSpdySession::UsesPendingStreamForFrame; - - private: - StrictMock crypto_stream_; - - bool writev_consumes_all_data_; - WebTransportHttp3VersionSet locally_supported_web_transport_versions_; - HttpDatagramSupport local_http_datagram_support_ = HttpDatagramSupport::kNone; -}; - -class QuicSpdySessionTestBase : public QuicTestWithParam { - public: - bool ClearMaxStreamsControlFrame(const QuicFrame& frame) { - if (frame.type == MAX_STREAMS_FRAME) { - DeleteFrame(&const_cast(frame)); - return true; - } - return false; - } - - protected: - explicit QuicSpdySessionTestBase(Perspective perspective, - bool allow_extended_connect) - : connection_(new StrictMock( - &helper_, &alarm_factory_, perspective, - SupportedVersions(GetParam()))), - allow_extended_connect_(allow_extended_connect) {} - - void Initialize() { - session_.emplace(connection_); - if (qpack_maximum_dynamic_table_capacity_.has_value()) { - session_->set_qpack_maximum_dynamic_table_capacity( - *qpack_maximum_dynamic_table_capacity_); - } - if (connection_->perspective() == Perspective::IS_SERVER && - VersionIsIetfQuic(transport_version())) { - session_->set_allow_extended_connect(allow_extended_connect_); - } - session_->Initialize(); - session_->config()->SetInitialStreamFlowControlWindowToSend( - kInitialStreamFlowControlWindowForTest); - session_->config()->SetInitialSessionFlowControlWindowToSend( - kInitialSessionFlowControlWindowForTest); - if (VersionIsIetfQuic(transport_version())) { - QuicConfigPeer::SetReceivedMaxUnidirectionalStreams( - session_->config(), kHttp3StaticUnidirectionalStreamCount); - } - QuicConfigPeer::SetReceivedInitialSessionFlowControlWindow( - session_->config(), kMinimumFlowControlSendWindow); - QuicConfigPeer::SetReceivedInitialMaxStreamDataBytesUnidirectional( - session_->config(), kMinimumFlowControlSendWindow); - QuicConfigPeer::SetReceivedInitialMaxStreamDataBytesIncomingBidirectional( - session_->config(), kMinimumFlowControlSendWindow); - QuicConfigPeer::SetReceivedInitialMaxStreamDataBytesOutgoingBidirectional( - session_->config(), kMinimumFlowControlSendWindow); - session_->OnConfigNegotiated(); - connection_->AdvanceTime(QuicTime::Delta::FromSeconds(1)); - TestCryptoStream* crypto_stream = session_->GetMutableCryptoStream(); - EXPECT_CALL(*crypto_stream, HasPendingRetransmission()) - .Times(testing::AnyNumber()); - writer_ = static_cast( - QuicConnectionPeer::GetWriter(session_->connection())); - } - - void CheckClosedStreams() { - QuicStreamId first_stream_id = QuicUtils::GetFirstBidirectionalStreamId( - transport_version(), Perspective::IS_CLIENT); - if (!VersionIsIetfQuic(transport_version())) { - first_stream_id = QuicUtils::GetCryptoStreamId(transport_version()); - } - for (QuicStreamId i = first_stream_id; i < 100; i++) { - if (closed_streams_.find(i) == closed_streams_.end()) { - EXPECT_FALSE(session_->IsClosedStream(i)) << " stream id: " << i; - } else { - EXPECT_TRUE(session_->IsClosedStream(i)) << " stream id: " << i; - } - } - } - - void CloseStream(QuicStreamId id) { - if (!VersionIsIetfQuic(transport_version())) { - EXPECT_CALL(*connection_, SendControlFrame(_)) - .WillOnce(&ClearControlFrame); - } else { - // IETF QUIC has two frames, RST_STREAM and STOP_SENDING - EXPECT_CALL(*connection_, SendControlFrame(_)) - .Times(2) - .WillRepeatedly(&ClearControlFrame); - } - EXPECT_CALL(*connection_, OnStreamReset(id, _)); - - // QPACK streams might write data upon stream reset. Let the test session - // handle the data. - session_->set_writev_consumes_all_data(true); - - session_->ResetStream(id, QUIC_STREAM_CANCELLED); - closed_streams_.insert(id); - } - - ParsedQuicVersion version() const { return connection_->version(); } - - QuicTransportVersion transport_version() const { - return connection_->transport_version(); - } - - QuicStreamId GetNthClientInitiatedBidirectionalId(int n) { - return GetNthClientInitiatedBidirectionalStreamId(transport_version(), n); - } - - QuicStreamId GetNthServerInitiatedBidirectionalId(int n) { - return GetNthServerInitiatedBidirectionalStreamId(transport_version(), n); - } - - QuicStreamId IdDelta() { - return QuicUtils::StreamIdDelta(transport_version()); - } - - QuicStreamId StreamCountToId(QuicStreamCount stream_count, - Perspective perspective, bool bidirectional) { - // Calculate and build up stream ID rather than use - // GetFirst... because the test that relies on this method - // needs to do the stream count where #1 is 0/1/2/3, and not - // take into account that stream 0 is special. - QuicStreamId id = - ((stream_count - 1) * QuicUtils::StreamIdDelta(transport_version())); - if (!bidirectional) { - id |= 0x2; - } - if (perspective == Perspective::IS_SERVER) { - id |= 0x1; - } - return id; - } - - void CompleteHandshake() { - if (VersionIsIetfQuic(transport_version())) { - EXPECT_CALL(*writer_, WritePacket(_, _, _, _, _, _)) - .WillOnce(Return(WriteResult(WRITE_STATUS_OK, 0))); - } - if (connection_->version().IsIetfQuic() && - connection_->perspective() == Perspective::IS_SERVER) { - // HANDSHAKE_DONE frame. - EXPECT_CALL(*connection_, SendControlFrame(_)) - .WillOnce(&ClearControlFrame); - } - - CryptoHandshakeMessage message; - session_->GetMutableCryptoStream()->OnHandshakeMessage(message); - testing::Mock::VerifyAndClearExpectations(writer_); - testing::Mock::VerifyAndClearExpectations(connection_); - } - - void ReceiveWebTransportSettings(WebTransportHttp3VersionSet versions = - kDefaultSupportedWebTransportVersions) { - SettingsFrame settings; - settings.values[SETTINGS_H3_DATAGRAM] = 1; - if (versions.IsSet(WebTransportHttp3Version::kDraft02)) { - settings.values[SETTINGS_WEBTRANS_DRAFT00] = 1; - } - if (versions.IsSet(WebTransportHttp3Version::kDraft07)) { - settings.values[SETTINGS_WEBTRANS_MAX_SESSIONS_DRAFT07] = 16; - } - settings.values[SETTINGS_ENABLE_CONNECT_PROTOCOL] = 1; - std::string data = std::string(1, kControlStream) + - HttpEncoder::SerializeSettingsFrame(settings); - QuicStreamId control_stream_id = - session_->perspective() == Perspective::IS_SERVER - ? GetNthClientInitiatedUnidirectionalStreamId(transport_version(), - 3) - : GetNthServerInitiatedUnidirectionalStreamId(transport_version(), - 3); - QuicStreamFrame frame(control_stream_id, /*fin=*/false, /*offset=*/0, data); - session_->OnStreamFrame(frame); - } - - void ReceiveWebTransportSession(WebTransportSessionId session_id) { - QuicStreamFrame frame(session_id, /*fin=*/false, /*offset=*/0, - absl::string_view()); - session_->OnStreamFrame(frame); - QuicSpdyStream* stream = - static_cast(session_->GetOrCreateStream(session_id)); - QuicHeaderList headers; - headers.OnHeader(":method", "CONNECT"); - headers.OnHeader(":protocol", "webtransport"); - stream->OnStreamHeaderList(/*fin=*/true, 0, headers); - WebTransportHttp3* web_transport = - session_->GetWebTransportSession(session_id); - ASSERT_TRUE(web_transport != nullptr); - quiche::HttpHeaderBlock header_block; - web_transport->HeadersReceived(header_block); - } - - void ReceiveWebTransportUnidirectionalStream(WebTransportSessionId session_id, - QuicStreamId stream_id) { - char buffer[256]; - QuicDataWriter data_writer(sizeof(buffer), buffer); - ASSERT_TRUE(data_writer.WriteVarInt62(kWebTransportUnidirectionalStream)); - ASSERT_TRUE(data_writer.WriteVarInt62(session_id)); - ASSERT_TRUE(data_writer.WriteStringPiece("test data")); - std::string data(buffer, data_writer.length()); - QuicStreamFrame frame(stream_id, /*fin=*/false, /*offset=*/0, data); - session_->OnStreamFrame(frame); - } - - void TestHttpDatagramSetting(HttpDatagramSupport local_support, - HttpDatagramSupport remote_support, - HttpDatagramSupport expected_support, - bool expected_datagram_supported); - - MockQuicConnectionHelper helper_; - MockAlarmFactory alarm_factory_; - StrictMock* connection_; - bool allow_extended_connect_; - std::optional session_; - std::set closed_streams_; - std::optional qpack_maximum_dynamic_table_capacity_; - MockPacketWriter* writer_; -}; - class QuicSpdySessionTestServer : public QuicSpdySessionTestBase { protected: QuicSpdySessionTestServer() @@ -3720,6 +3189,8 @@ TEST_P(QuicSpdySessionTestClient, AlpsTwoSettingsFrame) { EXPECT_EQ("multiple SETTINGS frames", error.value()); } +} // namespace + void QuicSpdySessionTestBase::TestHttpDatagramSetting( HttpDatagramSupport local_support, HttpDatagramSupport remote_support, HttpDatagramSupport expected_support, bool expected_datagram_supported) { @@ -3752,7 +3223,7 @@ void QuicSpdySessionTestBase::TestHttpDatagramSetting( QuicStreamId stream_id = GetNthServerInitiatedUnidirectionalStreamId(transport_version(), 3); QuicStreamFrame frame(stream_id, /*fin=*/false, /*offset=*/0, data); - StrictMock debug_visitor; + testing::StrictMock debug_visitor; session_->set_debug_visitor(&debug_visitor); EXPECT_CALL(debug_visitor, OnPeerControlStreamCreated(stream_id)); EXPECT_CALL(debug_visitor, OnSettingsFrameReceived(settings)); @@ -3761,6 +3232,8 @@ void QuicSpdySessionTestBase::TestHttpDatagramSetting( EXPECT_EQ(session_->SupportsH3Datagram(), expected_datagram_supported); } +namespace { + TEST_P(QuicSpdySessionTestClient, HttpDatagramSettingLocal04Remote04) { Initialize(); TestHttpDatagramSetting( diff --git a/quiche/quic/core/http/quic_spdy_stream.cc b/quiche/quic/core/http/quic_spdy_stream.cc index c46a77ba4..cd5259186 100644 --- a/quiche/quic/core/http/quic_spdy_stream.cc +++ b/quiche/quic/core/http/quic_spdy_stream.cc @@ -945,6 +945,7 @@ void QuicSpdyStream::OnClose() { << ", but the session could not be found."; return; } + web_transport_data_->adapter.OnClosingWithUnreadData(); web_transport->OnStreamClosed(id()); } } @@ -1226,8 +1227,7 @@ void QuicSpdyStream::OnWebTransportStreamFrameType( absl::StrCat("Stream ", id(), " received WEBTRANSPORT_STREAM at a non-zero offset"); QUIC_DLOG(ERROR) << ENDPOINT << error; - OnUnrecoverableError(QUIC_HTTP_INVALID_FRAME_SEQUENCE_ON_SPDY_STREAM, - error); + OnUnrecoverableError(QUIC_HTTP_FRAME_ERROR, error); return; } } @@ -1413,8 +1413,16 @@ void QuicSpdyStream::MaybeProcessReceivedWebTransportHeaders() { } QUICHE_DCHECK(IsValidWebTransportSessionId(id(), version())); + const auto wt_version = spdy_session_->SupportedWebTransportVersion(); + const bool is_draft15 = + wt_version == WebTransportHttp3Version::kDraft15; + std::string method; std::string protocol; + std::string scheme; + std::string subprotocol_offer; + bool has_authority = false; + bool has_path = false; for (const auto& [header_name, header_value] : header_list_) { if (header_name == ":method") { if (!method.empty() || header_value.empty()) { @@ -1428,6 +1436,18 @@ void QuicSpdyStream::MaybeProcessReceivedWebTransportHeaders() { } protocol = header_value; } + if (header_name == ":scheme") { + scheme = header_value; + } + if (header_name == ":authority") { + has_authority = true; + } + if (header_name == ":path") { + has_path = true; + } + if (header_name == webtransport::kSubprotocolRequestHeader) { + subprotocol_offer = header_value; + } if (header_name == "datagram-flow-id") { QUIC_DLOG(ERROR) << ENDPOINT << "Rejecting WebTransport due to unexpected " @@ -1436,12 +1456,53 @@ void QuicSpdyStream::MaybeProcessReceivedWebTransportHeaders() { } } - if (method != "CONNECT" || protocol != "webtransport") { + if (method != "CONNECT") { + return; + } + if (is_draft15 ? protocol != "webtransport-h3" + : protocol != "webtransport") { return; } + // Section 3.2: :scheme, :authority, and :path are required. + if (is_draft15) { + if (scheme != "https") { + QUIC_DLOG(WARNING) << ENDPOINT + << "Rejecting WebTransport: :scheme is not 'https'"; + return; + } + if (!has_authority || !has_path) { + QUIC_DLOG(WARNING) + << ENDPOINT + << "Rejecting WebTransport: missing :authority or :path"; + return; + } + } + + // Section 5.2: Reject excess sessions with H3_REQUEST_REJECTED. + if (!spdy_session_->CanCreateNewWebTransportSession()) { + QUIC_DLOG(WARNING) << ENDPOINT + << "Rejecting WebTransport: session limit exceeded"; + spdy_session_->ResetStream( + id(), QuicResetStreamError( + QUIC_STREAM_CANCELLED, + static_cast(QuicHttp3ErrorCode::REQUEST_REJECTED))); + return; + } web_transport_ = std::make_unique(spdy_session_, this, id()); + ApplyWebTransportFlowControlLimits(); + if (!subprotocol_offer.empty()) { + absl::StatusOr> subprotocols_offered = + webtransport::ParseSubprotocolRequestHeader(subprotocol_offer); + if (subprotocols_offered.ok()) { + web_transport_->set_subprotocols_offered( + *std::move(subprotocols_offered)); + } + } + + spdy_session_->OnWebTransportSessionCreated(); + web_transport_->set_session_counted(true); } void QuicSpdyStream::MaybeProcessSentWebTransportHeaders( @@ -1462,7 +1523,9 @@ void QuicSpdyStream::MaybeProcessSentWebTransportHeaders( if (method_it == headers.end() || protocol_it == headers.end()) { return; } - if (method_it->second != "CONNECT" && protocol_it->second != "webtransport") { + if (method_it->second != "CONNECT" || + (protocol_it->second != "webtransport" && + protocol_it->second != "webtransport-h3")) { return; } @@ -1471,8 +1534,17 @@ void QuicSpdyStream::MaybeProcessSentWebTransportHeaders( headers["sec-webtransport-http3-draft02"] = "1"; } + if (!spdy_session_->CanCreateNewWebTransportSession()) { + QUIC_DLOG(WARNING) << ENDPOINT + << "Not creating WebTransport session: limit exceeded"; + return; + } + web_transport_ = std::make_unique(spdy_session_, this, id()); + ApplyWebTransportFlowControlLimits(); + spdy_session_->OnWebTransportSessionCreated(); + web_transport_->set_session_counted(true); // Store the offered subprotocols so that we can later validate the // server-selected one against those. @@ -1492,6 +1564,23 @@ void QuicSpdyStream::MaybeProcessSentWebTransportHeaders( } } +void QuicSpdyStream::ApplyWebTransportFlowControlLimits() { + QUICHE_DCHECK(web_transport_ != nullptr); + if (spdy_session_->wt_flow_control_enabled()) { + web_transport_->SetInitialStreamLimits( + spdy_session_->peer_wt_initial_max_streams_bidi(), + spdy_session_->peer_wt_initial_max_streams_uni(), + spdy_session_->local_wt_initial_max_streams_bidi(), + spdy_session_->local_wt_initial_max_streams_uni()); + // Section 5.5.3: initial_max_data=0 means "peer must send WT_MAX_DATA + // before any data may be sent," not "data FC disabled." Always enable + // data limits when FC is negotiated. + web_transport_->SetInitialDataLimit( + spdy_session_->peer_wt_initial_max_data(), + spdy_session_->local_wt_initial_max_data()); + } +} + void QuicSpdyStream::OnCanWriteNewData() { if (web_transport_data_ != nullptr) { web_transport_data_->adapter.OnCanWriteNewData(); @@ -1630,14 +1719,61 @@ bool QuicSpdyStream::OnCapsule(const Capsule& capsule) { } return connect_udp_bind_visitor_->OnCompressionCloseCapsule( capsule.compression_close_capsule()); + // Draft-15 session-level flow control capsules. + case CapsuleType::WT_MAX_STREAMS_BIDI: + if (web_transport_ != nullptr && + spdy_session_->SupportedWebTransportVersion() == + WebTransportHttp3Version::kDraft15) { + web_transport_->OnMaxStreamsCapsuleReceived( + webtransport::StreamType::kBidirectional, + capsule.web_transport_max_streams().max_stream_count); + } + return true; + case CapsuleType::WT_MAX_STREAMS_UNIDI: + if (web_transport_ != nullptr && + spdy_session_->SupportedWebTransportVersion() == + WebTransportHttp3Version::kDraft15) { + web_transport_->OnMaxStreamsCapsuleReceived( + webtransport::StreamType::kUnidirectional, + capsule.web_transport_max_streams().max_stream_count); + } + return true; + case CapsuleType::WT_MAX_DATA: + if (web_transport_ != nullptr && + spdy_session_->SupportedWebTransportVersion() == + WebTransportHttp3Version::kDraft15) { + web_transport_->OnMaxDataCapsuleReceived( + capsule.web_transport_max_data().max_data); + } + return true; + // Draft-15 FC capsules that are silently accepted (Section 5.1). + case CapsuleType::WT_DATA_BLOCKED: + case CapsuleType::WT_STREAMS_BLOCKED_BIDI: + case CapsuleType::WT_STREAMS_BLOCKED_UNIDI: + if (web_transport_ != nullptr && + spdy_session_->SupportedWebTransportVersion() == + WebTransportHttp3Version::kDraft15) { + return true; + } + break; + // Section 5.4 MUST: WT_MAX_STREAM_DATA and WT_STREAM_DATA_BLOCKED are + // prohibited in draft-15 (per-stream FC is provided by QUIC natively). + // Receipt MUST be treated as a session error. + case CapsuleType::WT_MAX_STREAM_DATA: + case CapsuleType::WT_STREAM_DATA_BLOCKED: + if (web_transport_ != nullptr && + spdy_session_->SupportedWebTransportVersion() == + WebTransportHttp3Version::kDraft15) { + web_transport_->OnInternalError(kWtFlowControlError, + "Prohibited per-stream FC capsule received"); + return false; + } + return true; // Ignore WebTransport over HTTP/2 capsules. case CapsuleType::WT_RESET_STREAM: case CapsuleType::WT_STOP_SENDING: case CapsuleType::WT_STREAM: case CapsuleType::WT_STREAM_WITH_FIN: - case CapsuleType::WT_MAX_STREAM_DATA: - case CapsuleType::WT_MAX_STREAMS_BIDI: - case CapsuleType::WT_MAX_STREAMS_UNIDI: return true; } if (datagram_visitor_) { @@ -1864,6 +2000,9 @@ void QuicSpdyStream::HandleBodyAvailable() { reinterpret_cast(iov.iov_base), iov.iov_len))) { break; } + if (reading_stopped()) { + break; + } MarkConsumed(iov.iov_len); } // If we received a FIN, make sure that there isn't a partial capsule buffered diff --git a/quiche/quic/core/http/quic_spdy_stream.h b/quiche/quic/core/http/quic_spdy_stream.h index edc94ed9d..7f003b463 100644 --- a/quiche/quic/core/http/quic_spdy_stream.h +++ b/quiche/quic/core/http/quic_spdy_stream.h @@ -285,6 +285,13 @@ class QUICHE_EXPORT QuicSpdyStream } return &web_transport_data_->adapter; } + // Returns the WebTransport stream adapter, or null. + WebTransportStreamAdapter* web_transport_stream_adapter() { + if (web_transport_data_ == nullptr) { + return nullptr; + } + return &web_transport_data_->adapter; + } // Sends a WEBTRANSPORT_STREAM frame and sets up the appropriate metadata. void ConvertToWebTransportDataStream(WebTransportSessionId session_id); @@ -474,6 +481,8 @@ class QUICHE_EXPORT QuicSpdyStream void MaybeProcessSentWebTransportHeaders(quiche::HttpHeaderBlock& headers); void MaybeProcessReceivedWebTransportHeaders(); + // Applies peer's draft-15 flow control limits to a newly created WT session. + void ApplyWebTransportFlowControlLimits(); // Writes HTTP/3 DATA frame header. If |force_write| is true, use // WriteOrBufferData if send buffer cannot accomodate the header + data. diff --git a/quiche/quic/core/http/quic_spdy_stream_test.cc b/quiche/quic/core/http/quic_spdy_stream_test.cc index d64b40237..fc664d06b 100644 --- a/quiche/quic/core/http/quic_spdy_stream_test.cc +++ b/quiche/quic/core/http/quic_spdy_stream_test.cc @@ -3297,16 +3297,18 @@ TEST_P(QuicSpdyStreamTest, TwoResetStreamFrames) { EXPECT_FALSE(stream_->write_side_closed()); } -TEST_P(QuicSpdyStreamTest, ProcessWebTransportHeadersAsClient) { +TEST_P(QuicSpdyStreamTest, ProcessWebTransportHeadersAsClientDraft07) { if (!IsIetfQuic()) { return; } InitializeWithPerspective(kShouldProcessData, Perspective::IS_CLIENT); + WebTransportHttp3VersionSet draft07_only( + {WebTransportHttp3Version::kDraft07}); session_->set_local_http_datagram_support(HttpDatagramSupport::kRfc); - session_->EnableWebTransport(); + session_->EnableWebTransport(draft07_only); session_->OnSetting(SETTINGS_ENABLE_CONNECT_PROTOCOL, 1); - QuicSpdySessionPeer::EnableWebTransport(session_.get()); + QuicSpdySessionPeer::EnableWebTransport(session_.get(), draft07_only); QuicSpdySessionPeer::SetHttpDatagramSupport(session_.get(), HttpDatagramSupport::kRfc); @@ -3333,16 +3335,58 @@ TEST_P(QuicSpdyStreamTest, ProcessWebTransportHeadersAsClient) { EXPECT_EQ(stream_->web_transport()->GetNegotiatedSubprotocol(), "moqt-01"); } -TEST_P(QuicSpdyStreamTest, WebTransportIgnoreSubprotocolsThatWereNotOffered) { +TEST_P(QuicSpdyStreamTest, ProcessWebTransportHeadersAsClientDraft15) { if (!IsIetfQuic()) { return; } InitializeWithPerspective(kShouldProcessData, Perspective::IS_CLIENT); + WebTransportHttp3VersionSet draft15_only( + {WebTransportHttp3Version::kDraft15}); session_->set_local_http_datagram_support(HttpDatagramSupport::kRfc); - session_->EnableWebTransport(); + session_->EnableWebTransport(draft15_only); + session_->OnSetting(SETTINGS_ENABLE_CONNECT_PROTOCOL, 1); + session_->OnSetting(SETTINGS_WT_ENABLED, 1); + QuicSpdySessionPeer::EnableWebTransport(session_.get(), draft15_only); + QuicSpdySessionPeer::SetHttpDatagramSupport(session_.get(), + HttpDatagramSupport::kRfc); + + EXPECT_CALL(*stream_, WriteHeadersMock(false)); + EXPECT_CALL(*session_, WritevData(stream_->id(), _, _, _, _, _)) + .Times(AnyNumber()); + + quiche::HttpHeaderBlock request_headers; + request_headers[":method"] = "CONNECT"; + request_headers[":protocol"] = "webtransport-h3"; + request_headers["wt-available-protocols"] = R"("moqt-00", "moqt-01"; a=b)"; + stream_->WriteHeaders(std::move(request_headers), /*fin=*/false, nullptr); + ASSERT_TRUE(stream_->web_transport() != nullptr); + EXPECT_EQ(stream_->id(), stream_->web_transport()->id()); + EXPECT_THAT(stream_->web_transport()->subprotocols_offered(), + ElementsAre("moqt-00", "moqt-01")); + + quiche::HttpHeaderBlock response_headers; + response_headers[":status"] = "200"; + response_headers["wt-protocol"] = "\"moqt-01\""; + stream_->web_transport()->HeadersReceived(response_headers); + EXPECT_EQ(stream_->web_transport()->rejection_reason(), + WebTransportHttp3RejectionReason::kNone); + EXPECT_EQ(stream_->web_transport()->GetNegotiatedSubprotocol(), "moqt-01"); +} + +TEST_P(QuicSpdyStreamTest, + WebTransportIgnoreSubprotocolsThatWereNotOfferedDraft07) { + if (!IsIetfQuic()) { + return; + } + + InitializeWithPerspective(kShouldProcessData, Perspective::IS_CLIENT); + WebTransportHttp3VersionSet draft07_only( + {WebTransportHttp3Version::kDraft07}); + session_->set_local_http_datagram_support(HttpDatagramSupport::kRfc); + session_->EnableWebTransport(draft07_only); session_->OnSetting(SETTINGS_ENABLE_CONNECT_PROTOCOL, 1); - QuicSpdySessionPeer::EnableWebTransport(session_.get()); + QuicSpdySessionPeer::EnableWebTransport(session_.get(), draft07_only); QuicSpdySessionPeer::SetHttpDatagramSupport(session_.get(), HttpDatagramSupport::kRfc); @@ -3366,16 +3410,54 @@ TEST_P(QuicSpdyStreamTest, WebTransportIgnoreSubprotocolsThatWereNotOffered) { EXPECT_EQ(stream_->web_transport()->GetNegotiatedSubprotocol(), std::nullopt); } -TEST_P(QuicSpdyStreamTest, WebTransportInvalidSubprotocolResponse) { +TEST_P(QuicSpdyStreamTest, + WebTransportIgnoreSubprotocolsThatWereNotOfferedDraft15) { if (!IsIetfQuic()) { return; } InitializeWithPerspective(kShouldProcessData, Perspective::IS_CLIENT); + WebTransportHttp3VersionSet draft15_only( + {WebTransportHttp3Version::kDraft15}); session_->set_local_http_datagram_support(HttpDatagramSupport::kRfc); - session_->EnableWebTransport(); + session_->EnableWebTransport(draft15_only); session_->OnSetting(SETTINGS_ENABLE_CONNECT_PROTOCOL, 1); - QuicSpdySessionPeer::EnableWebTransport(session_.get()); + session_->OnSetting(SETTINGS_WT_ENABLED, 1); + QuicSpdySessionPeer::EnableWebTransport(session_.get(), draft15_only); + QuicSpdySessionPeer::SetHttpDatagramSupport(session_.get(), + HttpDatagramSupport::kRfc); + + EXPECT_CALL(*stream_, WriteHeadersMock(false)); + EXPECT_CALL(*session_, WritevData(stream_->id(), _, _, _, _, _)) + .Times(AnyNumber()); + + quiche::HttpHeaderBlock request_headers; + request_headers[":method"] = "CONNECT"; + request_headers[":protocol"] = "webtransport-h3"; + request_headers["wt-available-protocols"] = R"("moqt-00", "moqt-01"; a=b)"; + stream_->WriteHeaders(std::move(request_headers), /*fin=*/false, nullptr); + ASSERT_TRUE(stream_->web_transport() != nullptr); + + quiche::HttpHeaderBlock response_headers; + response_headers[":status"] = "200"; + response_headers["wt-protocol"] = "\"moqt-02\""; + stream_->web_transport()->HeadersReceived(response_headers); + EXPECT_EQ(stream_->web_transport()->rejection_reason(), + WebTransportHttp3RejectionReason::kSubprotocolNegotiationFailed); +} + +TEST_P(QuicSpdyStreamTest, WebTransportInvalidSubprotocolResponseDraft07) { + if (!IsIetfQuic()) { + return; + } + + InitializeWithPerspective(kShouldProcessData, Perspective::IS_CLIENT); + WebTransportHttp3VersionSet draft07_only( + {WebTransportHttp3Version::kDraft07}); + session_->set_local_http_datagram_support(HttpDatagramSupport::kRfc); + session_->EnableWebTransport(draft07_only); + session_->OnSetting(SETTINGS_ENABLE_CONNECT_PROTOCOL, 1); + QuicSpdySessionPeer::EnableWebTransport(session_.get(), draft07_only); QuicSpdySessionPeer::SetHttpDatagramSupport(session_.get(), HttpDatagramSupport::kRfc); @@ -3399,15 +3481,52 @@ TEST_P(QuicSpdyStreamTest, WebTransportInvalidSubprotocolResponse) { EXPECT_EQ(stream_->web_transport()->GetNegotiatedSubprotocol(), std::nullopt); } -TEST_P(QuicSpdyStreamTest, ProcessWebTransportHeadersAsServer) { +TEST_P(QuicSpdyStreamTest, WebTransportInvalidSubprotocolResponseDraft15) { + if (!IsIetfQuic()) { + return; + } + + InitializeWithPerspective(kShouldProcessData, Perspective::IS_CLIENT); + WebTransportHttp3VersionSet draft15_only( + {WebTransportHttp3Version::kDraft15}); + session_->set_local_http_datagram_support(HttpDatagramSupport::kRfc); + session_->EnableWebTransport(draft15_only); + session_->OnSetting(SETTINGS_ENABLE_CONNECT_PROTOCOL, 1); + session_->OnSetting(SETTINGS_WT_ENABLED, 1); + QuicSpdySessionPeer::EnableWebTransport(session_.get(), draft15_only); + QuicSpdySessionPeer::SetHttpDatagramSupport(session_.get(), + HttpDatagramSupport::kRfc); + + EXPECT_CALL(*stream_, WriteHeadersMock(false)); + EXPECT_CALL(*session_, WritevData(stream_->id(), _, _, _, _, _)) + .Times(AnyNumber()); + + quiche::HttpHeaderBlock request_headers; + request_headers[":method"] = "CONNECT"; + request_headers[":protocol"] = "webtransport-h3"; + request_headers["wt-available-protocols"] = R"("moqt-00", "moqt-01"; a=b)"; + stream_->WriteHeaders(std::move(request_headers), /*fin=*/false, nullptr); + ASSERT_TRUE(stream_->web_transport() != nullptr); + + quiche::HttpHeaderBlock response_headers; + response_headers[":status"] = "200"; + response_headers["wt-protocol"] = "12345.67"; + stream_->web_transport()->HeadersReceived(response_headers); + EXPECT_EQ(stream_->web_transport()->rejection_reason(), + WebTransportHttp3RejectionReason::kSubprotocolNegotiationFailed); +} + +TEST_P(QuicSpdyStreamTest, ProcessWebTransportHeadersAsServerDraft07) { if (!IsIetfQuic()) { return; } InitializeWithPerspective(kShouldProcessData, Perspective::IS_SERVER); + WebTransportHttp3VersionSet draft07_only( + {WebTransportHttp3Version::kDraft07}); session_->set_local_http_datagram_support(HttpDatagramSupport::kRfc); - session_->EnableWebTransport(); - QuicSpdySessionPeer::EnableWebTransport(session_.get()); + session_->EnableWebTransport(draft07_only); + QuicSpdySessionPeer::EnableWebTransport(session_.get(), draft07_only); QuicSpdySessionPeer::SetHttpDatagramSupport(session_.get(), HttpDatagramSupport::kRfc); @@ -3434,6 +3553,46 @@ TEST_P(QuicSpdyStreamTest, ProcessWebTransportHeadersAsServer) { EXPECT_EQ(stream_->web_transport()->GetNegotiatedSubprotocol(), "moqt-01"); } +TEST_P(QuicSpdyStreamTest, ProcessWebTransportHeadersAsServerDraft15) { + if (!IsIetfQuic()) { + return; + } + + InitializeWithPerspective(kShouldProcessData, Perspective::IS_SERVER); + WebTransportHttp3VersionSet draft15_only( + {WebTransportHttp3Version::kDraft15}); + session_->set_local_http_datagram_support(HttpDatagramSupport::kRfc); + session_->EnableWebTransport(draft15_only); + QuicSpdySessionPeer::EnableWebTransport(session_.get(), draft15_only); + QuicSpdySessionPeer::SetHttpDatagramSupport(session_.get(), + HttpDatagramSupport::kRfc); + + headers_[":method"] = "CONNECT"; + headers_[":protocol"] = "webtransport-h3"; + headers_[":scheme"] = "https"; + headers_[":authority"] = "www.google.com"; + headers_[":path"] = "/wt"; + headers_["wt-available-protocols"] = R"("moqt-00", "moqt-01"; a=b)"; + + stream_->OnStreamHeadersPriority( + spdy::SpdyStreamPrecedence(kV3HighestPriority)); + ProcessHeaders(false, headers_); + EXPECT_EQ("", stream_->data()); + EXPECT_FALSE(stream_->header_list().empty()); + EXPECT_FALSE(stream_->IsDoneReading()); + ASSERT_TRUE(stream_->web_transport() != nullptr); + EXPECT_EQ(stream_->id(), stream_->web_transport()->id()); + + EXPECT_CALL(*stream_, WriteHeadersMock(false)); + EXPECT_CALL(*session_, WritevData(stream_->id(), _, _, _, _, _)) + .Times(AnyNumber()); + quiche::HttpHeaderBlock response_headers; + response_headers[":status"] = "200"; + response_headers["wt-protocol"] = "\"moqt-01\""; + stream_->WriteHeaders(std::move(response_headers), /*fin=*/false, nullptr); + EXPECT_EQ(stream_->web_transport()->GetNegotiatedSubprotocol(), "moqt-01"); +} + TEST_P(QuicSpdyStreamTest, IncomingWebTransportStreamWhenUnsupported) { if (!IsIetfQuic()) { return; @@ -3524,7 +3683,7 @@ TEST_P(QuicSpdyStreamTest, IncomingWebTransportStreamWithPaddingDraft07) { /* offset = */ 0, webtransport_stream_frame); EXPECT_CALL(*connection_, - CloseConnection(QUIC_HTTP_INVALID_FRAME_SEQUENCE_ON_SPDY_STREAM, + CloseConnection(QUIC_HTTP_FRAME_ERROR, HasSubstr("non-zero offset"), _)); stream_->OnStreamFrame(stream_frame); EXPECT_TRUE(stream_->web_transport_stream() == nullptr); diff --git a/quiche/quic/core/http/web_transport_buffering_draft15_test.cc b/quiche/quic/core/http/web_transport_buffering_draft15_test.cc new file mode 100644 index 000000000..ee315c6bd --- /dev/null +++ b/quiche/quic/core/http/web_transport_buffering_draft15_test.cc @@ -0,0 +1,411 @@ +// Copyright 2024 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. + +// Draft-15 acceptance tests for stream/datagram buffering (Section 4.6). + +#include +#include +#include + +#include "absl/strings/str_cat.h" + +#include "quiche/quic/core/http/http_constants.h" +#include "quiche/quic/core/http/quic_spdy_session.h" +#include "quiche/quic/core/http/web_transport_draft15_test_utils.h" +#include "quiche/quic/core/quic_constants.h" +#include "quiche/quic/core/quic_data_writer.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/web_transport/test_tools/draft15_constants.h" + +namespace quic { +namespace { + +using ::testing::_; +using ::testing::Not; +using test::MockWebTransportSessionVisitor; + +class BufferingDraft15Test : public test::Draft15SessionTest { + protected: + BufferingDraft15Test() : Draft15SessionTest(Perspective::IS_SERVER) {} +}; + +INSTANTIATE_TEST_SUITE_P(BufferingDraft15, BufferingDraft15Test, + ::testing::ValuesIn(CurrentSupportedVersions())); + +TEST_P(BufferingDraft15Test, BufferStreamsUntilSession) { + // Section 4.6 SHOULD: Streams arriving before the session is fully + // established should be buffered pending session association. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + + // The session ID will be on the first client-initiated bidi stream. + QuicStreamId future_session_id = GetNthClientInitiatedBidirectionalId(0); + + // Inject a uni stream BEFORE the session is established. + QuicStreamId uni_stream_id = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 4); + ReceiveWebTransportUnidirectionalStream(future_session_id, uni_stream_id, + "pre-session data"); + + // Now establish the session. + auto* wt = AttemptWebTransportDraft15Session(future_session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + // The previously buffered stream should now be available. + WebTransportStream* incoming = wt->AcceptIncomingUnidirectionalStream(); + EXPECT_NE(incoming, nullptr) + << "Stream injected before session should be buffered and delivered"; +} + +TEST_P(BufferingDraft15Test, BufferDatagramsUntilSession) { + // Section 4.6 SHOULD: "Endpoints SHOULD buffer [...] datagrams [...] until + // the streams can be associated with the appropriate session." + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/65536); + session_->set_buffer_web_transport_datagrams(true); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + + // Create the QUIC stream without delivering headers — session not yet + // established. + QuicStreamFrame frame(session_id, /*fin=*/false, /*offset=*/0, + absl::string_view()); + session_->OnStreamFrame(frame); + ASSERT_NE(session_->GetOrCreateStream(session_id), nullptr); + + // HTTP/3 datagrams use quarter-stream-ID encoding: varint(session_id/4) + + // payload. + uint64_t quarter_id = session_id / kHttpDatagramStreamIdDivisor; + std::string datagram; + char varint_buf[8]; + QuicDataWriter writer(sizeof(varint_buf), varint_buf); + ASSERT_TRUE(writer.WriteVarInt62(quarter_id)); + datagram.append(varint_buf, writer.length()); + datagram.append("pre-session payload"); + session_->OnDatagramReceived(datagram); + + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Session should be established"; + + // Expectations must be set before SetVisitor because SetVisitor flushes + // buffered datagrams. + int datagram_count = 0; + auto mock = std::make_unique< + testing::NiceMock>(); + auto* raw = mock.get(); + EXPECT_CALL(*raw, OnDatagramReceived(_)) + .WillRepeatedly([&datagram_count](absl::string_view) { + ++datagram_count; + }); + wt->SetVisitor(std::move(mock)); + + EXPECT_GE(datagram_count, 1) + << "Pre-session datagram must be buffered and " + "delivered after session establishment"; + testing::Mock::VerifyAndClearExpectations(raw); +} + +TEST_P(BufferingDraft15Test, ExcessBufferedStreamsRejected) { + // Section 4.6 MUST: Excess buffered streams are rejected with + // WT_BUFFERED_STREAM_REJECTED. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + EXPECT_EQ(webtransport::draft15::kWtBufferedStreamRejected, 0x3994bd84u); + + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/100, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + + // Use a future session ID that hasn't been established yet. + QuicStreamId future_session_id = GetNthClientInitiatedBidirectionalId(0); + + // Inject more uni streams than the buffering limit allows, all referencing + // the future session ID. The first kMaxUnassociatedWebTransportStreams + // should be buffered; the rest should be reset. + // Section 4.6: Excess buffered streams SHALL be closed with RESET_STREAM + // and/or STOP_SENDING with WT_BUFFERED_STREAM_REJECTED (0x3994bd84). + // For incoming uni streams, only STOP_SENDING is applicable. + EXPECT_CALL(*connection_, + SendControlFrame(test::IsStopSendingWithIetfCode( + webtransport::draft15::kWtBufferedStreamRejected))) + .Times(4) // total_streams - kMaxUnassociatedWebTransportStreams + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, + SendControlFrame( + Not(test::IsStopSendingWithIetfCode( + webtransport::draft15::kWtBufferedStreamRejected)))) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + const size_t total_streams = kMaxUnassociatedWebTransportStreams + 4; + for (size_t i = 0; i < total_streams; ++i) { + // Use client-initiated uni stream IDs starting at index 4 (skip HTTP/3 + // control streams at indices 0-2 and the control stream at index 3). + QuicStreamId uni_stream_id = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), + 4 + i); + ReceiveWebTransportUnidirectionalStream(future_session_id, uni_stream_id, + "data"); + } + + // Now establish the session. + auto* wt = AttemptWebTransportDraft15Session(future_session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + // Count how many incoming uni streams are available. It should be at most + // kMaxUnassociatedWebTransportStreams. + size_t accepted_count = 0; + while (wt->AcceptIncomingUnidirectionalStream() != nullptr) { + ++accepted_count; + } + EXPECT_LE(accepted_count, kMaxUnassociatedWebTransportStreams) + << "Excess streams beyond the buffer limit should have been rejected"; +} + +// A WebTransport server visitor that accepts incoming streams in response to +// OnIncoming*StreamAvailable() callbacks, as real server applications do +// (e.g., QuicSimpleServerStream, WebTransportOnlyServerSession). +class CallbackDrivenVisitor : public WebTransportVisitor { + public: + explicit CallbackDrivenVisitor(WebTransportSession* session) + : session_(session) {} + + void OnSessionReady() override { session_ready_ = true; } + void OnSessionClosed(WebTransportSessionError /*error_code*/, + const std::string& /*error_message*/) override {} + + void OnIncomingUnidirectionalStreamAvailable() override { + while (WebTransportStream* stream = + session_->AcceptIncomingUnidirectionalStream()) { + accepted_uni_streams_.push_back(stream); + } + } + + void OnIncomingBidirectionalStreamAvailable() override { + while (WebTransportStream* stream = + session_->AcceptIncomingBidirectionalStream()) { + accepted_bidi_streams_.push_back(stream); + } + } + + void OnDatagramReceived(absl::string_view /*datagram*/) override {} + void OnCanCreateNewOutgoingBidirectionalStream() override {} + void OnCanCreateNewOutgoingUnidirectionalStream() override {} + + bool session_ready() const { return session_ready_; } + const std::vector& accepted_uni_streams() const { + return accepted_uni_streams_; + } + + private: + WebTransportSession* session_; + bool session_ready_ = false; + std::vector accepted_uni_streams_; + std::vector accepted_bidi_streams_; +}; + +TEST_P(BufferingDraft15Test, BufferedStreamDeliveredToCallbackDrivenVisitor) { + // Section 4.6: Streams arriving before the CONNECT request (due to QUIC + // transport-layer reordering) are buffered. Once the session is established, + // the visitor must be notified about them via + // OnIncomingUnidirectionalStreamAvailable() so that a callback-driven + // application can accept them. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/65536); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + + // A unidirectional stream arrives before the CONNECT request. + QuicStreamId uni_stream_id = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 4); + ReceiveWebTransportUnidirectionalStream(session_id, uni_stream_id, + "early data"); + + // Create the session without calling HeadersReceived — we need to set the + // visitor first (matching the real server flow). + QuicStreamFrame frame(session_id, /*fin=*/false, /*offset=*/0, + absl::string_view()); + session_->OnStreamFrame(frame); + auto* connect_stream = static_cast( + session_->GetOrCreateStream(session_id)); + ASSERT_NE(connect_stream, nullptr); + QuicHeaderList request_headers; + request_headers.OnHeader(":method", "CONNECT"); + request_headers.OnHeader(":protocol", "webtransport-h3"); + request_headers.OnHeader(":scheme", "https"); + request_headers.OnHeader(":authority", "test.example.com"); + request_headers.OnHeader(":path", "/wt"); + connect_stream->OnStreamHeaderList(/*fin=*/false, 0, request_headers); + auto* wt = session_->GetWebTransportSession(session_id); + ASSERT_NE(wt, nullptr); + + // Attach a callback-driven visitor (the standard server application pattern). + auto visitor = std::make_unique(wt); + CallbackDrivenVisitor* raw_visitor = visitor.get(); + wt->SetVisitor(std::move(visitor)); + + // Server calls HeadersReceived() after setting the visitor. + quiche::HttpHeaderBlock response_headers; + wt->HeadersReceived(response_headers); + ASSERT_TRUE(raw_visitor->session_ready()); + + // The pre-buffered stream must have been delivered to the visitor. + EXPECT_EQ(raw_visitor->accepted_uni_streams().size(), 1u); +} + +TEST_P(BufferingDraft15Test, ExcessBufferedDatagramsDropped) { + // Section 4.6 MUST/SHALL: "Endpoints MUST limit the number of [...] buffered + // datagrams [...]. Excess datagrams SHALL be dropped." + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/65536); + session_->set_buffer_web_transport_datagrams(true); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + + // Create the QUIC stream without delivering headers. + QuicStreamFrame frame(session_id, /*fin=*/false, /*offset=*/0, + absl::string_view()); + session_->OnStreamFrame(frame); + ASSERT_NE(session_->GetOrCreateStream(session_id), nullptr); + + const int kNumDatagrams = 200; + uint64_t quarter_id = session_id / kHttpDatagramStreamIdDivisor; + for (int i = 0; i < kNumDatagrams; ++i) { + std::string datagram; + char varint_buf[8]; + QuicDataWriter writer(sizeof(varint_buf), varint_buf); + ASSERT_TRUE(writer.WriteVarInt62(quarter_id)); + datagram.append(varint_buf, writer.length()); + datagram.append(absl::StrCat("datagram-", i)); + session_->OnDatagramReceived(datagram); + } + + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Session should be established"; + + // Expectations must be set before SetVisitor because SetVisitor flushes + // buffered datagrams. + int datagram_count = 0; + auto mock = std::make_unique< + testing::NiceMock>(); + auto* raw = mock.get(); + EXPECT_CALL(*raw, OnDatagramReceived(_)) + .WillRepeatedly([&datagram_count](absl::string_view) { + ++datagram_count; + }); + wt->SetVisitor(std::move(mock)); + + EXPECT_GT(datagram_count, 0) + << "Pre-session datagrams must be buffered"; + EXPECT_LT(datagram_count, kNumDatagrams) + << "Excess buffered datagrams must be dropped"; + testing::Mock::VerifyAndClearExpectations(raw); +} + +TEST_P(BufferingDraft15Test, Section5_4_PreAssociationDataCountedAgainstMaxData) { + // Section 5.4: Data arriving on a WT stream BEFORE the session is established + // (transport-layer reordering per Section 4.6) must still be counted against + // WT_MAX_DATA. Otherwise the receive-side flow control is bypassed. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + + const uint64_t kLocalMaxData = 50; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/kLocalMaxData); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + + // Inject a uni stream with exactly kLocalMaxData bytes BEFORE the session + // is established. + QuicStreamId uni_stream_id = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 4); + std::string payload(kLocalMaxData, 'x'); + ReceiveWebTransportUnidirectionalStream(session_id, uni_stream_id, payload); + + // Set up mock expectations before establishing the session, since the + // FC violation can fire during session establishment or stream injection. + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + + // Now establish the session. The buffered data should be counted. + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + auto* visitor = AttachMockVisitor(wt); + EXPECT_CALL(*visitor, OnIncomingUnidirectionalStreamAvailable()) + .Times(testing::AnyNumber()); + EXPECT_CALL(*visitor, OnSessionClosed(_, _)) + .Times(testing::AnyNumber()); + + // Inject 1 more byte on a new stream. Total = kLocalMaxData + 1 > limit. + QuicStreamId uni2_id = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 5); + ReceiveWebTransportUnidirectionalStream(session_id, uni2_id, "Z"); + + // Session should be terminated: pre-association data (50) + new data (1) = 51 + // exceeds WT_MAX_DATA = 50. + EXPECT_FALSE(wt->CanOpenNextOutgoingBidirectionalStream()) + << "Pre-association data must be counted against " + "WT_MAX_DATA. Total received (51) exceeds limit (50)"; + + testing::Mock::VerifyAndClearExpectations(visitor); + testing::Mock::VerifyAndClearExpectations(connection_); +} + +} // namespace +} // namespace quic diff --git a/quiche/quic/core/http/web_transport_capsule_dispatch_draft15_test.cc b/quiche/quic/core/http/web_transport_capsule_dispatch_draft15_test.cc new file mode 100644 index 000000000..bdc2eb60a --- /dev/null +++ b/quiche/quic/core/http/web_transport_capsule_dispatch_draft15_test.cc @@ -0,0 +1,401 @@ +// Copyright 2024 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. + +// Draft-15 tests for capsule dispatch through a WebTransport session. +// These require a full QUIC session with draft-15 negotiation. +// Pure capsule serialization tests live in quiche/common/capsule_draft15_test.cc. + +#include + +#include "quiche/common/capsule.h" +#include "quiche/quic/core/http/web_transport_draft15_test_utils.h" +#include "quiche/quic/core/http/web_transport_http3.h" +#include "quiche/quic/core/quic_error_codes.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/common/platform/api/quiche_expect_bug.h" +#include "quiche/quic/test_tools/quic_stream_peer.h" +#include "quiche/quic/test_tools/quic_test_utils.h" + +namespace quic { +namespace { + +using ::testing::_; +using ::testing::Return; + +class CapsuleDraft15SessionTest : public test::Draft15SessionTest { + protected: + CapsuleDraft15SessionTest() + : Draft15SessionTest(Perspective::IS_SERVER) {} +}; + +INSTANTIATE_TEST_SUITE_P( + CapsuleDraft15SessionTests, CapsuleDraft15SessionTest, + ::testing::ValuesIn(CurrentSupportedVersions())); + +TEST_P(CapsuleDraft15SessionTest, WtCloseSessionFinAfterCapsule) { + // Section 6 MUST: An endpoint that sends a CLOSE_WEBTRANSPORT_SESSION + // capsule MUST send a FIN on the CONNECT stream after the capsule. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + auto* wt = SetUpWebTransportDraft15ServerSession(GetNthClientInitiatedBidirectionalId(0)); + ASSERT_NE(wt, nullptr); + + session_->set_writev_consumes_all_data(true); + wt->CloseSession(42, "bye"); + + QuicSpdyStream* connect_stream = static_cast( + session_->GetOrCreateStream(GetNthClientInitiatedBidirectionalId(0))); + ASSERT_NE(connect_stream, nullptr); + EXPECT_TRUE(connect_stream->write_side_closed()) + << "CONNECT stream write side should be closed after CloseSession " + "(FIN sent)"; +} + +TEST_P(CapsuleDraft15SessionTest, DataAfterCloseSessionIsError) { + // Section 6 MUST: Receiving data after CLOSE_WEBTRANSPORT_SESSION + // should result in the read side being closed (StopReading) and + // a STOP_SENDING with H3_MESSAGE_ERROR sent to the peer. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id); + ASSERT_NE(wt, nullptr); + + // Get stream reference before close (stream may be destroyed after). + QuicSpdyStream* connect_stream = static_cast( + session_->GetOrCreateStream(session_id)); + ASSERT_NE(connect_stream, nullptr); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + wt->OnCloseReceived(0, ""); + EXPECT_TRUE(wt->close_received()); + EXPECT_TRUE(connect_stream->reading_stopped()) + << "CONNECT stream should stop reading after " + "CLOSE_WEBTRANSPORT_SESSION to reject further data"; + testing::Mock::VerifyAndClearExpectations(connection_); +} + +TEST_P(CapsuleDraft15SessionTest, CleanCloseWithoutCapsule) { + // Section 6: If the CONNECT stream receives a FIN without a + // CLOSE_WEBTRANSPORT_SESSION capsule, the session is closed with + // error code 0 and empty message. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + auto* wt = SetUpWebTransportDraft15ServerSession(GetNthClientInitiatedBidirectionalId(0)); + ASSERT_NE(wt, nullptr); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(Return(WriteResult(WRITE_STATUS_OK, 0))); + + auto* visitor = AttachMockVisitor(wt); + EXPECT_CALL(*visitor, OnSessionClosed(0, "")); + + wt->OnConnectStreamFinReceived(); + testing::Mock::VerifyAndClearExpectations(writer_); + testing::Mock::VerifyAndClearExpectations(visitor); +} + +TEST_P(CapsuleDraft15SessionTest, SessionTerminationResetsStreams) { + // Section 6 MUST: When a session is terminated, all associated streams + // MUST be reset with WT_SESSION_GONE. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + ASSERT_NE(wt, nullptr); + + auto* visitor = AttachMockVisitor(wt); + EXPECT_CALL(*visitor, OnIncomingBidirectionalStreamAvailable()) + .Times(testing::AnyNumber()); + EXPECT_CALL(*visitor, OnIncomingUnidirectionalStreamAvailable()) + .Times(testing::AnyNumber()); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + + QuicStreamId peer_uni_id = + test::GetNthClientInitiatedUnidirectionalStreamId( + transport_version(), 4); + ReceiveWebTransportUnidirectionalStream(session_id, peer_uni_id, "data"); + QuicStreamId peer_bidi_id = GetNthClientInitiatedBidirectionalId(1); + ReceiveWebTransportBidirectionalStream(session_id, peer_bidi_id); + + EXPECT_GT(wt->NumberOfAssociatedStreams(), 0u); + + EXPECT_CALL(*visitor, OnSessionClosed(_, _)).Times(testing::AtMost(1)); + + session_->set_writev_consumes_all_data(true); + wt->CloseSession(0, "terminated"); + + EXPECT_EQ(wt->NumberOfAssociatedStreams(), 0u) + << "All associated streams should be reset after session termination"; + testing::Mock::VerifyAndClearExpectations(visitor); + testing::Mock::VerifyAndClearExpectations(connection_); +} + +TEST_P(CapsuleDraft15SessionTest, NoNewStreamsAfterTermination) { + // Section 6 MUST NOT: After session termination, no new streams or + // datagrams may be created. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + auto* wt = SetUpWebTransportDraft15ServerSession(GetNthClientInitiatedBidirectionalId(0)); + ASSERT_NE(wt, nullptr); + + session_->set_writev_consumes_all_data(true); + wt->CloseSession(0, ""); + + EXPECT_EQ(wt->OpenOutgoingBidirectionalStream(), nullptr); + EXPECT_EQ(wt->OpenOutgoingUnidirectionalStream(), nullptr); + + auto status = wt->SendOrQueueDatagram("post-termination"); + EXPECT_NE(status.code, webtransport::DatagramStatusCode::kSuccess); +} + +TEST_P(CapsuleDraft15SessionTest, WtMaxStreamDataProhibited) { + // Section 5.4 MUST: WT_MAX_STREAM_DATA capsules MUST result in a session + // error — per-stream flow control is not used in draft-15. + EXPECT_EQ(static_cast(quiche::CapsuleType::WT_MAX_STREAM_DATA), + 0x190b4d3eu); + + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + auto* wt = SetUpWebTransportDraft15ServerSession(GetNthClientInitiatedBidirectionalId(0), + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + ASSERT_NE(wt, nullptr); + + auto* visitor = AttachMockVisitor(wt); + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*visitor, OnSessionClosed(_, _)).Times(1); + + InjectCapsuleOnConnectStream( + GetNthClientInitiatedBidirectionalId(0), + quiche::Capsule(quiche::WebTransportMaxStreamDataCapsule{0, 1024})); + testing::Mock::VerifyAndClearExpectations(visitor); +} + +TEST_P(CapsuleDraft15SessionTest, WtStreamDataBlockedProhibited) { + // Section 5.4 MUST: WT_STREAM_DATA_BLOCKED capsules MUST result in a + // session error. + EXPECT_EQ( + static_cast(quiche::CapsuleType::WT_STREAM_DATA_BLOCKED), + 0x190b4d42u); + + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + auto* wt = SetUpWebTransportDraft15ServerSession(GetNthClientInitiatedBidirectionalId(0), + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + ASSERT_NE(wt, nullptr); + + auto* visitor = AttachMockVisitor(wt); + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*visitor, OnSessionClosed(_, _)).Times(1); + + InjectCapsuleOnConnectStream( + GetNthClientInitiatedBidirectionalId(0), + quiche::Capsule( + quiche::WebTransportStreamDataBlockedCapsule{0, 1024})); + testing::Mock::VerifyAndClearExpectations(visitor); +} + +TEST_P(CapsuleDraft15SessionTest, CloseSessionMessageTooLongAtSession) { + // Section 6 MUST NOT: The error message in CloseSession() MUST NOT + // exceed 1024 bytes. The message is truncated with a QUICHE_BUG. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + auto* wt = SetUpWebTransportDraft15ServerSession(GetNthClientInitiatedBidirectionalId(0)); + ASSERT_NE(wt, nullptr); + + session_->set_writev_consumes_all_data(true); + + std::string long_message(1025, 'x'); + EXPECT_QUICHE_BUG(wt->CloseSession(42, long_message), + "exceeds 1024 bytes"); +} + +TEST_P(CapsuleDraft15SessionTest, DrainSessionIdempotent) { + // Section 4.7: NotifySessionDraining() emits a WT_DRAIN_SESSION capsule + // on the CONNECT stream. Calling it twice should emit data only once. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id); + ASSERT_NE(wt, nullptr); + + session_->set_writev_consumes_all_data(true); + + QuicStream* connect_stream = session_->GetOrCreateStream(session_id); + ASSERT_NE(connect_stream, nullptr); + QuicStreamOffset bytes_before = + test::QuicStreamPeer::SendBuffer(connect_stream).stream_bytes_written(); + + wt->NotifySessionDraining(); + + QuicStreamOffset bytes_after_first = + test::QuicStreamPeer::SendBuffer(connect_stream).stream_bytes_written(); + EXPECT_GT(bytes_after_first, bytes_before) + << "NotifySessionDraining() must emit a " + "WT_DRAIN_SESSION capsule on the CONNECT stream"; + + wt->NotifySessionDraining(); + QuicStreamOffset bytes_after_second = + test::QuicStreamPeer::SendBuffer(connect_stream).stream_bytes_written(); + EXPECT_EQ(bytes_after_second, bytes_after_first) + << "Second NotifySessionDraining() should not emit additional data"; +} + +TEST_P(CapsuleDraft15SessionTest, + Section6_OnCloseReceivedResetsAssociatedStreams) { + // Section 6 MUST: "Upon learning that the session has been terminated, the + // endpoint MUST reset the send side and abort reading on the receive side + // of all [...] streams associated with the session [...] using the + // WT_SESSION_GONE error code." + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + ASSERT_NE(wt, nullptr); + + auto* visitor = AttachMockVisitor(wt); + EXPECT_CALL(*visitor, OnIncomingBidirectionalStreamAvailable()) + .Times(testing::AnyNumber()); + EXPECT_CALL(*visitor, OnIncomingUnidirectionalStreamAvailable()) + .Times(testing::AnyNumber()); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + + // Associate some streams with the session. + QuicStreamId peer_uni_id = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 4); + ReceiveWebTransportUnidirectionalStream(session_id, peer_uni_id, "data"); + QuicStreamId peer_bidi_id = GetNthClientInitiatedBidirectionalId(1); + ReceiveWebTransportBidirectionalStream(session_id, peer_bidi_id); + + EXPECT_GT(wt->NumberOfAssociatedStreams(), 0u) + << "Precondition: session should have associated streams"; + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*visitor, OnSessionClosed(_, _)).Times(testing::AtMost(1)); + wt->OnCloseReceived(0, "bye"); + + EXPECT_EQ(wt->NumberOfAssociatedStreams(), 0u) + << "All associated streams must be reset immediately upon " + "receiving CLOSE_WEBTRANSPORT_SESSION, not deferred until " + "OnConnectStreamClosing"; + testing::Mock::VerifyAndClearExpectations(visitor); + testing::Mock::VerifyAndClearExpectations(connection_); +} + +TEST_P(CapsuleDraft15SessionTest, + Section6_OnFinReceivedResetsAssociatedStreams) { + // Section 6: Same behavior when session terminates via FIN without capsule. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + ASSERT_NE(wt, nullptr); + + auto* visitor = AttachMockVisitor(wt); + EXPECT_CALL(*visitor, OnIncomingBidirectionalStreamAvailable()) + .Times(testing::AnyNumber()); + EXPECT_CALL(*visitor, OnIncomingUnidirectionalStreamAvailable()) + .Times(testing::AnyNumber()); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + + QuicStreamId peer_uni_id = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 4); + ReceiveWebTransportUnidirectionalStream(session_id, peer_uni_id, "data"); + + EXPECT_GT(wt->NumberOfAssociatedStreams(), 0u); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*visitor, OnSessionClosed(0, "")); + EXPECT_CALL(*writer_, WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(Return(WriteResult(WRITE_STATUS_OK, 0))); + wt->OnConnectStreamFinReceived(); + + EXPECT_EQ(wt->NumberOfAssociatedStreams(), 0u) + << "Streams must be reset upon FIN without capsule"; + testing::Mock::VerifyAndClearExpectations(visitor); + testing::Mock::VerifyAndClearExpectations(connection_); +} + +TEST_P(CapsuleDraft15SessionTest, + Section6_PostCloseDataResetsWithMessageError) { + // Section 6 MUST: "If any additional stream data is received on the + // CONNECT stream after receiving a WT_CLOSE_SESSION capsule, the stream + // MUST be reset with code H3_MESSAGE_ERROR." + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id); + ASSERT_NE(wt, nullptr); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + + // Capture STOP_SENDING frames during OnCloseReceived. + bool got_stop_sending_message_error = false; + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly( + [&got_stop_sending_message_error](const QuicFrame& frame) { + if (frame.type == STOP_SENDING_FRAME && + frame.stop_sending_frame.ietf_error_code == + static_cast( + QuicHttp3ErrorCode::MESSAGE_ERROR)) { + got_stop_sending_message_error = true; + } + test::ClearControlFrame(frame); + return true; + }); + + // Receive CLOSE_WEBTRANSPORT_SESSION from peer. + wt->OnCloseReceived(0, ""); + ASSERT_TRUE(wt->close_received()); + + // Section 6: OnCloseReceived sends STOP_SENDING to reject further data. + EXPECT_TRUE(got_stop_sending_message_error) + << "After receiving WT_CLOSE_SESSION, the CONNECT stream " + "should send STOP_SENDING with H3_MESSAGE_ERROR"; + testing::Mock::VerifyAndClearExpectations(connection_); +} + +TEST_P(CapsuleDraft15SessionTest, DrainSessionReceivedTwiceIsIdempotent) { + // Receiving WT_DRAIN_SESSION twice should not crash — the callback is + // cleared after the first invocation, and the second is a no-op. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + auto* wt = SetUpWebTransportDraft15ServerSession(GetNthClientInitiatedBidirectionalId(0)); + ASSERT_NE(wt, nullptr); + + bool drain_called = false; + wt->SetOnDraining([&drain_called]() { drain_called = true; }); + + // First WT_DRAIN_SESSION — callback should fire. + wt->OnDrainSessionReceived(); + EXPECT_TRUE(drain_called) << "Drain callback should fire on first receive"; + + // Second WT_DRAIN_SESSION — should be a no-op (no crash). + drain_called = false; + wt->OnDrainSessionReceived(); + EXPECT_FALSE(drain_called) + << "Drain callback should not fire again on second receive"; + + EXPECT_TRUE(connection_->connected()) + << "Connection should remain open after duplicate drain"; +} + +} // namespace +} // namespace quic diff --git a/quiche/quic/core/http/web_transport_draft15_test_utils.h b/quiche/quic/core/http/web_transport_draft15_test_utils.h new file mode 100644 index 000000000..743c5f9ba --- /dev/null +++ b/quiche/quic/core/http/web_transport_draft15_test_utils.h @@ -0,0 +1,371 @@ +// Copyright 2024 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. + +// Shared test infrastructure for draft-15 WebTransport tests. +// Extends QuicSpdySessionTestBase (from quic_spdy_session_test_utils.h) with +// draft-15-specific helpers for SETTINGS negotiation, session establishment, +// and capsule injection. + +#ifndef QUICHE_QUIC_CORE_HTTP_WEB_TRANSPORT_DRAFT15_TEST_UTILS_H_ +#define QUICHE_QUIC_CORE_HTTP_WEB_TRANSPORT_DRAFT15_TEST_UTILS_H_ + +#include +#include +#include +#include + +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "quiche/quic/core/http/http_constants.h" +#include "quiche/quic/core/http/http_encoder.h" +#include "quiche/quic/core/http/http_frames.h" +#include "quiche/quic/core/http/quic_header_list.h" +#include "quiche/quic/core/http/quic_spdy_session.h" +#include "quiche/quic/core/http/quic_spdy_stream.h" +#include "quiche/quic/core/http/web_transport_http3.h" +#include "quiche/quic/core/quic_constants.h" +#include "quiche/quic/core/quic_data_writer.h" +#include "quiche/quic/core/quic_types.h" +#include "quiche/quic/core/quic_versions.h" +#include "quiche/quic/test_tools/quic_config_peer.h" +#include "quiche/quic/test_tools/quic_connection_peer.h" +#include "quiche/quic/test_tools/quic_spdy_session_test_utils.h" +#include "quiche/quic/test_tools/quic_test_utils.h" +#include "quiche/quic/test_tools/web_transport_test_tools.h" +#include "quiche/common/capsule.h" +#include "quiche/web_transport/web_transport_headers.h" + +namespace quic { +namespace test { + +// Matches a QuicFrame that is a RST_STREAM with the given ietf_error_code. +// Use with SendControlFrame expectations to verify the HTTP/3 wire-level +// error code (e.g., kWtSessionGone, kWtBufferedStreamRejected). +MATCHER_P(IsRstStreamWithIetfCode, expected_code, + absl::StrCat("is RST_STREAM with ietf_error_code=0x", + absl::Hex(static_cast(expected_code)))) { + if (arg.type != RST_STREAM_FRAME || arg.rst_stream_frame == nullptr) { + return false; + } + *result_listener + << "ietf_error_code=0x" + << absl::StrCat(absl::Hex(arg.rst_stream_frame->ietf_error_code)); + return arg.rst_stream_frame->ietf_error_code == + static_cast(expected_code); +} + +// Matches a QuicFrame that is a STOP_SENDING with the given ietf_error_code. +MATCHER_P(IsStopSendingWithIetfCode, expected_code, + absl::StrCat("is STOP_SENDING with ietf_error_code=0x", + absl::Hex(static_cast(expected_code)))) { + if (arg.type != STOP_SENDING_FRAME) { + return false; + } + *result_listener + << "ietf_error_code=0x" + << absl::StrCat(absl::Hex(arg.stop_sending_frame.ietf_error_code)); + return arg.stop_sending_frame.ietf_error_code == + static_cast(expected_code); +} + +// --------------------------------------------------------------------------- +// Draft15SessionTest — parameterized test fixture for draft-15 tests. +// Extends QuicSpdySessionTestBase with draft-15-specific helpers. +// --------------------------------------------------------------------------- +class Draft15SessionTest : public QuicSpdySessionTestBase { + protected: + explicit Draft15SessionTest( + Perspective perspective = Perspective::IS_SERVER, + bool allow_extended_connect = true) + : QuicSpdySessionTestBase(perspective, allow_extended_connect) {} + + void Initialize( + WebTransportHttp3VersionSet wt_versions = WebTransportHttp3VersionSet(), + HttpDatagramSupport datagram_support = HttpDatagramSupport::kNone, + uint64_t local_max_streams_uni = 0, + uint64_t local_max_streams_bidi = 0, + uint64_t local_max_data = 0) { + session_.emplace(connection_); + if (connection_->perspective() == Perspective::IS_SERVER && + VersionIsIetfQuic(transport_version())) { + session_->set_allow_extended_connect(allow_extended_connect_); + } + session_->set_locally_supported_web_transport_versions(wt_versions); + session_->set_local_http_datagram_support(datagram_support); + // Set local WT FC limits before Initialize() so they appear in SETTINGS. + if (local_max_streams_bidi > 0) { + session_->set_wt_initial_max_streams_bidi(local_max_streams_bidi); + } + if (local_max_streams_uni > 0) { + session_->set_wt_initial_max_streams_uni(local_max_streams_uni); + } + if (local_max_data > 0) { + session_->set_wt_initial_max_data(local_max_data); + } + session_->Initialize(); + session_->config()->SetInitialStreamFlowControlWindowToSend( + kInitialStreamFlowControlWindowForTest); + session_->config()->SetInitialSessionFlowControlWindowToSend( + kInitialSessionFlowControlWindowForTest); + if (VersionIsIetfQuic(transport_version())) { + // Allow enough incoming uni streams for HTTP/3 control + WT streams. + QuicConfigPeer::SetReceivedMaxUnidirectionalStreams( + session_->config(), kHttp3StaticUnidirectionalStreamCount + 16); + } + QuicConfigPeer::SetReceivedInitialSessionFlowControlWindow( + session_->config(), kMinimumFlowControlSendWindow); + QuicConfigPeer::SetReceivedInitialMaxStreamDataBytesUnidirectional( + session_->config(), kMinimumFlowControlSendWindow); + QuicConfigPeer::SetReceivedInitialMaxStreamDataBytesIncomingBidirectional( + session_->config(), kMinimumFlowControlSendWindow); + QuicConfigPeer::SetReceivedInitialMaxStreamDataBytesOutgoingBidirectional( + session_->config(), kMinimumFlowControlSendWindow); + session_->OnConfigNegotiated(); + // Section 3.1: max_datagram_frame_size > 0 is required for WebTransport. + QuicConfigPeer::SetReceivedMaxDatagramFrameSize( + session_->config(), kMaxAcceptedDatagramFrameSize); + connection_->AdvanceTime(QuicTime::Delta::FromSeconds(1)); + TestCryptoStream* crypto_stream = session_->GetMutableCryptoStream(); + EXPECT_CALL(*crypto_stream, HasPendingRetransmission()) + .Times(testing::AnyNumber()); + writer_ = static_cast( + QuicConnectionPeer::GetWriter(session_->connection())); + } + + // Sends a peer SETTINGS frame containing draft-15 WebTransport settings. + // Also enables the reset_stream_at transport parameter on the connection + // (Section 3.1 requires it for draft-15). This must be done after + // CompleteHandshake() since OnConfigNegotiated() resets the flag. + void ReceiveWebTransportDraft15Settings( + uint64_t wt_enabled_value = 1, + uint64_t initial_max_streams_uni = 0, + uint64_t initial_max_streams_bidi = 0, + uint64_t initial_max_data = 0) { + const_cast(&connection_->framer()) + ->set_process_reset_stream_at(true); + SettingsFrame settings; + settings.values[SETTINGS_H3_DATAGRAM] = 1; + settings.values[SETTINGS_ENABLE_CONNECT_PROTOCOL] = 1; + settings.values[SETTINGS_WT_ENABLED] = wt_enabled_value; + if (initial_max_streams_uni > 0) { + settings.values[SETTINGS_WT_INITIAL_MAX_STREAMS_UNI] = + initial_max_streams_uni; + } + if (initial_max_streams_bidi > 0) { + settings.values[SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI] = + initial_max_streams_bidi; + } + if (initial_max_data > 0) { + settings.values[SETTINGS_WT_INITIAL_MAX_DATA] = initial_max_data; + } + std::string data = std::string(1, kControlStream) + + HttpEncoder::SerializeSettingsFrame(settings); + QuicStreamId control_stream_id = + session_->perspective() == Perspective::IS_SERVER + ? GetNthClientInitiatedUnidirectionalStreamId( + transport_version(), 3) + : GetNthServerInitiatedUnidirectionalStreamId( + transport_version(), 3); + QuicStreamFrame frame(control_stream_id, /*fin=*/false, /*offset=*/0, + data); + session_->OnStreamFrame(frame); + } + + // Sends peer SETTINGS for draft-07 WebTransport (for comparison tests). + void ReceiveWebTransportDraft07Settings(uint64_t max_sessions = 16) { + SettingsFrame settings; + settings.values[SETTINGS_H3_DATAGRAM] = 1; + settings.values[SETTINGS_ENABLE_CONNECT_PROTOCOL] = 1; + settings.values[SETTINGS_WEBTRANS_MAX_SESSIONS_DRAFT07] = max_sessions; + std::string data = std::string(1, kControlStream) + + HttpEncoder::SerializeSettingsFrame(settings); + QuicStreamId control_stream_id = + session_->perspective() == Perspective::IS_SERVER + ? GetNthClientInitiatedUnidirectionalStreamId( + transport_version(), 3) + : GetNthServerInitiatedUnidirectionalStreamId( + transport_version(), 3); + QuicStreamFrame frame(control_stream_id, /*fin=*/false, /*offset=*/0, + data); + session_->OnStreamFrame(frame); + } + + // Server-perspective: creates an incoming CONNECT stream via OnStreamFrame, + // delivers draft-15 headers, and completes the server handshake by calling + // HeadersReceived (which processes buffered streams and marks the session + // ready). Returns nullptr if the session could not be established. + WebTransportHttp3* AttemptWebTransportDraft15Session( + QuicStreamId session_id, + const std::string& path = "/wt") { + QuicStreamFrame frame(session_id, /*fin=*/false, /*offset=*/0, + absl::string_view()); + session_->OnStreamFrame(frame); + QuicSpdyStream* connect_stream = static_cast( + session_->GetOrCreateStream(session_id)); + if (connect_stream == nullptr) return nullptr; + QuicHeaderList headers; + headers.OnHeader(":method", "CONNECT"); + headers.OnHeader(":protocol", "webtransport-h3"); + headers.OnHeader(":scheme", "https"); + headers.OnHeader(":authority", "test.example.com"); + headers.OnHeader(":path", path); + connect_stream->OnStreamHeaderList(/*fin=*/false, 0, headers); + WebTransportHttp3* wt = session_->GetWebTransportSession(session_id); + if (wt != nullptr) { + quiche::HttpHeaderBlock response_headers; + wt->HeadersReceived(response_headers); + } + return wt; + } + + // Client-perspective: creates an outgoing CONNECT stream with WriteHeaders, + // which triggers MaybeProcessSentWebTransportHeaders. + WebTransportHttp3* AttemptWebTransportDraft15ClientSession( + const std::string& path = "/wt") { + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, + WritePacket(testing::_, testing::_, testing::_, testing::_, + testing::_, testing::_)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + TestStream* stream = session_->CreateOutgoingBidirectionalStream(); + if (stream == nullptr) return nullptr; + quiche::HttpHeaderBlock headers; + headers[":method"] = "CONNECT"; + headers[":protocol"] = "webtransport-h3"; + headers[":scheme"] = "https"; + headers[":authority"] = "test.example.com"; + headers[":path"] = path; + stream->WriteHeaders(std::move(headers), /*fin=*/false, nullptr); + testing::Mock::VerifyAndClearExpectations(writer_); + return stream->web_transport(); + } + + // Composite setup: Initialize + CompleteHandshake + ReceiveWebTransportDraft15Settings + + // AttemptWebTransportDraft15Session. Returns non-null WebTransportHttp3* or fails. + WebTransportHttp3* SetUpWebTransportDraft15ServerSession( + QuicStreamId session_id, + uint64_t initial_max_streams_uni = 0, + uint64_t initial_max_streams_bidi = 0, + uint64_t initial_max_data = 0) { + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + initial_max_streams_uni, + initial_max_streams_bidi, + initial_max_data); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + initial_max_streams_uni, + initial_max_streams_bidi, + initial_max_data); + return AttemptWebTransportDraft15Session(session_id); + } + + // Client-perspective composite setup. + WebTransportHttp3* SetUpWebTransportDraft15ClientSession( + uint64_t initial_max_streams_uni = 0, + uint64_t initial_max_streams_bidi = 0, + uint64_t initial_max_data = 0, + const std::string& path = "/wt") { + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + initial_max_streams_uni, + initial_max_streams_bidi, + initial_max_data); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + initial_max_streams_uni, + initial_max_streams_bidi, + initial_max_data); + return AttemptWebTransportDraft15ClientSession(path); + } + + // Dispatches a capsule to the WebTransport session associated with the + // given CONNECT stream. This calls the OnCapsule handler directly, + // bypassing HTTP/3 frame decoding and CapsuleParser. This is appropriate + // for unit tests where headers are delivered via OnStreamHeaderList() + // (not through the HTTP/3 decoder). Real capsule parsing is tested in + // end-to-end tests that use the full HTTP/3 stack. + void InjectCapsuleOnConnectStream(QuicStreamId session_id, + const quiche::Capsule& capsule) { + QuicSpdyStream* stream = static_cast( + session_->GetOrCreateStream(session_id)); + ASSERT_NE(stream, nullptr); + stream->OnCapsule(capsule); + } + + // Injects an incoming bidirectional WT stream: varint(0x41) + varint(session_id). + void ReceiveWebTransportBidirectionalStream( + QuicStreamId session_id, + QuicStreamId stream_id) { + std::string data; + // Encode the WT_STREAM signal (0x41) as a proper QUIC varint. + char type_buf[8]; + QuicDataWriter type_writer(sizeof(type_buf), type_buf); + ASSERT_TRUE(type_writer.WriteVarInt62(0x41)); + data.append(type_buf, type_writer.length()); + // Encode session_id as varint. + char varint_buf[8]; + QuicDataWriter varint_writer(sizeof(varint_buf), varint_buf); + ASSERT_TRUE(varint_writer.WriteVarInt62(session_id)); + data.append(varint_buf, varint_writer.length()); + QuicStreamFrame frame(stream_id, /*fin=*/false, /*offset=*/0, data); + session_->OnStreamFrame(frame); + } + + // Client-perspective: delivers response headers on a CONNECT stream and + // triggers HeadersReceived() on the WebTransport session, mirroring the + // behavior of QuicSpdyClientStream::OnInitialHeadersComplete(). + void ReceiveWebTransportDraft15Response( + QuicStreamId stream_id, + int status_code, + std::optional wt_protocol = std::nullopt) { + QuicSpdyStream* stream = static_cast( + session_->GetOrCreateStream(stream_id)); + ASSERT_NE(stream, nullptr); + // Serialize wt-protocol as a Structured Fields Item (quoted string), + // matching the wire format that ParseSubprotocolResponseHeader expects. + std::string serialized_wt_protocol; + if (wt_protocol.has_value()) { + auto serialized = + webtransport::SerializeSubprotocolResponseHeader(*wt_protocol); + ASSERT_TRUE(serialized.ok()) << serialized.status(); + serialized_wt_protocol = *serialized; + } + QuicHeaderList headers; + headers.OnHeader(":status", std::to_string(status_code)); + if (wt_protocol.has_value()) { + headers.OnHeader("wt-protocol", serialized_wt_protocol); + } + stream->OnStreamHeaderList(/*fin=*/false, 0, headers); + // TestStream inherits from QuicSpdyStream, not + // QuicSpdyClientStream. The latter calls + // web_transport()->HeadersReceived() automatically in + // OnInitialHeadersComplete(); we must do so explicitly here. + if (stream->web_transport() != nullptr) { + quiche::HttpHeaderBlock header_block; + header_block[":status"] = std::to_string(status_code); + if (wt_protocol.has_value()) { + header_block["wt-protocol"] = serialized_wt_protocol; + } + stream->web_transport()->HeadersReceived(header_block); + } + } + + // Creates and attaches a MockWebTransportSessionVisitor. Returns raw + // pointer for EXPECT_CALL usage. + MockWebTransportSessionVisitor* AttachMockVisitor(WebTransportHttp3* wt) { + auto visitor = std::make_unique< + testing::NiceMock>(); + MockWebTransportSessionVisitor* raw = visitor.get(); + wt->SetVisitor(std::move(visitor)); + return raw; + } +}; + +} // namespace test +} // namespace quic + +#endif // QUICHE_QUIC_CORE_HTTP_WEB_TRANSPORT_DRAFT15_TEST_UTILS_H_ diff --git a/quiche/quic/core/http/web_transport_error_codes_draft15_test.cc b/quiche/quic/core/http/web_transport_error_codes_draft15_test.cc new file mode 100644 index 000000000..9e13d2783 --- /dev/null +++ b/quiche/quic/core/http/web_transport_error_codes_draft15_test.cc @@ -0,0 +1,554 @@ +// Copyright 2024 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. + +// Draft-15 acceptance tests for WebTransport error code mapping and new +// error code codepoints (Section 4.4, 9.5). + +#include +#include + +#include "quiche/quic/core/http/quic_spdy_stream.h" +#include "quiche/quic/core/http/web_transport_draft15_test_utils.h" +#include "quiche/quic/core/http/web_transport_http3.h" +#include "quiche/quic/core/quic_framer.h" +#include "quiche/quic/platform/api/quic_test.h" +#include "quiche/common/capsule.h" +#include "quiche/common/platform/api/quiche_expect_bug.h" +#include "quiche/web_transport/test_tools/draft15_constants.h" +#include "quiche/web_transport/test_tools/mock_web_transport.h" + +namespace quic { +namespace { + +using ::testing::_; +using ::testing::Not; +using ::testing::Optional; + +// --- Error code mapping (Section 4.4) --- +// These tests validate the WebTransport <-> HTTP/3 error code mapping algorithm. +// The mapping is the same across drafts, so these PASS immediately. + +TEST(WebTransportErrorCodesDraft15, ErrorCodeRangeFirst) { + // WebTransportErrorToHttp3(0) must equal the first app error codepoint. + EXPECT_EQ(webtransport::draft15::kWtApplicationErrorFirst, + WebTransportErrorToHttp3(0x00)); +} + +TEST(WebTransportErrorCodesDraft15, ErrorCodeRangeLast) { + // WebTransportErrorToHttp3(0xffffffff) must equal the last app error + // codepoint. + EXPECT_EQ(webtransport::draft15::kWtApplicationErrorLast, + WebTransportErrorToHttp3(0xffffffff)); +} + +TEST(WebTransportErrorCodesDraft15, GREASESkipping) { + // The mapping must skip GREASE codepoints (0x1f * N + 0x21). + // Error 0x1c maps to a non-GREASE value: + uint64_t mapped_1c = WebTransportErrorToHttp3(0x1c); + EXPECT_NE(mapped_1c % 0x1f, 0x21 % 0x1f) + << "Error 0x1c should not map to a GREASE codepoint"; + + // Error 0x1d maps to a non-GREASE value: + uint64_t mapped_1d = WebTransportErrorToHttp3(0x1d); + EXPECT_NE(mapped_1d % 0x1f, 0x21 % 0x1f) + << "Error 0x1d should not map to a GREASE codepoint"; + + // The codepoint between them (0x52e4a40fa8f9) IS a GREASE codepoint and + // must not appear in the range of the mapping. + EXPECT_EQ(Http3ErrorToWebTransport(0x52e4a40fa8f9), std::nullopt) + << "GREASE codepoint must not reverse-map to a valid error"; +} + +TEST(WebTransportErrorCodesDraft15, OutsideApplicationErrorRange) { + // Codes outside the WT_APPLICATION_ERROR range should not map. + EXPECT_EQ(Http3ErrorToWebTransport(0), std::nullopt); + EXPECT_EQ( + Http3ErrorToWebTransport(webtransport::draft15::kWtApplicationErrorFirst - + 1), + std::nullopt); + EXPECT_EQ( + Http3ErrorToWebTransport(webtransport::draft15::kWtApplicationErrorLast + + 1), + std::nullopt); +} + +// --- Error code round-trip test (Section 4.4) --- +// Verifies that encoding then decoding produces the original value for +// several representative error codes. + +TEST(WebTransportErrorCodesDraft15, ErrorCodeRoundTrip) { + // For several representative error codes, verify the round-trip: + // Http3ErrorToWebTransport(WebTransportErrorToHttp3(e)) == e + const uint32_t test_codes[] = {0, 0xff, 0xffff, 0xffffffff}; + for (uint32_t e : test_codes) { + uint64_t http3_code = WebTransportErrorToHttp3(e); + EXPECT_THAT(Http3ErrorToWebTransport(http3_code), Optional(e)) + << "Round-trip failed for error code " << e; + } +} + +// --- SessionGoneOnStreamReset (Section 9.5) --- +// Verifies the kWtSessionGone constant and documents expected behavior. + +TEST(WebTransportErrorCodesDraft15, SessionGoneOnStreamReset) { + // kWtSessionGone (0x170d7b68) is an HTTP/3 error code used to reset + // streams associated with a terminated WebTransport session. + // This is a fixed protocol error code, not an application error code, + // so it should NOT reverse-map via Http3ErrorToWebTransport (which only + // handles the WT_APPLICATION_ERROR range). + EXPECT_EQ(webtransport::draft15::kWtSessionGone, 0x170d7b68u); + EXPECT_EQ(Http3ErrorToWebTransport(webtransport::draft15::kWtSessionGone), + std::nullopt) + << "kWtSessionGone is a protocol error code, not an application error; " + "it should not reverse-map"; +} + +// --- ResetStreamAtReliableSize (Section 4.4, requires session) --- + +class ErrorCodesDraft15SessionTest : public test::Draft15SessionTest { + protected: + ErrorCodesDraft15SessionTest() : Draft15SessionTest(Perspective::IS_SERVER) {} +}; + +INSTANTIATE_TEST_SUITE_P(ErrorCodesDraft15SessionTests, + ErrorCodesDraft15SessionTest, + ::testing::ValuesIn(CurrentSupportedVersions())); + +TEST_P(ErrorCodesDraft15SessionTest, + SessionTerminationAbortsIncomingStreamsWithSessionGone) { + // Section 6: "the endpoint MUST reset the send side and abort reading on + // the receive side of all [...] streams [...] using the WT_SESSION_GONE + // error code." For incoming streams, "abort reading" means STOP_SENDING. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + auto* wt = SetUpWebTransportDraft15ServerSession( + GetNthClientInitiatedBidirectionalId(0), + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + ASSERT_NE(wt, nullptr); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + + // Receive a peer-initiated unidirectional stream. + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)).Times(testing::AnyNumber()); + ReceiveWebTransportUnidirectionalStream( + GetNthClientInitiatedBidirectionalId(0), + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 4), + "data"); + ASSERT_GT(wt->NumberOfAssociatedStreams(), 0u); + testing::Mock::VerifyAndClearExpectations(connection_); + + // Close the session. The incoming stream must receive STOP_SENDING + // with kWtSessionGone (0x170d7b68). + EXPECT_CALL(*connection_, + SendControlFrame(test::IsStopSendingWithIetfCode( + webtransport::draft15::kWtSessionGone))) + .Times(testing::AtLeast(1)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, + SendControlFrame(Not(test::IsStopSendingWithIetfCode( + webtransport::draft15::kWtSessionGone)))) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)).Times(testing::AnyNumber()); + wt->CloseSession(0, "closing"); + testing::Mock::VerifyAndClearExpectations(connection_); +} + +TEST_P(ErrorCodesDraft15SessionTest, + SessionTerminationResetsOutgoingStreamsWithSessionGone) { + // Section 6: "the endpoint MUST reset the send side [...] of all [...] + // streams [...] using the WT_SESSION_GONE error code." + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + auto* wt = SetUpWebTransportDraft15ServerSession( + GetNthClientInitiatedBidirectionalId(0), + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + ASSERT_NE(wt, nullptr); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + + // Open an outgoing unidirectional stream and write data. + webtransport::Stream* stream = wt->OpenOutgoingUnidirectionalStream(); + ASSERT_NE(stream, nullptr); + EXPECT_TRUE(stream->Write("payload")); + QuicStreamId stream_id = stream->GetStreamId(); + ASSERT_GT(wt->NumberOfAssociatedStreams(), 0u); + + // Get the underlying QUIC stream before CloseSession destroys it. + QuicStream* quic_stream = session_->GetActiveStream(stream_id); + ASSERT_NE(quic_stream, nullptr); + + // Close the session. + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)).Times(testing::AnyNumber()); + wt->CloseSession(0, "closing"); + EXPECT_EQ(wt->NumberOfAssociatedStreams(), 0u); + + // The stream's error code must be kWtSessionGone on the wire. + EXPECT_EQ(quic_stream->ietf_application_error(), + webtransport::draft15::kWtSessionGone); + testing::Mock::VerifyAndClearExpectations(connection_); +} + +TEST_P(ErrorCodesDraft15SessionTest, ResetStreamAtReliableSize) { + // Section 4.4 MUST: When resetting a WT stream, the endpoint MUST use + // RESET_STREAM_AT with Reliable Size >= the stream header size. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + auto* wt = SetUpWebTransportDraft15ServerSession(GetNthClientInitiatedBidirectionalId(0), + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, + WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + + // Open a WT stream and write some data. + webtransport::Stream* stream = wt->OpenOutgoingUnidirectionalStream(); + ASSERT_NE(stream, nullptr); + EXPECT_TRUE(stream->Write("hello")); + + // Section 4.4 MUST: ResetWithUserCode must send RESET_STREAM_AT (not + // plain RST_STREAM) with reliable_offset >= stream header size. + bool got_rst_stream_at = false; + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly([&got_rst_stream_at](const QuicFrame& frame) { + if (frame.type == RESET_STREAM_AT_FRAME) { + got_rst_stream_at = true; + // WT uni stream preamble: varint(stream_type) + varint(session_id). + // For small session_ids, this is at least 2 bytes. + EXPECT_GE(frame.reset_stream_at_frame->reliable_offset, 2u) + << "RESET_STREAM_AT reliable_offset must be >= " + "stream header size"; + } + test::ClearControlFrame(frame); + return true; + }); + + stream->ResetWithUserCode(0); + + EXPECT_TRUE(got_rst_stream_at) + << "WT stream reset must use RESET_STREAM_AT, " + "not plain RST_STREAM"; + testing::Mock::VerifyAndClearExpectations(writer_); + testing::Mock::VerifyAndClearExpectations(connection_); +} + +TEST_P(ErrorCodesDraft15SessionTest, + Section4_4_NonWtErrorCodeDeliveredAsZero) { + // Section 4.4.2 SHOULD: "If an endpoint receives a RESET_STREAM [...] + // with an error code that is [...] not in the WebTransport application + // error code range, it SHOULD be treated as a stream reset with no + // application error provided." + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + ASSERT_NE(wt, nullptr); + + auto* visitor = AttachMockVisitor(wt); + EXPECT_CALL(*visitor, OnIncomingUnidirectionalStreamAvailable()) + .Times(testing::AnyNumber()); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)).Times(testing::AnyNumber()); + QuicStreamId uni_stream_id = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 4); + ReceiveWebTransportUnidirectionalStream(session_id, uni_stream_id, "data"); + + WebTransportStream* wt_stream = wt->AcceptIncomingUnidirectionalStream(); + ASSERT_NE(wt_stream, nullptr); + + auto stream_visitor = + std::make_unique>(); + auto* raw_stream_visitor = stream_visitor.get(); + wt_stream->SetVisitor(std::move(stream_visitor)); + + // 0x42 is outside the WT application error range + // [kWebTransportMappedErrorCodeFirst, ...]. + QuicStreamId quic_stream_id = + static_cast(wt_stream->GetStreamId()); + QuicRstStreamFrame rst_frame(/*control_frame_id=*/1, quic_stream_id, + QUIC_STREAM_CANCELLED, /*bytes_written=*/0); + rst_frame.ietf_error_code = 0x42; + + EXPECT_CALL(*raw_stream_visitor, OnResetStreamReceived(0)) + .Times(1); + auto* quic_stream = session_->GetOrCreateStream(quic_stream_id); + ASSERT_NE(quic_stream, nullptr); + quic_stream->OnStreamReset(rst_frame); + testing::Mock::VerifyAndClearExpectations(raw_stream_visitor); + testing::Mock::VerifyAndClearExpectations(connection_); +} + +TEST_P(ErrorCodesDraft15SessionTest, + Section4_4_ResetAssociatedStreamsUsesResetStreamAt) { + // Section 4.4 MUST: When a session is terminated and associated streams + // are reset, RESET_STREAM_AT must be used (not plain RST_STREAM). + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + auto* wt = SetUpWebTransportDraft15ServerSession(GetNthClientInitiatedBidirectionalId(0), + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, + WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + + // Open a WT stream and write some data so it has a preamble. + webtransport::Stream* stream = wt->OpenOutgoingUnidirectionalStream(); + ASSERT_NE(stream, nullptr); + EXPECT_TRUE(stream->Write("hello")); + + // Close the session — ResetAssociatedStreams() should use RESET_STREAM_AT. + bool got_rst_stream_at = false; + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly([&got_rst_stream_at](const QuicFrame& frame) { + if (frame.type == RESET_STREAM_AT_FRAME) { + got_rst_stream_at = true; + } + test::ClearControlFrame(frame); + return true; + }); + + wt->CloseSession(0, "done"); + + EXPECT_TRUE(got_rst_stream_at) + << "ResetAssociatedStreams must use RESET_STREAM_AT, " + "not plain RST_STREAM, when resetting WT data streams during " + "session teardown"; + testing::Mock::VerifyAndClearExpectations(writer_); + testing::Mock::VerifyAndClearExpectations(connection_); +} + +// --- Error code value assertions (Section 9.5) --- +// These verify the IANA-registered codepoint values. PASS immediately since +// they only check compile-time constants. + +TEST(WebTransportErrorCodesDraft15, WtBufferedStreamRejectedValue) { + EXPECT_EQ(webtransport::draft15::kWtBufferedStreamRejected, 0x3994bd84u); +} + +TEST(WebTransportErrorCodesDraft15, WtSessionGoneValue) { + EXPECT_EQ(webtransport::draft15::kWtSessionGone, 0x170d7b68u); +} + +TEST(WebTransportErrorCodesDraft15, WtFlowControlErrorValue) { + EXPECT_EQ(kWtFlowControlError, 0x045d4487u); +} + +TEST(WebTransportErrorCodesDraft15, WtAlpnErrorValue) { + EXPECT_EQ(kWtAlpnError, 0x0817b3ddu); +} + +TEST(WebTransportErrorCodesDraft15, WtRequirementsNotMetValue) { + EXPECT_EQ(webtransport::draft15::kWtRequirementsNotMet, 0x212c0d48u); +} + +// --- Prohibited per-stream FC capsules (Section 5.4) --- + +TEST_P(ErrorCodesDraft15SessionTest, Section5_4_ProhibitedWtMaxStreamData) { + // Section 5.4 MUST: WT_MAX_STREAM_DATA is prohibited in draft-15 (per-stream + // FC is provided by QUIC natively). Receipt MUST close the session with + // WT_FLOW_CONTROL_ERROR, not SESSION_NO_ERROR (0). + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + auto* wt = SetUpWebTransportDraft15ServerSession(GetNthClientInitiatedBidirectionalId(0), + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + ASSERT_NE(wt, nullptr); + auto* visitor = AttachMockVisitor(wt); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + + // Section 5.4 MUST: Session closed with WT_FLOW_CONTROL_ERROR. + EXPECT_CALL(*visitor, OnSessionClosed( + static_cast(kWtFlowControlError), _)) + .Times(1); + + InjectCapsuleOnConnectStream( + GetNthClientInitiatedBidirectionalId(0), + quiche::Capsule(quiche::WebTransportMaxStreamDataCapsule{ + /*stream_id=*/0, /*max_stream_data=*/1024})); +} + +TEST_P(ErrorCodesDraft15SessionTest, Section5_4_ProhibitedWtStreamDataBlocked) { + // Section 5.4 MUST: WT_STREAM_DATA_BLOCKED is prohibited in draft-15. + // Receipt MUST close the session with WT_FLOW_CONTROL_ERROR. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + auto* wt = SetUpWebTransportDraft15ServerSession(GetNthClientInitiatedBidirectionalId(0), + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + ASSERT_NE(wt, nullptr); + auto* visitor = AttachMockVisitor(wt); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + + // Section 5.4 MUST: Session closed with WT_FLOW_CONTROL_ERROR. + EXPECT_CALL(*visitor, OnSessionClosed( + static_cast(kWtFlowControlError), _)) + .Times(1); + + InjectCapsuleOnConnectStream( + GetNthClientInitiatedBidirectionalId(0), + quiche::Capsule(quiche::WebTransportStreamDataBlockedCapsule{ + /*stream_id=*/0, /*stream_data_limit=*/512})); +} + +TEST_P(ErrorCodesDraft15SessionTest, Section6_ConnectStreamStopsReadingAfterInternalError) { + // Section 6: After CloseSession() via OnInternalError(), the CONNECT + // stream should stop reading to prevent processing further capsules. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + auto* wt = SetUpWebTransportDraft15ServerSession(GetNthClientInitiatedBidirectionalId(0), + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + auto* visitor = AttachMockVisitor(wt); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, + WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + EXPECT_CALL(*visitor, OnSessionClosed(_, _)).Times(testing::AnyNumber()); + + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + + // Inject WT_MAX_DATA(65536) to set max_data_send_ = 65536. + InjectCapsuleOnConnectStream( + session_id, + quiche::Capsule(quiche::WebTransportMaxDataCapsule{/*max_data=*/65536})); + + // Inject WT_MAX_DATA(32000) — a decrease, which triggers OnInternalError + // with kWtFlowControlError, which calls CloseSession(). + InjectCapsuleOnConnectStream( + session_id, + quiche::Capsule(quiche::WebTransportMaxDataCapsule{/*max_data=*/32000})); + + // After CloseSession(), the CONNECT stream should have StopReading() + // called to prevent processing further capsules/data. + QuicSpdyStream* connect_stream = static_cast( + session_->GetOrCreateStream(session_id)); + ASSERT_NE(connect_stream, nullptr); + EXPECT_TRUE(connect_stream->reading_stopped()) + << "After CloseSession() via OnInternalError(), " + "StopReading() should have been called on the CONNECT stream"; +} + +TEST_P(ErrorCodesDraft15SessionTest, Section6_OversizedCloseMessageRejected) { + // Section 6: "its length MUST NOT exceed 1024 bytes." + // A WT_CLOSE_SESSION capsule with an error message exceeding 1024 bytes + // should be treated as a protocol error, NOT accepted as a normal close. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + ASSERT_NE(wt, nullptr); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + + // Deliver an oversized CLOSE_WEBTRANSPORT_SESSION. + std::string oversized_message(2000, 'x'); + wt->OnCloseReceived(/*error_code=*/42, oversized_message); + + // The close should be REJECTED (protocol error), NOT accepted. + // close_received() returns true only when the close is accepted normally. + EXPECT_FALSE(wt->close_received()) + << "Oversized error message (>1024 bytes) should be " + "rejected as a protocol error, not accepted as a normal close"; +} + +TEST_P(ErrorCodesDraft15SessionTest, + Section6_OversizedCloseMessageUsesCorrectErrorCode) { + // Section 9.5: WT_FLOW_CONTROL_ERROR (0x045d4487) is defined as + // "flow control error". An oversized close message is a framing violation, + // not a flow control error. The error code should NOT be kWtFlowControlError. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + ASSERT_NE(wt, nullptr); + auto* visitor = AttachMockVisitor(wt); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + + // A mock is necessary here because there's no other way to observe + // the error code used in the session closure. + EXPECT_CALL(*visitor, OnSessionClosed( + Not(static_cast(kWtFlowControlError)), + _)) + .Times(1); + + std::string oversized(2000, 'x'); + wt->OnCloseReceived(0, oversized); + testing::Mock::VerifyAndClearExpectations(visitor); +} + +TEST_P(ErrorCodesDraft15SessionTest, + Section6_CloseSessionTruncatesOversizedMessage) { + // Section 6 MUST NOT: "its length MUST NOT exceed 1024 bytes." + // The sending side must truncate oversized error messages. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + auto* wt = SetUpWebTransportDraft15ServerSession(GetNthClientInitiatedBidirectionalId(0)); + ASSERT_NE(wt, nullptr); + + session_->set_writev_consumes_all_data(true); + std::string long_message(2000, 'x'); + EXPECT_QUICHE_BUG(wt->CloseSession(42, long_message), + "exceeds 1024 bytes"); +} + +} // namespace +} // namespace quic diff --git a/quiche/quic/core/http/web_transport_flow_control_draft15_test.cc b/quiche/quic/core/http/web_transport_flow_control_draft15_test.cc new file mode 100644 index 000000000..739c52031 --- /dev/null +++ b/quiche/quic/core/http/web_transport_flow_control_draft15_test.cc @@ -0,0 +1,2211 @@ +// Copyright 2024 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. + +// Draft-15 acceptance tests for session-level flow control (Section 5). +// This is the largest test file — 22 tests covering FC negotiation, stream +// limits, data limits, and SETTINGS defaults. + +#include +#include +#include + +#include "quiche/common/capsule.h" +#include "quiche/quic/core/http/http_constants.h" +#include "quiche/quic/core/http/http_encoder.h" +#include "quiche/quic/core/http/http_frames.h" +#include "quiche/quic/core/http/quic_spdy_session.h" +#include "quiche/quic/test_tools/quic_session_peer.h" +#include "quiche/quic/core/http/web_transport_draft15_test_utils.h" +#include "quiche/quic/platform/api/quic_test.h" +#include "quiche/web_transport/test_tools/draft15_constants.h" +#include "quiche/web_transport/web_transport.h" + +namespace quic { +namespace { + +using ::quiche::Capsule; +using ::quiche::WebTransportMaxDataCapsule; +using ::quiche::WebTransportMaxStreamsCapsule; +using ::testing::_; +using ::testing::Invoke; + +class FlowControlDraft15Test : public test::Draft15SessionTest { + protected: + FlowControlDraft15Test() : Draft15SessionTest(Perspective::IS_SERVER) {} +}; + +INSTANTIATE_TEST_SUITE_P(FlowControlDraft15, FlowControlDraft15Test, + ::testing::ValuesIn(CurrentSupportedVersions())); + +// ========================================================================== +// Flow control negotiation (Section 5.1) +// ========================================================================== + +TEST_P(FlowControlDraft15Test, FlowControlEnabledBothSendNonZero) { + // Section 5.1: FC is enabled when both endpoints send at least one + // non-zero SETTINGS_WT_INITIAL_MAX_* value. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + EXPECT_EQ(webtransport::draft15::kSettingsWtInitialMaxStreamsUni, 0x2b64u); + EXPECT_EQ(webtransport::draft15::kSettingsWtInitialMaxStreamsBidi, 0x2b65u); + EXPECT_EQ(webtransport::draft15::kSettingsWtInitialMaxData, 0x2b61u); + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/65536); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + auto* wt = AttemptWebTransportDraft15Session(GetNthClientInitiatedBidirectionalId(0)); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + // With FC enabled and bidi limit of 10, the first 10 streams should + // succeed and the 11th should be blocked. + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, + WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + for (int i = 0; i < 10; ++i) { + webtransport::Stream* stream = wt->OpenOutgoingBidirectionalStream(); + EXPECT_NE(stream, nullptr) + << "Stream " << i << " should succeed (within limit of 10)"; + } + webtransport::Stream* blocked_stream = wt->OpenOutgoingBidirectionalStream(); + EXPECT_EQ(blocked_stream, nullptr) + << "11th bidi stream should be blocked by WT-level FC (limit=10)"; + testing::Mock::VerifyAndClearExpectations(writer_); +} + +TEST_P(FlowControlDraft15Test, FlowControlDisabledOnlyOneSends) { + // Section 5.1: FC is not enabled if only one endpoint sends + // non-zero limits. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + // Peer sends non-zero FC limits, but local side sends all zeros (default). + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + auto* wt = AttemptWebTransportDraft15Session(GetNthClientInitiatedBidirectionalId(0)); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + // With FC disabled (only peer sent non-zero), streams should only be + // limited by QUIC-level limits, not WT-level limits. Opening streams + // should succeed freely. + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, + WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + webtransport::Stream* stream = wt->OpenOutgoingBidirectionalStream(); + EXPECT_NE(stream, nullptr) + << "With FC disabled, opening a stream should succeed"; + testing::Mock::VerifyAndClearExpectations(writer_); +} + +TEST_P(FlowControlDraft15Test, FlowControlDisabledBothZero) { + // Section 5.1: All default values (0) = FC not enabled. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + // Both sides send all-zero FC limits (the default). + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/0, + /*initial_max_streams_bidi=*/0, + /*initial_max_data=*/0); + auto* wt = AttemptWebTransportDraft15Session(GetNthClientInitiatedBidirectionalId(0)); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + // FC disabled: session is functional, no WT-level FC applies. + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, + WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + webtransport::Stream* stream = wt->OpenOutgoingBidirectionalStream(); + EXPECT_NE(stream, nullptr) + << "With FC disabled (both zero), stream creation should succeed"; + testing::Mock::VerifyAndClearExpectations(writer_); +} + +TEST_P(FlowControlDraft15Test, IgnoreFCCapsulesWhenDisabled) { + // Section 5.1 MUST: FC capsules are ignored when FC is not enabled. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/0, + /*initial_max_streams_bidi=*/0, + /*initial_max_data=*/0); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + // Inject FC capsules on the CONNECT stream. With FC disabled, they should + // be silently ignored (no crash, no connection close). + InjectCapsuleOnConnectStream( + session_id, + Capsule(WebTransportMaxDataCapsule{/*max_data=*/65536})); + InjectCapsuleOnConnectStream( + session_id, + Capsule(WebTransportMaxStreamsCapsule{ + webtransport::StreamType::kBidirectional, /*max_stream_count=*/10})); + + // Session and connection should still be alive. + EXPECT_TRUE(connection_->connected()); +} + +// ========================================================================== +// Stream limits (Section 5.3) +// ========================================================================== + +TEST_P(FlowControlDraft15Test, WtMaxStreamsBidiCumulative) { + // Section 5.3: WT_MAX_STREAMS_BIDI is a cumulative limit including + // closed streams. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/65536); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/3, + /*initial_max_data=*/65536); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, + WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + + // Open 3 bidi streams (the limit). + for (int i = 0; i < 3; ++i) { + webtransport::Stream* stream = wt->OpenOutgoingBidirectionalStream(); + EXPECT_NE(stream, nullptr) << "Bidi stream " << i << " should succeed"; + } + + // 4th should fail (limit is 3). + webtransport::Stream* blocked = wt->OpenOutgoingBidirectionalStream(); + EXPECT_EQ(blocked, nullptr) + << "4th bidi stream should be blocked by WT_MAX_STREAMS_BIDI=3"; + + // Raise the limit via capsule to 5. + InjectCapsuleOnConnectStream( + session_id, + Capsule(WebTransportMaxStreamsCapsule{ + webtransport::StreamType::kBidirectional, /*max_stream_count=*/5})); + + // Now 4th should succeed. + webtransport::Stream* unblocked = wt->OpenOutgoingBidirectionalStream(); + EXPECT_NE(unblocked, nullptr) + << "4th bidi stream should succeed after WT_MAX_STREAMS raised to 5"; + testing::Mock::VerifyAndClearExpectations(writer_); +} + +TEST_P(FlowControlDraft15Test, WtMaxStreamsUnidiCumulative) { + // Section 5.3: Same for unidirectional streams. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/65536); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/3, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, + WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + + // Open 3 uni streams (the limit). + for (int i = 0; i < 3; ++i) { + webtransport::Stream* stream = wt->OpenOutgoingUnidirectionalStream(); + EXPECT_NE(stream, nullptr) << "Uni stream " << i << " should succeed"; + } + + // 4th should fail. + webtransport::Stream* blocked = wt->OpenOutgoingUnidirectionalStream(); + EXPECT_EQ(blocked, nullptr) + << "4th uni stream should be blocked by WT_MAX_STREAMS_UNI=3"; + + // Raise limit to 5. + InjectCapsuleOnConnectStream( + session_id, + Capsule(WebTransportMaxStreamsCapsule{ + webtransport::StreamType::kUnidirectional, /*max_stream_count=*/5})); + + // 4th should now succeed. + webtransport::Stream* unblocked = wt->OpenOutgoingUnidirectionalStream(); + EXPECT_NE(unblocked, nullptr) + << "4th uni stream should succeed after WT_MAX_STREAMS raised to 5"; + testing::Mock::VerifyAndClearExpectations(writer_); +} + +TEST_P(FlowControlDraft15Test, ExceedStreamLimitClosesSession) { + // Section 5.3 MUST: Exceeding stream limit closes the session with + // WT_FLOW_CONTROL_ERROR. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + EXPECT_EQ(kWtFlowControlError, 0x045d4487u); + // Local bidi=2 sets our incoming limit; peer values are higher. + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/2, + /*local_max_data=*/65536); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/2, + /*initial_max_streams_bidi=*/2, + /*initial_max_data=*/65536); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + auto* visitor = AttachMockVisitor(wt); + + // Peer sends 3 incoming bidi streams, exceeding the limit of 2. + // The session should be closed with WT_FLOW_CONTROL_ERROR. + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + EXPECT_CALL(*visitor, OnIncomingBidirectionalStreamAvailable()) + .Times(testing::AnyNumber()); + // Section 5.3 MUST: Exceeding incoming stream limit closes session. + EXPECT_CALL(*visitor, OnSessionClosed( + static_cast( + kWtFlowControlError), + _)) + .Times(1); + + ReceiveWebTransportBidirectionalStream( + session_id, GetNthClientInitiatedBidirectionalId(1)); + ReceiveWebTransportBidirectionalStream( + session_id, GetNthClientInitiatedBidirectionalId(2)); + // This 3rd stream exceeds the limit. + ReceiveWebTransportBidirectionalStream( + session_id, GetNthClientInitiatedBidirectionalId(3)); +} + +TEST_P(FlowControlDraft15Test, MaxStreamsCannotExceed2Pow60) { + // Section 5.6.2 MUST: Max stream count cannot exceed 2^60. + // "Receipt of a capsule with a Maximum Streams value larger than this + // limit MUST be treated as an HTTP/3 error of type H3_DATAGRAM_ERROR." + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/65536); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + EXPECT_CALL(*connection_, CloseConnection(_, _, _, _)) + .WillOnce( + Invoke(connection_, &test::MockQuicConnection::ReallyCloseConnection4)); + EXPECT_CALL(*connection_, SendConnectionClosePacket(_, _, _)) + .Times(testing::AnyNumber()); + + uint64_t too_large = (1ULL << 60) + 1; + InjectCapsuleOnConnectStream( + session_id, + Capsule(WebTransportMaxStreamsCapsule{ + webtransport::StreamType::kBidirectional, + /*max_stream_count=*/too_large})); + EXPECT_FALSE(connection_->connected()); +} + +TEST_P(FlowControlDraft15Test, MaxStreamsCannotExceed2Pow60_Unidi) { + // Section 5.6.2 MUST: Same requirement for unidirectional streams. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/65536); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + EXPECT_CALL(*connection_, CloseConnection(_, _, _, _)) + .WillOnce( + Invoke(connection_, &test::MockQuicConnection::ReallyCloseConnection4)); + EXPECT_CALL(*connection_, SendConnectionClosePacket(_, _, _)) + .Times(testing::AnyNumber()); + + uint64_t too_large = (1ULL << 60) + 1; + InjectCapsuleOnConnectStream( + session_id, + Capsule(WebTransportMaxStreamsCapsule{ + webtransport::StreamType::kUnidirectional, + /*max_stream_count=*/too_large})); + EXPECT_FALSE(connection_->connected()); +} + +TEST_P(FlowControlDraft15Test, DecreasingMaxStreamsIsError) { + // Section 5.6.2 MUST: Receiving a smaller WT_MAX_STREAMS value than + // previously advertised triggers WT_FLOW_CONTROL_ERROR. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/65536); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + auto* visitor = AttachMockVisitor(wt); + + // First, raise to 20. + InjectCapsuleOnConnectStream( + session_id, + Capsule(WebTransportMaxStreamsCapsule{ + webtransport::StreamType::kBidirectional, + /*max_stream_count=*/20})); + + // Section 5.6.2 MUST: Decreasing WT_MAX_STREAMS triggers + // WT_FLOW_CONTROL_ERROR. + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*visitor, OnSessionClosed( + static_cast( + kWtFlowControlError), + _)) + .Times(1); + InjectCapsuleOnConnectStream( + session_id, + Capsule(WebTransportMaxStreamsCapsule{ + webtransport::StreamType::kBidirectional, + /*max_stream_count=*/15})); +} + +TEST_P(FlowControlDraft15Test, StreamsBlockedSentAtLimit) { + // Section 5.6.3 SHOULD: WT_STREAMS_BLOCKED sent when the stream limit + // is reached. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/65536); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/2, + /*initial_max_streams_bidi=*/2, + /*initial_max_data=*/65536); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, + WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + + // Open 2 bidi streams (the limit). + for (int i = 0; i < 2; ++i) { + webtransport::Stream* stream = wt->OpenOutgoingBidirectionalStream(); + EXPECT_NE(stream, nullptr) << "Bidi stream " << i << " should succeed"; + } + + // 3rd should return nullptr (blocked at limit). + webtransport::Stream* blocked = wt->OpenOutgoingBidirectionalStream(); + EXPECT_EQ(blocked, nullptr) + << "3rd bidi stream should be blocked at WT_MAX_STREAMS_BIDI=2"; + // Section 5.6.3 SHOULD: A WT_STREAMS_BLOCKED capsule should be sent when + // the stream limit is reached. This is a SHOULD requirement; verifying the + // capsule emission requires intercepting CONNECT stream output. + // The primary assertion is that stream creation correctly returns nullptr. + testing::Mock::VerifyAndClearExpectations(writer_); +} + +TEST_P(FlowControlDraft15Test, ConnectStreamNotCounted) { + // Section 5.3: The CONNECT stream used for session establishment is + // not counted towards stream limits. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/65536); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/1, + /*initial_max_data=*/65536); + auto* wt = AttemptWebTransportDraft15Session(GetNthClientInitiatedBidirectionalId(0)); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + // The CONNECT stream itself should not count against the bidi limit. + // With bidi limit=1, we should still be able to open 1 WT bidi stream. + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, + WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + + webtransport::Stream* stream = wt->OpenOutgoingBidirectionalStream(); + EXPECT_NE(stream, nullptr) + << "With bidi limit=1, one WT bidi stream should be allowed " + "(CONNECT stream not counted)"; + testing::Mock::VerifyAndClearExpectations(writer_); +} + +// ========================================================================== +// Data limits (Section 5.4) +// ========================================================================== + +TEST_P(FlowControlDraft15Test, WtMaxDataCumulative) { + // Section 5.4: WT_MAX_DATA is a cumulative byte limit across all streams. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/65536); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/1024); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, + WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + + // Open a stream and write data up to and beyond the limit. + webtransport::Stream* stream = wt->OpenOutgoingBidirectionalStream(); + ASSERT_NE(stream, nullptr); + + // Write 512 bytes -- should succeed (under 1024 limit). + std::string data_512(512, 'a'); + EXPECT_TRUE(stream->Write(data_512)) + << "Writing 512 bytes should succeed (under 1024 limit)"; + + // Write another 512 bytes -- should succeed (exactly at 1024 limit). + EXPECT_TRUE(stream->Write(data_512)) + << "Writing another 512 bytes should succeed (at 1024 limit)"; + + // Section 5.4 MUST: Write beyond WT_MAX_DATA limit must fail. + std::string data_1(1, 'b'); + EXPECT_FALSE(stream->Write(data_1)) + << "Write beyond 1024-byte WT_MAX_DATA limit must fail"; + testing::Mock::VerifyAndClearExpectations(writer_); +} + +TEST_P(FlowControlDraft15Test, StreamHeaderExcluded) { + // Section 5.4: Stream header bytes (signal/type/session ID) are not + // counted towards the data limit. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/65536); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/10); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, + WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + + // Open a uni stream. The stream header (0x54 type byte + session ID varint) + // should not count towards the 10-byte data limit. + webtransport::Stream* stream = wt->OpenOutgoingUnidirectionalStream(); + ASSERT_NE(stream, nullptr); + + // If header bytes are excluded from WT_MAX_DATA, writing 10 payload bytes + // should succeed (exactly at the limit). + std::string data_10(10, 'x'); + EXPECT_TRUE(stream->Write(data_10)) + << "Writing 10 payload bytes should succeed when header bytes are " + "excluded from the 10-byte WT_MAX_DATA limit"; + + // Section 5.4 MUST: Write beyond WT_MAX_DATA limit must fail. + std::string data_1(1, 'y'); + EXPECT_FALSE(stream->Write(data_1)) + << "Write beyond 10-byte WT_MAX_DATA limit must fail"; + testing::Mock::VerifyAndClearExpectations(writer_); +} + +TEST_P(FlowControlDraft15Test, ExceedDataLimitClosesSession) { + // Section 5.6.4 MUST: Exceeding the data limit closes the session with + // WT_FLOW_CONTROL_ERROR. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + // Local data=64 sets our incoming data limit; peer values are higher. + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/64); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/64); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + auto* visitor = AttachMockVisitor(wt); + + // Peer sends more data than the 64-byte WT_MAX_DATA limit via an + // incoming uni stream. + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + std::string oversized_payload(128, 'x'); + EXPECT_CALL(*visitor, OnIncomingUnidirectionalStreamAvailable()) + .Times(testing::AnyNumber()); + // Section 5.6.4 MUST: Exceeding data limit closes session. + EXPECT_CALL(*visitor, OnSessionClosed( + static_cast( + kWtFlowControlError), + _)) + .Times(1); + + QuicStreamId peer_uni_stream_id = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 4); + ReceiveWebTransportUnidirectionalStream( + session_id, peer_uni_stream_id, oversized_payload); +} + +TEST_P(FlowControlDraft15Test, DecreasingMaxDataIsError) { + // Section 5.6.4 MUST: Receiving a smaller WT_MAX_DATA value triggers + // WT_FLOW_CONTROL_ERROR. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/65536); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + auto* visitor = AttachMockVisitor(wt); + + // Raise to 128000. + InjectCapsuleOnConnectStream( + session_id, + Capsule(WebTransportMaxDataCapsule{/*max_data=*/128000})); + + // Section 5.6.4 MUST: Decreasing WT_MAX_DATA triggers + // WT_FLOW_CONTROL_ERROR. + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*visitor, OnSessionClosed( + static_cast( + kWtFlowControlError), + _)) + .Times(1); + InjectCapsuleOnConnectStream( + session_id, + Capsule(WebTransportMaxDataCapsule{/*max_data=*/64000})); +} + +TEST_P(FlowControlDraft15Test, DataBlockedSentWhenLimited) { + // Section 5.6.5 SHOULD: WT_DATA_BLOCKED sent when the data limit is + // reached. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/65536); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/128); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, + WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + + // Open a stream and write up to the data limit. + webtransport::Stream* stream = wt->OpenOutgoingBidirectionalStream(); + ASSERT_NE(stream, nullptr); + + // Write exactly 128 bytes (the limit). + std::string data_128(128, 'a'); + EXPECT_TRUE(stream->Write(data_128)) + << "Writing 128 bytes should succeed (at the WT_MAX_DATA limit)"; + + // Section 5.6.5 SHOULD: Write beyond WT_MAX_DATA limit must fail + // and a WT_DATA_BLOCKED capsule SHOULD be emitted. + std::string data_1(1, 'b'); + EXPECT_FALSE(stream->Write(data_1)) + << "Write beyond 128-byte WT_MAX_DATA limit must fail"; + testing::Mock::VerifyAndClearExpectations(writer_); +} + +TEST_P(FlowControlDraft15Test, ResetStreamCountsInDataLimit) { + // Section 5.4: When a stream is reset, the final size of the stream + // counts as consumed flow control credit. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/65536); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/1024); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, + WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + + // Open two streams. Write 512 bytes on the first, then reset it. + // The final size (512 bytes) should count towards the 1024-byte WT_MAX_DATA + // limit. The second stream should then only be able to write 512 more bytes. + webtransport::Stream* stream1 = wt->OpenOutgoingBidirectionalStream(); + ASSERT_NE(stream1, nullptr); + std::string data_512(512, 'a'); + EXPECT_TRUE(stream1->Write(data_512)) + << "Writing 512 bytes should succeed"; + + // Reset the first stream. Its final size (512) counts as consumed FC credit. + stream1->ResetWithUserCode(0); + + // Open a second stream. + webtransport::Stream* stream2 = wt->OpenOutgoingBidirectionalStream(); + ASSERT_NE(stream2, nullptr); + + // Write 512 bytes on the second stream (total consumed = 1024 = limit). + EXPECT_TRUE(stream2->Write(data_512)) + << "Writing 512 bytes on second stream should succeed (total = 1024)"; + + // Section 5.4 MUST: Reset stream's final size counts as consumed FC credit. + // Total consumed = 512 (reset) + 512 (written) = 1024 = limit. + // Writing 1 more byte must fail. + std::string data_1(1, 'b'); + EXPECT_FALSE(stream2->Write(data_1)) + << "Reset stream's final size must count towards the WT_MAX_DATA limit"; + testing::Mock::VerifyAndClearExpectations(writer_); +} + +// ========================================================================== +// SETTINGS defaults (Section 5.5) +// ========================================================================== + +TEST_P(FlowControlDraft15Test, InitialMaxStreamsUniDefault0) { + // Section 5.5.1: Default value of SETTINGS_WT_INITIAL_MAX_STREAMS_UNI + // is 0. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + EXPECT_EQ(webtransport::draft15::kSettingsWtInitialMaxStreamsUni, 0x2b64u); + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + // Check that draft-15 SETTINGS are emitted (fails because no kDraft15 + // branch in FillSettingsFrame). + EXPECT_TRUE(session_->settings().values.contains(SETTINGS_WT_ENABLED)) + << "Expected SETTINGS_WT_ENABLED in emitted settings for draft-15"; + // The default value for SETTINGS_WT_INITIAL_MAX_STREAMS_UNI should be 0 + // (or absent from SETTINGS). + auto it = session_->settings().values.find(SETTINGS_WT_INITIAL_MAX_STREAMS_UNI); + if (it != session_->settings().values.end()) { + EXPECT_EQ(it->second, 0u) + << "Default SETTINGS_WT_INITIAL_MAX_STREAMS_UNI should be 0"; + } +} + +TEST_P(FlowControlDraft15Test, InitialMaxStreamsBidiDefault0) { + // Section 5.5.2: Default value of SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI + // is 0. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + EXPECT_EQ(webtransport::draft15::kSettingsWtInitialMaxStreamsBidi, 0x2b65u); + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + // Check that draft-15 SETTINGS are emitted. + EXPECT_TRUE(session_->settings().values.contains(SETTINGS_WT_ENABLED)) + << "Expected SETTINGS_WT_ENABLED in emitted settings for draft-15"; + // The default value for SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI should be 0 + // (or absent). + auto it = session_->settings().values.find(SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI); + if (it != session_->settings().values.end()) { + EXPECT_EQ(it->second, 0u) + << "Default SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI should be 0"; + } +} + +TEST_P(FlowControlDraft15Test, InitialMaxDataDefault0) { + // Section 5.5.3: Default value of SETTINGS_WT_INITIAL_MAX_DATA is 0. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + EXPECT_EQ(webtransport::draft15::kSettingsWtInitialMaxData, 0x2b61u); + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + // Check that draft-15 SETTINGS are emitted. + EXPECT_TRUE(session_->settings().values.contains(SETTINGS_WT_ENABLED)) + << "Expected SETTINGS_WT_ENABLED in emitted settings for draft-15"; + // The default value for SETTINGS_WT_INITIAL_MAX_DATA should be 0 + // (or absent). + auto it = session_->settings().values.find(SETTINGS_WT_INITIAL_MAX_DATA); + if (it != session_->settings().values.end()) { + EXPECT_EQ(it->second, 0u) + << "Default SETTINGS_WT_INITIAL_MAX_DATA should be 0"; + } +} + +TEST_P(FlowControlDraft15Test, NonZeroFCSettingsEmitted) { + // Section 5.5: When configured with non-zero FC limits, the emitted + // SETTINGS frame MUST contain the corresponding values. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/42, + /*local_max_streams_bidi=*/17, + /*local_max_data=*/100000); + CompleteHandshake(); + + const auto& vals = session_->settings().values; + ASSERT_TRUE(vals.contains(SETTINGS_WT_INITIAL_MAX_STREAMS_UNI)) + << "Non-zero SETTINGS_WT_INITIAL_MAX_STREAMS_UNI must be emitted"; + EXPECT_EQ(vals.at(SETTINGS_WT_INITIAL_MAX_STREAMS_UNI), 42u); + ASSERT_TRUE(vals.contains(SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI)) + << "Non-zero SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI must be emitted"; + EXPECT_EQ(vals.at(SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI), 17u); + ASSERT_TRUE(vals.contains(SETTINGS_WT_INITIAL_MAX_DATA)) + << "Non-zero SETTINGS_WT_INITIAL_MAX_DATA must be emitted"; + EXPECT_EQ(vals.at(SETTINGS_WT_INITIAL_MAX_DATA), 100000u); +} + +TEST_P(FlowControlDraft15Test, CumulativeLimitNotConcurrent) { + // Section 5.6.2: Stream limits are cumulative, not concurrent. Closing + // streams does NOT free slots. Once 3 streams have been opened (even if + // all are closed), a 4th should fail with WT_MAX_STREAMS_BIDI=3. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/65536); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/3, + /*initial_max_data=*/65536); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, + WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + + // Open 3 bidi streams and immediately close them by sending FIN. + for (int i = 0; i < 3; ++i) { + webtransport::Stream* stream = wt->OpenOutgoingBidirectionalStream(); + ASSERT_NE(stream, nullptr) << "Bidi stream " << i << " should succeed"; + EXPECT_TRUE(stream->SendFin()); + } + + // Despite all 3 streams being closed, the cumulative limit of 3 has been + // reached. A 4th stream should be blocked. + webtransport::Stream* blocked = wt->OpenOutgoingBidirectionalStream(); + EXPECT_EQ(blocked, nullptr) + << "4th bidi stream should be blocked because stream limits are " + "cumulative (3 total opened), not concurrent (0 currently open)"; + testing::Mock::VerifyAndClearExpectations(writer_); +} + +// ========================================================================== +// Section 5: Asymmetric stream/data limits +// Incoming limits come from local SETTINGS, outgoing from peer SETTINGS. +// ========================================================================== + +TEST_P(FlowControlDraft15Test, Section5_OutgoingLimitsFromPeerSettings) { + // Section 5: Outgoing stream limits are determined by the peer's + // SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI value, not our local value. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/100, + /*local_max_streams_bidi=*/100, + /*local_max_data=*/65536); + CompleteHandshake(); + // Peer advertises bidi limit of 5 (our outgoing limit). + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/100, + /*initial_max_streams_bidi=*/5, + /*initial_max_data=*/65536); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, + WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + + // Open 5 outgoing bidi streams (peer's limit). + for (int i = 0; i < 5; ++i) { + webtransport::Stream* stream = wt->OpenOutgoingBidirectionalStream(); + EXPECT_NE(stream, nullptr) + << "Outgoing bidi stream " << i + << " should succeed (peer allows 5)"; + } + + // 6th should be blocked by peer's limit of 5. + webtransport::Stream* blocked = wt->OpenOutgoingBidirectionalStream(); + EXPECT_EQ(blocked, nullptr) + << "6th outgoing bidi stream should be blocked by peer's " + "SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI=5"; + testing::Mock::VerifyAndClearExpectations(writer_); +} + +TEST_P(FlowControlDraft15Test, Section5_IncomingLimitsFromLocalSettings) { + // Section 5: Incoming stream limits are determined by OUR local + // SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI, not the peer's value. + // Peer advertises bidi=100, but our local limit is 3. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/100, + /*local_max_streams_bidi=*/3, + /*local_max_data=*/65536); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/100, + /*initial_max_streams_bidi=*/100, + /*initial_max_data=*/65536); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + auto* visitor = AttachMockVisitor(wt); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + EXPECT_CALL(*visitor, OnIncomingBidirectionalStreamAvailable()) + .Times(testing::AnyNumber()); + + // Receive 3 incoming bidi streams (within our local limit of 3). + ReceiveWebTransportBidirectionalStream( + session_id, GetNthClientInitiatedBidirectionalId(1)); + ReceiveWebTransportBidirectionalStream( + session_id, GetNthClientInitiatedBidirectionalId(2)); + ReceiveWebTransportBidirectionalStream( + session_id, GetNthClientInitiatedBidirectionalId(3)); + + // Section 5 MUST: 4th incoming stream exceeds our local limit of 3. + // Session should be closed with WT_FLOW_CONTROL_ERROR. + EXPECT_CALL(*visitor, OnSessionClosed( + static_cast(kWtFlowControlError), + _)) + .Times(1); + ReceiveWebTransportBidirectionalStream( + session_id, GetNthClientInitiatedBidirectionalId(4)); +} + +TEST_P(FlowControlDraft15Test, Section5_AsymmetricUniStreamLimits) { + // Section 5: Outgoing uni limit from peer (2), incoming uni limit from + // local (10). These are independent. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/100, + /*local_max_data=*/65536); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/2, + /*initial_max_streams_bidi=*/100, + /*initial_max_data=*/65536); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + auto* visitor = AttachMockVisitor(wt); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, + WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + EXPECT_CALL(*visitor, OnIncomingUnidirectionalStreamAvailable()) + .Times(testing::AnyNumber()); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + EXPECT_CALL(*visitor, OnSessionClosed(_, _)) + .Times(testing::AnyNumber()); + + // Outgoing: peer allows 2 uni streams. + for (int i = 0; i < 2; ++i) { + webtransport::Stream* stream = wt->OpenOutgoingUnidirectionalStream(); + EXPECT_NE(stream, nullptr) + << "Outgoing uni stream " << i << " should succeed (peer allows 2)"; + } + webtransport::Stream* blocked = wt->OpenOutgoingUnidirectionalStream(); + EXPECT_EQ(blocked, nullptr) + << "3rd outgoing uni stream should be blocked by peer limit of 2"; + + // Incoming: our local limit is 10. Receive 3 streams — this should + // succeed since 3 < 10. With the buggy implementation, the incoming limit + // is set to peer's value (2), so the 3rd stream will close the session. + for (int i = 0; i < 3; ++i) { + QuicStreamId peer_stream_id = + test::GetNthClientInitiatedUnidirectionalStreamId( + transport_version(), 4 + i); + ReceiveWebTransportUnidirectionalStream( + session_id, peer_stream_id, "payload"); + } + + // Section 5: 3 incoming uni streams is within our local limit of 10. + // The WT session should still be functional (able to open new streams). + // Note: connection_->connected() is NOT a valid check here because + // CloseSession() only closes the WT session, not the QUIC connection. + EXPECT_TRUE(wt->CanOpenNextOutgoingBidirectionalStream()) + << "After receiving 3 incoming uni streams (within local " + "limit of 10), the WT session should still be functional"; + testing::Mock::VerifyAndClearExpectations(writer_); + testing::Mock::VerifyAndClearExpectations(visitor); + testing::Mock::VerifyAndClearExpectations(connection_); +} + +TEST_P(FlowControlDraft15Test, Section5_4_AsymmetricDataLimits) { + // Section 5.4: Outgoing data limit from peer (1024), incoming data limit + // from local (256). These are independent. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/256); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/1024); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + auto* visitor = AttachMockVisitor(wt); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, + WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + EXPECT_CALL(*visitor, OnIncomingUnidirectionalStreamAvailable()) + .Times(testing::AnyNumber()); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + EXPECT_CALL(*visitor, OnSessionClosed(_, _)) + .Times(testing::AnyNumber()); + + // Outgoing: peer's data limit is 1024. + webtransport::Stream* out_stream = wt->OpenOutgoingBidirectionalStream(); + ASSERT_NE(out_stream, nullptr); + std::string data_1024(1024, 'a'); + EXPECT_TRUE(out_stream->Write(data_1024)) + << "Writing 1024 bytes should succeed (peer's WT_MAX_DATA=1024)"; + std::string data_1(1, 'b'); + EXPECT_FALSE(out_stream->Write(data_1)) + << "Write beyond peer's 1024-byte limit must fail"; + + // Incoming: our local data limit is 256. Receiving 512 bytes should + // exceed it and close the session with WT_FLOW_CONTROL_ERROR. + // With the buggy implementation, max_data_receive_ is set to the peer's + // value (1024), so 512 bytes will NOT trigger an error. + QuicStreamId peer_uni_id = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 4); + std::string oversized(512, 'x'); + ReceiveWebTransportUnidirectionalStream(session_id, peer_uni_id, oversized); + + // Section 5.4: With local max_data=256, receiving 512 bytes should have + // closed the WT session. Verify via CanOpenNextOutgoingBidirectionalStream() + // (returns false when session is terminated). + EXPECT_FALSE(wt->CanOpenNextOutgoingBidirectionalStream()) + << "Receiving 512 bytes should exceed our local " + "WT_MAX_DATA=256 and terminate the WT session"; + testing::Mock::VerifyAndClearExpectations(writer_); + testing::Mock::VerifyAndClearExpectations(visitor); + testing::Mock::VerifyAndClearExpectations(connection_); +} + +TEST_P(FlowControlDraft15Test, Section5_1_FCRequiresBothSidesNonZero) { + // Section 5.1: FC is only enabled when BOTH endpoints send at least one + // non-zero SETTINGS_WT_INITIAL_MAX_* value. + // Only peer sends non-zero — FC should be disabled. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + // Local limits are all 0 (default). + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/2, + /*initial_max_data=*/65536); + auto* wt = AttemptWebTransportDraft15Session(GetNthClientInitiatedBidirectionalId(0)); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + // Section 5.1: With FC disabled (only peer sent non-zero), WT-level + // limits should not apply. Opening more streams than the peer's bidi + // limit of 2 should still succeed since WT FC is not active. + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, + WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + + // Open 5 bidi streams. With FC disabled, none should be blocked. + int successful = 0; + for (int i = 0; i < 5; ++i) { + webtransport::Stream* stream = wt->OpenOutgoingBidirectionalStream(); + if (stream != nullptr) { + ++successful; + } + } + // Section 5.1: All 5 should succeed because WT-level FC should not be + // active when only one side advertises non-zero limits. + EXPECT_EQ(successful, 5) + << "With FC disabled (only peer sent non-zero), all 5 " + "bidi streams should succeed. Got " << successful; + testing::Mock::VerifyAndClearExpectations(writer_); +} + +// ========================================================================== +// Section 5.4: Incoming data must be counted on ALL stream types. +// ========================================================================== + +TEST_P(FlowControlDraft15Test, Section5_4_BidiStreamDataCountedAgainstMaxData) { + // Section 5.4: Incoming data on a bidirectional stream must be counted + // against WT_MAX_DATA. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/128); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + auto* visitor = AttachMockVisitor(wt); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + EXPECT_CALL(*visitor, OnIncomingBidirectionalStreamAvailable()) + .Times(testing::AnyNumber()); + EXPECT_CALL(*visitor, OnSessionClosed(_, _)) + .Times(testing::AnyNumber()); + + // Receive an incoming bidi stream (preamble only: 0x41 + session_id). + QuicStreamId bidi_stream_id = GetNthClientInitiatedBidirectionalId(1); + ReceiveWebTransportBidirectionalStream(session_id, bidi_stream_id); + + // Now send 256 bytes of payload on that stream. The preamble was + // varint(0x41)=1 byte + varint(session_id)=1 byte = 2 bytes at offset 0. + // Payload starts at offset 2. + std::string payload(256, 'x'); + QuicStreamFrame data_frame(bidi_stream_id, /*fin=*/false, /*offset=*/2, + payload); + session_->OnStreamFrame(data_frame); + + // Section 5.4: 256 bytes received exceeds our local WT_MAX_DATA=128. + // The session should have been closed with WT_FLOW_CONTROL_ERROR. + EXPECT_FALSE(wt->CanOpenNextOutgoingBidirectionalStream()) + << "Receiving 256 bytes on a bidi stream should exceed " + "local WT_MAX_DATA=128 and terminate the session"; + testing::Mock::VerifyAndClearExpectations(visitor); + testing::Mock::VerifyAndClearExpectations(connection_); +} + +TEST_P(FlowControlDraft15Test, Section5_4_UniStreamSubsequentDataCounted) { + // Section 5.4: Data arriving on a uni stream AFTER initial association + // must also be counted against WT_MAX_DATA. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/64); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + auto* visitor = AttachMockVisitor(wt); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + EXPECT_CALL(*visitor, OnIncomingUnidirectionalStreamAvailable()) + .Times(testing::AnyNumber()); + EXPECT_CALL(*visitor, OnSessionClosed(_, _)) + .Times(testing::AnyNumber()); + + // Send a uni stream with a small initial payload (16 bytes) that fits + // within the 64-byte limit. + QuicStreamId uni_stream_id = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 4); + std::string small_payload(16, 'a'); + ReceiveWebTransportUnidirectionalStream( + session_id, uni_stream_id, small_payload); + + // Session should still be alive (16 <= 64). + EXPECT_TRUE(wt->CanOpenNextOutgoingBidirectionalStream()) + << "16 bytes is within the 64-byte limit"; + + // Now send 128 more bytes on the SAME stream at the right offset. + // Preamble: varint(0x54)=1 byte + varint(session_id)=1 byte = 2 bytes. + // First payload was 16 bytes. Offset = 2 + 16 = 18. + std::string more_payload(128, 'b'); + QuicStreamFrame data_frame(uni_stream_id, /*fin=*/false, /*offset=*/18, + more_payload); + session_->OnStreamFrame(data_frame); + + // Section 5.4: Total received = 16 + 128 = 144 bytes > 64 limit. + // Session should be terminated with WT_FLOW_CONTROL_ERROR. + EXPECT_FALSE(wt->CanOpenNextOutgoingBidirectionalStream()) + << "Subsequent data on a uni stream (total 144 bytes) " + "should exceed local WT_MAX_DATA=64 and terminate the session"; + testing::Mock::VerifyAndClearExpectations(visitor); + testing::Mock::VerifyAndClearExpectations(connection_); +} + +TEST_P(FlowControlDraft15Test, + Section5_4_IncomingDataAcrossMultipleStreamsCumulative) { + // Section 5.4: WT_MAX_DATA is cumulative across all incoming streams, + // including both bidirectional and unidirectional. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/100); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + auto* visitor = AttachMockVisitor(wt); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + EXPECT_CALL(*visitor, OnIncomingBidirectionalStreamAvailable()) + .Times(testing::AnyNumber()); + EXPECT_CALL(*visitor, OnIncomingUnidirectionalStreamAvailable()) + .Times(testing::AnyNumber()); + EXPECT_CALL(*visitor, OnSessionClosed(_, _)) + .Times(testing::AnyNumber()); + + // Receive a bidi stream with 60 bytes of payload. + QuicStreamId bidi_stream_id = GetNthClientInitiatedBidirectionalId(1); + ReceiveWebTransportBidirectionalStream(session_id, bidi_stream_id); + std::string bidi_payload(60, 'x'); + QuicStreamFrame bidi_data(bidi_stream_id, /*fin=*/false, /*offset=*/2, + bidi_payload); + session_->OnStreamFrame(bidi_data); + + // Receive a uni stream with 60 bytes of payload. + QuicStreamId uni_stream_id = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 4); + std::string uni_payload(60, 'y'); + ReceiveWebTransportUnidirectionalStream( + session_id, uni_stream_id, uni_payload); + + // Section 5.4: Total = 60 (bidi) + 60 (uni) = 120 > 100 limit. + // Session should be terminated with WT_FLOW_CONTROL_ERROR. + EXPECT_FALSE(wt->CanOpenNextOutgoingBidirectionalStream()) + << "Cumulative incoming data across bidi (60) + uni (60) " + "= 120 bytes should exceed local WT_MAX_DATA=100"; + testing::Mock::VerifyAndClearExpectations(visitor); + testing::Mock::VerifyAndClearExpectations(connection_); +} + +TEST_P(FlowControlDraft15Test, Section5_4_DataFCDoesNotDoubleCountUnconsumedBytes) { + // Section 5.4: OnDataAvailable() must count only the new bytes received, + // not all unconsumed bytes. Bytes not consumed between calls must not be + // double-counted against WT_MAX_DATA. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + // Set local_max_data=200 so the 160 bytes total should fit. + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/200); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + auto* visitor = AttachMockVisitor(wt); + + // The session must NOT be closed — 160 total bytes < 200 limit. + EXPECT_CALL(*visitor, OnSessionClosed(_, _)).Times(0); + EXPECT_CALL(*visitor, OnIncomingUnidirectionalStreamAvailable()) + .Times(testing::AnyNumber()); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, + WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + + // Deliver a uni stream with 80 bytes of payload. + QuicStreamId uni_stream_id = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 4); + std::string payload_80(80, 'a'); + ReceiveWebTransportUnidirectionalStream(session_id, uni_stream_id, + payload_80); + + // Do NOT consume data — the visitor's OnCanRead is a no-op (NiceMock). + + // Compute preamble size: varint(0x54)=2 bytes + varint(session_id=0)=1 byte. + const size_t preamble_size = 3; + + // Send 80 more bytes on the same stream at the correct offset. + std::string payload_80b(80, 'b'); + QuicStreamFrame second_frame(uni_stream_id, /*fin=*/false, + /*offset=*/preamble_size + 80, payload_80b); + session_->OnStreamFrame(second_frame); + + // Section 5.4: total_data_received_ should be 160 (80 + 80), NOT 240 + // (80 + 160). The session should still be alive. + EXPECT_TRUE(connection_->connected()) + << "160 bytes total < 200 limit, but double-counting " + "would report 240 bytes and incorrectly close the session"; + testing::Mock::VerifyAndClearExpectations(visitor); +} + +TEST_P(FlowControlDraft15Test, Section5_ReceiverWindowReplenishesAfterConsumption) { + // Section 5.4: The receiver window must be replenished by sending + // WT_MAX_DATA capsules as data is consumed, so that cumulative + // total_data_received_ does not exceed the limit. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/100); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + auto* visitor = AttachMockVisitor(wt); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, + WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + EXPECT_CALL(*visitor, OnIncomingUnidirectionalStreamAvailable()) + .Times(testing::AnyNumber()); + + // Deliver first uni stream with 90 bytes. Do NOT consume in callback + // (NiceMock default does nothing) so the adapter counts the bytes. + // After delivery: total_data_received_ = 90, max_data_receive_ = 100. + QuicStreamId uni_stream_1 = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 4); + std::string payload_90(90, 'a'); + ReceiveWebTransportUnidirectionalStream(session_id, uni_stream_1, payload_90); + + // Accept and consume all data from stream 1 — this should trigger + // the receiver to send WT_MAX_DATA, replenishing the window. + webtransport::Stream* s1 = wt->AcceptIncomingUnidirectionalStream(); + ASSERT_NE(s1, nullptr); + std::string buf; + (void)s1->Read(&buf); + ASSERT_EQ(buf.size(), 90u); + + // The session must NOT be closed when the second stream arrives. + EXPECT_CALL(*visitor, OnSessionClosed(_, _)).Times(0); + + // Deliver second uni stream with 20 bytes. The adapter counts 20 more bytes. + // Section 5.4: After consuming 90 bytes, max_data_receive_ should be + // updated to at least 190. 110 < 190, so session stays alive. + QuicStreamId uni_stream_2 = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 5); + std::string payload_20(20, 'b'); + ReceiveWebTransportUnidirectionalStream(session_id, uni_stream_2, payload_20); + + EXPECT_TRUE(connection_->connected()) + << "After consuming 90 bytes, window should replenish"; + testing::Mock::VerifyAndClearExpectations(visitor); +} + +TEST_P(FlowControlDraft15Test, Section5_IncomingStreamLimitReplenishesAfterClose) { + // Section 5.3: After streams are closed, the incoming stream limit should + // be replenished by sending WT_MAX_STREAMS capsules. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/2, + /*local_max_data=*/65536); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/100, + /*initial_max_data=*/65536); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + auto* visitor = AttachMockVisitor(wt); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, + WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + EXPECT_CALL(*visitor, OnIncomingBidirectionalStreamAvailable()) + .Times(testing::AnyNumber()); + + // Deliver 2 incoming bidi streams (the limit). + QuicStreamId bidi_1 = GetNthClientInitiatedBidirectionalId(1); + QuicStreamId bidi_2 = GetNthClientInitiatedBidirectionalId(2); + ReceiveWebTransportBidirectionalStream(session_id, bidi_1); + ReceiveWebTransportBidirectionalStream(session_id, bidi_2); + + // Accept and close both streams. + webtransport::Stream* s1 = wt->AcceptIncomingBidirectionalStream(); + webtransport::Stream* s2 = wt->AcceptIncomingBidirectionalStream(); + ASSERT_NE(s1, nullptr); + ASSERT_NE(s2, nullptr); + s1->SendStopSending(0); + s1->ResetWithUserCode(0); + s2->SendStopSending(0); + s2->ResetWithUserCode(0); + + // The session must NOT be closed when the 3rd stream arrives. + EXPECT_CALL(*visitor, OnSessionClosed(_, _)).Times(0); + + // Deliver a 3rd incoming bidi stream. + QuicStreamId bidi_3 = GetNthClientInitiatedBidirectionalId(3); + ReceiveWebTransportBidirectionalStream(session_id, bidi_3); + + // Section 5: After closing 2 streams, the receiver should have sent + // WT_MAX_STREAMS to allow the peer to open new streams. + EXPECT_TRUE(connection_->connected()) + << "After closing 2 streams, receiver should send " + "WT_MAX_STREAMS to allow opening new streams"; + testing::Mock::VerifyAndClearExpectations(visitor); +} + +// ========================================================================== +// Section 5.5.3: SETTINGS_WT_INITIAL_MAX_DATA default of 0 means +// "endpoint needs to send WT_MAX_DATA capsule before peer may send data." +// ========================================================================== + +TEST_P(FlowControlDraft15Test, Section5_DataLimitZeroBlocksSending) { + // Section 5.5.3: "The default value ... is '0', indicating that the + // endpoint needs to send a WT_MAX_DATA capsule within each session + // before its peer is allowed to send any stream data within that session." + // + // When FC is enabled (peer sends SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI=10) + // but SETTINGS_WT_INITIAL_MAX_DATA is absent (defaults to 0), CanSendData + // must return false until a WT_MAX_DATA capsule is received. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + + // Both sides enable FC via stream limits, but neither advertises data limits. + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/0); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/0); + + ASSERT_TRUE(session_->wt_flow_control_enabled()) + << "FC should be enabled when both sides send non-zero stream limits"; + + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr); + + // With initial_max_data = 0, no data should be sendable. + EXPECT_FALSE(wt->CanSendData(1)) + << "initial_max_data=0 means no data until WT_MAX_DATA"; + + // After receiving a WT_MAX_DATA capsule, data should be sendable. + InjectCapsuleOnConnectStream( + session_id, + Capsule(WebTransportMaxDataCapsule{/*max_data=*/1000})); + EXPECT_TRUE(wt->CanSendData(1)) + << "After WT_MAX_DATA(1000), 1 byte should be sendable"; +} + +// ========================================================================== +// Section 5.6: Window replenishment must be based on consumed data, +// not received data (following QUIC's flow control pattern per RFC 9000 §4.2). +// ========================================================================== + +TEST_P(FlowControlDraft15Test, Section5_WindowReplenishmentBasedOnConsumption) { + // Section 5.6: "Endpoints SHOULD send WT_MAX_DATA ... as they consume + // data" — window should NOT grow just because data was received; the + // application must actually consume it. + // + // With receive-based replenishment (bug): reading 1 byte after receiving + // 80 causes replenishment because available = max(100) - received(80) = 20 + // < threshold(50). Window grows to 200 prematurely. + // + // With consumption-based replenishment (correct): reading 1 byte gives + // available = max(100) - consumed(1) = 99 >= threshold(50), no + // replenishment — the receiver hasn't processed enough data. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/100); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + + // Receive 80 bytes on a uni stream. + QuicStreamId uni_1 = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 4); + std::string payload_80(80, 'a'); + ReceiveWebTransportUnidirectionalStream(session_id, uni_1, payload_80); + + // Consume only 1 byte. With receive-based logic (bug), this triggers + // premature replenishment. With consumption-based logic (correct), it + // does not. + webtransport::Stream* s1 = wt->AcceptIncomingUnidirectionalStream(); + ASSERT_NE(s1, nullptr); + char buf[1]; + auto result = s1->Read(absl::MakeSpan(buf, 1)); + ASSERT_EQ(result.bytes_read, 1u); + + // Now deliver 21 more bytes on a new stream (total received = 101 > 100). + // Correct: no premature replenishment, so 101 > limit(100) → session + // terminates with WT_FLOW_CONTROL_ERROR. Verify by checking that the + // session can no longer open outgoing streams (IsTerminated). + // Buggy: premature replenishment bumped limit to 200, so 101 < 200, OK. + QuicStreamId uni_2 = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 5); + std::string payload_21(21, 'b'); + ReceiveWebTransportUnidirectionalStream(session_id, uni_2, payload_21); + + EXPECT_EQ(wt->OpenOutgoingBidirectionalStream(), nullptr) + << "After receiving 101 bytes with only 1 consumed, " + "the session should be terminated (no premature replenishment)"; +} + +// ========================================================================== +// Section 5.6.2: Visitor notified when WT_MAX_STREAMS raises the limit. +// ========================================================================== + +// ========================================================================== +// Section 5.6.2: SETTINGS_WT_INITIAL_MAX_STREAMS values must not exceed 2^60. +// ========================================================================== + +TEST_P(FlowControlDraft15Test, Section5_SettingsMaxStreamsExceeding2p60Rejected) { + // Section 5.6.2: "This value cannot exceed 2^60 ... Receipt of a capsule + // with a Maximum Streams value greater than 2^60 MUST be treated as a + // session error." SETTINGS provide initial values for the same limit and + // should be validated identically. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/65536); + CompleteHandshake(); + + // Send peer SETTINGS with WT_INITIAL_MAX_STREAMS_BIDI exceeding 2^60. + SettingsFrame settings; + settings.values[SETTINGS_H3_DATAGRAM] = 1; + settings.values[SETTINGS_ENABLE_CONNECT_PROTOCOL] = 1; + settings.values[SETTINGS_WT_ENABLED] = 1; + settings.values[SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI] = (1ULL << 60) + 1; + std::string data = std::string(1, kControlStream) + + HttpEncoder::SerializeSettingsFrame(settings); + QuicStreamId control_stream_id = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 3); + // Allow connection-level mock calls during the close sequence. + EXPECT_CALL(*connection_, + CloseConnection(QUIC_HTTP_INVALID_SETTING_VALUE, _, _)) + .WillOnce( + testing::Invoke(connection_, + &test::MockQuicConnection::ReallyCloseConnection)); + EXPECT_CALL(*connection_, SendConnectionClosePacket(_, _, _)) + .Times(testing::AnyNumber()); + + QuicStreamFrame frame(control_stream_id, /*fin=*/false, /*offset=*/0, data); + session_->OnStreamFrame(frame); + + EXPECT_FALSE(connection_->connected()) + << "SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI > 2^60 " + "should close the connection"; +} + +// A visitor that opens a bidi stream when notified that stream creation +// is now possible — the standard callback-driven application pattern. +class StreamOpeningVisitor : public WebTransportVisitor { + public: + explicit StreamOpeningVisitor(WebTransportSession* session) + : session_(session) {} + void OnSessionReady() override {} + void OnSessionClosed(WebTransportSessionError, const std::string&) override {} + void OnIncomingBidirectionalStreamAvailable() override {} + void OnIncomingUnidirectionalStreamAvailable() override {} + void OnDatagramReceived(absl::string_view) override {} + void OnCanCreateNewOutgoingBidirectionalStream() override { + WebTransportStream* stream = session_->OpenOutgoingBidirectionalStream(); + if (stream != nullptr) { + opened_bidi_streams_.push_back(stream); + } + } + void OnCanCreateNewOutgoingUnidirectionalStream() override {} + + const std::vector& opened_bidi_streams() const { + return opened_bidi_streams_; + } + + private: + WebTransportSession* session_; + std::vector opened_bidi_streams_; +}; + +TEST_P(FlowControlDraft15Test, Section5_VisitorNotifiedWhenStreamLimitRaised) { + // Section 5.6.2: When the peer sends WT_MAX_STREAMS raising the outgoing + // stream limit, a callback-driven application should be able to open new + // streams from the OnCanCreateNewOutgoingBidirectionalStream callback. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/1, + /*initial_max_data=*/65536); + ASSERT_NE(wt, nullptr); + + // Open one bidi stream, exhausting the limit of 1. + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + webtransport::Stream* stream = wt->OpenOutgoingBidirectionalStream(); + ASSERT_NE(stream, nullptr); + EXPECT_EQ(wt->OpenOutgoingBidirectionalStream(), nullptr) + << "Second stream should be blocked by WT limit of 1"; + + // Attach a callback-driven visitor that opens a stream when notified. + auto visitor = std::make_unique(wt); + StreamOpeningVisitor* raw_visitor = visitor.get(); + wt->SetVisitor(std::move(visitor)); + + // Deliver WT_MAX_STREAMS_BIDI raising the limit from 1 to 2. + InjectCapsuleOnConnectStream( + session_id, + Capsule(WebTransportMaxStreamsCapsule{ + webtransport::StreamType::kBidirectional, /*max_stream_count=*/2})); + + // The visitor should have opened a stream in response to the callback. + EXPECT_EQ(raw_visitor->opened_bidi_streams().size(), 1u) + << "Visitor must be notified when WT_MAX_STREAMS raises the limit, " + "enabling it to open new streams"; +} + +// ========================================================================== +// Section 5.6.3 / 5.6.5: BLOCKED capsules sent when limits are reached. +// ========================================================================== + +TEST_P(FlowControlDraft15Test, Section5_StreamsBlockedSentWhenLimitReached) { + // Section 5.6.3: "A sender SHOULD send a WT_STREAMS_BLOCKED capsule + // when it wishes to open a stream but is unable to do so due to the + // maximum stream limit set by its peer." + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/1, + /*initial_max_data=*/65536); + ASSERT_NE(wt, nullptr); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + + // Open one bidi stream, exhausting the WT limit of 1. + webtransport::Stream* stream = wt->OpenOutgoingBidirectionalStream(); + ASSERT_NE(stream, nullptr); + + // Record bytes written on the CONNECT stream before the blocked attempt. + QuicSpdyStream* connect_stream = static_cast( + session_->GetOrCreateStream(session_id)); + ASSERT_NE(connect_stream, nullptr); + uint64_t bytes_before = connect_stream->stream_bytes_written(); + + // Attempt to open another stream — should be blocked by WT limit. + EXPECT_EQ(wt->OpenOutgoingBidirectionalStream(), nullptr); + + // A WT_STREAMS_BLOCKED capsule should have been written to the CONNECT + // stream, increasing bytes_written. + uint64_t bytes_after = connect_stream->stream_bytes_written(); + EXPECT_GT(bytes_after, bytes_before) + << "A WT_STREAMS_BLOCKED capsule should be sent when " + "stream creation is blocked by WT-level limits"; +} + +TEST_P(FlowControlDraft15Test, Section5_DataBlockedSentWhenLimitReached) { + // Section 5.6.5: "A sender SHOULD send a WT_DATA_BLOCKED capsule when + // it wishes to send data but is unable to do so due to WebTransport + // session-level flow control." + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/10); + ASSERT_NE(wt, nullptr); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + + // Open a bidi stream and exhaust the data limit by sending 10 bytes. + webtransport::Stream* stream = wt->OpenOutgoingBidirectionalStream(); + ASSERT_NE(stream, nullptr); + EXPECT_TRUE(wt->CanSendData(10)); + wt->OnDataSent(10); // Record the data as sent. + EXPECT_FALSE(wt->CanSendData(1)) + << "Data limit of 10 should be exhausted"; + + // Record bytes written on the CONNECT stream before the blocked write. + QuicSpdyStream* connect_stream = static_cast( + session_->GetOrCreateStream(session_id)); + ASSERT_NE(connect_stream, nullptr); + uint64_t bytes_before = connect_stream->stream_bytes_written(); + + // Attempt to write more data — should be blocked by WT data limit. + // CanSendData returns false, and the adapter would call + // MaybeSendDataBlocked. + wt->MaybeSendDataBlocked(); + + uint64_t bytes_after = connect_stream->stream_bytes_written(); + EXPECT_GT(bytes_after, bytes_before) + << "A WT_DATA_BLOCKED capsule should be sent when " + "data sending is blocked by WT-level limits"; +} + +TEST_P(FlowControlDraft15Test, + Section5_6_2_MaxStreamsExceeding2p60ClosesConnection) { + // Section 5.6.2 MUST: "Receipt of a capsule with a Maximum Streams value + // larger than this limit MUST be treated as an HTTP/3 error of type + // H3_DATAGRAM_ERROR." This is a CONNECTION-level error, not a session error. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + ASSERT_NE(wt, nullptr); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + EXPECT_CALL(*connection_, CloseConnection(_, _, _, _)) + .WillOnce( + Invoke(connection_, &test::MockQuicConnection::ReallyCloseConnection4)); + EXPECT_CALL(*connection_, SendConnectionClosePacket(_, _, _)) + .Times(testing::AnyNumber()); + + uint64_t too_large = (1ULL << 60) + 1; + InjectCapsuleOnConnectStream( + session_id, + quiche::Capsule(quiche::WebTransportMaxStreamsCapsule{ + webtransport::StreamType::kBidirectional, too_large})); + + EXPECT_FALSE(connection_->connected()) + << "WT_MAX_STREAMS > 2^60 must close the CONNECTION " + "with H3_DATAGRAM_ERROR, not just the session"; +} + +TEST_P(FlowControlDraft15Test, + Section5_6_2_SameValueMaxStreamsIsNoOp) { + // Section 5.6.2: Receiving WT_MAX_STREAMS with a value equal to the + // current limit is legal but carries no new information. It must NOT + // reset the blocked-sent flag, as that would allow amplification: a + // malicious peer could repeat same-value capsules to trigger unlimited + // WT_STREAMS_BLOCKED responses. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/1, + /*initial_max_data=*/65536); + ASSERT_NE(wt, nullptr); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + + // Open one bidi stream, exhausting the WT limit of 1. + webtransport::Stream* s = wt->OpenOutgoingBidirectionalStream(); + ASSERT_NE(s, nullptr); + + QuicSpdyStream* connect_stream = static_cast( + session_->GetOrCreateStream(session_id)); + ASSERT_NE(connect_stream, nullptr); + + // First blocked attempt — sends WT_STREAMS_BLOCKED. + EXPECT_EQ(wt->OpenOutgoingBidirectionalStream(), nullptr); + uint64_t bytes_after_first_blocked = connect_stream->stream_bytes_written(); + + // Inject WT_MAX_STREAMS_BIDI(1) — same value as current limit. + InjectCapsuleOnConnectStream( + session_id, + quiche::Capsule(quiche::WebTransportMaxStreamsCapsule{ + webtransport::StreamType::kBidirectional, 1})); + + // Second blocked attempt — should NOT send another WT_STREAMS_BLOCKED + // because the limit didn't increase. + EXPECT_EQ(wt->OpenOutgoingBidirectionalStream(), nullptr); + uint64_t bytes_after_second_blocked = connect_stream->stream_bytes_written(); + + EXPECT_EQ(bytes_after_second_blocked, bytes_after_first_blocked) + << "Receiving WT_MAX_STREAMS with the same value " + "must not reset the blocked-sent flag (a duplicate " + "WT_STREAMS_BLOCKED capsule was sent, enabling amplification)"; +} + +TEST_P(FlowControlDraft15Test, + Section5_6_4_SameValueMaxDataIsNoOp) { + // Section 5.6.4: Same-value WT_MAX_DATA must not reset the + // data-blocked-sent flag. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/5); + ASSERT_NE(wt, nullptr); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + + // Open a stream and try to write more than the WT_MAX_DATA limit. + webtransport::Stream* s = wt->OpenOutgoingBidirectionalStream(); + ASSERT_NE(s, nullptr); + + QuicSpdyStream* connect_stream = static_cast( + session_->GetOrCreateStream(session_id)); + ASSERT_NE(connect_stream, nullptr); + + // Write exactly the limit to exhaust it. + EXPECT_TRUE(s->Write("hello")); // 5 bytes = limit + + // Next write should be blocked (data limit exhausted). + EXPECT_FALSE(wt->CanSendData(1)); + + // Explicitly send WT_DATA_BLOCKED to mark the flag as sent. + wt->MaybeSendDataBlocked(); + uint64_t bytes_after_first_blocked = connect_stream->stream_bytes_written(); + + // Inject WT_MAX_DATA(5) — same value as current limit. + InjectCapsuleOnConnectStream( + session_id, + quiche::Capsule(quiche::WebTransportMaxDataCapsule{/*max_data=*/5})); + + // CanSendData should still be false — limit didn't change. + EXPECT_FALSE(wt->CanSendData(1)); + + // Call MaybeSendDataBlocked again. If the flag was reset by the + // same-value WT_MAX_DATA, a duplicate capsule will be sent. + wt->MaybeSendDataBlocked(); + uint64_t bytes_after_same_value = connect_stream->stream_bytes_written(); + EXPECT_EQ(bytes_after_same_value, bytes_after_first_blocked) + << "Receiving WT_MAX_DATA with the same value " + "must not reset the data-blocked-sent flag"; +} + +TEST_P(FlowControlDraft15Test, + Section6_FCCapsulesIgnoredAfterTermination) { + // Section 6: After a session is terminated, flow control capsules + // should not mutate session state. Specifically, receiving WT_MAX_DATA + // or WT_MAX_STREAMS after CloseSession() should be harmless (no crash, + // no double-close, no state update). + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/100); + ASSERT_NE(wt, nullptr); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + + // Terminate the session. + wt->CloseSession(0, "done"); + + // These should not crash, assert, or trigger a double-close QUICHE_BUG. + wt->OnMaxDataCapsuleReceived(999999); + wt->OnMaxStreamsCapsuleReceived( + webtransport::StreamType::kBidirectional, 999); + wt->OnMaxStreamsCapsuleReceived( + webtransport::StreamType::kUnidirectional, 999); + + // Verify the session is still in a clean terminated state. + EXPECT_TRUE(connection_->connected()) + << "Post-termination FC capsules should be silently ignored, " + "not trigger connection errors"; +} + +TEST_P(FlowControlDraft15Test, + Section5_4_CanSendDataSafeWhenOverdrawn) { + // Section 5.4: Defense-in-depth test. If total_data_sent_ ever + // exceeds max_data_send_ (e.g., due to an accounting bug), CanSendData + // must return false, not wrap around due to unsigned underflow. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/100); + ASSERT_NE(wt, nullptr); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + + // Simulate an accounting overshoot: report more data sent than allowed. + wt->OnDataSent(150); + + // CanSendData(1) must return false, not true (from unsigned wrap). + EXPECT_FALSE(wt->CanSendData(1)) + << "When total_data_sent_ > max_data_send_, " + "CanSendData must return false, not wrap around via " + "unsigned underflow"; +} + +TEST_P(FlowControlDraft15Test, + Section5_6_4_MaxDataReplenishmentClampsToVarint62) { + // Verify that WT_MAX_DATA replenishment clamps to varint62 max (2^62-1) + // rather than overflowing. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + constexpr uint64_t kMaxVarint62 = (1ULL << 62) - 1; + // Use a large initial limit close to the varint62 max. When replenishment + // computes new_max = max_data_receive_ + initial_max_data_receive_, it + // would overflow past kMaxVarint62 without clamping. + constexpr uint64_t kNearMax = kMaxVarint62 - 100; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/65536); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr); + + // Override the local data limit to near varint62 max. + wt->SetInitialDataLimit(/*max_data_send=*/65536, + /*max_data_receive=*/kNearMax); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + + // Simulate receiving and consuming enough data to trigger replenishment. + // Replenishment triggers when available < initial/2, so consume most of + // the window. OnIncomingDataReceived tracks received bytes; + // OnIncomingDataConsumed tracks consumed bytes and triggers replenishment. + wt->OnIncomingDataReceived(kNearMax); + wt->OnIncomingDataConsumed(kNearMax); + + // If the clamping works, max_data_receive_ should be kMaxVarint62, + // not an overflowed value. We can verify by checking that CanSendData + // (peer's limit) is unaffected and the connection is still alive. + EXPECT_TRUE(connection_->connected()) + << "Connection should remain alive after clamped replenishment"; + testing::Mock::VerifyAndClearExpectations(writer_); +} + +TEST_P(FlowControlDraft15Test, Section5_6_4_MaxDataRaiseUnblocksStreams) { + // Section 5.6.4: When WT_MAX_DATA is raised, streams that were previously + // blocked by the session data limit should be scheduled for a write attempt. + // This mirrors QUIC's MAX_DATA → OnWindowUpdateFrame → + // MarkConnectionLevelWriteBlocked path. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/65536); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/100); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + + // Open a stream and write exactly 100 bytes (exhausting the limit). + webtransport::Stream* stream = wt->OpenOutgoingBidirectionalStream(); + ASSERT_NE(stream, nullptr); + std::string data_100(100, 'a'); + EXPECT_TRUE(stream->Write(data_100)); + + // Confirm the limit is exhausted. + EXPECT_FALSE(wt->CanSendData(1)) + << "Limit should be exhausted after writing 100 bytes"; + + // Clear the write-blocked list state so we can observe the effect of + // raising WT_MAX_DATA. + session_->OnCanWrite(); + + // Raise the data limit. + wt->OnMaxDataCapsuleReceived(200); + + // The stream should now be on the write-blocked list, scheduled for a + // write attempt. + EXPECT_TRUE(test::QuicSessionPeer::GetWriteBlockedStreams(&*session_) + ->HasWriteBlockedDataStreams()) + << "Raising WT_MAX_DATA must schedule associated " + "streams for write attempts"; + testing::Mock::VerifyAndClearExpectations(writer_); +} + +TEST_P(FlowControlDraft15Test, ResetIncomingStreamAccountsUnreadData) { + // Section 5.4: Resetting an incoming stream before reading must account + // for received bytes as consumed, so the WT_MAX_DATA window replenishes. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/100); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr); + auto* visitor = AttachMockVisitor(wt); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + EXPECT_CALL(*visitor, OnIncomingUnidirectionalStreamAvailable()) + .Times(testing::AnyNumber()); + + // Peer sends 60 bytes on stream A. + QuicStreamId uni_a = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 4); + ReceiveWebTransportUnidirectionalStream(session_id, uni_a, + std::string(60, 'a')); + + // Accept stream A but don't read — reset it immediately. + webtransport::Stream* stream_a = wt->AcceptIncomingUnidirectionalStream(); + ASSERT_NE(stream_a, nullptr); + stream_a->SendStopSending(0); + + // Peer sends 60 more bytes on stream B (total received = 120 > limit 100). + // Stream A's 60 bytes should have been accounted as consumed when reset, + // triggering WT_MAX_DATA replenishment so that 120 < new limit. + EXPECT_CALL(*visitor, OnSessionClosed(_, _)).Times(0); + + QuicStreamId uni_b = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 5); + ReceiveWebTransportUnidirectionalStream(session_id, uni_b, + std::string(60, 'b')); + + EXPECT_NE(wt->OpenOutgoingBidirectionalStream(), nullptr) + << "Unread data on a reset stream must be accounted as " + "consumed so the WT_MAX_DATA window replenishes"; + testing::Mock::VerifyAndClearExpectations(visitor); +} + +} // namespace +} // namespace quic diff --git a/quiche/quic/core/http/web_transport_http3.cc b/quiche/quic/core/http/web_transport_http3.cc index 404479666..bdd230e61 100644 --- a/quiche/quic/core/http/web_transport_http3.cc +++ b/quiche/quic/core/http/web_transport_http3.cc @@ -62,14 +62,40 @@ WebTransportHttp3::WebTransportHttp3(QuicSpdySession* session, connect_stream_->RegisterHttp3DatagramVisitor(this); } +void WebTransportHttp3::SetWebTransportSessionOnAdapter( + QuicStreamId stream_id) { + QuicStream* stream = session_->GetActiveStream(stream_id); + if (stream == nullptr) { + return; + } + if (QuicUtils::IsBidirectionalStreamId(stream_id, session_->version())) { + auto* spdy_stream = static_cast(stream); + if (spdy_stream->web_transport_stream_adapter() != nullptr) { + spdy_stream->web_transport_stream_adapter() + ->SetWebTransportSession(this); + } + } else { + static_cast(stream) + ->SetWebTransportSession(this); + } +} + void WebTransportHttp3::AssociateStream(QuicStreamId stream_id) { streams_.insert(stream_id); + // Set direct WT session pointer on the stream's adapter for FC. + SetWebTransportSessionOnAdapter(stream_id); + ParsedQuicVersion version = session_->version(); if (QuicUtils::IsOutgoingStreamId(version, stream_id, session_->perspective())) { return; } + // Section 5.3: Check incoming stream limits. + OnIncomingStreamAssociated(stream_id); + if (close_sent_) { + return; // Session was closed due to stream limit violation. + } if (QuicUtils::IsBidirectionalStreamId(stream_id, version)) { incoming_bidirectional_streams_.push_back(stream_id); visitor_->OnIncomingBidirectionalStreamAvailable(); @@ -79,16 +105,18 @@ void WebTransportHttp3::AssociateStream(QuicStreamId stream_id) { } } -void WebTransportHttp3::OnConnectStreamClosing() { - // Copy the stream list before iterating over it, as calls to ResetStream() - // can potentially mutate the |session_| list. - std::vector streams(streams_.begin(), streams_.end()); - streams_.clear(); - for (QuicStreamId id : streams) { - session_->ResetStream(id, QUIC_STREAM_WEBTRANSPORT_SESSION_GONE); +void WebTransportHttp3::MaybeDecrementSessionCount() { + if (session_counted_) { + session_counted_ = false; + session_->OnWebTransportSessionDestroyed(); } +} + +void WebTransportHttp3::OnConnectStreamClosing() { + ResetAssociatedStreams(); connect_stream_->UnregisterHttp3DatagramVisitor(); + MaybeDecrementSessionCount(); MaybeNotifyClose(); } @@ -101,6 +129,7 @@ void WebTransportHttp3::CloseSession(WebTransportSessionError error_code, return; } close_sent_ = true; + MaybeDecrementSessionCount(); // There can be a race between us trying to send our close and peer sending // one. If we received a close, however, we cannot send ours since we already @@ -112,12 +141,24 @@ void WebTransportHttp3::CloseSession(WebTransportSessionError error_code, } error_code_ = error_code; - error_message_ = std::string(error_message); + // Section 6: "its length MUST NOT exceed 1024 bytes." + if (error_message.size() > 1024) { + QUICHE_BUG(webtransport_close_message_too_long) + << "CloseSession error message exceeds 1024 bytes, truncating"; + error_message_ = std::string(error_message.substr(0, 1024)); + } else { + error_message_ = std::string(error_message); + } + + // Section 6: Reset all associated streams upon session termination. + ResetAssociatedStreams(); + QuicConnection::ScopedPacketFlusher flusher( connect_stream_->spdy_session()->connection()); connect_stream_->WriteCapsule( - quiche::Capsule::CloseWebTransportSession(error_code, error_message), + quiche::Capsule::CloseWebTransportSession(error_code, error_message_), /*fin=*/true); + connect_stream_->StopReading(); } void WebTransportHttp3::OnCloseReceived(WebTransportSessionError error_code, @@ -125,8 +166,18 @@ void WebTransportHttp3::OnCloseReceived(WebTransportSessionError error_code, if (close_received_) { QUIC_BUG(WebTransportHttp3 notified of close received twice) << "WebTransportHttp3::OnCloseReceived() may be only called once."; + return; + } + // Section 6: "its length MUST NOT exceed 1024 bytes." + if (error_message.size() > 1024) { + OnInternalError(0, "WT_CLOSE_SESSION error message exceeds 1024 bytes"); + return; } close_received_ = true; + MaybeDecrementSessionCount(); + + // Section 6: Reset all associated streams upon session termination. + ResetAssociatedStreams(); // If the peer has sent a close after we sent our own, keep the local error. if (close_sent_) { @@ -138,6 +189,12 @@ void WebTransportHttp3::OnCloseReceived(WebTransportSessionError error_code, error_code_ = error_code; error_message_ = std::string(error_message); connect_stream_->WriteOrBufferBody("", /*fin=*/true); + // Section 6 MUST: "If any additional stream data is received on the CONNECT + // stream after receiving a WT_CLOSE_SESSION capsule, the stream MUST be + // reset with code H3_MESSAGE_ERROR." + connect_stream_->SendStopSending(QuicResetStreamError( + QUIC_STREAM_CANCELLED, + static_cast(QuicHttp3ErrorCode::MESSAGE_ERROR))); MaybeNotifyClose(); } @@ -149,6 +206,10 @@ void WebTransportHttp3::OnConnectStreamFinReceived() { return; } close_received_ = true; + MaybeDecrementSessionCount(); + + ResetAssociatedStreams(); + if (close_sent_) { QUIC_DLOG(INFO) << "Ignoring received FIN as we've already sent our close."; return; @@ -189,6 +250,17 @@ void WebTransportHttp3::HeadersReceived( return; } MaybeSetSubprotocolFromResponseHeaders(headers); + + // Section 3.3: Client MUST close with WT_ALPN_ERROR if it offered + // subprotocols and the server did not select a valid one. + if (session_->SupportedWebTransportVersion() == + WebTransportHttp3Version::kDraft15 && + !subprotocols_offered_.empty() && !subprotocol_selected_.has_value()) { + rejection_reason_ = + WebTransportHttp3RejectionReason::kSubprotocolNegotiationFailed; + OnInternalError(kWtAlpnError, "ALPN negotiation failed"); + return; + } } QUIC_DVLOG(1) << ENDPOINT << "WebTransport session " << id_ << " ready."; @@ -216,7 +288,7 @@ WebTransportStream* WebTransportHttp3::AcceptIncomingUnidirectionalStream() { while (!incoming_unidirectional_streams_.empty()) { QuicStreamId id = incoming_unidirectional_streams_.front(); incoming_unidirectional_streams_.pop_front(); - QuicStream* stream = session_->GetOrCreateStream(id); + QuicStream* stream = session_->GetActiveStream(id); if (stream == nullptr) { // Skip the streams that were reset in between the time they were // receieved and the time the client has polled for them. @@ -229,32 +301,312 @@ WebTransportStream* WebTransportHttp3::AcceptIncomingUnidirectionalStream() { } bool WebTransportHttp3::CanOpenNextOutgoingBidirectionalStream() { + if (IsTerminated()) { + return false; + } + if (!CanOpenNextOutgoingStream(webtransport::StreamType::kBidirectional)) { + return false; + } return session_->CanOpenOutgoingBidirectionalWebTransportStream(id_); } bool WebTransportHttp3::CanOpenNextOutgoingUnidirectionalStream() { + if (IsTerminated()) { + return false; + } + if (!CanOpenNextOutgoingStream(webtransport::StreamType::kUnidirectional)) { + return false; + } return session_->CanOpenOutgoingUnidirectionalWebTransportStream(id_); } WebTransportStream* WebTransportHttp3::OpenOutgoingBidirectionalStream() { + // Section 6: After session termination, no new streams may be opened. + if (IsTerminated()) { + return nullptr; + } + // Section 5.3: Check WT-level stream limit. + if (!CanOpenNextOutgoingStream(webtransport::StreamType::kBidirectional)) { + MaybeSendStreamsBlocked(webtransport::StreamType::kBidirectional); + return nullptr; + } QuicSpdyStream* stream = session_->CreateOutgoingBidirectionalWebTransportStream(this); if (stream == nullptr) { - // If stream cannot be created due to flow control or other errors, return - // nullptr. return nullptr; } + ++outgoing_bidi_stream_count_; return stream->web_transport_stream(); } WebTransportStream* WebTransportHttp3::OpenOutgoingUnidirectionalStream() { + // Section 6: After session termination, no new streams may be opened. + if (IsTerminated()) { + return nullptr; + } + // Section 5.3: Check WT-level stream limit. + if (!CanOpenNextOutgoingStream(webtransport::StreamType::kUnidirectional)) { + MaybeSendStreamsBlocked(webtransport::StreamType::kUnidirectional); + return nullptr; + } WebTransportHttp3UnidirectionalStream* stream = session_->CreateOutgoingUnidirectionalWebTransportStream(this); if (stream == nullptr) { - // If stream cannot be created due to flow control, return nullptr. return nullptr; } + ++outgoing_uni_stream_count_; return stream->interface(); } +bool WebTransportHttp3::CanOpenNextOutgoingStream( + webtransport::StreamType stream_type) const { + if (!wt_stream_limits_enabled_) { + return true; + } + if (stream_type == webtransport::StreamType::kBidirectional) { + return outgoing_bidi_stream_count_ < max_outgoing_bidi_streams_; + } + return outgoing_uni_stream_count_ < max_outgoing_uni_streams_; +} + +void WebTransportHttp3::SetInitialStreamLimits(uint64_t max_outgoing_bidi, + uint64_t max_outgoing_uni, + uint64_t max_incoming_bidi, + uint64_t max_incoming_uni) { + max_outgoing_bidi_streams_ = max_outgoing_bidi; + max_outgoing_uni_streams_ = max_outgoing_uni; + max_incoming_bidi_streams_ = max_incoming_bidi; + max_incoming_uni_streams_ = max_incoming_uni; + initial_max_incoming_bidi_streams_ = max_incoming_bidi; + initial_max_incoming_uni_streams_ = max_incoming_uni; + wt_stream_limits_enabled_ = true; +} + +void WebTransportHttp3::SetInitialDataLimit(uint64_t max_data_send, + uint64_t max_data_receive) { + max_data_send_ = max_data_send; + max_data_receive_ = max_data_receive; + initial_max_data_receive_ = max_data_receive; + wt_data_limits_enabled_ = true; +} + +void WebTransportHttp3::OnMaxDataCapsuleReceived(uint64_t max_data) { + if (!wt_data_limits_enabled_) { + return; + } + // Section 5.6.4: WT_MAX_DATA must not decrease. + if (max_data < max_data_send_) { + OnInternalError( + kWtFlowControlError, + "WT_MAX_DATA decreased"); + return; + } + if (max_data == max_data_send_) { + return; + } + max_data_send_ = max_data; + data_blocked_sent_ = false; + for (QuicStreamId id : streams_) { + session_->MarkConnectionLevelWriteBlocked(id); + } +} + +bool WebTransportHttp3::CanSendData(size_t bytes) const { + if (!wt_data_limits_enabled_) { + return true; + } + if (total_data_sent_ > max_data_send_) { + return false; + } + return bytes <= max_data_send_ - total_data_sent_; +} + +void WebTransportHttp3::OnDataSent(size_t bytes) { + total_data_sent_ += bytes; +} + +void WebTransportHttp3::OnIncomingDataReceived(size_t bytes) { + if (!wt_data_limits_enabled_) { + return; + } + total_data_received_ += bytes; + if (total_data_received_ > max_data_receive_) { + OnInternalError( + kWtFlowControlError, + "Incoming data exceeded WT_MAX_DATA limit"); + } +} + +void WebTransportHttp3::OnIncomingDataConsumed(size_t bytes) { + if (!wt_data_limits_enabled_ || close_sent_) return; + total_data_consumed_ += bytes; + if (total_data_consumed_ > max_data_receive_) return; + // Section 5.6.4: Send WT_MAX_DATA when available window drops below half. + uint64_t available = max_data_receive_ - total_data_consumed_; + if (available < initial_max_data_receive_ / 2) { + constexpr uint64_t kMaxVarint62 = (1ULL << 62) - 1; + uint64_t new_max = max_data_receive_ + initial_max_data_receive_; + if (new_max > kMaxVarint62) { + new_max = kMaxVarint62; + } + if (new_max == max_data_receive_) return; + max_data_receive_ = new_max; + QuicConnection::ScopedPacketFlusher flusher( + connect_stream_->spdy_session()->connection()); + connect_stream_->WriteCapsule( + quiche::Capsule(quiche::WebTransportMaxDataCapsule{max_data_receive_})); + } +} + +void WebTransportHttp3::OnStreamClosed(QuicStreamId stream_id) { + streams_.erase(stream_id); + if (!wt_stream_limits_enabled_ || close_sent_) return; + ParsedQuicVersion version = session_->version(); + if (QuicUtils::IsOutgoingStreamId(version, stream_id, + session_->perspective())) { + return; + } + if (QuicUtils::IsBidirectionalStreamId(stream_id, version)) { + MaybeReplenishStreamLimit(webtransport::StreamType::kBidirectional); + } else { + MaybeReplenishStreamLimit(webtransport::StreamType::kUnidirectional); + } +} + +void WebTransportHttp3::MaybeReplenishStreamLimit( + webtransport::StreamType type) { + uint64_t& max_incoming = + (type == webtransport::StreamType::kBidirectional) + ? max_incoming_bidi_streams_ + : max_incoming_uni_streams_; + const uint64_t& count = + (type == webtransport::StreamType::kBidirectional) + ? incoming_bidi_stream_count_ + : incoming_uni_stream_count_; + const uint64_t initial = + (type == webtransport::StreamType::kBidirectional) + ? initial_max_incoming_bidi_streams_ + : initial_max_incoming_uni_streams_; + uint64_t available = max_incoming - count; + // Section 5.6.2: Send WT_MAX_STREAMS when available window drops below half. + if (available < initial / 2) { + constexpr uint64_t kMaxStreamsUpperBound = 1ULL << 60; + uint64_t new_max = max_incoming + initial; + if (new_max > kMaxStreamsUpperBound) { + new_max = kMaxStreamsUpperBound; + } + if (new_max == max_incoming) return; + max_incoming = new_max; + QuicConnection::ScopedPacketFlusher flusher( + connect_stream_->spdy_session()->connection()); + connect_stream_->WriteCapsule(quiche::Capsule( + quiche::WebTransportMaxStreamsCapsule{type, max_incoming})); + } +} + +void WebTransportHttp3::MaybeSendStreamsBlocked( + webtransport::StreamType type) { + if (!wt_stream_limits_enabled_ || close_sent_) return; + bool& sent = (type == webtransport::StreamType::kBidirectional) + ? bidi_streams_blocked_sent_ + : uni_streams_blocked_sent_; + if (sent) return; + sent = true; + uint64_t limit = (type == webtransport::StreamType::kBidirectional) + ? max_outgoing_bidi_streams_ + : max_outgoing_uni_streams_; + QuicConnection::ScopedPacketFlusher flusher( + connect_stream_->spdy_session()->connection()); + connect_stream_->WriteCapsule(quiche::Capsule( + quiche::WebTransportStreamsBlockedCapsule{type, limit})); +} + +void WebTransportHttp3::MaybeSendDataBlocked() { + if (!wt_data_limits_enabled_ || close_sent_ || data_blocked_sent_) return; + data_blocked_sent_ = true; + QuicConnection::ScopedPacketFlusher flusher( + connect_stream_->spdy_session()->connection()); + connect_stream_->WriteCapsule(quiche::Capsule( + quiche::WebTransportDataBlockedCapsule{max_data_send_})); +} + +void WebTransportHttp3::OnIncomingStreamAssociated(QuicStreamId stream_id) { + if (!wt_stream_limits_enabled_) { + return; + } + ParsedQuicVersion version = session_->version(); + if (QuicUtils::IsOutgoingStreamId(version, stream_id, + session_->perspective())) { + return; + } + if (QuicUtils::IsBidirectionalStreamId(stream_id, version)) { + ++incoming_bidi_stream_count_; + if (incoming_bidi_stream_count_ > max_incoming_bidi_streams_) { + OnInternalError( + kWtFlowControlError, + "Incoming bidirectional stream count exceeds limit"); + } + } else { + ++incoming_uni_stream_count_; + if (incoming_uni_stream_count_ > max_incoming_uni_streams_) { + OnInternalError( + kWtFlowControlError, + "Incoming unidirectional stream count exceeds limit"); + } + } +} + +void WebTransportHttp3::OnMaxStreamsCapsuleReceived( + webtransport::StreamType stream_type, uint64_t max_stream_count) { + if (!wt_stream_limits_enabled_) { + return; + } + // Section 5.6.2: Maximum Streams cannot exceed 2^60. + constexpr uint64_t kMaxStreamsUpperBound = 1ULL << 60; + if (max_stream_count > kMaxStreamsUpperBound) { + QUIC_DLOG(ERROR) << ENDPOINT << "Received WT_MAX_STREAMS with value " + << max_stream_count << " exceeding 2^60 limit."; + session_->connection()->CloseConnection( + QUIC_HTTP_FRAME_ERROR, + static_cast(kH3DatagramError), + "WT_MAX_STREAMS value exceeds 2^60", + ConnectionCloseBehavior::SEND_CONNECTION_CLOSE_PACKET); + return; + } + if (stream_type == webtransport::StreamType::kBidirectional) { + // Section 5.6.2: WT_MAX_STREAMS must not decrease. + if (max_stream_count < max_outgoing_bidi_streams_) { + QUIC_DLOG(ERROR) << ENDPOINT + << "Received WT_MAX_STREAMS_BIDI with decreased " + "value, closing session."; + OnInternalError( + kWtFlowControlError, + "WT_MAX_STREAMS decreased"); + return; + } + if (max_stream_count == max_outgoing_bidi_streams_) { + return; + } + max_outgoing_bidi_streams_ = max_stream_count; + bidi_streams_blocked_sent_ = false; + visitor_->OnCanCreateNewOutgoingBidirectionalStream(); + } else { + if (max_stream_count < max_outgoing_uni_streams_) { + QUIC_DLOG(ERROR) << ENDPOINT + << "Received WT_MAX_STREAMS_UNIDI with decreased " + "value, closing session."; + OnInternalError( + kWtFlowControlError, + "WT_MAX_STREAMS decreased"); + return; + } + if (max_stream_count == max_outgoing_uni_streams_) { + return; + } + max_outgoing_uni_streams_ = max_stream_count; + uni_streams_blocked_sent_ = false; + visitor_->OnCanCreateNewOutgoingUnidirectionalStream(); + } +} + webtransport::Stream* WebTransportHttp3::GetStreamById( webtransport::StreamId id) { if (!streams_.contains(id)) { @@ -273,6 +625,11 @@ webtransport::Stream* WebTransportHttp3::GetStreamById( webtransport::DatagramStatus WebTransportHttp3::SendOrQueueDatagram( absl::string_view datagram) { + if (IsTerminated()) { + return webtransport::DatagramStatus( + webtransport::DatagramStatusCode::kInternalError, + "Session is closed"); + } return DatagramStatusToWebTransportStatus( connect_stream_->SendHttp3Datagram(datagram)); } @@ -294,12 +651,65 @@ void WebTransportHttp3::NotifySessionDraining() { } } +void WebTransportHttp3::SetVisitor( + std::unique_ptr visitor) { + visitor_ = std::move(visitor); + // Draft-15 Section 4.6: streams and datagrams that arrive before the + // session is fully established must be buffered and delivered once the + // session is ready. So, flush any buffered datagrams now — SetVisitor is the + // earliest point where both ready_ and a real visitor exist (the noop + // visitor is still active during HeadersReceived). + if (ready_) { + session_->FlushBufferedDatagramsForSession(this); + } +} + void WebTransportHttp3::OnHttp3Datagram(QuicStreamId stream_id, absl::string_view payload) { QUICHE_DCHECK_EQ(stream_id, connect_stream_->id()); visitor_->OnDatagramReceived(payload); } +void WebTransportHttp3::OnInternalError(WebTransportSessionError error_code, + absl::string_view error_message) { + if (IsTerminated()) { + return; + } + CloseSession(error_code, error_message); + MaybeNotifyClose(); +} + +void WebTransportHttp3::ResetAssociatedStreams() { + // Copy the stream list before iterating over it, as calls below can + // potentially mutate the |session_| stream map. + std::vector streams(streams_.begin(), streams_.end()); + streams_.clear(); + for (QuicStreamId id : streams) { + QuicStream* stream = session_->GetOrCreateStream(id); + if (stream == nullptr) { + continue; + } + QuicResetStreamError error = + (session_->SupportedWebTransportVersion() == + WebTransportHttp3Version::kDraft15) + ? QuicResetStreamError(QUIC_STREAM_WEBTRANSPORT_SESSION_GONE, + kWtSessionGone) + : QuicResetStreamError::FromInternal( + QUIC_STREAM_WEBTRANSPORT_SESSION_GONE); + // Section 4.4: Use RESET_STREAM_AT when available to ensure the peer + // can associate the stream with the correct session even after reset. + // Only use RESET_STREAM_AT when data has been written (the stream header + // counts); without data, there is nothing to reliably deliver. + if (stream->stream_bytes_written() > 0 && stream->SetReliableSize()) { + stream->PartialResetWriteSide(error); + } else { + stream->ResetWriteSide(error); + } + // Section 6: "abort reading on the receive side" + stream->SendStopSending(error); + } +} + void WebTransportHttp3::MaybeNotifyClose() { if (close_notified_) { return; @@ -393,6 +803,9 @@ void WebTransportHttp3UnidirectionalStream::OnDataAvailable() { } } + // The adapter's OnDataAvailable() counts readable bytes against + // WT_MAX_DATA (Section 5.4), covering both initially-buffered and + // subsequently-arriving payload data. adapter_.OnDataAvailable(); } @@ -413,6 +826,7 @@ void WebTransportHttp3UnidirectionalStream::OnClose() { << ", but the session could not be found."; return; } + adapter_.OnClosingWithUnreadData(); session->OnStreamClosed(id()); } @@ -506,4 +920,42 @@ void WebTransportHttp3::MaybeSetSubprotocolFromResponseHeaders( subprotocol_selected_ = *std::move(subprotocol); } +absl::StatusOr WebTransportHttp3::GetKeyingMaterial( + absl::string_view label, absl::string_view context, size_t length) { + if (session_->SupportedWebTransportVersion() != + WebTransportHttp3Version::kDraft15) { + return absl::UnimplementedError( + "Keying material export requires draft-15"); + } + if (IsTerminated()) { + return absl::FailedPreconditionError("Session is closed"); + } + // Section 4.8: label and context fields are each limited to 255 bytes. + if (label.size() > 255) { + return absl::InvalidArgumentError("label exceeds 255 bytes"); + } + if (context.size() > 255) { + return absl::InvalidArgumentError("context exceeds 255 bytes"); + } + // Section 4.8: Build the "WebTransport Exporter Context" struct. + std::string exporter_context; + exporter_context.resize(8 + 1 + label.size() + 1 + context.size()); + QuicDataWriter writer(exporter_context.size(), exporter_context.data()); + writer.WriteUInt64(id_); + writer.WriteUInt8(static_cast(label.size())); + if (!label.empty()) { + writer.WriteStringPiece(label); + } + writer.WriteUInt8(static_cast(context.size())); + if (!context.empty()) { + writer.WriteStringPiece(context); + } + std::string result; + if (!session_->ExportKeyingMaterial("EXPORTER-WebTransport", + exporter_context, length, &result)) { + return absl::InternalError("TLS exporter failed"); + } + return result; +} + } // namespace quic diff --git a/quiche/quic/core/http/web_transport_http3.h b/quiche/quic/core/http/web_transport_http3.h index a360986dd..14794d08b 100644 --- a/quiche/quic/core/http/web_transport_http3.h +++ b/quiche/quic/core/http/web_transport_http3.h @@ -13,8 +13,10 @@ #include "absl/base/attributes.h" #include "absl/container/flat_hash_set.h" +#include "absl/status/statusor.h" #include "absl/strings/string_view.h" #include "absl/time/time.h" +#include "quiche/quic/core/http/http_constants.h" #include "quiche/quic/core/http/quic_spdy_session.h" #include "quiche/quic/core/http/web_transport_stream_adapter.h" #include "quiche/quic/core/quic_error_codes.h" @@ -38,6 +40,7 @@ enum class WebTransportHttp3RejectionReason { kWrongStatusCode, kMissingDraftVersion, kUnsupportedDraftVersion, + kSubprotocolNegotiationFailed, }; // A session of WebTransport over HTTP/3. The session is owned by @@ -53,15 +56,13 @@ class QUICHE_EXPORT WebTransportHttp3 WebTransportSessionId id); void HeadersReceived(const quiche::HttpHeaderBlock& headers); - void SetVisitor(std::unique_ptr visitor) { - visitor_ = std::move(visitor); - } + void SetVisitor(std::unique_ptr visitor); WebTransportSessionId id() { return id_; } bool ready() { return ready_; } void AssociateStream(QuicStreamId stream_id); - void OnStreamClosed(QuicStreamId stream_id) { streams_.erase(stream_id); } + void OnStreamClosed(QuicStreamId stream_id); void OnConnectStreamClosing(); size_t NumberOfAssociatedStreams() { return streams_.size(); } @@ -114,6 +115,7 @@ class QUICHE_EXPORT WebTransportHttp3 const quiche::UnknownCapsule& /*capsule*/) override {} bool close_received() const { return close_received_; } + void set_session_counted(bool counted) { session_counted_ = counted; } WebTransportHttp3RejectionReason rejection_reason() const { return rejection_reason_; } @@ -121,6 +123,28 @@ class QUICHE_EXPORT WebTransportHttp3 void OnGoAwayReceived(); void OnDrainSessionReceived(); + // Session-level stream limits (Section 5.3). + void OnMaxStreamsCapsuleReceived(webtransport::StreamType stream_type, + uint64_t max_stream_count); + void SetInitialStreamLimits(uint64_t max_outgoing_bidi, + uint64_t max_outgoing_uni, + uint64_t max_incoming_bidi, + uint64_t max_incoming_uni); + bool CanOpenNextOutgoingStream(webtransport::StreamType stream_type) const; + + // Session-level data limits (Section 5.4). + void SetInitialDataLimit(uint64_t max_data_send, uint64_t max_data_receive); + void OnMaxDataCapsuleReceived(uint64_t max_data); + bool CanSendData(size_t bytes) const; + void OnDataSent(size_t bytes); + void OnIncomingDataReceived(size_t bytes); + void OnIncomingDataConsumed(size_t bytes); + + void OnIncomingStreamAssociated(QuicStreamId stream_id); + void MaybeReplenishStreamLimit(webtransport::StreamType type); + void MaybeSendStreamsBlocked(webtransport::StreamType type); + void MaybeSendDataBlocked(); + const std::vector& subprotocols_offered() const { return subprotocols_offered_; } @@ -133,7 +157,30 @@ class QUICHE_EXPORT WebTransportHttp3 void MaybeSetSubprotocolFromResponseHeaders( const quiche::HttpHeaderBlock& headers); + // Closes the session and notifies the visitor due to a protocol error + // detected by the WT implementation (as opposed to the application). + void OnInternalError(WebTransportSessionError error_code, + absl::string_view error_message); + + // Section 4.8: Derives per-session keying material via TLS exporters. + absl::StatusOr GetKeyingMaterial(absl::string_view label, + absl::string_view context, + size_t length); + private: + // Returns true if the session has been closed (either locally or by peer). + bool IsTerminated() const { return close_sent_ || close_received_; } + + // Sets the direct WebTransportHttp3 pointer on a stream's adapter. + void SetWebTransportSessionOnAdapter(QuicStreamId stream_id); + + // Resets all associated streams with QUIC_STREAM_WEBTRANSPORT_SESSION_GONE + // and clears the stream set. + void ResetAssociatedStreams(); + + // Decrements the session counter if this session is still counted. + void MaybeDecrementSessionCount(); + // Notifies the visitor that the connection has been closed. Ensures that the // visitor is only ever called once. void MaybeNotifyClose(); @@ -151,6 +198,7 @@ class QUICHE_EXPORT WebTransportHttp3 bool close_sent_ = false; bool close_received_ = false; bool close_notified_ = false; + bool session_counted_ = false; // On client side, stores the offered subprotocols. std::vector subprotocols_offered_; @@ -160,6 +208,41 @@ class QUICHE_EXPORT WebTransportHttp3 quiche::SingleUseCallback drain_callback_ = nullptr; + // Draft-15 session-level stream limits (Section 5). + // Cumulative count of outgoing streams opened on this session. + uint64_t outgoing_bidi_stream_count_ = 0; + uint64_t outgoing_uni_stream_count_ = 0; + // Maximum number of outgoing streams allowed by the peer (from SETTINGS + // initial values and WT_MAX_STREAMS capsules). 0 means unlimited when + // WT FC is not negotiated. + uint64_t max_outgoing_bidi_streams_ = 0; + uint64_t max_outgoing_uni_streams_ = 0; + // Whether WT-level stream/data limits are active for this session. + bool wt_stream_limits_enabled_ = false; + // Tracks whether BLOCKED capsules have been sent at the current limit, + // to avoid redundant sends. Reset when the limit is raised. + bool bidi_streams_blocked_sent_ = false; + bool uni_streams_blocked_sent_ = false; + bool data_blocked_sent_ = false; + + // Draft-15 session-level data limits (Section 5.4). + uint64_t total_data_sent_ = 0; + uint64_t max_data_send_ = 0; // Peer's WT_MAX_DATA for data we send + uint64_t total_data_received_ = 0; + uint64_t total_data_consumed_ = 0; // Bytes consumed by the application + uint64_t max_data_receive_ = 0; // Our WT_MAX_DATA for data we receive + uint64_t initial_max_data_receive_ = 0; // Initial window size for threshold + bool wt_data_limits_enabled_ = false; + + // Incoming stream count tracking for enforcement. + uint64_t incoming_bidi_stream_count_ = 0; + uint64_t incoming_uni_stream_count_ = 0; + // Max incoming streams (from our SETTINGS, not peer's). + uint64_t max_incoming_bidi_streams_ = 0; + uint64_t max_incoming_uni_streams_ = 0; + uint64_t initial_max_incoming_bidi_streams_ = 0; + uint64_t initial_max_incoming_uni_streams_ = 0; + WebTransportHttp3RejectionReason rejection_reason_ = WebTransportHttp3RejectionReason::kNone; bool drain_sent_ = false; @@ -192,6 +275,9 @@ class QUICHE_EXPORT WebTransportHttp3UnidirectionalStream : public QuicStream { WebTransportStream* interface() { return &adapter_; } void SetUnblocked() { sequencer()->SetUnblocked(); } + void SetWebTransportSession(WebTransportHttp3* session) { + adapter_.SetWebTransportSession(session); + } private: QuicSpdySession* session_; diff --git a/quiche/quic/core/http/web_transport_keying_material_draft15_test.cc b/quiche/quic/core/http/web_transport_keying_material_draft15_test.cc new file mode 100644 index 000000000..a655de189 --- /dev/null +++ b/quiche/quic/core/http/web_transport_keying_material_draft15_test.cc @@ -0,0 +1,607 @@ +// Copyright 2024 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. + +// Draft-15 acceptance tests for keying material exporters (Section 4.8). + +#include +#include +#include + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "quiche/quic/core/http/quic_spdy_session.h" +#include "quiche/quic/core/http/web_transport_draft15_test_utils.h" +#include "quiche/quic/core/http/web_transport_http3.h" +#include "quiche/quic/core/quic_data_writer.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" + +namespace quic { +namespace { + +using ::testing::_; + +// Helper: builds the expected "WebTransport Exporter Context" struct +// (Section 4.8) for a given session ID, app label, and app context. +std::string BuildExpectedExporterContext(uint64_t session_id, + absl::string_view label, + absl::string_view context) { + std::string buf; + buf.resize(8 + 1 + label.size() + 1 + context.size()); + QuicDataWriter writer(buf.size(), buf.data()); + writer.WriteUInt64(session_id); + writer.WriteUInt8(static_cast(label.size())); + if (!label.empty()) { + writer.WriteStringPiece(label); + } + writer.WriteUInt8(static_cast(context.size())); + if (!context.empty()) { + writer.WriteStringPiece(context); + } + return buf; +} + +// =================================================================== +// Context struct serialization tests (no session needed) +// =================================================================== + +TEST(WebTransportKeyingMaterialDraft15, ContextSerialization_EmptyLabelEmptyContext) { + // Section 4.8: session_id=0, label="", context="" -> + // 8 bytes (session ID) + 1 byte (label len=0) + 1 byte (ctx len=0) = 10 bytes + std::string ctx = BuildExpectedExporterContext(0, "", ""); + EXPECT_EQ(ctx.size(), 10u); + // All zero session ID + two zero length bytes. + EXPECT_EQ(ctx, std::string(8, '\0') + std::string(1, '\0') + std::string(1, '\0')); +} + +TEST(WebTransportKeyingMaterialDraft15, ContextSerialization_NonEmptyLabel) { + // label="my-label", context="" -> session_id(8) + len(1) + "my-label"(8) + len(1) = 18 + std::string ctx = BuildExpectedExporterContext(0, "my-label", ""); + EXPECT_EQ(ctx.size(), 18u); + EXPECT_EQ(ctx[8], 8); // label length + EXPECT_EQ(ctx.substr(9, 8), "my-label"); + EXPECT_EQ(ctx[17], 0); // context length +} + +TEST(WebTransportKeyingMaterialDraft15, ContextSerialization_NonEmptyContext) { + // label="", context="ctx" -> session_id(8) + len(1) + len(1) + "ctx"(3) = 13 + std::string ctx = BuildExpectedExporterContext(0, "", "ctx"); + EXPECT_EQ(ctx.size(), 13u); + EXPECT_EQ(ctx[8], 0); // label length + EXPECT_EQ(ctx[9], 3); // context length + EXPECT_EQ(ctx.substr(10, 3), "ctx"); +} + +TEST(WebTransportKeyingMaterialDraft15, ContextSerialization_BothNonEmpty) { + // label="lbl", context="ctx" -> + // session_id(8) + len(1) + "lbl"(3) + len(1) + "ctx"(3) = 16 + std::string ctx = BuildExpectedExporterContext(0, "lbl", "ctx"); + EXPECT_EQ(ctx.size(), 16u); + EXPECT_EQ(ctx[8], 3); // label length + EXPECT_EQ(ctx.substr(9, 3), "lbl"); + EXPECT_EQ(ctx[12], 3); // context length + EXPECT_EQ(ctx.substr(13, 3), "ctx"); +} + +TEST(WebTransportKeyingMaterialDraft15, ContextSerialization_SessionIdEndianness) { + // Section 4.8: Session ID is 64-bit. Verify big-endian encoding. + // session_id=0x0102030405060708 -> bytes 01 02 03 04 05 06 07 08 + std::string ctx = BuildExpectedExporterContext(0x0102030405060708ULL, "", ""); + EXPECT_EQ(ctx.size(), 10u); + EXPECT_EQ(static_cast(ctx[0]), 0x01); + EXPECT_EQ(static_cast(ctx[1]), 0x02); + EXPECT_EQ(static_cast(ctx[2]), 0x03); + EXPECT_EQ(static_cast(ctx[3]), 0x04); + EXPECT_EQ(static_cast(ctx[4]), 0x05); + EXPECT_EQ(static_cast(ctx[5]), 0x06); + EXPECT_EQ(static_cast(ctx[6]), 0x07); + EXPECT_EQ(static_cast(ctx[7]), 0x08); +} + +TEST(WebTransportKeyingMaterialDraft15, ContextSerialization_MaxLabelLength) { + // Label length field is 8 bits -> max 255 bytes. 255-byte label must work. + std::string label(255, 'L'); + std::string ctx = BuildExpectedExporterContext(0, label, ""); + EXPECT_EQ(ctx.size(), 8u + 1u + 255u + 1u); + EXPECT_EQ(static_cast(ctx[8]), 0xFF); +} + +TEST(WebTransportKeyingMaterialDraft15, ContextSerialization_MaxContextLength) { + // Context length field is 8 bits -> max 255 bytes. 255-byte context must work. + std::string context(255, 'C'); + std::string ctx = BuildExpectedExporterContext(0, "", context); + EXPECT_EQ(ctx.size(), 8u + 1u + 1u + 255u); + EXPECT_EQ(static_cast(ctx[9]), 0xFF); +} + +TEST(WebTransportKeyingMaterialDraft15, ContextSerialization_OmittedContextIsZeroLength) { + // Section 4.8: "the WebTransport Application-Supplied Exporter Context + // becomes zero-length if omitted" -- empty context produces len=0, not absent. + std::string ctx = BuildExpectedExporterContext(42, "label", ""); + // The context length byte must be present and zero. + size_t context_len_offset = 8 + 1 + 5; // session_id + label_len + "label" + EXPECT_EQ(static_cast(ctx[context_len_offset]), 0x00); + // Total size includes the zero-length context field. + EXPECT_EQ(ctx.size(), 8u + 1u + 5u + 1u); +} + +// =================================================================== +// Session-level tests +// =================================================================== + +class KeyingMaterialDraft15Test : public test::Draft15SessionTest { + protected: + KeyingMaterialDraft15Test() : Draft15SessionTest(Perspective::IS_SERVER) {} +}; + +INSTANTIATE_TEST_SUITE_P(KeyingMaterialDraft15, KeyingMaterialDraft15Test, + ::testing::ValuesIn(CurrentSupportedVersions())); + +// --- Basic API contract --- + +TEST_P(KeyingMaterialDraft15Test, BasicExport) { + // Section 4.8 SHALL: GetKeyingMaterial returns keying material of + // the requested length. Verify result.size() == requested length. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id); + ASSERT_NE(wt, nullptr); + + auto result = wt->GetKeyingMaterial("label", "context", 32); + ASSERT_TRUE(result.ok()) << result.status(); + EXPECT_EQ(result->size(), 32u); +} + +TEST_P(KeyingMaterialDraft15Test, ExportWithLabel) { + // App-supplied label is included in the context struct. + // Different labels must produce different keying material. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id); + ASSERT_NE(wt, nullptr); + + auto result_a = wt->GetKeyingMaterial("label-a", "", 32); + auto result_b = wt->GetKeyingMaterial("label-b", "", 32); + ASSERT_TRUE(result_a.ok()) << result_a.status(); + ASSERT_TRUE(result_b.ok()) << result_b.status(); + EXPECT_NE(*result_a, *result_b) + << "Different app labels must produce different keying material"; +} + +TEST_P(KeyingMaterialDraft15Test, ExportWithContext) { + // App-supplied context is included in the context struct. + // Different contexts must produce different keying material. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id); + ASSERT_NE(wt, nullptr); + + auto result_a = wt->GetKeyingMaterial("", "context-a", 32); + auto result_b = wt->GetKeyingMaterial("", "context-b", 32); + ASSERT_TRUE(result_a.ok()) << result_a.status(); + ASSERT_TRUE(result_b.ok()) << result_b.status(); + EXPECT_NE(*result_a, *result_b) + << "Different app contexts must produce different keying material"; +} + +TEST_P(KeyingMaterialDraft15Test, ExportWithLabelAndContext) { + // Both label and context non-empty -> both included in struct. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id); + ASSERT_NE(wt, nullptr); + + auto result = wt->GetKeyingMaterial("my-label", "my-context", 32); + ASSERT_TRUE(result.ok()) << result.status(); + EXPECT_EQ(result->size(), 32u); +} + +TEST_P(KeyingMaterialDraft15Test, OmittedContextProducesZeroLengthField) { + // Section 4.8: empty context string -> zero-length field in struct, + // not "no context". Must still produce valid output. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id); + ASSERT_NE(wt, nullptr); + + auto result = wt->GetKeyingMaterial("label", "", 32); + ASSERT_TRUE(result.ok()) << result.status(); + EXPECT_EQ(result->size(), 32u); +} + +// --- Length edge cases --- + +TEST_P(KeyingMaterialDraft15Test, LengthZero) { + // Requesting 0 bytes of keying material. Should succeed with empty string. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id); + ASSERT_NE(wt, nullptr); + + auto result = wt->GetKeyingMaterial("label", "context", 0); + ASSERT_TRUE(result.ok()) << result.status(); + EXPECT_EQ(result->size(), 0u); +} + +TEST_P(KeyingMaterialDraft15Test, LengthOne) { + // Requesting 1 byte. Minimal non-trivial case. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id); + ASSERT_NE(wt, nullptr); + + auto result = wt->GetKeyingMaterial("label", "context", 1); + ASSERT_TRUE(result.ok()) << result.status(); + EXPECT_EQ(result->size(), 1u); +} + +TEST_P(KeyingMaterialDraft15Test, LengthLarge) { + // Requesting a large amount (1024 bytes). Must succeed. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id); + ASSERT_NE(wt, nullptr); + + auto result = wt->GetKeyingMaterial("label", "context", 1024); + ASSERT_TRUE(result.ok()) << result.status(); + EXPECT_EQ(result->size(), 1024u); +} + +TEST_P(KeyingMaterialDraft15Test, LengthMaxReasonable) { + // Requesting 64KB. Implementation should handle or return a + // clear error, not crash or OOM. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id); + ASSERT_NE(wt, nullptr); + + auto result = wt->GetKeyingMaterial("label", "context", 65536); + // Either succeeds with the right size or returns a clear error. + if (result.ok()) { + EXPECT_EQ(result->size(), 65536u); + } + // If !ok(), that's acceptable — just must not crash. +} + +// --- Session isolation --- + +TEST_P(KeyingMaterialDraft15Test, DifferentSessionsDifferentMaterial) { + // Section 4.8: "separates keying material for different sessions" + // Two sessions on the same connection with the same label/context + // must produce different keying material (session ID differs). + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + // Need FC to allow multiple sessions. + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/65536); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + + QuicStreamId session_id_0 = GetNthClientInitiatedBidirectionalId(0); + QuicStreamId session_id_1 = GetNthClientInitiatedBidirectionalId(1); + auto* wt0 = AttemptWebTransportDraft15Session(session_id_0); + auto* wt1 = AttemptWebTransportDraft15Session(session_id_1); + ASSERT_NE(wt0, nullptr); + ASSERT_NE(wt1, nullptr); + + auto result0 = wt0->GetKeyingMaterial("label", "context", 32); + auto result1 = wt1->GetKeyingMaterial("label", "context", 32); + ASSERT_TRUE(result0.ok()) << result0.status(); + ASSERT_TRUE(result1.ok()) << result1.status(); + EXPECT_NE(*result0, *result1) + << "Different sessions must produce different keying material " + "(session IDs " << session_id_0 << " vs " << session_id_1 << ")"; +} + +TEST_P(KeyingMaterialDraft15Test, SameSessionSameMaterial) { + // Same session, same label, same context, same length -> must be + // deterministic (same result every time). + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id); + ASSERT_NE(wt, nullptr); + + auto result1 = wt->GetKeyingMaterial("label", "context", 32); + auto result2 = wt->GetKeyingMaterial("label", "context", 32); + ASSERT_TRUE(result1.ok()) << result1.status(); + ASSERT_TRUE(result2.ok()) << result2.status(); + EXPECT_EQ(*result1, *result2); +} + +// --- TLS exporter arguments (wire-level correctness) --- +// These tests capture the actual arguments passed to +// QuicCryptoStream::ExportKeyingMaterial and verify them byte-for-byte. +// This is the core of the SHALL -- if the context struct is wrong +// (wrong endianness, swapped fields, missing length bytes), the output +// will be incompatible with other implementations. + +TEST_P(KeyingMaterialDraft15Test, UsesFixedTlsLabel) { + // Section 4.8 SHALL: The TLS exporter label is always + // "EXPORTER-WebTransport", regardless of the app-supplied label. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id); + ASSERT_NE(wt, nullptr); + + auto result = wt->GetKeyingMaterial("my-app-label", "my-context", 32); + ASSERT_TRUE(result.ok()) << result.status(); + + auto* crypto = session_->GetMutableCryptoStream(); + EXPECT_EQ(crypto->last_export_label(), "EXPORTER-WebTransport") + << "To implement Section 4.8, the TLS label must be " + "'EXPORTER-WebTransport', not the app-supplied label"; +} + +TEST_P(KeyingMaterialDraft15Test, ContextStructEncoding_EmptyLabelEmptyContext) { + // Capture the context arg passed to ExportKeyingMaterial. + // session_id=N, label="", context="" -> + // Expected bytes: [N as 8 big-endian bytes] [0x00] [0x00] + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id); + ASSERT_NE(wt, nullptr); + + auto result = wt->GetKeyingMaterial("", "", 32); + ASSERT_TRUE(result.ok()) << result.status(); + + std::string expected = BuildExpectedExporterContext(session_id, "", ""); + auto* crypto = session_->GetMutableCryptoStream(); + EXPECT_EQ(crypto->last_export_context(), expected) + << "Context struct encoding mismatch for empty label and context"; +} + +TEST_P(KeyingMaterialDraft15Test, ContextStructEncoding_WithLabelAndContext) { + // session_id=N, label="abc", context="xy" -> + // Expected: [N BE 8 bytes] [0x03] [0x61 0x62 0x63] [0x02] [0x78 0x79] + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id); + ASSERT_NE(wt, nullptr); + + auto result = wt->GetKeyingMaterial("abc", "xy", 32); + ASSERT_TRUE(result.ok()) << result.status(); + + std::string expected = BuildExpectedExporterContext(session_id, "abc", "xy"); + auto* crypto = session_->GetMutableCryptoStream(); + EXPECT_EQ(crypto->last_export_context(), expected) + << "Context struct encoding mismatch for label='abc', context='xy'"; +} + +TEST_P(KeyingMaterialDraft15Test, ContextStructEncoding_SessionIdIsBigEndian) { + // Use a session ID with distinct bytes. Capture the context arg. + // First 8 bytes must be big-endian, NOT host byte order. + // This catches endianness bugs that would silently pass + // "different sessions -> different output" tests on any single machine. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id); + ASSERT_NE(wt, nullptr); + + auto result = wt->GetKeyingMaterial("", "", 32); + ASSERT_TRUE(result.ok()) << result.status(); + + auto* crypto = session_->GetMutableCryptoStream(); + const std::string& ctx = crypto->last_export_context(); + ASSERT_GE(ctx.size(), 8u); + // Verify the session ID is encoded big-endian by comparing against + // the expected struct built with QuicDataWriter (which writes BE). + std::string expected = BuildExpectedExporterContext(session_id, "", ""); + EXPECT_EQ(ctx.substr(0, 8), expected.substr(0, 8)) + << "Session ID must be encoded big-endian in the exporter context"; +} + +TEST_P(KeyingMaterialDraft15Test, ContextStructEncoding_MaxLabelMaxContext) { + // 255-byte label + 255-byte context. Verify the length bytes are 0xFF + // and the full struct is 8 + 1 + 255 + 1 + 255 = 520 bytes. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id); + ASSERT_NE(wt, nullptr); + + std::string label(255, 'L'); + std::string context(255, 'C'); + auto result = wt->GetKeyingMaterial(label, context, 32); + ASSERT_TRUE(result.ok()) << result.status(); + + auto* crypto = session_->GetMutableCryptoStream(); + const std::string& ctx = crypto->last_export_context(); + EXPECT_EQ(ctx.size(), 520u); + EXPECT_EQ(static_cast(ctx[8]), 0xFF) + << "Label length byte should be 0xFF for 255-byte label"; + EXPECT_EQ(static_cast(ctx[8 + 1 + 255]), 0xFF) + << "Context length byte should be 0xFF for 255-byte context"; +} + +// --- Error conditions --- + +// FailsBeforeHandshakeComplete: not testable here because TestCryptoStream's +// ExportKeyingMaterial always returns true regardless of handshake state. +// With a real TLS stack (covered by e2e tests), ExportKeyingMaterial returns +// false before handshake completion, causing GetKeyingMaterial to return +// InternalError. + +TEST_P(KeyingMaterialDraft15Test, FailsAfterSessionClosed) { + // After CloseSession(), GetKeyingMaterial should return an error. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id); + ASSERT_NE(wt, nullptr); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + + wt->CloseSession(0, "done"); + + auto result = wt->GetKeyingMaterial("label", "context", 32); + EXPECT_FALSE(result.ok()) + << "GetKeyingMaterial should fail after session is closed"; +} + +// FailsWhenCryptoStreamReturnsFailure: not testable with TestCryptoStream +// (always returns true). The implementation propagates failures: if +// ExportKeyingMaterial returns false, GetKeyingMaterial returns +// absl::InternalError("TLS exporter failed"). + +// --- Label/context boundary values --- + +TEST_P(KeyingMaterialDraft15Test, EmptyLabel) { + // App label="" -> label length byte = 0, no label bytes in context struct. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id); + ASSERT_NE(wt, nullptr); + + auto result = wt->GetKeyingMaterial("", "context", 32); + ASSERT_TRUE(result.ok()) << result.status(); + + auto* crypto = session_->GetMutableCryptoStream(); + std::string expected = BuildExpectedExporterContext(session_id, "", "context"); + EXPECT_EQ(crypto->last_export_context(), expected); +} + +TEST_P(KeyingMaterialDraft15Test, MaxLengthLabel) { + // 255-byte app label -> label length byte = 0xFF, 255 label bytes. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id); + ASSERT_NE(wt, nullptr); + + std::string label(255, 'X'); + auto result = wt->GetKeyingMaterial(label, "", 32); + ASSERT_TRUE(result.ok()) << result.status(); +} + +TEST_P(KeyingMaterialDraft15Test, LabelExceeds255Bytes) { + // 256-byte app label -> must return error (8-bit length field overflow). + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id); + ASSERT_NE(wt, nullptr); + + std::string label(256, 'X'); + auto result = wt->GetKeyingMaterial(label, "", 32); + EXPECT_FALSE(result.ok()) + << "256-byte label exceeds 8-bit length field and must be rejected"; +} + +TEST_P(KeyingMaterialDraft15Test, EmptyContext) { + // App context="" -> context length byte = 0. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id); + ASSERT_NE(wt, nullptr); + + auto result = wt->GetKeyingMaterial("label", "", 32); + ASSERT_TRUE(result.ok()) << result.status(); + + auto* crypto = session_->GetMutableCryptoStream(); + std::string expected = BuildExpectedExporterContext(session_id, "label", ""); + EXPECT_EQ(crypto->last_export_context(), expected); +} + +TEST_P(KeyingMaterialDraft15Test, MaxLengthContext) { + // 255-byte app context -> context length byte = 0xFF. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id); + ASSERT_NE(wt, nullptr); + + std::string context(255, 'Y'); + auto result = wt->GetKeyingMaterial("", context, 32); + ASSERT_TRUE(result.ok()) << result.status(); +} + +TEST_P(KeyingMaterialDraft15Test, ContextExceeds255Bytes) { + // 256-byte app context -> must return error (8-bit length field overflow). + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id); + ASSERT_NE(wt, nullptr); + + std::string context(256, 'Y'); + auto result = wt->GetKeyingMaterial("", context, 32); + EXPECT_FALSE(result.ok()) + << "256-byte context exceeds 8-bit length field and must be rejected"; +} + +TEST_P(KeyingMaterialDraft15Test, LabelWithNullBytes) { + // App label containing \0 bytes. The TLS exporter label + // "EXPORTER-WebTransport" is fixed and has no nulls, but the app + // label goes into the context struct, not the TLS label. Must work. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id); + ASSERT_NE(wt, nullptr); + + std::string label("ab\0cd", 5); + auto result = wt->GetKeyingMaterial(label, "", 32); + ASSERT_TRUE(result.ok()) << result.status(); + + auto* crypto = session_->GetMutableCryptoStream(); + std::string expected = BuildExpectedExporterContext(session_id, label, ""); + EXPECT_EQ(crypto->last_export_context(), expected); +} + +TEST_P(KeyingMaterialDraft15Test, ContextWithNullBytes) { + // App context containing \0 bytes. Binary data in context is valid. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id); + ASSERT_NE(wt, nullptr); + + std::string context("xy\0z", 4); + auto result = wt->GetKeyingMaterial("", context, 32); + ASSERT_TRUE(result.ok()) << result.status(); + + auto* crypto = session_->GetMutableCryptoStream(); + std::string expected = BuildExpectedExporterContext(session_id, "", context); + EXPECT_EQ(crypto->last_export_context(), expected); +} + +// --- Draft-07 behavior --- + +TEST_P(KeyingMaterialDraft15Test, NotAvailableOnDraft07) { + // Section 4.8 is a draft-15 feature. On a draft-07 session, + // GetKeyingMaterial should return an error or not-implemented. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft07}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + ReceiveWebTransportDraft07Settings(); + + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + // Create a draft-07 session. + QuicStreamFrame frame(session_id, /*fin=*/false, /*offset=*/0, + absl::string_view()); + session_->OnStreamFrame(frame); + auto* connect_stream = static_cast( + session_->GetOrCreateStream(session_id)); + ASSERT_NE(connect_stream, nullptr); + QuicHeaderList headers; + headers.OnHeader(":method", "CONNECT"); + headers.OnHeader(":protocol", "webtransport"); + connect_stream->OnStreamHeaderList(/*fin=*/false, 0, headers); + auto* wt = session_->GetWebTransportSession(session_id); + ASSERT_NE(wt, nullptr); + + auto result = wt->GetKeyingMaterial("label", "context", 32); + EXPECT_FALSE(result.ok()) + << "Keying material export should not be available on " + "draft-07 sessions (only supported in draft-15)"; +} + +} // namespace +} // namespace quic diff --git a/quiche/quic/core/http/web_transport_session_establishment_draft15_test.cc b/quiche/quic/core/http/web_transport_session_establishment_draft15_test.cc new file mode 100644 index 000000000..b9d7e0e14 --- /dev/null +++ b/quiche/quic/core/http/web_transport_session_establishment_draft15_test.cc @@ -0,0 +1,840 @@ +// Copyright 2024 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. + +// Draft-15 tests for session establishment (Section 3.1, 3.2). + +#include +#include + +#include "quiche/quic/core/http/http_constants.h" +#include "quiche/quic/core/http/http_encoder.h" +#include "quiche/quic/core/http/http_frames.h" +#include "quiche/quic/core/http/web_transport_draft15_test_utils.h" +#include "quiche/quic/core/http/web_transport_http3.h" +#include "quiche/quic/core/quic_constants.h" +#include "quiche/quic/core/quic_types.h" +#include "quiche/quic/core/quic_versions.h" +#include "quiche/quic/platform/api/quic_expect_bug.h" +#include "quiche/quic/platform/api/quic_test.h" +#include "quiche/quic/test_tools/quic_config_peer.h" +#include "quiche/quic/test_tools/quic_connection_peer.h" +#include "quiche/quic/test_tools/quic_test_utils.h" +#include "quiche/web_transport/test_tools/draft15_constants.h" + +namespace quic { +namespace { + +using ::testing::_; + +// --- Server-perspective fixture --- + +class SessionEstablishmentDraft15Test : public test::Draft15SessionTest { + protected: + SessionEstablishmentDraft15Test() + : Draft15SessionTest(Perspective::IS_SERVER) {} +}; + +INSTANTIATE_TEST_SUITE_P(SessionEstablishmentDraft15, + SessionEstablishmentDraft15Test, + ::testing::ValuesIn(CurrentSupportedVersions())); + +// --- Client-perspective fixture --- + +class SessionEstablishmentDraft15ClientTest : public test::Draft15SessionTest { + protected: + SessionEstablishmentDraft15ClientTest() + : Draft15SessionTest(Perspective::IS_CLIENT) {} +}; + +INSTANTIATE_TEST_SUITE_P(SessionEstablishmentDraft15Client, + SessionEstablishmentDraft15ClientTest, + ::testing::ValuesIn(CurrentSupportedVersions())); + +// --- SETTINGS requirements (Section 3.1) --- + +TEST_P(SessionEstablishmentDraft15Test, ServerSendsRequiredSettings) { + // Section 3.1 MUST: Server sends SETTINGS_WT_ENABLED > 0, + // SETTINGS_ENABLE_CONNECT_PROTOCOL = 1, SETTINGS_H3_DATAGRAM = 1. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + EXPECT_EQ(webtransport::draft15::kSettingsWtEnabled, 0x2c7cf000u); + EXPECT_TRUE(session_->settings().values.contains(SETTINGS_WT_ENABLED)) + << "Server must emit SETTINGS_WT_ENABLED for draft-15"; + EXPECT_TRUE( + session_->settings().values.contains(SETTINGS_ENABLE_CONNECT_PROTOCOL)) + << "Server must emit SETTINGS_ENABLE_CONNECT_PROTOCOL"; + EXPECT_TRUE(session_->settings().values.contains(SETTINGS_H3_DATAGRAM)) + << "Server must emit SETTINGS_H3_DATAGRAM"; +} + +TEST_P(SessionEstablishmentDraft15ClientTest, ClientSendsRequiredSettings) { + // Section 3.1 MUST: Client sends SETTINGS_H3_DATAGRAM = 1 and + // SETTINGS_WT_ENABLED with draft-15 codepoint. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + EXPECT_EQ(webtransport::draft15::kSettingsWtEnabled, 0x2c7cf000u); + EXPECT_TRUE(session_->settings().values.contains(SETTINGS_WT_ENABLED)) + << "Client must emit SETTINGS_WT_ENABLED for draft-15"; + EXPECT_TRUE(session_->settings().values.contains(SETTINGS_H3_DATAGRAM)) + << "Client must emit SETTINGS_H3_DATAGRAM"; +} + +TEST_P(SessionEstablishmentDraft15ClientTest, ClientWaitsForServerSettings) { + // Section 3.1 MUST NOT: Client must not initiate a session before + // receiving server SETTINGS. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + // Before receiving server settings, WebTransport should not be available. + EXPECT_FALSE(session_->SupportsWebTransport()) + << "Client must not report WT support before receiving server SETTINGS"; + // Now receive server settings. + ReceiveWebTransportDraft15Settings(); + EXPECT_TRUE(session_->SupportsWebTransport()) + << "After receiving server SETTINGS, WT should be available"; +} + +TEST_P(SessionEstablishmentDraft15Test, ServerWaitsForClientSettings) { + // Section 7.1 MUST NOT: Server must not process requests until client + // SETTINGS received. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + // Before receiving client settings, WebTransport should not be available. + EXPECT_FALSE(session_->SupportsWebTransport()) + << "Server must not report WT support before receiving client SETTINGS"; + // Now receive client settings. + ReceiveWebTransportDraft15Settings(); + EXPECT_TRUE(session_->SupportsWebTransport()) + << "After receiving client SETTINGS, WT should be available"; +} + +TEST_P(SessionEstablishmentDraft15Test, MissingSettingsWtEnabled) { + // Section 3.1: Without SETTINGS_WT_ENABLED, WebTransport is unavailable. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + // Send settings that have H3_DATAGRAM and ENABLE_CONNECT_PROTOCOL but NOT + // SETTINGS_WT_ENABLED. + SettingsFrame settings; + settings.values[SETTINGS_H3_DATAGRAM] = 1; + settings.values[SETTINGS_ENABLE_CONNECT_PROTOCOL] = 1; + // Deliberately omit SETTINGS_WT_ENABLED. + std::string data = std::string(1, kControlStream) + + HttpEncoder::SerializeSettingsFrame(settings); + QuicStreamId control_stream_id = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 3); + QuicStreamFrame frame(control_stream_id, /*fin=*/false, /*offset=*/0, data); + session_->OnStreamFrame(frame); + EXPECT_FALSE(session_->SupportsWebTransport()) + << "Without SETTINGS_WT_ENABLED, WebTransport should not be available"; +} + +TEST_P(SessionEstablishmentDraft15ClientTest, MissingEnableConnectProtocol) { + // Section 3.1: Extended CONNECT support is required for WebTransport. + // In the current implementation, receiving SETTINGS_WT_ENABLED (draft-15) + // or SETTINGS_ENABLE_CONNECT_PROTOCOL on the client implicitly sets + // allow_extended_connect_. This test verifies that when the peer sends + // only H3_DATAGRAM (without either WT_ENABLED or ENABLE_CONNECT_PROTOCOL), + // WebTransport is not available. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + // Send settings with only H3_DATAGRAM — no WT_ENABLED, no + // ENABLE_CONNECT_PROTOCOL. + SettingsFrame settings; + settings.values[SETTINGS_H3_DATAGRAM] = 1; + std::string data = std::string(1, kControlStream) + + HttpEncoder::SerializeSettingsFrame(settings); + QuicStreamId control_stream_id = + test::GetNthServerInitiatedUnidirectionalStreamId(transport_version(), 3); + QuicStreamFrame frame(control_stream_id, /*fin=*/false, /*offset=*/0, data); + session_->OnStreamFrame(frame); + EXPECT_FALSE(session_->SupportsWebTransport()) + << "Without SETTINGS_ENABLE_CONNECT_PROTOCOL or SETTINGS_WT_ENABLED, " + "WT should not be available"; +} + +TEST_P(SessionEstablishmentDraft15Test, MissingH3Datagram) { + // Section 3.1: Without SETTINGS_H3_DATAGRAM, WebTransport is unavailable. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + // Send settings with WT_ENABLED and ENABLE_CONNECT_PROTOCOL but not + // H3_DATAGRAM. + SettingsFrame settings; + settings.values[SETTINGS_WT_ENABLED] = 1; + settings.values[SETTINGS_ENABLE_CONNECT_PROTOCOL] = 1; + // Deliberately omit SETTINGS_H3_DATAGRAM. + std::string data = std::string(1, kControlStream) + + HttpEncoder::SerializeSettingsFrame(settings); + QuicStreamId control_stream_id = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 3); + QuicStreamFrame frame(control_stream_id, /*fin=*/false, /*offset=*/0, data); + // ValidateWebTransportSettingsConsistency() detects missing datagram support + // and closes the connection with WT_REQUIREMENTS_NOT_MET for draft-15. + EXPECT_CALL(*connection_, + CloseConnection( + QUIC_HTTP_INVALID_SETTING_VALUE, + static_cast( + webtransport::draft15::kWtRequirementsNotMet), + _, _)); + session_->OnStreamFrame(frame); + EXPECT_FALSE(session_->SupportsWebTransport()) + << "Without SETTINGS_H3_DATAGRAM, WebTransport should not be available"; +} + +// --- CONNECT request format (Section 3.2) --- + +TEST_P(SessionEstablishmentDraft15Test, UpgradeTokenWebtransportH3) { + // Section 3.2 MUST: The :protocol pseudo-header is "webtransport-h3" + // (not "webtransport" as in draft-02/07). + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(); + + // Establish a valid draft-15 session with :protocol = "webtransport-h3". + QuicStreamId valid_stream_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(valid_stream_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + EXPECT_EQ(webtransport::draft15::kProtocolToken, "webtransport-h3"); + + // Now attempt a session with the old token :protocol = "webtransport" + // (used by draft-02/07). Draft-15 must reject this. + QuicStreamId old_token_stream_id = GetNthClientInitiatedBidirectionalId(1); + QuicStreamFrame old_frame(old_token_stream_id, /*fin=*/false, /*offset=*/0, + absl::string_view()); + session_->OnStreamFrame(old_frame); + QuicSpdyStream* old_stream = static_cast( + session_->GetOrCreateStream(old_token_stream_id)); + ASSERT_NE(old_stream, nullptr); + QuicHeaderList old_headers; + old_headers.OnHeader(":method", "CONNECT"); + old_headers.OnHeader(":protocol", "webtransport"); // Old token. + old_headers.OnHeader(":scheme", "https"); + old_headers.OnHeader(":authority", "test.example.com"); + old_headers.OnHeader(":path", "/wt"); + old_stream->OnStreamHeaderList(/*fin=*/false, 0, old_headers); + WebTransportHttp3* old_wt = + session_->GetWebTransportSession(old_token_stream_id); + // When draft-15 is the only version, the old "webtransport" token should + // not create a valid WebTransport session. + EXPECT_EQ(old_wt, nullptr) + << "Draft-15 must reject :protocol='webtransport' (old token)"; +} + +TEST_P(SessionEstablishmentDraft15Test, SchemeHttps) { + // Section 3.2 MUST: :scheme is "https". + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(); + + // Valid session with :scheme = "https". + QuicStreamId valid_stream_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(valid_stream_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + // Attempt a session with :scheme = "http" (not https). Must be rejected. + QuicStreamId http_stream_id = GetNthClientInitiatedBidirectionalId(1); + QuicStreamFrame http_frame(http_stream_id, /*fin=*/false, /*offset=*/0, + absl::string_view()); + session_->OnStreamFrame(http_frame); + QuicSpdyStream* http_stream = static_cast( + session_->GetOrCreateStream(http_stream_id)); + ASSERT_NE(http_stream, nullptr); + QuicHeaderList http_headers; + http_headers.OnHeader(":method", "CONNECT"); + http_headers.OnHeader(":protocol", "webtransport-h3"); + http_headers.OnHeader(":scheme", "http"); // Wrong scheme. + http_headers.OnHeader(":authority", "test.example.com"); + http_headers.OnHeader(":path", "/wt"); + http_stream->OnStreamHeaderList(/*fin=*/false, 0, http_headers); + WebTransportHttp3* http_wt = + session_->GetWebTransportSession(http_stream_id); + EXPECT_EQ(http_wt, nullptr) + << "Draft-15 must reject :scheme='http' (only 'https' allowed)"; +} + +TEST_P(SessionEstablishmentDraft15Test, AuthorityAndPathPresent) { + // Section 3.2 MUST: Both :authority and :path must be present. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(); + + // Valid session with both :authority and :path present. + QuicStreamId valid_stream_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(valid_stream_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + // Attempt without :authority. + QuicStreamId no_auth_stream_id = GetNthClientInitiatedBidirectionalId(1); + { + QuicStreamFrame frame(no_auth_stream_id, /*fin=*/false, /*offset=*/0, + absl::string_view()); + session_->OnStreamFrame(frame); + QuicSpdyStream* stream = static_cast( + session_->GetOrCreateStream(no_auth_stream_id)); + ASSERT_NE(stream, nullptr); + QuicHeaderList headers; + headers.OnHeader(":method", "CONNECT"); + headers.OnHeader(":protocol", "webtransport-h3"); + headers.OnHeader(":scheme", "https"); + // Deliberately omit :authority. + headers.OnHeader(":path", "/wt"); + stream->OnStreamHeaderList(/*fin=*/false, 0, headers); + EXPECT_EQ(session_->GetWebTransportSession(no_auth_stream_id), nullptr) + << "Draft-15 must reject CONNECT missing :authority"; + } + + // Attempt without :path. + QuicStreamId no_path_stream_id = GetNthClientInitiatedBidirectionalId(2); + { + QuicStreamFrame frame(no_path_stream_id, /*fin=*/false, /*offset=*/0, + absl::string_view()); + session_->OnStreamFrame(frame); + QuicSpdyStream* stream = static_cast( + session_->GetOrCreateStream(no_path_stream_id)); + ASSERT_NE(stream, nullptr); + QuicHeaderList headers; + headers.OnHeader(":method", "CONNECT"); + headers.OnHeader(":protocol", "webtransport-h3"); + headers.OnHeader(":scheme", "https"); + headers.OnHeader(":authority", "test.example.com"); + // Deliberately omit :path. + stream->OnStreamHeaderList(/*fin=*/false, 0, headers); + EXPECT_EQ(session_->GetWebTransportSession(no_path_stream_id), nullptr) + << "Draft-15 must reject CONNECT missing :path"; + } +} + +TEST_P(SessionEstablishmentDraft15Test, RejectRedirects) { + // Section 3.2 MUST NOT: 3xx responses must not be auto-followed. + // Server-side: verify that the session is established with the correct + // draft-15 protocol token and that the upgrade token is "webtransport-h3". + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(); + auto* wt = AttemptWebTransportDraft15Session(GetNthClientInitiatedBidirectionalId(0)); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + // The session was created using the draft-15 protocol token. + EXPECT_EQ(webtransport::draft15::kProtocolToken, "webtransport-h3"); + // Server does not auto-follow redirects; it either accepts (200) or rejects. + // Verify the session is valid (server-side sessions are always "accepted" + // once established). + EXPECT_EQ(wt->id(), GetNthClientInitiatedBidirectionalId(0)); +} + +TEST_P(SessionEstablishmentDraft15ClientTest, RejectRedirectsClient) { + // Section 3.2 MUST NOT: Client must not auto-follow 3xx responses. + // A 301 response should result in rejection with kWrongStatusCode. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(); + + auto* wt = AttemptWebTransportDraft15ClientSession(); + ASSERT_NE(wt, nullptr) << "Draft-15 client session could not be created"; + QuicStreamId stream_id = wt->id(); + + // Server responds with 301 (redirect). + ReceiveWebTransportDraft15Response(stream_id, 301); + EXPECT_EQ(wt->rejection_reason(), + WebTransportHttp3RejectionReason::kWrongStatusCode) + << "Client must reject 3xx responses with kWrongStatusCode"; + EXPECT_FALSE(wt->ready()) + << "Session must not be ready after a redirect response"; +} + +TEST_P(SessionEstablishmentDraft15Test, No0RTTSessionInitiation) { + // Section 3.2 MUST NOT: WT CONNECT requests must not be sent in 0-RTT. + // The CONNECT stream is only created after the handshake completes (1-RTT). + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + + // After handshake, encryption should be at ENCRYPTION_FORWARD_SECURE. + EXPECT_EQ(connection_->encryption_level(), ENCRYPTION_FORWARD_SECURE) + << "After handshake, encryption must be at ENCRYPTION_FORWARD_SECURE"; + + ReceiveWebTransportDraft15Settings(); + auto* wt = AttemptWebTransportDraft15Session(GetNthClientInitiatedBidirectionalId(0)); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + // Verify the session was established at 1-RTT encryption level, not 0-RTT. + // The connection should be using forward-secure encryption. + EXPECT_EQ(connection_->encryption_level(), ENCRYPTION_FORWARD_SECURE) + << "WebTransport CONNECT must be established at 1-RTT, not 0-RTT"; +} + +TEST_P(SessionEstablishmentDraft15ClientTest, NoReducedLimitsOn0RTTAccept) { + // Section 3.2 MUST: "If the server accepts 0-RTT, the server MUST NOT + // reduce [...] initial flow control values, from the values negotiated + // during the previous session; such change [...] MUST result in a + // H3_SETTINGS_ERROR connection error." + // + // Uses the ALPS pattern from quic_spdy_session_test.cc + // (AlpsSettingsViaControlStreamConflictsAlpsSettings): inject initial + // SETTINGS via OnAlpsData(), then send reduced values on the control stream. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + + // Step 1: Simulate the initial SETTINGS from the server via ALPS (as if + // from the previous session / 0-RTT handshake) with max_streams_bidi=10. + SettingsFrame alps_settings; + alps_settings.values[SETTINGS_H3_DATAGRAM] = 1; + alps_settings.values[SETTINGS_ENABLE_CONNECT_PROTOCOL] = 1; + alps_settings.values[SETTINGS_WT_ENABLED] = 1; + alps_settings.values[SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI] = 10; + std::string alps_data = HttpEncoder::SerializeSettingsFrame(alps_settings); + auto error = session_->OnAlpsData( + reinterpret_cast(alps_data.data()), alps_data.size()); + ASSERT_FALSE(error) << "OnAlpsData failed: " << *error; + + // Step 2: Send reduced SETTINGS on the control stream (max_streams_bidi=5). + // This simulates the server resuming with a lower limit than 0-RTT promised. + EXPECT_CALL( + *connection_, + CloseConnection(QUIC_HTTP_ZERO_RTT_RESUMPTION_SETTINGS_MISMATCH, _, + ConnectionCloseBehavior::SEND_CONNECTION_CLOSE_PACKET)) + .Times(1); + + SettingsFrame reduced_settings; + reduced_settings.values[SETTINGS_H3_DATAGRAM] = 1; + reduced_settings.values[SETTINGS_ENABLE_CONNECT_PROTOCOL] = 1; + reduced_settings.values[SETTINGS_WT_ENABLED] = 1; + reduced_settings.values[SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI] = 5; + std::string control_stream_data = + std::string(1, kControlStream) + + HttpEncoder::SerializeSettingsFrame(reduced_settings); + QuicStreamId control_stream_id = + test::GetNthServerInitiatedUnidirectionalStreamId( + transport_version(), 3); + session_->OnStreamFrame(QuicStreamFrame(control_stream_id, /*fin=*/false, + /*offset=*/0, control_stream_data)); + testing::Mock::VerifyAndClearExpectations(connection_); +} + +TEST_P(SessionEstablishmentDraft15ClientTest, NoReducedLimitsOn0RTTAccept_Uni) { + // Section 3.2 MUST: Same requirement for SETTINGS_WT_INITIAL_MAX_STREAMS_UNI. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + + SettingsFrame alps_settings; + alps_settings.values[SETTINGS_H3_DATAGRAM] = 1; + alps_settings.values[SETTINGS_ENABLE_CONNECT_PROTOCOL] = 1; + alps_settings.values[SETTINGS_WT_ENABLED] = 1; + alps_settings.values[SETTINGS_WT_INITIAL_MAX_STREAMS_UNI] = 10; + std::string alps_data = HttpEncoder::SerializeSettingsFrame(alps_settings); + auto error = session_->OnAlpsData( + reinterpret_cast(alps_data.data()), alps_data.size()); + ASSERT_FALSE(error) << "OnAlpsData failed: " << *error; + + EXPECT_CALL( + *connection_, + CloseConnection(QUIC_HTTP_ZERO_RTT_RESUMPTION_SETTINGS_MISMATCH, _, + ConnectionCloseBehavior::SEND_CONNECTION_CLOSE_PACKET)) + .Times(1); + + SettingsFrame reduced_settings; + reduced_settings.values[SETTINGS_H3_DATAGRAM] = 1; + reduced_settings.values[SETTINGS_ENABLE_CONNECT_PROTOCOL] = 1; + reduced_settings.values[SETTINGS_WT_ENABLED] = 1; + reduced_settings.values[SETTINGS_WT_INITIAL_MAX_STREAMS_UNI] = 5; + std::string control_stream_data = + std::string(1, kControlStream) + + HttpEncoder::SerializeSettingsFrame(reduced_settings); + QuicStreamId control_stream_id = + test::GetNthServerInitiatedUnidirectionalStreamId( + transport_version(), 3); + session_->OnStreamFrame(QuicStreamFrame(control_stream_id, /*fin=*/false, + /*offset=*/0, control_stream_data)); + testing::Mock::VerifyAndClearExpectations(connection_); +} + +TEST_P(SessionEstablishmentDraft15ClientTest, NoReducedLimitsOn0RTTAccept_Data) { + // Section 3.2 MUST: Same requirement for SETTINGS_WT_INITIAL_MAX_DATA. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + + SettingsFrame alps_settings; + alps_settings.values[SETTINGS_H3_DATAGRAM] = 1; + alps_settings.values[SETTINGS_ENABLE_CONNECT_PROTOCOL] = 1; + alps_settings.values[SETTINGS_WT_ENABLED] = 1; + alps_settings.values[SETTINGS_WT_INITIAL_MAX_DATA] = 65536; + std::string alps_data = HttpEncoder::SerializeSettingsFrame(alps_settings); + auto error = session_->OnAlpsData( + reinterpret_cast(alps_data.data()), alps_data.size()); + ASSERT_FALSE(error) << "OnAlpsData failed: " << *error; + + EXPECT_CALL( + *connection_, + CloseConnection(QUIC_HTTP_ZERO_RTT_RESUMPTION_SETTINGS_MISMATCH, _, + ConnectionCloseBehavior::SEND_CONNECTION_CLOSE_PACKET)) + .Times(1); + + SettingsFrame reduced_settings; + reduced_settings.values[SETTINGS_H3_DATAGRAM] = 1; + reduced_settings.values[SETTINGS_ENABLE_CONNECT_PROTOCOL] = 1; + reduced_settings.values[SETTINGS_WT_ENABLED] = 1; + reduced_settings.values[SETTINGS_WT_INITIAL_MAX_DATA] = 32768; + std::string control_stream_data = + std::string(1, kControlStream) + + HttpEncoder::SerializeSettingsFrame(reduced_settings); + QuicStreamId control_stream_id = + test::GetNthServerInitiatedUnidirectionalStreamId( + transport_version(), 3); + session_->OnStreamFrame(QuicStreamFrame(control_stream_id, /*fin=*/false, + /*offset=*/0, control_stream_data)); + testing::Mock::VerifyAndClearExpectations(connection_); +} + +TEST_P(SessionEstablishmentDraft15ClientTest, + RequirementsNotMetOnMissingDatagramSupport) { + // Section 3.1 MAY: If the server's SETTINGS do not have correct values + // for every required setting, the client MAY close the HTTP/3 connection + // with WT_REQUIREMENTS_NOT_MET (0x212c0d48). + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + + // The connection should be closed with WT_REQUIREMENTS_NOT_MET when + // SETTINGS are received without H3_DATAGRAM. + EXPECT_CALL(*connection_, + CloseConnection( + QUIC_HTTP_INVALID_SETTING_VALUE, + static_cast( + webtransport::draft15::kWtRequirementsNotMet), + _, _)); + + // Send server SETTINGS with WT_ENABLED but WITHOUT H3_DATAGRAM. + SettingsFrame settings; + settings.values[SETTINGS_ENABLE_CONNECT_PROTOCOL] = 1; + settings.values[SETTINGS_WT_ENABLED] = 1; + // Deliberately omit SETTINGS_H3_DATAGRAM. + std::string data = std::string(1, kControlStream) + + HttpEncoder::SerializeSettingsFrame(settings); + QuicStreamId control_stream_id = + test::GetNthServerInitiatedUnidirectionalStreamId( + transport_version(), 3); + QuicStreamFrame frame(control_stream_id, /*fin=*/false, /*offset=*/0, data); + session_->OnStreamFrame(frame); +} + +TEST_P(SessionEstablishmentDraft15ClientTest, + RequirementsNotMetPropagatesThroughClosePath) { + // Section 3.1: Verify that WT_REQUIREMENTS_NOT_MET (0x212c0d48) + // propagates correctly through OnInternalError → CloseSession → + // OnSessionClosed, so that when the detection logic is implemented, + // the visitor receives the correct error code. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + auto* wt = SetUpWebTransportDraft15ClientSession(); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + auto* visitor = AttachMockVisitor(wt); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, + WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + + // Deliver the server's 200 response so the session becomes ready. + quiche::HttpHeaderBlock response_headers; + response_headers[":status"] = "200"; + EXPECT_CALL(*visitor, OnSessionReady()); + wt->HeadersReceived(response_headers); + + // Simulate the client detecting requirements not met. + EXPECT_CALL(*visitor, OnSessionClosed( + static_cast( + webtransport::draft15::kWtRequirementsNotMet), _)) + .Times(1); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + + wt->OnInternalError( + static_cast( + webtransport::draft15::kWtRequirementsNotMet), + "Server does not meet client requirements"); + + testing::Mock::VerifyAndClearExpectations(visitor); + testing::Mock::VerifyAndClearExpectations(writer_); +} + +TEST_P(SessionEstablishmentDraft15Test, ServerReply404ForUnknownPath) { + // Section 3.2 SHOULD: Server replies with 404 for unknown path. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/65536); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + + // Create a session at a known path "/wt". + QuicStreamId known_stream_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(known_stream_id, "/wt"); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + // Create a session at an unknown path "/nonexistent". + // The CONNECT stream headers are available for application-layer routing. + // The path-based 404 decision is at the application layer; the QUIC session + // creates the WT session object regardless and lets the application decide. + QuicStreamId unknown_stream_id = GetNthClientInitiatedBidirectionalId(1); + auto* unknown_wt = AttemptWebTransportDraft15Session(unknown_stream_id, "/nonexistent"); + // The session object is created; the application layer is responsible for + // sending a 404. Verify both sessions are created. + ASSERT_NE(unknown_wt, nullptr) + << "Session object should be created for application-layer routing"; + // Verify the two sessions are distinct. + EXPECT_NE(wt->id(), unknown_wt->id()) + << "Sessions on different streams must have different IDs"; +} + +TEST_P(SessionEstablishmentDraft15Test, + Section3_1_MissingResetStreamAtRejectsSession) { + // Section 3.1: Both client and server MUST send an empty reset_stream_at + // transport parameter. "If the server receives SETTINGS that do not have + // correct values for every required setting, or transport parameters that + // do not have correct values for every required transport parameter, the + // server MUST treat all established and newly incoming WebTransport + // sessions as malformed." + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + + // Use the base class Initialize() which does NOT set reset_stream_at, + // then configure draft-15 support manually. This avoids the + // Draft15SessionTest::Initialize() path which enables reset_stream_at + // as part of the standard draft-15 setup. + QuicSpdySessionTestBase::Initialize(); + session_->set_locally_supported_web_transport_versions( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15})); + session_->set_local_http_datagram_support(HttpDatagramSupport::kRfc); + CompleteHandshake(); + + ASSERT_FALSE(connection_->reliable_stream_reset_enabled()) + << "Test precondition: reset_stream_at should not be negotiated"; + + // Receiving peer SETTINGS should trigger ValidateWebTransportSettingsConsistency(), + // which should detect the missing reset_stream_at transport parameter and + // close the connection with WT_REQUIREMENTS_NOT_MET. + // Send settings manually (not via ReceiveWebTransportDraft15Settings which enables + // reset_stream_at as part of standard draft-15 setup). + EXPECT_CALL(*connection_, + CloseConnection( + QUIC_HTTP_INVALID_SETTING_VALUE, + static_cast( + webtransport::draft15::kWtRequirementsNotMet), + _, _)); + SettingsFrame settings; + settings.values[SETTINGS_H3_DATAGRAM] = 1; + settings.values[SETTINGS_ENABLE_CONNECT_PROTOCOL] = 1; + settings.values[SETTINGS_WT_ENABLED] = 1; + std::string data = std::string(1, kControlStream) + + HttpEncoder::SerializeSettingsFrame(settings); + QuicStreamId control_stream_id = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 3); + session_->OnStreamFrame( + QuicStreamFrame(control_stream_id, /*fin=*/false, /*offset=*/0, data)); +} + +TEST_P(SessionEstablishmentDraft15Test, SettingsWtEnabledValueGreaterThan1) { + // The production code enforces SETTINGS_WT_ENABLED as a boolean flag + // (0 or 1). Values > 1 trigger a QUICHE_BUG (fatal in debug builds). + // This test verifies that behavior using EXPECT_QUIC_BUG. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + // Send SETTINGS_WT_ENABLED = 2 -- triggers a QUICHE_BUG (fatal in debug). + EXPECT_QUIC_BUG(ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/2), + "bad received setting"); +} + +TEST_P(SessionEstablishmentDraft15Test, SettingsWtEnabledValueZeroDisabled) { + // Section 3.1: SETTINGS_WT_ENABLED = 0 means disabled. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + // Send SETTINGS_WT_ENABLED = 0 (disabled). + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/0); + EXPECT_FALSE(session_->SupportsWebTransport()) + << "SETTINGS_WT_ENABLED=0 should mean WebTransport is disabled"; +} + +TEST_P(SessionEstablishmentDraft15ClientTest, + Section3_1_WtEnabledWithoutExtendedConnectRejected) { + // Section 3.1: Servers MUST send both SETTINGS_WT_ENABLED=1 AND + // SETTINGS_ENABLE_CONNECT_PROTOCOL=1. A client receiving WT_ENABLED + // without ENABLE_CONNECT_PROTOCOL should reject the settings. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + + // Send WT_ENABLED + H3_DATAGRAM but omit ENABLE_CONNECT_PROTOCOL. + SettingsFrame settings; + settings.values[SETTINGS_WT_ENABLED] = 1; + settings.values[SETTINGS_H3_DATAGRAM] = 1; + // Deliberately omit SETTINGS_ENABLE_CONNECT_PROTOCOL. + std::string data = std::string(1, kControlStream) + + HttpEncoder::SerializeSettingsFrame(settings); + QuicStreamId control_stream_id = + test::GetNthServerInitiatedUnidirectionalStreamId(transport_version(), 3); + QuicStreamFrame frame(control_stream_id, /*fin=*/false, /*offset=*/0, data); + + EXPECT_CALL(*connection_, CloseConnection(_, _, _, _)) + .WillOnce(testing::Invoke( + connection_, &test::MockQuicConnection::ReallyCloseConnection4)); + EXPECT_CALL(*connection_, SendConnectionClosePacket(_, _, _)) + .Times(testing::AnyNumber()); + + session_->OnStreamFrame(frame); + + // The connection should be closed because ENABLE_CONNECT_PROTOCOL is missing. + EXPECT_FALSE(connection_->connected()) + << "Client must reject WT_ENABLED when " + "SETTINGS_ENABLE_CONNECT_PROTOCOL is missing"; +} + +TEST_P(SessionEstablishmentDraft15Test, + Section3_1_ZeroMaxDatagramFrameSizeRejectsSession) { + // Section 3.1: Both client and server MUST send max_datagram_frame_size + // transport parameter with a value greater than 0. A peer that negotiates + // max_datagram_frame_size=0 must be rejected. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + + QuicSpdySessionTestBase::Initialize(); + session_->set_locally_supported_web_transport_versions( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15})); + session_->set_local_http_datagram_support(HttpDatagramSupport::kRfc); + CompleteHandshake(); + + // Override the received max_datagram_frame_size to 0 (simulating a peer + // that sent the transport parameter with value 0). + test::QuicConfigPeer::SetReceivedMaxDatagramFrameSize( + session_->config(), 0); + + // Enable reset_stream_at so that the only missing requirement is + // max_datagram_frame_size > 0. + const_cast(&connection_->framer()) + ->set_process_reset_stream_at(true); + + EXPECT_CALL(*connection_, + CloseConnection( + QUIC_HTTP_INVALID_SETTING_VALUE, + static_cast( + webtransport::draft15::kWtRequirementsNotMet), + _, _)); + + SettingsFrame settings; + settings.values[SETTINGS_H3_DATAGRAM] = 1; + settings.values[SETTINGS_ENABLE_CONNECT_PROTOCOL] = 1; + settings.values[SETTINGS_WT_ENABLED] = 1; + std::string data = std::string(1, kControlStream) + + HttpEncoder::SerializeSettingsFrame(settings); + QuicStreamId control_stream_id = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 3); + session_->OnStreamFrame( + QuicStreamFrame(control_stream_id, /*fin=*/false, /*offset=*/0, data)); +} + +TEST_P(SessionEstablishmentDraft15ClientTest, + NoReducedLimitsOn0RTTAccept_WtEnabled) { + // Section 3.2 MUST: "If the server accepts 0-RTT, the server MUST NOT + // reduce the limit of maximum open WebTransport sessions, or other initial + // flow control values." SETTINGS_WT_ENABLED going from 1 to 0 is a + // reduction that must be rejected. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + + // Step 1: Simulate initial SETTINGS via ALPS with WT_ENABLED=1. + SettingsFrame alps_settings; + alps_settings.values[SETTINGS_H3_DATAGRAM] = 1; + alps_settings.values[SETTINGS_ENABLE_CONNECT_PROTOCOL] = 1; + alps_settings.values[SETTINGS_WT_ENABLED] = 1; + std::string alps_data = HttpEncoder::SerializeSettingsFrame(alps_settings); + auto error = session_->OnAlpsData( + reinterpret_cast(alps_data.data()), alps_data.size()); + ASSERT_FALSE(error) << "OnAlpsData failed: " << *error; + + // Step 2: Send control stream SETTINGS with WT_ENABLED=0 (reduction). + EXPECT_CALL( + *connection_, + CloseConnection(QUIC_HTTP_ZERO_RTT_RESUMPTION_SETTINGS_MISMATCH, _, + ConnectionCloseBehavior::SEND_CONNECTION_CLOSE_PACKET)) + .Times(1); + + SettingsFrame reduced_settings; + reduced_settings.values[SETTINGS_H3_DATAGRAM] = 1; + reduced_settings.values[SETTINGS_ENABLE_CONNECT_PROTOCOL] = 1; + reduced_settings.values[SETTINGS_WT_ENABLED] = 0; + std::string control_stream_data = + std::string(1, kControlStream) + + HttpEncoder::SerializeSettingsFrame(reduced_settings); + QuicStreamId control_stream_id = + test::GetNthServerInitiatedUnidirectionalStreamId( + transport_version(), 3); + session_->OnStreamFrame(QuicStreamFrame(control_stream_id, /*fin=*/false, + /*offset=*/0, control_stream_data)); + testing::Mock::VerifyAndClearExpectations(connection_); +} + +} // namespace +} // namespace quic diff --git a/quiche/quic/core/http/web_transport_session_limiting_draft15_test.cc b/quiche/quic/core/http/web_transport_session_limiting_draft15_test.cc new file mode 100644 index 000000000..1bc0b3a3b --- /dev/null +++ b/quiche/quic/core/http/web_transport_session_limiting_draft15_test.cc @@ -0,0 +1,191 @@ +// Copyright 2024 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. + +// Draft-15 acceptance tests for session limiting (Section 5.2). + +#include + +#include "quiche/quic/core/http/web_transport_draft15_test_utils.h" +#include "quiche/quic/core/http/http_constants.h" +#include "quiche/quic/core/http/quic_spdy_session.h" +#include "quiche/quic/core/quic_error_codes.h" +#include "quiche/quic/platform/api/quic_test.h" +#include "quiche/web_transport/test_tools/draft15_constants.h" + +namespace quic { +namespace { + +using ::testing::_; +using ::testing::Not; + +class SessionLimitingDraft15Test : public test::Draft15SessionTest { + protected: + SessionLimitingDraft15Test() : Draft15SessionTest(Perspective::IS_SERVER) {} +}; + +INSTANTIATE_TEST_SUITE_P(SessionLimitingDraft15, SessionLimitingDraft15Test, + ::testing::ValuesIn(CurrentSupportedVersions())); + +TEST_P(SessionLimitingDraft15Test, + Section5_1_ExcessSessionResetWithRequestRejected) { + // Section 5.1 MUST: "A server that receives more than one session on an + // underlying transport connection when flow control is not enabled MUST + // reset the excessive CONNECT streams with a H3_REQUEST_REJECTED status." + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/0, + /*initial_max_streams_bidi=*/0, + /*initial_max_data=*/0); + + // First session succeeds. + auto* wt = AttemptWebTransportDraft15Session(GetNthClientInitiatedBidirectionalId(0)); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + // Attempt second session. The CONNECT stream MUST be reset with + // H3_REQUEST_REJECTED (0x10B). + EXPECT_CALL(*connection_, + SendControlFrame(test::IsRstStreamWithIetfCode( + static_cast(QuicHttp3ErrorCode::REQUEST_REJECTED)))) + .Times(testing::AtLeast(1)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, + SendControlFrame( + Not(test::IsRstStreamWithIetfCode(static_cast( + QuicHttp3ErrorCode::REQUEST_REJECTED))))) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + + QuicStreamId second_id = GetNthClientInitiatedBidirectionalId(1); + auto* wt2 = AttemptWebTransportDraft15Session(second_id); + EXPECT_EQ(wt2, nullptr) + << "Server should reject excess sessions"; + testing::Mock::VerifyAndClearExpectations(connection_); +} + +TEST_P(SessionLimitingDraft15Test, + Section5_1_CanCreateNewSessionAfterPreviousClosed) { + // Section 5.1: Without FC, at most one session is allowed. After + // explicitly closing a session, the counter must decrement so a + // new session can be created. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1); + + QuicStreamId first_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(first_id); + ASSERT_NE(wt, nullptr); + + // Close the first session. + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + wt->CloseSession(0, "done"); + testing::Mock::VerifyAndClearExpectations(writer_); + + // After closing, CanCreateNewWebTransportSession() should return true. + EXPECT_TRUE(session_->CanCreateNewWebTransportSession()) + << "After closing a session, the session count " + "should decrement to allow creating a new session"; +} + +TEST_P(SessionLimitingDraft15Test, + Section5_1_CanCreateNewSessionAfterPeerClose) { + // Section 5.1: Without FC, at most one session is allowed. After the + // PEER closes a session, the counter must decrement so a new session + // can be created. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1); + + QuicStreamId first_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(first_id); + ASSERT_NE(wt, nullptr); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + wt->OnCloseReceived(0, "bye"); + testing::Mock::VerifyAndClearExpectations(writer_); + testing::Mock::VerifyAndClearExpectations(connection_); + + EXPECT_TRUE(session_->CanCreateNewWebTransportSession()) + << "After peer closes a session, the session count " + "should decrement to allow creating a new session"; +} + +TEST_P(SessionLimitingDraft15Test, + Section6_8_SessionCountDecrementsAfterPeerClose) { + // Section 6.8: After a peer-initiated session close, the session count + // must decrement, allowing a new session to be created (no FC). + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1); + + QuicStreamId first_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptWebTransportDraft15Session(first_id); + ASSERT_NE(wt, nullptr); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + wt->OnCloseReceived(0, "done"); + testing::Mock::VerifyAndClearExpectations(writer_); + testing::Mock::VerifyAndClearExpectations(connection_); + + QuicStreamId second_id = GetNthClientInitiatedBidirectionalId(1); + auto* wt2 = AttemptWebTransportDraft15Session(second_id); + EXPECT_NE(wt2, nullptr) + << "After peer-initiated close, session count should " + "decrement and a new session should be creatable"; +} + +TEST_P(SessionLimitingDraft15Test, FCEnabledAllowsMultipleSessions) { + // With non-zero FC limits, multiple sessions should be allowed. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc, + /*local_max_streams_uni=*/10, + /*local_max_streams_bidi=*/10, + /*local_max_data=*/65536); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + + // First session. + auto* wt1 = AttemptWebTransportDraft15Session(GetNthClientInitiatedBidirectionalId(0)); + ASSERT_NE(wt1, nullptr) << "First session should succeed"; + + // Second session should also succeed when FC is enabled. + auto* wt2 = AttemptWebTransportDraft15Session(GetNthClientInitiatedBidirectionalId(1)); + EXPECT_NE(wt2, nullptr) + << "With FC enabled, multiple sessions should be allowed"; +} + +} // namespace +} // namespace quic diff --git a/quiche/quic/core/http/web_transport_stream_adapter.cc b/quiche/quic/core/http/web_transport_stream_adapter.cc index 5546930cb..f848bd217 100644 --- a/quiche/quic/core/http/web_transport_stream_adapter.cc +++ b/quiche/quic/core/http/web_transport_stream_adapter.cc @@ -29,6 +29,16 @@ namespace quic { +void WebTransportStreamAdapter::SetWebTransportSession( + WebTransportHttp3* session) { + wt_session_ = session; + // Report any data that arrived before the session was associated, so it + // is counted against WT_MAX_DATA (Section 5.4). + if (wt_session_ != nullptr && last_reported_readable_ > 0) { + wt_session_->OnIncomingDataReceived(last_reported_readable_); + } +} + WebTransportStreamAdapter::WebTransportStreamAdapter( QuicSession* session, QuicStream* stream, QuicStreamSequencer* sequencer, std::optional session_id) @@ -44,6 +54,16 @@ WebTransportStream::ReadResult WebTransportStreamAdapter::Read( iov.iov_base = buffer.data(); iov.iov_len = buffer.size(); const size_t result = sequencer_->Readv(&iov, 1); + if (result > 0) { + if (last_reported_readable_ >= result) { + last_reported_readable_ -= result; + } else { + last_reported_readable_ = 0; + } + if (wt_session_ != nullptr) { + wt_session_->OnIncomingDataConsumed(result); + } + } if (!fin_read_ && sequencer_->IsClosed()) { fin_read_ = true; stream_->OnFinRead(); @@ -78,11 +98,21 @@ absl::Status WebTransportStreamAdapter::Writev( } size_t total_size = MemSliceSpanTotalSize(data); + + // Section 5.4: Check WT session data limit before writing. + if (wt_session_ != nullptr && !wt_session_->CanSendData(total_size)) { + return absl::ResourceExhaustedError( + "WT session data limit exceeded"); + } + QuicConsumedData consumed = stream_->WriteMemSlices( data, /*fin=*/options.send_fin(), /*buffer_unconditionally=*/options.buffer_unconditionally()); if (consumed.bytes_consumed == total_size) { + if (wt_session_ != nullptr && total_size > 0) { + wt_session_->OnDataSent(total_size); + } return absl::OkStatus(); } if (consumed.bytes_consumed == 0) { @@ -114,7 +144,14 @@ absl::Status WebTransportStreamAdapter::CheckBeforeStreamWrite() const { } bool WebTransportStreamAdapter::CanWrite() const { - return CheckBeforeStreamWrite().ok(); + if (!CheckBeforeStreamWrite().ok()) { + return false; + } + // Section 5.4: Also check WT session data limit. + if (wt_session_ != nullptr && !wt_session_->CanSendData(1)) { + return false; + } + return true; } size_t WebTransportStreamAdapter::ReadableBytes() const { @@ -141,6 +178,16 @@ bool WebTransportStreamAdapter::SkipBytes(size_t bytes) { return true; } sequencer_->MarkConsumed(bytes); + if (bytes > 0) { + if (last_reported_readable_ >= bytes) { + last_reported_readable_ -= bytes; + } else { + last_reported_readable_ = 0; + } + if (wt_session_ != nullptr) { + wt_session_->OnIncomingDataConsumed(bytes); + } + } if (!fin_read_ && sequencer_->IsClosed()) { fin_read_ = true; stream_->OnFinRead(); @@ -149,11 +196,22 @@ bool WebTransportStreamAdapter::SkipBytes(size_t bytes) { } void WebTransportStreamAdapter::OnDataAvailable() { + // Section 5.4: Count incoming payload bytes against WT_MAX_DATA before + // any early returns — the FC check must fire even if no visitor is set yet. + // Only report the delta since the last call to avoid double-counting. + size_t readable = ReadableBytes(); + if (readable > last_reported_readable_) { + size_t delta = readable - last_reported_readable_; + if (wt_session_ != nullptr) { + wt_session_->OnIncomingDataReceived(delta); + } + } + last_reported_readable_ = readable; if (visitor_ == nullptr) { return; } const bool fin_readable = sequencer_->IsClosed() && !fin_read_; - if (ReadableBytes() == 0 && !fin_readable) { + if (readable == 0 && !fin_readable) { return; } visitor_->OnCanRead(); @@ -172,8 +230,18 @@ void WebTransportStreamAdapter::OnCanWriteNewData() { void WebTransportStreamAdapter::ResetWithUserCode( WebTransportStreamError error) { - stream_->ResetWriteSide(QuicResetStreamError( - QUIC_STREAM_CANCELLED, WebTransportErrorToHttp3(error))); + // Section 4.4: WebTransport MUST use RESET_STREAM_AT with reliable_size + // >= stream header size, ensuring the peer can associate the stream + // with the correct session even after reset. Only use RESET_STREAM_AT + // when data has been written; without data, there is nothing to + // reliably deliver. + if (stream_->stream_bytes_written() > 0 && stream_->SetReliableSize()) { + stream_->PartialResetWriteSide(QuicResetStreamError( + QUIC_STREAM_CANCELLED, WebTransportErrorToHttp3(error))); + } else { + stream_->ResetWriteSide(QuicResetStreamError( + QUIC_STREAM_CANCELLED, WebTransportErrorToHttp3(error))); + } } void WebTransportStreamAdapter::SendStopSending(WebTransportStreamError error) { @@ -221,4 +289,11 @@ void WebTransportStreamAdapter::SetSessionId(QuicStreamId id) { } } +void WebTransportStreamAdapter::OnClosingWithUnreadData() { + if (last_reported_readable_ > 0 && wt_session_ != nullptr) { + wt_session_->OnIncomingDataConsumed(last_reported_readable_); + last_reported_readable_ = 0; + } +} + } // namespace quic diff --git a/quiche/quic/core/http/web_transport_stream_adapter.h b/quiche/quic/core/http/web_transport_stream_adapter.h index 91192bf78..b02bbe43e 100644 --- a/quiche/quic/core/http/web_transport_stream_adapter.h +++ b/quiche/quic/core/http/web_transport_stream_adapter.h @@ -28,6 +28,8 @@ namespace quic { +class WebTransportHttp3; + // Converts WebTransportStream API calls into QuicStream API calls. The users // of this class can either subclass it, or wrap around it. class QUICHE_EXPORT WebTransportStreamAdapter : public webtransport::Stream { @@ -71,6 +73,11 @@ class QUICHE_EXPORT WebTransportStreamAdapter : public webtransport::Stream { void OnCanWriteNewData(); void SetSessionId(QuicStreamId id); + void SetWebTransportSession(WebTransportHttp3* session); + + // Accounts for any received-but-unconsumed bytes as consumed. Called when + // the stream is closing without the application having read all data. + void OnClosingWithUnreadData(); private: absl::Status CheckBeforeStreamWrite() const; @@ -78,9 +85,11 @@ class QUICHE_EXPORT WebTransportStreamAdapter : public webtransport::Stream { QuicSession* session_; // Unowned. QuicStream* stream_; // Unowned. QuicStreamSequencer* sequencer_; // Unowned. + WebTransportHttp3* wt_session_ = nullptr; // Unowned. std::unique_ptr visitor_; std::optional session_id_; bool fin_read_ = false; + size_t last_reported_readable_ = 0; }; } // namespace quic diff --git a/quiche/quic/core/http/web_transport_streams_draft15_test.cc b/quiche/quic/core/http/web_transport_streams_draft15_test.cc new file mode 100644 index 000000000..379f287e6 --- /dev/null +++ b/quiche/quic/core/http/web_transport_streams_draft15_test.cc @@ -0,0 +1,356 @@ +// Copyright 2024 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. + +// Draft-15 acceptance tests for WebTransport stream format (Section 4.2, 4.3). + +#include +#include + +#include "quiche/quic/core/http/http_constants.h" +#include "quiche/quic/core/http/http_encoder.h" +#include "quiche/quic/core/http/http_frames.h" +#include "quiche/quic/core/http/web_transport_draft15_test_utils.h" +#include "quiche/quic/core/quic_data_writer.h" +#include "quiche/quic/core/quic_error_codes.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_stream_peer.h" +#include "quiche/quic/test_tools/quic_test_utils.h" +#include "quiche/web_transport/test_tools/draft15_constants.h" + +namespace quic { +namespace { + +using ::testing::_; + +// --- Stream type assertions (Section 4.2, 4.3) --- +// These validate existing constants against draft-15 spec values. +// PASS immediately. + +TEST(WebTransportStreamsDraft15, UnidirectionalStreamType0x54) { + // Section 4.2: Unidirectional WT streams use stream type byte 0x54. + EXPECT_EQ(kWebTransportUnidirectionalStream, + webtransport::draft15::kUniStreamType); + EXPECT_EQ(kWebTransportUnidirectionalStream, 0x54u); +} + +// --- Session-based tests (Section 4.3) --- + +class StreamsDraft15SessionTest : public test::Draft15SessionTest { + protected: + StreamsDraft15SessionTest() : Draft15SessionTest(Perspective::IS_SERVER) {} +}; + +INSTANTIATE_TEST_SUITE_P(StreamsDraft15SessionTests, + StreamsDraft15SessionTest, + ::testing::ValuesIn(CurrentSupportedVersions())); + +TEST_P(StreamsDraft15SessionTest, BidirectionalSignal0x41) { + // Section 4.3 MUST: Bidirectional WT streams start with signal byte 0x41 + // (WT_STREAM) followed by the session ID. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + + // Verify the constant value. + EXPECT_EQ(webtransport::draft15::kBidiSignal, 0x41u); + + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id, /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + // Inject a peer-initiated bidi stream with the 0x41 signal byte + session_id. + QuicStreamId peer_bidi_id = GetNthClientInitiatedBidirectionalId(1); + ReceiveWebTransportBidirectionalStream(session_id, peer_bidi_id); + + // The session should have an incoming bidi stream available. + WebTransportStream* incoming = wt->AcceptIncomingBidirectionalStream(); + EXPECT_NE(incoming, nullptr) + << "Expected incoming bidi stream to be associated with the WT session"; +} + +TEST_P(StreamsDraft15SessionTest, SessionIdMustBeClientInitiatedBidi) { + // Section 4 MUST: The session ID MUST be a client-initiated bidirectional + // stream ID. Non-client-initiated IDs trigger H3_ID_ERROR. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id, /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + // A server-initiated bidirectional stream ID is not valid as a session ID. + QuicStreamId server_bidi_id = GetNthServerInitiatedBidirectionalId(0); + // Verify the ID is indeed server-initiated (bit 0 = 1 for server). + EXPECT_EQ(server_bidi_id % 4, 1u) + << "Expected a server-initiated bidirectional stream ID"; + + // Inject a uni stream that references the invalid server-initiated session ID. + // This should trigger an error since session IDs must be client-initiated + // bidirectional stream IDs. + QuicStreamId uni_stream_id = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 4); + QuicErrorCode observed_error; + EXPECT_CALL(*connection_, CloseConnection(_, _, _)) + .WillOnce(testing::SaveArg<0>(&observed_error)); + ReceiveWebTransportUnidirectionalStream(server_bidi_id, uni_stream_id); + + QuicErrorCodeToIetfMapping mapping = + QuicErrorCodeToTransportErrorCode(observed_error); + EXPECT_EQ(mapping.error_code, + static_cast(QuicHttp3ErrorCode::ID_ERROR)) + << "Invalid session ID must close with H3_ID_ERROR (0x108)"; +} + +TEST_P(StreamsDraft15SessionTest, WtStreamNotAtStreamStart) { + // Section 4.3 MUST: The WT_STREAM signal (0x41) MUST appear at the start + // of the stream. If it appears elsewhere, it's an H3_FRAME_ERROR. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id, /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + // Use a different client-initiated bidi stream (not the CONNECT stream). + QuicStreamId bidi_stream_id = GetNthClientInitiatedBidirectionalId(1); + + // First, deliver headers on the bidi stream so it becomes a proper HTTP + // request stream, then deliver a WT_STREAM signal mid-stream (after the + // headers have been processed). + QuicStreamFrame frame0(bidi_stream_id, /*fin=*/false, /*offset=*/0, + absl::string_view()); + session_->OnStreamFrame(frame0); + QuicSpdyStream* bidi_stream = static_cast( + session_->GetOrCreateStream(bidi_stream_id)); + ASSERT_NE(bidi_stream, nullptr); + + // Deliver request headers via OnStreamHeaderList (bypasses QPACK). + QuicHeaderList headers; + headers.OnHeader(":method", "GET"); + headers.OnHeader(":path", "/test"); + headers.OnHeader(":scheme", "https"); + headers.OnHeader(":authority", "test.example.com"); + bidi_stream->OnStreamHeaderList(/*fin=*/false, 0, headers); + + // Now deliver a DATA frame to consume some stream bytes, pushing the + // sequencer offset past zero. + std::string data_frame; + data_frame.push_back(0x00); // DATA frame type + data_frame.push_back(0x03); // payload length = 3 + data_frame.append("abc"); // payload + + // Then append the WT_STREAM signal after the DATA frame. + std::string signal_data; + char type_buf[8]; + QuicDataWriter type_writer(sizeof(type_buf), type_buf); + ASSERT_TRUE(type_writer.WriteVarInt62(0x41)); + signal_data.append(type_buf, type_writer.length()); + char varint_buf[8]; + QuicDataWriter varint_writer(sizeof(varint_buf), varint_buf); + ASSERT_TRUE(varint_writer.WriteVarInt62(session_id)); + signal_data.append(varint_buf, varint_writer.length()); + + std::string combined = data_frame + signal_data; + + // Section 4.3 requires H3_FRAME_ERROR (0x106) on the wire. + QuicErrorCode observed_error; + EXPECT_CALL(*connection_, CloseConnection(_, _, _)) + .WillOnce(testing::SaveArg<0>(&observed_error)); + QuicStreamFrame frame2(bidi_stream_id, /*fin=*/false, + /*offset=*/0, combined); + session_->OnStreamFrame(frame2); + + QuicErrorCodeToIetfMapping mapping = + QuicErrorCodeToTransportErrorCode(observed_error); + EXPECT_EQ(mapping.error_code, + static_cast(QuicHttp3ErrorCode::FRAME_ERROR)) + << "WT_STREAM at non-zero offset must close with " + "H3_FRAME_ERROR (0x106)"; +} + +TEST_P(StreamsDraft15SessionTest, UniStreamPreambleFormat) { + // Section 4.2: Outgoing unidirectional WT streams must have a preamble of + // varint(0x54) + varint(session_id). + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + session_->set_writev_consumes_all_data(true); + auto* wt = AttemptWebTransportDraft15Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + // Open an outgoing unidirectional stream. + WebTransportStream* stream = wt->OpenOutgoingUnidirectionalStream(); + ASSERT_NE(stream, nullptr) + << "Expected to open an outgoing unidirectional stream"; + + // The stream should have buffered a preamble: varint(0x54) + varint(session_id). + // Build the expected preamble. + std::string expected_preamble; + expected_preamble.push_back(0x54); + char varint_buf[8]; + QuicDataWriter varint_writer(sizeof(varint_buf), varint_buf); + ASSERT_TRUE(varint_writer.WriteVarInt62(session_id)); + expected_preamble.append(varint_buf, varint_writer.length()); + + // Look up the underlying QUIC stream by its ID to check the send buffer. + webtransport::StreamId wt_stream_id = stream->GetStreamId(); + QuicStream* quic_stream = session_->GetOrCreateStream( + static_cast(wt_stream_id)); + ASSERT_NE(quic_stream, nullptr); + auto& send_buffer = test::QuicStreamPeer::SendBuffer(quic_stream); + EXPECT_GE(send_buffer.stream_bytes_written(), expected_preamble.size()) + << "Stream should have written the preamble bytes"; +} + +TEST_P(StreamsDraft15SessionTest, IncomingUniStreamAssociation) { + // Section 4.2: Incoming unidirectional streams with type 0x54 and a valid + // session ID should be associated with the WT session. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id, /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + // Attach a visitor to observe stream association callbacks. + auto* visitor = AttachMockVisitor(wt); + EXPECT_CALL(*visitor, OnIncomingUnidirectionalStreamAvailable()) + .Times(testing::AnyNumber()); + + size_t initial_streams = wt->NumberOfAssociatedStreams(); + + // Allow MAX_STREAMS and other control frames from stream creation. + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + + // Inject a unidirectional stream from the peer (client). + // Use index 4+ to avoid HTTP/3 control stream at index 3. + QuicStreamId uni_stream_id = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 4); + ReceiveWebTransportUnidirectionalStream(session_id, uni_stream_id, + "test payload"); + + // The incoming uni stream should be available on the WT session. + WebTransportStream* incoming = wt->AcceptIncomingUnidirectionalStream(); + EXPECT_NE(incoming, nullptr) + << "Expected incoming uni stream to be associated with the WT session"; + EXPECT_GT(wt->NumberOfAssociatedStreams(), initial_streams) + << "NumberOfAssociatedStreams should have increased"; + testing::Mock::VerifyAndClearExpectations(connection_); + testing::Mock::VerifyAndClearExpectations(visitor); +} + +TEST_P(StreamsDraft15SessionTest, IncomingBidiStreamAssociation) { + // Section 4.3: Incoming bidirectional streams with signal 0x41 and a valid + // session ID should be associated with the WT session. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id, /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + size_t initial_streams = wt->NumberOfAssociatedStreams(); + + // Inject a bidirectional stream from the peer (client). + QuicStreamId bidi_stream_id = GetNthClientInitiatedBidirectionalId(1); + ReceiveWebTransportBidirectionalStream(session_id, bidi_stream_id); + + // The incoming bidi stream should be available on the WT session. + WebTransportStream* incoming = wt->AcceptIncomingBidirectionalStream(); + EXPECT_NE(incoming, nullptr) + << "Expected incoming bidi stream to be associated with the WT session"; + EXPECT_GT(wt->NumberOfAssociatedStreams(), initial_streams) + << "NumberOfAssociatedStreams should have increased"; +} + +TEST_P(StreamsDraft15SessionTest, UnknownSessionIdBuffered) { + // Section 4.6: Streams referencing a session ID that doesn't exist yet + // should be buffered, not immediately reset. When the session is later + // established, the buffered stream should become associated. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(/*wt_enabled_value=*/1, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + + // Use a future session ID (not yet established). + QuicStreamId future_session_id = GetNthClientInitiatedBidirectionalId(1); + + // Inject a uni stream referencing the future session ID before establishing + // that session. The stream should be buffered, not reset. + QuicStreamId uni_stream_id = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 4); + ReceiveWebTransportUnidirectionalStream(future_session_id, uni_stream_id, + "buffered data"); + + // The stream should not be closed/reset yet. + QuicStream* raw_stream = session_->GetOrCreateStream(uni_stream_id); + EXPECT_NE(raw_stream, nullptr) + << "Buffered stream should still exist before session is established"; + + // Now establish the session with that ID. + auto* wt = AttemptWebTransportDraft15Session(future_session_id); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + // The previously buffered stream should now be associated with the session. + WebTransportStream* incoming = wt->AcceptIncomingUnidirectionalStream(); + EXPECT_NE(incoming, nullptr) + << "Buffered stream should be delivered after session establishment"; +} + +TEST_P(StreamsDraft15SessionTest, InvalidSessionIdOnUniStream) { + // Section 4 MUST: "If the Session ID [...] is not a client-initiated + // bidirectional stream [...] the recipient MUST close the connection with + // an H3_ID_ERROR error." H3_ID_ERROR = 0x108. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = SetUpWebTransportDraft15ServerSession(session_id, + /*initial_max_streams_uni=*/10, + /*initial_max_streams_bidi=*/10, + /*initial_max_data=*/65536); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + // A unidirectional stream ID is not a valid session ID. + QuicStreamId invalid_session_id = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 0); + + QuicStreamId uni_stream_id = + test::GetNthClientInitiatedUnidirectionalStreamId(transport_version(), 4); + + // Section 4 requires H3_ID_ERROR (0x108) on the wire. + QuicErrorCode observed_error; + EXPECT_CALL(*connection_, CloseConnection(_, _, _)) + .WillOnce(testing::SaveArg<0>(&observed_error)); + ReceiveWebTransportUnidirectionalStream(invalid_session_id, uni_stream_id); + + QuicErrorCodeToIetfMapping mapping = + QuicErrorCodeToTransportErrorCode(observed_error); + EXPECT_EQ(mapping.error_code, + static_cast(QuicHttp3ErrorCode::ID_ERROR)) + << "Invalid session ID must close with H3_ID_ERROR (0x108)"; +} + +} // namespace +} // namespace quic diff --git a/quiche/quic/core/http/web_transport_version_negotiation_draft15_test.cc b/quiche/quic/core/http/web_transport_version_negotiation_draft15_test.cc new file mode 100644 index 000000000..49892a2a6 --- /dev/null +++ b/quiche/quic/core/http/web_transport_version_negotiation_draft15_test.cc @@ -0,0 +1,281 @@ +// Copyright 2024 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. + +// Draft-15 acceptance tests for version negotiation (Section 7.1, 9.2). + +#include + +#include "absl/strings/string_view.h" +#include "quiche/quic/core/http/http_constants.h" +#include "quiche/quic/core/http/quic_header_list.h" +#include "quiche/quic/core/http/quic_spdy_session.h" +#include "quiche/quic/core/http/quic_spdy_stream.h" +#include "quiche/quic/core/http/web_transport_draft15_test_utils.h" +#include "quiche/quic/core/http/web_transport_http3.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/capsule.h" +#include "quiche/common/http/http_header_block.h" +#include "quiche/web_transport/test_tools/draft15_constants.h" + +namespace quic { +namespace { + +using ::testing::_; + +// --- SETTINGS codepoint (Section 9.2) --- + +TEST(WebTransportVersionNegotiationDraft15, Draft15Codepoint) { + // SETTINGS_WT_ENABLED uses codepoint 0x2c7cf000 in draft-15. + EXPECT_EQ(webtransport::draft15::kSettingsWtEnabled, 0x2c7cf000u); + // Verify legacy codepoints for comparison. + EXPECT_EQ(static_cast(SETTINGS_WEBTRANS_DRAFT00), 0x2b603742u); + EXPECT_EQ(static_cast(SETTINGS_WEBTRANS_MAX_SESSIONS_DRAFT07), + 0xc671706au); + // Legacy codepoints must match our constants. + EXPECT_EQ(webtransport::draft15::kSettingsWebtransDraft00, + static_cast(SETTINGS_WEBTRANS_DRAFT00)); + EXPECT_EQ(webtransport::draft15::kSettingsWebtransMaxSessionsDraft07, + static_cast(SETTINGS_WEBTRANS_MAX_SESSIONS_DRAFT07)); +} + +TEST(WebTransportVersionNegotiationDraft15, VersionEnumHasDraft15) { + // Structural: WebTransportHttp3Version must include a draft-15 value. + WebTransportHttp3VersionSet versions({WebTransportHttp3Version::kDraft02, + WebTransportHttp3Version::kDraft07, + WebTransportHttp3Version::kDraft15}); + EXPECT_TRUE(versions.IsSet(WebTransportHttp3Version::kDraft02)); + EXPECT_TRUE(versions.IsSet(WebTransportHttp3Version::kDraft07)); + EXPECT_TRUE(versions.IsSet(WebTransportHttp3Version::kDraft15)); +} + +// --- Session-based version negotiation tests (Section 7.1) --- + +class VersionNegotiationDraft15SessionTest : public test::Draft15SessionTest { + protected: + VersionNegotiationDraft15SessionTest() + : Draft15SessionTest(Perspective::IS_SERVER) {} +}; + +INSTANTIATE_TEST_SUITE_P(VersionNegotiationDraft15, + VersionNegotiationDraft15SessionTest, + ::testing::ValuesIn(CurrentSupportedVersions())); + +TEST_P(VersionNegotiationDraft15SessionTest, + BothEndpointsSendSettingsWtEnabled) { + // Section 7.1 MUST: Both client and server must emit SETTINGS_WT_ENABLED. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + // Check that emitted SETTINGS contains SETTINGS_WT_ENABLED. + // Fails because FillSettingsFrame() has no kDraft15 branch. + EXPECT_TRUE(session_->settings().values.contains(SETTINGS_WT_ENABLED)) + << "Expected SETTINGS_WT_ENABLED in emitted settings"; +} + +TEST_P(VersionNegotiationDraft15SessionTest, MultiVersionSupport) { + // Section 7.1: An endpoint supporting multiple drafts sends + // SETTINGS_WT_ENABLED for each. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft07, + WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + // Expect both draft-07 and draft-15 settings emitted. + EXPECT_TRUE( + session_->settings().values.contains(SETTINGS_WEBTRANS_MAX_SESSIONS_DRAFT07)); + EXPECT_TRUE(session_->settings().values.contains(SETTINGS_WT_ENABLED)) + << "Expected SETTINGS_WT_ENABLED for draft-15 in multi-version settings"; +} + +TEST_P(VersionNegotiationDraft15SessionTest, HighestMutuallySupportedWins) { + // Section 7.1: When both endpoints support draft-07 and draft-15, + // draft-15 is selected (highest mutually supported). + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft07, + WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(); // Peer also supports draft-15 + ASSERT_TRUE(session_->SupportsWebTransport()) + << "Draft-15 not yet negotiated"; + EXPECT_EQ(session_->SupportedWebTransportVersion(), + WebTransportHttp3Version::kDraft15); +} + +TEST_P(VersionNegotiationDraft15SessionTest, FallbackWhenMismatch) { + // Section 7.1: If server supports only draft-15 and client only draft-07, + // WebTransport is not available. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + // Verify we emitted draft-15 settings locally + EXPECT_TRUE(session_->settings().values.contains(SETTINGS_WT_ENABLED)) + << "Local side should emit SETTINGS_WT_ENABLED for draft-15"; + // Peer only supports draft-07 + ReceiveWebTransportDraft07Settings(); + // No mutual version -- WebTransport should not be available + EXPECT_FALSE(session_->SupportsWebTransport()) + << "No mutual version -- WT should not be available"; +} + +TEST_P(VersionNegotiationDraft15SessionTest, Draft15OnlyNoLegacySettings) { + // When only draft-15 is locally supported, the emitted SETTINGS must NOT + // contain legacy draft-00 or draft-07 codepoints. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft15}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + + // Draft-15 settings should be present. + EXPECT_TRUE(session_->settings().values.contains(SETTINGS_WT_ENABLED)) + << "Draft-15 only: must emit SETTINGS_WT_ENABLED"; + + // Legacy settings must NOT be present when only draft-15 is configured. + EXPECT_FALSE( + session_->settings().values.contains(SETTINGS_WEBTRANS_DRAFT00)) + << "Draft-15 only: must NOT emit SETTINGS_WEBTRANS_DRAFT00"; + EXPECT_FALSE( + session_->settings().values.contains(SETTINGS_WEBTRANS_MAX_SESSIONS_DRAFT07)) + << "Draft-15 only: must NOT emit SETTINGS_WEBTRANS_MAX_SESSIONS_DRAFT07"; +} + +TEST_P(VersionNegotiationDraft15SessionTest, Draft07OnlyNoNewSettings) { + // When only draft-07 is locally supported, the emitted SETTINGS must NOT + // contain the draft-15 SETTINGS_WT_ENABLED codepoint. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft07}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + + // Draft-07 settings should be present. + EXPECT_TRUE( + session_->settings().values.contains(SETTINGS_WEBTRANS_MAX_SESSIONS_DRAFT07)) + << "Draft-07 only: must emit SETTINGS_WEBTRANS_MAX_SESSIONS_DRAFT07"; + + // Draft-15 settings must NOT be present when only draft-07 is configured. + EXPECT_FALSE(session_->settings().values.contains(SETTINGS_WT_ENABLED)) + << "Draft-07 only: must NOT emit SETTINGS_WT_ENABLED"; +} + +// --- Draft-07 compatibility tests --- +// Draft-07 sessions should silently ignore draft-15-only FC capsules. + +class Draft07CompatibilityTest : public test::Draft15SessionTest { + protected: + Draft07CompatibilityTest() : Draft15SessionTest(Perspective::IS_SERVER) {} + + // Sends a CONNECT request with :protocol = "webtransport" (draft-07 style). + // Unlike ReceiveWebTransportSession() in quic_spdy_session_test_utils.h, + // does NOT send fin=true on headers (WT sessions keep the CONNECT stream + // open). + WebTransportHttp3* AttemptDraft07Session(QuicStreamId session_id) { + QuicStreamFrame frame(session_id, /*fin=*/false, /*offset=*/0, + absl::string_view()); + session_->OnStreamFrame(frame); + QuicSpdyStream* connect_stream = static_cast( + session_->GetOrCreateStream(session_id)); + if (connect_stream == nullptr) return nullptr; + QuicHeaderList headers; + headers.OnHeader(":method", "CONNECT"); + headers.OnHeader(":protocol", "webtransport"); + connect_stream->OnStreamHeaderList(/*fin=*/false, 0, headers); + WebTransportHttp3* wt = session_->GetWebTransportSession(session_id); + if (wt != nullptr) { + quiche::HttpHeaderBlock header_block; + wt->HeadersReceived(header_block); + } + return wt; + } +}; + +INSTANTIATE_TEST_SUITE_P(Draft07Compatibility, Draft07CompatibilityTest, + ::testing::ValuesIn(CurrentSupportedVersions())); + +TEST_P(Draft07CompatibilityTest, Draft07_IgnoresWtMaxStreamDataCapsule) { + // In draft-07, WT_MAX_STREAM_DATA is irrelevant (it's a draft-15 / + // WT-over-HTTP/2 capsule) and should be silently ignored. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft07}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + ReceiveWebTransportDraft07Settings(); + + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptDraft07Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-07 session could not be established"; + auto* visitor = AttachMockVisitor(wt); + + // The session must NOT be closed. + EXPECT_CALL(*visitor, OnSessionClosed(_, _)).Times(0); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, + WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + + // Inject a WT_MAX_STREAM_DATA capsule — should be silently ignored. + InjectCapsuleOnConnectStream( + session_id, + quiche::Capsule(quiche::WebTransportMaxStreamDataCapsule{ + /*stream_id=*/0, /*max_stream_data=*/1024})); + + EXPECT_TRUE(connection_->connected()) + << "Draft-07: WT_MAX_STREAM_DATA should be silently ignored."; + testing::Mock::VerifyAndClearExpectations(visitor); +} + +TEST_P(Draft07CompatibilityTest, Draft07_IgnoresWtStreamDataBlockedCapsule) { + // Same as above but for WT_STREAM_DATA_BLOCKED. + if (!VersionIsIetfQuic(GetParam().transport_version)) return; + Initialize( + WebTransportHttp3VersionSet({WebTransportHttp3Version::kDraft07}), + HttpDatagramSupport::kRfc); + CompleteHandshake(); + ReceiveWebTransportDraft07Settings(); + + QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + auto* wt = AttemptDraft07Session(session_id); + ASSERT_NE(wt, nullptr) << "Draft-07 session could not be established"; + auto* visitor = AttachMockVisitor(wt); + + // The session must NOT be closed. + EXPECT_CALL(*visitor, OnSessionClosed(_, _)).Times(0); + + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*writer_, + WritePacket(_, _, _, _, _, _)) + .WillRepeatedly(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + EXPECT_CALL(*connection_, SendControlFrame(_)) + .WillRepeatedly(&test::ClearControlFrame); + EXPECT_CALL(*connection_, OnStreamReset(_, _)) + .Times(testing::AnyNumber()); + + // Inject a WT_STREAM_DATA_BLOCKED capsule — should be silently ignored. + InjectCapsuleOnConnectStream( + session_id, + quiche::Capsule(quiche::WebTransportStreamDataBlockedCapsule{ + /*stream_id=*/0, /*stream_data_limit=*/512})); + + EXPECT_TRUE(connection_->connected()) + << "Draft-07: WT_STREAM_DATA_BLOCKED should be silently ignored."; + testing::Mock::VerifyAndClearExpectations(visitor); +} + +} // namespace +} // namespace quic diff --git a/quiche/quic/core/quic_error_codes.cc b/quiche/quic/core/quic_error_codes.cc index e7387730b..3f4a11bc5 100644 --- a/quiche/quic/core/quic_error_codes.cc +++ b/quiche/quic/core/quic_error_codes.cc @@ -293,6 +293,7 @@ const char* QuicErrorCodeToString(QuicErrorCode error) { RETURN_STRING_LITERAL(QUIC_UNEXPECTED_DATA_BEFORE_ENCRYPTION_ESTABLISHED); RETURN_STRING_LITERAL(QUIC_SERVER_UNHEALTHY); RETURN_STRING_LITERAL(QUIC_CLIENT_LOST_NETWORK_ACCESS); + RETURN_STRING_LITERAL(QUIC_HTTP_INVALID_SESSION_ID); RETURN_STRING_LITERAL(QUIC_LAST_ERROR); // Intentionally have no default case, so we'll break the build @@ -810,6 +811,8 @@ QuicErrorCodeToIetfMapping QuicErrorCodeToTransportErrorCode( return {true, static_cast(NO_IETF_QUIC_ERROR)}; case QUIC_CLIENT_LOST_NETWORK_ACCESS: return {true, static_cast(NO_IETF_QUIC_ERROR)}; + case QUIC_HTTP_INVALID_SESSION_ID: + return {false, static_cast(QuicHttp3ErrorCode::ID_ERROR)}; case QUIC_HANDSHAKE_FAILED_INVALID_HOSTNAME: return {true, static_cast(NO_IETF_QUIC_ERROR)}; case QUIC_HANDSHAKE_FAILED_REJECTING_ALL_CONNECTIONS: diff --git a/quiche/quic/core/quic_error_codes.h b/quiche/quic/core/quic_error_codes.h index 940989237..cfecf4504 100644 --- a/quiche/quic/core/quic_error_codes.h +++ b/quiche/quic/core/quic_error_codes.h @@ -646,8 +646,12 @@ enum QuicErrorCode : uint32_t { // Client application lost network access. QUIC_CLIENT_LOST_NETWORK_ACCESS = 215, + // WebTransport stream references an invalid session ID (not a + // client-initiated bidirectional stream). Maps to H3_ID_ERROR. + QUIC_HTTP_INVALID_SESSION_ID = 222, + // No error. Used as bound while iterating. - QUIC_LAST_ERROR = 222, + QUIC_LAST_ERROR = 223, }; // QuicErrorCodes is encoded as four octets on-the-wire when doing Google QUIC, // or a varint62 when doing IETF QUIC. Ensure that its value does not exceed diff --git a/quiche/quic/core/quic_session.cc b/quiche/quic/core/quic_session.cc index 15c790f4c..3c5e08fb4 100644 --- a/quiche/quic/core/quic_session.cc +++ b/quiche/quic/core/quic_session.cc @@ -150,6 +150,11 @@ void QuicSession::SavedConfig::DeleteConfig(ParsedQuicVersion version) { config_->GetInitialMaxStreamDataBytesIncomingBidirectionalToSend(); received_max_bidirectional_streams_ = config_->ReceivedMaxBidirectionalStreams(); + if (config_->HasReceivedMaxDatagramFrameSize()) { + has_received_max_datagram_frame_size_ = true; + received_max_datagram_frame_size_ = + config_->ReceivedMaxDatagramFrameSize(); + } idle_network_timeout_ = config_->IdleNetworkTimeout(); QUICHE_RELOADABLE_FLAG_COUNT(quic_delete_config); config_.reset(); @@ -1109,7 +1114,7 @@ bool QuicSession::WriteControlFrame(const QuicFrame& frame, return connection_->SendControlFrame(frame); } -void QuicSession::ResetStream(QuicStreamId id, QuicRstStreamErrorCode error) { +void QuicSession::ResetStream(QuicStreamId id, QuicResetStreamError error) { QuicStream* stream = GetStream(id); if (stream != nullptr && stream->is_static()) { connection()->CloseConnection( @@ -1119,13 +1124,13 @@ void QuicSession::ResetStream(QuicStreamId id, QuicRstStreamErrorCode error) { } if (stream != nullptr) { - stream->Reset(error); + stream->ResetWithError(error); return; } QuicConnection::ScopedPacketFlusher flusher(connection()); - MaybeSendStopSendingFrame(id, QuicResetStreamError::FromInternal(error)); - MaybeSendRstStreamFrame(id, QuicResetStreamError::FromInternal(error), 0); + MaybeSendStopSendingFrame(id, error); + MaybeSendRstStreamFrame(id, error, 0); } void QuicSession::MaybeSendRstStreamFrame(QuicStreamId id, diff --git a/quiche/quic/core/quic_session.h b/quiche/quic/core/quic_session.h index f205c64ab..dd6b13f3b 100644 --- a/quiche/quic/core/quic_session.h +++ b/quiche/quic/core/quic_session.h @@ -240,6 +240,20 @@ class QUICHE_EXPORT QuicSession return config_->IdleNetworkTimeout(); } + bool HasReceivedMaxDatagramFrameSize() const { + if (delete_config_ && config_ == nullptr) { + return has_received_max_datagram_frame_size_; + } + return config_->HasReceivedMaxDatagramFrameSize(); + } + + uint64_t ReceivedMaxDatagramFrameSize() const { + if (delete_config_ && config_ == nullptr) { + return received_max_datagram_frame_size_; + } + return config_->ReceivedMaxDatagramFrameSize(); + } + void set_delete_config(bool delete_config) { delete_config_ = delete_config; } @@ -267,6 +281,8 @@ class QUICHE_EXPORT QuicSession uint64_t get_initial_max_stream_data_bytes_incoming_bidirectional_to_send_; uint64_t received_max_bidirectional_streams_; QuicTime::Delta idle_network_timeout_ = QuicTime::Delta::Zero(); + bool has_received_max_datagram_frame_size_ = false; + uint64_t received_max_datagram_frame_size_ = 0; }; // Does not take ownership of |connection| or |visitor|. @@ -444,7 +460,10 @@ class QUICHE_EXPORT QuicSession // Called to send RST_STREAM (and STOP_SENDING) and close stream. If stream // |id| does not exist, just send RST_STREAM (and STOP_SENDING). - virtual void ResetStream(QuicStreamId id, QuicRstStreamErrorCode error); + virtual void ResetStream(QuicStreamId id, QuicResetStreamError error); + virtual void ResetStream(QuicStreamId id, QuicRstStreamErrorCode error) { + ResetStream(id, QuicResetStreamError::FromInternal(error)); + } // Called when the session wants to go away and not accept any new streams. virtual void SendGoAway(QuicErrorCode error_code, const std::string& reason); diff --git a/quiche/quic/moqt/tools/moqt_client.cc b/quiche/quic/moqt/tools/moqt_client.cc index 514020496..fcdf54c50 100644 --- a/quiche/quic/moqt/tools/moqt_client.cc +++ b/quiche/quic/moqt/tools/moqt_client.cc @@ -38,7 +38,8 @@ MoqtClient::MoqtClient(quic::QuicSocketAddress peer_address, : spdy_client_(peer_address, server_id, GetMoqtSupportedQuicVersions(), event_loop, std::move(proof_verifier)) { TuneQuicConfig(*spdy_client_.config()); - spdy_client_.set_enable_web_transport(true); + spdy_client_.set_supported_web_transport_versions( + quic::kDefaultSupportedWebTransportVersions); } void MoqtClient::Connect(std::string path, MoqtSessionCallbacks callbacks) { diff --git a/quiche/quic/test_tools/quic_spdy_session_peer.cc b/quiche/quic/test_tools/quic_spdy_session_peer.cc index b7b465018..0d61cea99 100644 --- a/quiche/quic/test_tools/quic_spdy_session_peer.cc +++ b/quiche/quic/test_tools/quic_spdy_session_peer.cc @@ -119,5 +119,13 @@ void QuicSpdySessionPeer::EnableWebTransport(QuicSpdySession* session) { session->peer_web_transport_versions_ = kDefaultSupportedWebTransportVersions; } +// static +void QuicSpdySessionPeer::EnableWebTransport( + QuicSpdySession* session, WebTransportHttp3VersionSet versions) { + QUICHE_DCHECK(session->WillNegotiateWebTransport()); + SetHttpDatagramSupport(session, HttpDatagramSupport::kRfc); + session->peer_web_transport_versions_ = versions; +} + } // namespace test } // namespace quic diff --git a/quiche/quic/test_tools/quic_spdy_session_peer.h b/quiche/quic/test_tools/quic_spdy_session_peer.h index ee8081677..b957a119e 100644 --- a/quiche/quic/test_tools/quic_spdy_session_peer.h +++ b/quiche/quic/test_tools/quic_spdy_session_peer.h @@ -54,6 +54,8 @@ class QuicSpdySessionPeer { HttpDatagramSupport http_datagram_support); static HttpDatagramSupport LocalHttpDatagramSupport(QuicSpdySession* session); static void EnableWebTransport(QuicSpdySession* session); + static void EnableWebTransport(QuicSpdySession* session, + WebTransportHttp3VersionSet versions); }; } // namespace test diff --git a/quiche/quic/test_tools/quic_spdy_session_test_utils.h b/quiche/quic/test_tools/quic_spdy_session_test_utils.h new file mode 100644 index 000000000..61c2c396c --- /dev/null +++ b/quiche/quic/test_tools/quic_spdy_session_test_utils.h @@ -0,0 +1,629 @@ +// Copyright (c) 2012 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. + +// Shared test infrastructure for QuicSpdySession tests. +// Provides TestCryptoStream, TestStream, TestSession, and +// QuicSpdySessionTestBase — used by both quic_spdy_session_test.cc and +// the draft-15 WebTransport test suite. + +#ifndef QUICHE_QUIC_TEST_TOOLS_QUIC_SPDY_SESSION_TEST_UTILS_H_ +#define QUICHE_QUIC_TEST_TOOLS_QUIC_SPDY_SESSION_TEST_UTILS_H_ + +#include +#include +#include +#include +#include + +#include "absl/memory/memory.h" +#include "absl/strings/string_view.h" +#include "quiche/quic/core/crypto/crypto_protocol.h" +#include "quiche/quic/core/crypto/transport_parameters.h" +#include "quiche/quic/core/http/http_constants.h" +#include "quiche/quic/core/http/http_encoder.h" +#include "quiche/quic/core/http/http_frames.h" +#include "quiche/quic/core/http/quic_header_list.h" +#include "quiche/quic/core/http/quic_spdy_session.h" +#include "quiche/quic/core/http/quic_spdy_stream.h" +#include "quiche/quic/core/http/web_transport_http3.h" +#include "quiche/quic/core/quic_config.h" +#include "quiche/quic/core/quic_crypto_stream.h" +#include "quiche/quic/core/quic_data_writer.h" +#include "quiche/quic/core/quic_error_codes.h" +#include "quiche/quic/core/quic_stream.h" +#include "quiche/quic/core/quic_types.h" +#include "quiche/quic/core/quic_utils.h" +#include "quiche/quic/core/quic_versions.h" +#include "quiche/quic/platform/api/quic_test.h" +#include "quiche/quic/test_tools/quic_config_peer.h" +#include "quiche/quic/test_tools/quic_connection_peer.h" +#include "quiche/quic/test_tools/quic_session_peer.h" +#include "quiche/quic/test_tools/quic_spdy_session_peer.h" +#include "quiche/quic/test_tools/quic_stream_peer.h" +#include "quiche/quic/test_tools/quic_test_utils.h" +#include "quiche/common/quiche_endian.h" + +namespace quic { +namespace test { + +// --------------------------------------------------------------------------- +// TestCryptoStream — minimal crypto stream for unit tests. +// --------------------------------------------------------------------------- +class TestCryptoStream : public QuicCryptoStream, public QuicCryptoHandshaker { + public: + explicit TestCryptoStream(QuicSession* session) + : QuicCryptoStream(session), + QuicCryptoHandshaker(this, session), + encryption_established_(false), + one_rtt_keys_available_(false), + params_(new QuicCryptoNegotiatedParameters) { + // Simulate a negotiated cipher_suite with a fake value. + params_->cipher_suite = 1; + } + + void EstablishZeroRttEncryption() { + encryption_established_ = true; + session()->connection()->SetEncrypter( + ENCRYPTION_ZERO_RTT, + std::make_unique(ENCRYPTION_ZERO_RTT)); + } + + void OnHandshakeMessage(const CryptoHandshakeMessage& /*message*/) override { + encryption_established_ = true; + one_rtt_keys_available_ = true; + QuicErrorCode error; + std::string error_details; + session()->config()->SetInitialStreamFlowControlWindowToSend( + kInitialStreamFlowControlWindowForTest); + session()->config()->SetInitialSessionFlowControlWindowToSend( + kInitialSessionFlowControlWindowForTest); + if (session()->version().IsIetfQuic()) { + if (session()->perspective() == Perspective::IS_CLIENT) { + session()->config()->SetOriginalConnectionIdToSend( + session()->connection()->connection_id()); + session()->config()->SetInitialSourceConnectionIdToSend( + session()->connection()->connection_id()); + } else { + session()->config()->SetInitialSourceConnectionIdToSend( + session()->connection()->client_connection_id()); + } + TransportParameters transport_parameters; + EXPECT_TRUE( + session()->config()->FillTransportParameters(&transport_parameters)); + error = session()->config()->ProcessTransportParameters( + transport_parameters, /* is_resumption = */ false, &error_details); + } else { + CryptoHandshakeMessage msg; + session()->config()->ToHandshakeMessage(&msg, transport_version()); + error = + session()->config()->ProcessPeerHello(msg, CLIENT, &error_details); + } + EXPECT_THAT(error, IsQuicNoError()); + session()->OnNewEncryptionKeyAvailable( + ENCRYPTION_FORWARD_SECURE, + std::make_unique(ENCRYPTION_FORWARD_SECURE)); + session()->OnConfigNegotiated(); + if (session()->connection()->version().IsIetfQuic()) { + session()->OnTlsHandshakeComplete(); + } else { + session()->SetDefaultEncryptionLevel(ENCRYPTION_FORWARD_SECURE); + } + session()->DiscardOldEncryptionKey(ENCRYPTION_INITIAL); + } + + // QuicCryptoStream implementation + ssl_early_data_reason_t EarlyDataReason() const override { + return ssl_early_data_unknown; + } + bool encryption_established() const override { + return encryption_established_; + } + bool one_rtt_keys_available() const override { + return one_rtt_keys_available_; + } + HandshakeState GetHandshakeState() const override { + return one_rtt_keys_available() ? HANDSHAKE_COMPLETE : HANDSHAKE_START; + } + void SetServerApplicationStateForResumption( + std::unique_ptr /*application_state*/) override {} + std::unique_ptr AdvanceKeysAndCreateCurrentOneRttDecrypter() + override { + return nullptr; + } + std::unique_ptr CreateCurrentOneRttEncrypter() override { + return nullptr; + } + const QuicCryptoNegotiatedParameters& crypto_negotiated_params() + const override { + return *params_; + } + CryptoMessageParser* crypto_message_parser() override { + return QuicCryptoHandshaker::crypto_message_parser(); + } + void OnPacketDecrypted(EncryptionLevel /*level*/) override {} + void OnOneRttPacketAcknowledged() override {} + void OnHandshakePacketSent() override {} + void OnHandshakeDoneReceived() override {} + void OnNewTokenReceived(absl::string_view /*token*/) override {} + std::string GetAddressToken( + const CachedNetworkParameters* /*cached_network_params*/) const override { + return ""; + } + bool ValidateAddressToken(absl::string_view /*token*/) const override { + return true; + } + const CachedNetworkParameters* PreviousCachedNetworkParams() const override { + return nullptr; + } + void SetPreviousCachedNetworkParams( + CachedNetworkParameters /*cached_network_params*/) override {} + + MOCK_METHOD(void, OnCanWrite, (), (override)); + + bool HasPendingCryptoRetransmission() const override { return false; } + + MOCK_METHOD(bool, HasPendingRetransmission, (), (const, override)); + + void OnConnectionClosed(const QuicConnectionCloseFrame& /*frame*/, + ConnectionCloseSource /*source*/) override {} + SSL* GetSsl() const override { return nullptr; } + bool IsCryptoFrameExpectedForEncryptionLevel( + EncryptionLevel level) const override { + return level != ENCRYPTION_ZERO_RTT; + } + EncryptionLevel GetEncryptionLevelToSendCryptoDataOfSpace( + PacketNumberSpace space) const override { + switch (space) { + case INITIAL_DATA: + return ENCRYPTION_INITIAL; + case HANDSHAKE_DATA: + return ENCRYPTION_HANDSHAKE; + case APPLICATION_DATA: + return ENCRYPTION_FORWARD_SECURE; + default: + QUICHE_DCHECK(false); + return NUM_ENCRYPTION_LEVELS; + } + } + + bool ExportKeyingMaterial(absl::string_view label, + absl::string_view context, + size_t result_len, std::string* + result) override { + last_export_label_ = std::string(label); + last_export_context_ = std::string(context); + if (result != nullptr) { + // Produce deterministic output that varies with label+context so + // tests can distinguish calls with different arguments. + result->resize(result_len); + for (size_t i = 0; i < result_len; ++i) { + uint8_t byte = 0xAB; + if (i < label.size()) { + byte ^= static_cast(label[i]); + } + if (i < context.size()) { + byte ^= static_cast(context[i]); + } + (*result)[i] = static_cast(byte); + } + } + return true; + } + + const std::string& last_export_label() const { return last_export_label_; } + const std::string& last_export_context() const { + return last_export_context_; + } + + private: + using QuicCryptoStream::session; + std::string last_export_label_; + std::string last_export_context_; + + bool encryption_established_; + bool one_rtt_keys_available_; + quiche::QuicheReferenceCountedPointer params_; +}; + +// --------------------------------------------------------------------------- +// TestStream — minimal SPDY stream for unit tests. +// --------------------------------------------------------------------------- +class TestStream : public QuicSpdyStream { + public: + TestStream(QuicStreamId id, QuicSpdySession* session, StreamType type) + : QuicSpdyStream(id, session, type) {} + + TestStream(PendingStream* pending, QuicSpdySession* session) + : QuicSpdyStream(pending, session) {} + + using QuicStream::CloseWriteSide; + + void OnBodyAvailable() override {} + + MOCK_METHOD(void, OnCanWrite, (), (override)); + MOCK_METHOD(bool, RetransmitStreamData, + (QuicStreamOffset, QuicByteCount, bool, TransmissionType), + (override)); + + MOCK_METHOD(bool, HasPendingRetransmission, (), (const, override)); + + protected: + bool ValidateReceivedHeaders(const QuicHeaderList& /*header_list*/) override { + return true; + } +}; + +// --------------------------------------------------------------------------- +// TestSession — QuicSpdySession subclass for unit tests. +// --------------------------------------------------------------------------- +class TestSession : public QuicSpdySession { + public: + explicit TestSession(QuicConnection* connection) + : QuicSpdySession(connection, nullptr, DefaultQuicConfig(), + CurrentSupportedVersions()), + crypto_stream_(this), + writev_consumes_all_data_(false) { + this->connection()->SetEncrypter( + ENCRYPTION_FORWARD_SECURE, + std::make_unique(ENCRYPTION_FORWARD_SECURE)); + if (this->connection()->version().IsIetfQuic()) { + QuicConnectionPeer::SetAddressValidated(this->connection()); + } + } + + ~TestSession() override { DeleteConnection(); } + + TestCryptoStream* GetMutableCryptoStream() override { + return &crypto_stream_; + } + + const TestCryptoStream* GetCryptoStream() const override { + return &crypto_stream_; + } + + TestStream* CreateOutgoingBidirectionalStream() override { + TestStream* stream = new TestStream(GetNextOutgoingBidirectionalStreamId(), + this, BIDIRECTIONAL); + ActivateStream(absl::WrapUnique(stream)); + return stream; + } + + TestStream* CreateIncomingStream(QuicStreamId id) override { + // Enforce the limit on the number of open streams. + if (!VersionIsIetfQuic(connection()->transport_version()) && + stream_id_manager().num_open_incoming_streams() + 1 > + max_open_incoming_bidirectional_streams()) { + connection()->CloseConnection( + QUIC_TOO_MANY_OPEN_STREAMS, "Too many streams!", + ConnectionCloseBehavior::SEND_CONNECTION_CLOSE_PACKET); + return nullptr; + } else { + TestStream* stream = new TestStream( + id, this, + DetermineStreamType(id, connection()->version(), perspective(), + /*is_incoming=*/true, BIDIRECTIONAL)); + ActivateStream(absl::WrapUnique(stream)); + return stream; + } + } + + TestStream* CreateIncomingStream(PendingStream* pending) override { + TestStream* stream = new TestStream(pending, this); + ActivateStream(absl::WrapUnique(stream)); + return stream; + } + + bool ShouldCreateIncomingStream(QuicStreamId /*id*/) override { return true; } + + bool ShouldCreateOutgoingBidirectionalStream() override { return true; } + + bool IsClosedStream(QuicStreamId id) { + return QuicSession::IsClosedStream(id); + } + + QuicStream* GetOrCreateStream(QuicStreamId stream_id) { + return QuicSpdySession::GetOrCreateStream(stream_id); + } + + QuicConsumedData WritevData(QuicStreamId id, size_t write_length, + QuicStreamOffset offset, StreamSendingState state, + TransmissionType type, + EncryptionLevel level) override { + bool fin = state != NO_FIN; + QuicConsumedData consumed(write_length, fin); + if (!writev_consumes_all_data_) { + consumed = + QuicSession::WritevData(id, write_length, offset, state, type, level); + } + QuicSessionPeer::GetWriteBlockedStreams(this)->UpdateBytesForStream( + id, consumed.bytes_consumed); + return consumed; + } + + void set_writev_consumes_all_data(bool val) { + writev_consumes_all_data_ = val; + } + + QuicConsumedData SendStreamData(QuicStream* stream) { + if (!QuicUtils::IsCryptoStreamId(connection()->transport_version(), + stream->id()) && + connection()->encryption_level() != ENCRYPTION_FORWARD_SECURE) { + this->connection()->SetDefaultEncryptionLevel(ENCRYPTION_FORWARD_SECURE); + } + QuicStreamPeer::SendBuffer(stream).SaveStreamData("not empty"); + QuicConsumedData consumed = + WritevData(stream->id(), 9, 0, FIN, NOT_RETRANSMISSION, + GetEncryptionLevelToSendApplicationData()); + QuicStreamPeer::SendBuffer(stream).OnStreamDataConsumed( + consumed.bytes_consumed); + return consumed; + } + + QuicConsumedData SendLargeFakeData(QuicStream* stream, int bytes) { + QUICHE_DCHECK(writev_consumes_all_data_); + return WritevData(stream->id(), bytes, 0, FIN, NOT_RETRANSMISSION, + GetEncryptionLevelToSendApplicationData()); + } + + WebTransportHttp3VersionSet LocallySupportedWebTransportVersions() + const override { + return locally_supported_web_transport_versions_; + } + void set_supports_webtransport(bool value) { + locally_supported_web_transport_versions_ = + value ? kDefaultSupportedWebTransportVersions + : WebTransportHttp3VersionSet(); + } + void set_locally_supported_web_transport_versions( + WebTransportHttp3VersionSet versions) { + locally_supported_web_transport_versions_ = std::move(versions); + } + + HttpDatagramSupport LocalHttpDatagramSupport() override { + return local_http_datagram_support_; + } + void set_local_http_datagram_support(HttpDatagramSupport value) { + local_http_datagram_support_ = value; + } + + MOCK_METHOD(void, OnAcceptChFrame, (const AcceptChFrame&), (override)); + + using QuicSession::closed_streams; + using QuicSession::pending_streams_size; + using QuicSession::ShouldKeepConnectionAlive; + using QuicSpdySession::settings; + using QuicSpdySession::UsesPendingStreamForFrame; + + private: + testing::StrictMock crypto_stream_; + + bool writev_consumes_all_data_; + WebTransportHttp3VersionSet locally_supported_web_transport_versions_; + HttpDatagramSupport local_http_datagram_support_ = HttpDatagramSupport::kNone; +}; + +// --------------------------------------------------------------------------- +// QuicSpdySessionTestBase — parameterized test fixture base class. +// --------------------------------------------------------------------------- +class QuicSpdySessionTestBase : public QuicTestWithParam { + public: + bool ClearMaxStreamsControlFrame(const QuicFrame& frame) { + if (frame.type == MAX_STREAMS_FRAME) { + DeleteFrame(&const_cast(frame)); + return true; + } + return false; + } + + protected: + explicit QuicSpdySessionTestBase(Perspective perspective, + bool allow_extended_connect = true) + : connection_(new testing::StrictMock( + &helper_, &alarm_factory_, perspective, + SupportedVersions(GetParam()))), + allow_extended_connect_(allow_extended_connect) {} + + void Initialize() { + session_.emplace(connection_); + if (qpack_maximum_dynamic_table_capacity_.has_value()) { + session_->set_qpack_maximum_dynamic_table_capacity( + *qpack_maximum_dynamic_table_capacity_); + } + if (connection_->perspective() == Perspective::IS_SERVER && + VersionIsIetfQuic(transport_version())) { + session_->set_allow_extended_connect(allow_extended_connect_); + } + session_->Initialize(); + session_->config()->SetInitialStreamFlowControlWindowToSend( + kInitialStreamFlowControlWindowForTest); + session_->config()->SetInitialSessionFlowControlWindowToSend( + kInitialSessionFlowControlWindowForTest); + if (VersionIsIetfQuic(transport_version())) { + QuicConfigPeer::SetReceivedMaxUnidirectionalStreams( + session_->config(), kHttp3StaticUnidirectionalStreamCount); + } + QuicConfigPeer::SetReceivedInitialSessionFlowControlWindow( + session_->config(), kMinimumFlowControlSendWindow); + QuicConfigPeer::SetReceivedInitialMaxStreamDataBytesUnidirectional( + session_->config(), kMinimumFlowControlSendWindow); + QuicConfigPeer::SetReceivedInitialMaxStreamDataBytesIncomingBidirectional( + session_->config(), kMinimumFlowControlSendWindow); + QuicConfigPeer::SetReceivedInitialMaxStreamDataBytesOutgoingBidirectional( + session_->config(), kMinimumFlowControlSendWindow); + session_->OnConfigNegotiated(); + connection_->AdvanceTime(QuicTime::Delta::FromSeconds(1)); + TestCryptoStream* crypto_stream = session_->GetMutableCryptoStream(); + EXPECT_CALL(*crypto_stream, HasPendingRetransmission()) + .Times(testing::AnyNumber()); + writer_ = static_cast( + QuicConnectionPeer::GetWriter(session_->connection())); + } + + void CheckClosedStreams() { + QuicStreamId first_stream_id = QuicUtils::GetFirstBidirectionalStreamId( + transport_version(), Perspective::IS_CLIENT); + if (!VersionIsIetfQuic(transport_version())) { + first_stream_id = QuicUtils::GetCryptoStreamId(transport_version()); + } + for (QuicStreamId i = first_stream_id; i < 100; i++) { + if (closed_streams_.find(i) == closed_streams_.end()) { + EXPECT_FALSE(session_->IsClosedStream(i)) << " stream id: " << i; + } else { + EXPECT_TRUE(session_->IsClosedStream(i)) << " stream id: " << i; + } + } + } + + void CloseStream(QuicStreamId id) { + if (!VersionIsIetfQuic(transport_version())) { + EXPECT_CALL(*connection_, SendControlFrame(testing::_)) + .WillOnce(&ClearControlFrame); + } else { + // IETF QUIC has two frames, RST_STREAM and STOP_SENDING + EXPECT_CALL(*connection_, SendControlFrame(testing::_)) + .Times(2) + .WillRepeatedly(&ClearControlFrame); + } + EXPECT_CALL(*connection_, OnStreamReset(id, testing::_)); + + // QPACK streams might write data upon stream reset. Let the test session + // handle the data. + session_->set_writev_consumes_all_data(true); + + session_->ResetStream(id, QUIC_STREAM_CANCELLED); + closed_streams_.insert(id); + } + + ParsedQuicVersion version() const { return connection_->version(); } + + QuicTransportVersion transport_version() const { + return connection_->transport_version(); + } + + QuicStreamId GetNthClientInitiatedBidirectionalId(int n) { + return GetNthClientInitiatedBidirectionalStreamId(transport_version(), n); + } + + QuicStreamId GetNthServerInitiatedBidirectionalId(int n) { + return GetNthServerInitiatedBidirectionalStreamId(transport_version(), n); + } + + QuicStreamId IdDelta() { + return QuicUtils::StreamIdDelta(transport_version()); + } + + QuicStreamCount StreamCountToId(QuicStreamCount stream_count, + Perspective perspective, bool bidirectional) { + // Calculate and build up stream ID rather than use + // GetFirst... because the test that relies on this method + // needs to do the stream count where #1 is 0/1/2/3, and not + // take into account that stream 0 is special. + QuicStreamId id = + ((stream_count - 1) * QuicUtils::StreamIdDelta(transport_version())); + if (!bidirectional) { + id |= 0x2; + } + if (perspective == Perspective::IS_SERVER) { + id |= 0x1; + } + return id; + } + + void CompleteHandshake() { + if (VersionIsIetfQuic(transport_version())) { + EXPECT_CALL(*writer_, WritePacket(testing::_, testing::_, testing::_, + testing::_, testing::_, testing::_)) + .WillOnce(testing::Return(WriteResult(WRITE_STATUS_OK, 0))); + } + if (connection_->version().IsIetfQuic() && + connection_->perspective() == Perspective::IS_SERVER) { + // HANDSHAKE_DONE frame. + EXPECT_CALL(*connection_, SendControlFrame(testing::_)) + .WillOnce(&ClearControlFrame); + } + + CryptoHandshakeMessage message; + session_->GetMutableCryptoStream()->OnHandshakeMessage(message); + testing::Mock::VerifyAndClearExpectations(writer_); + testing::Mock::VerifyAndClearExpectations(connection_); + } + + void ReceiveWebTransportSettings(WebTransportHttp3VersionSet versions = + kDefaultSupportedWebTransportVersions) { + SettingsFrame settings; + settings.values[SETTINGS_H3_DATAGRAM] = 1; + if (versions.IsSet(WebTransportHttp3Version::kDraft02)) { + settings.values[SETTINGS_WEBTRANS_DRAFT00] = 1; + } + if (versions.IsSet(WebTransportHttp3Version::kDraft07)) { + settings.values[SETTINGS_WEBTRANS_MAX_SESSIONS_DRAFT07] = 16; + } + settings.values[SETTINGS_ENABLE_CONNECT_PROTOCOL] = 1; + std::string data = std::string(1, kControlStream) + + HttpEncoder::SerializeSettingsFrame(settings); + QuicStreamId control_stream_id = + session_->perspective() == Perspective::IS_SERVER + ? GetNthClientInitiatedUnidirectionalStreamId(transport_version(), + 3) + : GetNthServerInitiatedUnidirectionalStreamId(transport_version(), + 3); + QuicStreamFrame frame(control_stream_id, /*fin=*/false, /*offset=*/0, data); + session_->OnStreamFrame(frame); + } + + void ReceiveWebTransportSession(WebTransportSessionId session_id) { + QuicStreamFrame frame(session_id, /*fin=*/false, /*offset=*/0, + absl::string_view()); + session_->OnStreamFrame(frame); + QuicSpdyStream* stream = + static_cast(session_->GetOrCreateStream(session_id)); + QuicHeaderList headers; + headers.OnHeader(":method", "CONNECT"); + headers.OnHeader(":protocol", "webtransport"); + stream->OnStreamHeaderList(/*fin=*/true, 0, headers); + WebTransportHttp3* web_transport = + session_->GetWebTransportSession(session_id); + ASSERT_TRUE(web_transport != nullptr); + quiche::HttpHeaderBlock header_block; + web_transport->HeadersReceived(header_block); + } + + void ReceiveWebTransportUnidirectionalStream( + WebTransportSessionId session_id, QuicStreamId stream_id, + absl::string_view payload = "test data") { + std::string data; + // Encode the stream type as a QUIC varint. + char type_buf[8]; + QuicDataWriter type_writer(sizeof(type_buf), type_buf); + ASSERT_TRUE(type_writer.WriteVarInt62(kWebTransportUnidirectionalStream)); + data.append(type_buf, type_writer.length()); + // Encode session_id as varint. + char varint_buf[8]; + QuicDataWriter varint_writer(sizeof(varint_buf), varint_buf); + ASSERT_TRUE(varint_writer.WriteVarInt62(session_id)); + data.append(varint_buf, varint_writer.length()); + data.append(payload); + QuicStreamFrame frame(stream_id, /*fin=*/false, /*offset=*/0, data); + session_->OnStreamFrame(frame); + } + + void TestHttpDatagramSetting(HttpDatagramSupport local_support, + HttpDatagramSupport remote_support, + HttpDatagramSupport expected_support, + bool expected_datagram_supported); + + MockQuicConnectionHelper helper_; + MockAlarmFactory alarm_factory_; + testing::StrictMock* connection_; + bool allow_extended_connect_; + std::optional session_; + std::set closed_streams_; + std::optional qpack_maximum_dynamic_table_capacity_; + MockPacketWriter* writer_; +}; + +} // namespace test +} // namespace quic + +#endif // QUICHE_QUIC_TEST_TOOLS_QUIC_SPDY_SESSION_TEST_UTILS_H_ diff --git a/quiche/quic/test_tools/quic_test_backend.h b/quiche/quic/test_tools/quic_test_backend.h index 75b03d2e5..d32f57710 100644 --- a/quiche/quic/test_tools/quic_test_backend.h +++ b/quiche/quic/test_tools/quic_test_backend.h @@ -5,6 +5,7 @@ #ifndef QUICHE_QUIC_TEST_TOOLS_QUIC_TEST_BACKEND_H_ #define QUICHE_QUIC_TEST_TOOLS_QUIC_TEST_BACKEND_H_ +#include "quiche/quic/core/http/quic_spdy_session.h" #include "quiche/quic/tools/quic_memory_cache_backend.h" #include "quiche/common/http/http_header_block.h" #include "quiche/common/platform/api/quiche_logging.h" @@ -20,11 +21,19 @@ class QuicTestBackend : public QuicMemoryCacheBackend { WebTransportResponse ProcessWebTransportRequest( const quiche::HttpHeaderBlock& request_headers, WebTransportSession* session) override; - bool SupportsWebTransport() override { return enable_webtransport_; } + bool SupportsWebTransport() override { return wt_versions_.Any(); } + WebTransportHttp3VersionSet SupportedWebTransportVersions() override { + return wt_versions_; + } void set_enable_webtransport(bool enable_webtransport) { QUICHE_DCHECK(!enable_webtransport || enable_extended_connect_); - enable_webtransport_ = enable_webtransport; + wt_versions_ = enable_webtransport ? kDefaultSupportedWebTransportVersions + : WebTransportHttp3VersionSet(); + } + void set_supported_web_transport_versions( + WebTransportHttp3VersionSet versions) { + wt_versions_ = versions; } bool SupportsExtendedConnect() override { return enable_extended_connect_; } @@ -34,7 +43,7 @@ class QuicTestBackend : public QuicMemoryCacheBackend { } private: - bool enable_webtransport_ = false; + WebTransportHttp3VersionSet wt_versions_; bool enable_extended_connect_ = true; }; diff --git a/quiche/quic/test_tools/quic_test_client.cc b/quiche/quic/test_tools/quic_test_client.cc index feb51b1fb..4694eddac 100644 --- a/quiche/quic/test_tools/quic_test_client.cc +++ b/quiche/quic/test_tools/quic_test_client.cc @@ -267,6 +267,25 @@ void MockableQuicClient::UseClientConnectionIdLength( override_client_connection_id_length_ = client_connection_id_length; } +std::unique_ptr MockableQuicClient::CreateQuicClientSession( + const ParsedQuicVersionVector& supported_versions, + QuicConnection* connection) { + auto session = + QuicDefaultClient::CreateQuicClientSession(supported_versions, connection); + auto* spdy_session = static_cast(session.get()); + if (wt_initial_max_streams_bidi_ > 0) { + spdy_session->set_wt_initial_max_streams_bidi( + wt_initial_max_streams_bidi_); + } + if (wt_initial_max_streams_uni_ > 0) { + spdy_session->set_wt_initial_max_streams_uni(wt_initial_max_streams_uni_); + } + if (wt_initial_max_data_ > 0) { + spdy_session->set_wt_initial_max_data(wt_initial_max_data_); + } + return session; +} + void MockableQuicClient::UseWriter(QuicPacketWriterWrapper* writer) { mockable_network_helper()->UseWriter(writer); } diff --git a/quiche/quic/test_tools/quic_test_client.h b/quiche/quic/test_tools/quic_test_client.h index 34fc9915c..df98419bd 100644 --- a/quiche/quic/test_tools/quic_test_client.h +++ b/quiche/quic/test_tools/quic_test_client.h @@ -144,6 +144,20 @@ class MockableQuicClient : public QuicDefaultClient { return negotiated_config_; } + // WebTransport draft-15 session-level flow control limits for the client. + // Must be called before Connect(). + void set_wt_initial_max_streams_bidi(uint64_t v) { + wt_initial_max_streams_bidi_ = v; + } + void set_wt_initial_max_streams_uni(uint64_t v) { + wt_initial_max_streams_uni_ = v; + } + void set_wt_initial_max_data(uint64_t v) { wt_initial_max_data_ = v; } + + std::unique_ptr CreateQuicClientSession( + const ParsedQuicVersionVector& supported_versions, + QuicConnection* connection) override; + private: // Client connection ID to use, if client_connection_id_overridden_. // TODO(wub): Move client_connection_id_(length_) overrides to QuicClientBase. @@ -154,6 +168,9 @@ class MockableQuicClient : public QuicDefaultClient { // Owned by the base class. QuicTestMigrationHelper* migration_helper_ = nullptr; std::optional negotiated_config_; + uint64_t wt_initial_max_streams_bidi_ = 0; + uint64_t wt_initial_max_streams_uni_ = 0; + uint64_t wt_initial_max_data_ = 0; }; // A toy QUIC client used for testing. diff --git a/quiche/quic/test_tools/quic_test_server.cc b/quiche/quic/test_tools/quic_test_server.cc index 2f88fe0da..5be22c884 100644 --- a/quiche/quic/test_tools/quic_test_server.cc +++ b/quiche/quic/test_tools/quic_test_server.cc @@ -126,6 +126,7 @@ class QuicTestDispatcher : public QuicSimpleDispatcher { session->set_allow_extended_connect( server_backend()->SupportsExtendedConnect()); } + ApplyWebTransportFlowControlLimits(session.get()); session->Initialize(); return session; } diff --git a/quiche/quic/tools/quic_default_client.cc b/quiche/quic/tools/quic_default_client.cc index 9ddc291ce..edf55bdfd 100644 --- a/quiche/quic/tools/quic_default_client.cc +++ b/quiche/quic/tools/quic_default_client.cc @@ -229,12 +229,12 @@ std::unique_ptr QuicDefaultClient::CreateQuicClientSession( static_cast(connection->writer()), migration_helper_.get(), migration_config_, network_helper(), server_id(), crypto_config(), drop_response_body(), - enable_web_transport()); + supported_web_transport_versions()); } return std::make_unique( *config(), supported_versions, connection, this, network_helper(), server_id(), crypto_config(), drop_response_body(), - enable_web_transport()); + supported_web_transport_versions()); } QuicClientDefaultNetworkHelper* QuicDefaultClient::default_network_helper() { diff --git a/quiche/quic/tools/quic_simple_client_session.cc b/quiche/quic/tools/quic_simple_client_session.cc index 7d0e50ebf..ca1238f4c 100644 --- a/quiche/quic/tools/quic_simple_client_session.cc +++ b/quiche/quic/tools/quic_simple_client_session.cc @@ -18,25 +18,25 @@ QuicSimpleClientSession::QuicSimpleClientSession( const QuicConfig& config, const ParsedQuicVersionVector& supported_versions, QuicConnection* connection, QuicClientBase::NetworkHelper* network_helper, const QuicServerId& server_id, QuicCryptoClientConfig* crypto_config, - bool drop_response_body, bool enable_web_transport) + bool drop_response_body, WebTransportHttp3VersionSet wt_versions) : QuicSimpleClientSession(config, supported_versions, connection, /*visitor=*/nullptr, network_helper, server_id, crypto_config, drop_response_body, - enable_web_transport) {} + wt_versions) {} QuicSimpleClientSession::QuicSimpleClientSession( const QuicConfig& config, const ParsedQuicVersionVector& supported_versions, QuicConnection* connection, QuicSession::Visitor* visitor, QuicClientBase::NetworkHelper* network_helper, const QuicServerId& server_id, QuicCryptoClientConfig* crypto_config, - bool drop_response_body, bool enable_web_transport) + bool drop_response_body, WebTransportHttp3VersionSet wt_versions) : QuicSimpleClientSession(config, supported_versions, connection, visitor, /*writer=*/nullptr, /*migration_helper=*/nullptr, QuicConnectionMigrationConfig{ .allow_server_preferred_address = false}, network_helper, server_id, crypto_config, - drop_response_body, enable_web_transport) {} + drop_response_body, wt_versions) {} QuicSimpleClientSession::QuicSimpleClientSession( const QuicConfig& config, const ParsedQuicVersionVector& supported_versions, @@ -46,15 +46,15 @@ QuicSimpleClientSession::QuicSimpleClientSession( const QuicConnectionMigrationConfig& migration_config, QuicClientBase::NetworkHelper* network_helper, const QuicServerId& server_id, QuicCryptoClientConfig* crypto_config, - bool drop_response_body, bool enable_web_transport) + bool drop_response_body, WebTransportHttp3VersionSet wt_versions) : QuicSpdyClientSession( config, supported_versions, connection, visitor, writer, migration_helper, migration_config, server_id, crypto_config, - enable_web_transport ? QuicPriorityType::kWebTransport - : QuicPriorityType::kHttp), + wt_versions.Any() ? QuicPriorityType::kWebTransport + : QuicPriorityType::kHttp), network_helper_(network_helper), drop_response_body_(drop_response_body), - enable_web_transport_(enable_web_transport) {} + wt_versions_(wt_versions) {} std::unique_ptr QuicSimpleClientSession::CreateClientStream() { @@ -70,12 +70,11 @@ QuicSimpleClientSession::CreateClientStream() { WebTransportHttp3VersionSet QuicSimpleClientSession::LocallySupportedWebTransportVersions() const { - return enable_web_transport_ ? kDefaultSupportedWebTransportVersions - : WebTransportHttp3VersionSet(); + return wt_versions_; } HttpDatagramSupport QuicSimpleClientSession::LocalHttpDatagramSupport() { - return enable_web_transport_ ? HttpDatagramSupport::kRfcAndDraft04 + return wt_versions_.Any() ? HttpDatagramSupport::kRfcAndDraft04 : HttpDatagramSupport::kNone; } diff --git a/quiche/quic/tools/quic_simple_client_session.h b/quiche/quic/tools/quic_simple_client_session.h index 41bd52a8c..14f6fb0b0 100644 --- a/quiche/quic/tools/quic_simple_client_session.h +++ b/quiche/quic/tools/quic_simple_client_session.h @@ -9,6 +9,7 @@ #include #include "quiche/quic/core/http/quic_spdy_client_session.h" +#include "quiche/quic/core/http/quic_spdy_session.h" #include "quiche/quic/tools/quic_client_base.h" #include "quiche/quic/tools/quic_simple_client_stream.h" #include "quiche/common/http/http_header_block.h" @@ -24,7 +25,7 @@ class QuicSimpleClientSession : public QuicSpdyClientSession { QuicClientBase::NetworkHelper* network_helper, const QuicServerId& server_id, QuicCryptoClientConfig* crypto_config, - bool drop_response_body, bool enable_web_transport); + bool drop_response_body, WebTransportHttp3VersionSet wt_versions); QuicSimpleClientSession(const QuicConfig& config, const ParsedQuicVersionVector& supported_versions, @@ -33,7 +34,7 @@ class QuicSimpleClientSession : public QuicSpdyClientSession { QuicClientBase::NetworkHelper* network_helper, const QuicServerId& server_id, QuicCryptoClientConfig* crypto_config, - bool drop_response_body, bool enable_web_transport); + bool drop_response_body, WebTransportHttp3VersionSet wt_versions); QuicSimpleClientSession(const QuicConfig& config, const ParsedQuicVersionVector& supported_versions, @@ -45,7 +46,7 @@ class QuicSimpleClientSession : public QuicSpdyClientSession { QuicClientBase::NetworkHelper* network_helper, const QuicServerId& server_id, QuicCryptoClientConfig* crypto_config, - bool drop_response_body, bool enable_web_transport); + bool drop_response_body, WebTransportHttp3VersionSet wt_versions); std::unique_ptr CreateClientStream() override; WebTransportHttp3VersionSet LocallySupportedWebTransportVersions() @@ -68,7 +69,7 @@ class QuicSimpleClientSession : public QuicSpdyClientSession { on_interim_headers_; QuicClientBase::NetworkHelper* network_helper_; const bool drop_response_body_; - const bool enable_web_transport_; + const WebTransportHttp3VersionSet wt_versions_; }; } // namespace quic diff --git a/quiche/quic/tools/quic_simple_dispatcher.cc b/quiche/quic/tools/quic_simple_dispatcher.cc index e8d0d23aa..1824a5d11 100644 --- a/quiche/quic/tools/quic_simple_dispatcher.cc +++ b/quiche/quic/tools/quic_simple_dispatcher.cc @@ -65,8 +65,25 @@ std::unique_ptr QuicSimpleDispatcher::CreateQuicSession( auto session = std::make_unique( config(), GetSupportedVersions(), connection, this, session_helper(), crypto_config(), compressed_certs_cache(), quic_simple_server_backend_); + ApplyWebTransportFlowControlLimits(session.get()); session->Initialize(); return session; } +void QuicSimpleDispatcher::ApplyWebTransportFlowControlLimits( + QuicServerSessionBase* session) { + if (quic_simple_server_backend_->wt_initial_max_streams_bidi() > 0) { + session->set_wt_initial_max_streams_bidi( + quic_simple_server_backend_->wt_initial_max_streams_bidi()); + } + if (quic_simple_server_backend_->wt_initial_max_streams_uni() > 0) { + session->set_wt_initial_max_streams_uni( + quic_simple_server_backend_->wt_initial_max_streams_uni()); + } + if (quic_simple_server_backend_->wt_initial_max_data() > 0) { + session->set_wt_initial_max_data( + quic_simple_server_backend_->wt_initial_max_data()); + } +} + } // namespace quic diff --git a/quiche/quic/tools/quic_simple_dispatcher.h b/quiche/quic/tools/quic_simple_dispatcher.h index 1a6e58b30..3fcbfc49f 100644 --- a/quiche/quic/tools/quic_simple_dispatcher.h +++ b/quiche/quic/tools/quic_simple_dispatcher.h @@ -52,6 +52,11 @@ class QuicSimpleDispatcher : public QuicDispatcher { return quic_simple_server_backend_; } + // Applies WebTransport draft-15 FC limits from the backend to a session. + // Must be called before session->Initialize(). + void ApplyWebTransportFlowControlLimits(QuicServerSessionBase* session); + + private: QuicSimpleServerBackend* quic_simple_server_backend_; // Unowned. diff --git a/quiche/quic/tools/quic_simple_server_backend.h b/quiche/quic/tools/quic_simple_server_backend.h index 4d28b32c1..779e594d8 100644 --- a/quiche/quic/tools/quic_simple_server_backend.h +++ b/quiche/quic/tools/quic_simple_server_backend.h @@ -9,6 +9,7 @@ #include #include "absl/strings/string_view.h" +#include "quiche/quic/core/http/quic_spdy_session.h" #include "quiche/quic/core/http/quic_spdy_stream.h" #include "quiche/quic/core/quic_error_codes.h" #include "quiche/quic/core/quic_types.h" @@ -115,7 +116,32 @@ class QuicSimpleServerBackend { return response; } virtual bool SupportsWebTransport() { return false; } + virtual WebTransportHttp3VersionSet SupportedWebTransportVersions() { + return SupportsWebTransport() ? kDefaultSupportedWebTransportVersions + : WebTransportHttp3VersionSet(); + } virtual bool SupportsExtendedConnect() { return true; } + + // Draft-15 session-level flow control limits advertised by the server. + void set_wt_initial_max_streams_bidi(uint64_t v) { + wt_initial_max_streams_bidi_ = v; + } + void set_wt_initial_max_streams_uni(uint64_t v) { + wt_initial_max_streams_uni_ = v; + } + void set_wt_initial_max_data(uint64_t v) { wt_initial_max_data_ = v; } + uint64_t wt_initial_max_streams_bidi() const { + return wt_initial_max_streams_bidi_; + } + uint64_t wt_initial_max_streams_uni() const { + return wt_initial_max_streams_uni_; + } + uint64_t wt_initial_max_data() const { return wt_initial_max_data_; } + + private: + uint64_t wt_initial_max_streams_bidi_ = 0; + uint64_t wt_initial_max_streams_uni_ = 0; + uint64_t wt_initial_max_data_ = 0; }; } // namespace quic diff --git a/quiche/quic/tools/quic_simple_server_session.h b/quiche/quic/tools/quic_simple_server_session.h index 69f3d0e5d..20cc70b0e 100644 --- a/quiche/quic/tools/quic_simple_server_session.h +++ b/quiche/quic/tools/quic_simple_server_session.h @@ -71,9 +71,7 @@ class QuicSimpleServerSession : public QuicServerSessionBase { WebTransportHttp3VersionSet LocallySupportedWebTransportVersions() const override { - return quic_simple_server_backend_->SupportsWebTransport() - ? kDefaultSupportedWebTransportVersions - : WebTransportHttp3VersionSet(); + return quic_simple_server_backend_->SupportedWebTransportVersions(); } HttpDatagramSupport LocalHttpDatagramSupport() override { if (ShouldNegotiateWebTransport()) { diff --git a/quiche/quic/tools/quic_spdy_client_base.h b/quiche/quic/tools/quic_spdy_client_base.h index 503bbf876..cc33ea34c 100644 --- a/quiche/quic/tools/quic_spdy_client_base.h +++ b/quiche/quic/tools/quic_spdy_client_base.h @@ -13,6 +13,7 @@ #include "absl/strings/string_view.h" #include "quiche/quic/core/crypto/crypto_handshake.h" #include "quiche/quic/core/http/quic_spdy_client_session.h" +#include "quiche/quic/core/http/quic_spdy_session.h" #include "quiche/quic/core/http/quic_spdy_client_stream.h" #include "quiche/quic/core/quic_config.h" #include "quiche/quic/platform/api/quic_socket_address.h" @@ -99,10 +100,14 @@ class QuicSpdyClientBase : public QuicClientBase, } bool drop_response_body() const { return drop_response_body_; } - void set_enable_web_transport(bool enable_web_transport) { - enable_web_transport_ = enable_web_transport; + void set_supported_web_transport_versions( + WebTransportHttp3VersionSet versions) { + supported_wt_versions_ = versions; } - bool enable_web_transport() const { return enable_web_transport_; } + const WebTransportHttp3VersionSet& supported_web_transport_versions() const { + return supported_wt_versions_; + } + bool web_transport_enabled() const { return supported_wt_versions_.Any(); } // QuicClientBase methods. bool goaway_received() const override; @@ -152,7 +157,7 @@ class QuicSpdyClientBase : public QuicClientBase, std::unique_ptr response_listener_; bool drop_response_body_ = false; - bool enable_web_transport_ = false; + WebTransportHttp3VersionSet supported_wt_versions_; // If not zero, used to set client's max inbound header size before session // initialize. size_t max_inbound_header_list_size_ = 0; diff --git a/quiche/web_transport/test_tools/draft15_constants.h b/quiche/web_transport/test_tools/draft15_constants.h new file mode 100644 index 000000000..b45c9ba28 --- /dev/null +++ b/quiche/web_transport/test_tools/draft15_constants.h @@ -0,0 +1,80 @@ +// Copyright 2024 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. + +// Machine-readable encoding of draft-ietf-webtrans-http3-15 IANA-registered +// codepoints. All draft-15 acceptance tests include this as the "spec truth" +// reference. Values come from: +// https://www.ietf.org/archive/id/draft-ietf-webtrans-http3-15.html + +#ifndef QUICHE_WEB_TRANSPORT_TEST_TOOLS_DRAFT15_CONSTANTS_H_ +#define QUICHE_WEB_TRANSPORT_TEST_TOOLS_DRAFT15_CONSTANTS_H_ + +#include + +#include "absl/strings/string_view.h" +#include "quiche/common/capsule.h" +#include "quiche/quic/core/http/http_constants.h" + +namespace webtransport::draft15 { + +// --- SETTINGS (Section 9.2) --- +// Reference production constants from http_constants.h to avoid duplication. +inline constexpr uint64_t kSettingsWtEnabled = + quic::SETTINGS_WT_ENABLED; +inline constexpr uint64_t kSettingsWtInitialMaxStreamsUni = + quic::SETTINGS_WT_INITIAL_MAX_STREAMS_UNI; +inline constexpr uint64_t kSettingsWtInitialMaxStreamsBidi = + quic::SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI; +inline constexpr uint64_t kSettingsWtInitialMaxData = + quic::SETTINGS_WT_INITIAL_MAX_DATA; + +// --- Capsule types (Section 9.6) --- +// Reference production constants from capsule.h to avoid duplication. +inline constexpr uint64_t kWtCloseSession = + static_cast(quiche::CapsuleType::CLOSE_WEBTRANSPORT_SESSION); +inline constexpr uint64_t kWtDrainSession = + static_cast(quiche::CapsuleType::DRAIN_WEBTRANSPORT_SESSION); +inline constexpr uint64_t kWtMaxData = + static_cast(quiche::CapsuleType::WT_MAX_DATA); +inline constexpr uint64_t kWtMaxStreamsBidi = + static_cast(quiche::CapsuleType::WT_MAX_STREAMS_BIDI); +inline constexpr uint64_t kWtMaxStreamsUnidi = + static_cast(quiche::CapsuleType::WT_MAX_STREAMS_UNIDI); +inline constexpr uint64_t kWtDataBlocked = + static_cast(quiche::CapsuleType::WT_DATA_BLOCKED); +inline constexpr uint64_t kWtStreamsBlockedBidi = + static_cast(quiche::CapsuleType::WT_STREAMS_BLOCKED_BIDI); +inline constexpr uint64_t kWtStreamsBlockedUnidi = + static_cast(quiche::CapsuleType::WT_STREAMS_BLOCKED_UNIDI); + +// --- Error codes (Section 9.5) --- +// Production constants defined in quiche/quic/core/http/http_constants.h. +// Reference them here to avoid duplicate definitions. +inline constexpr uint64_t kWtBufferedStreamRejected = + quic::kWtBufferedStreamRejected; +inline constexpr uint64_t kWtSessionGone = quic::kWtSessionGone; +inline constexpr uint64_t kWtRequirementsNotMet = quic::kWtRequirementsNotMet; + +// --- WT_APPLICATION_ERROR range (Section 4.4) --- +inline constexpr uint64_t kWtApplicationErrorFirst = 0x52e4a40fa8db; +inline constexpr uint64_t kWtApplicationErrorLast = 0x52e5ac983162; + +// --- Stream types (Section 4.2, 4.3) --- +// Unidirectional stream type byte. +inline constexpr uint64_t kUniStreamType = 0x54; +// Bidirectional stream signal (WT_STREAM). +inline constexpr uint64_t kBidiSignal = 0x41; + +// --- Upgrade token (Section 9.1) --- +inline constexpr absl::string_view kProtocolToken = "webtransport-h3"; + +// --- Legacy codepoints (draft-02/draft-07, for comparison) --- +inline constexpr uint64_t kSettingsWebtransDraft00 = + quic::SETTINGS_WEBTRANS_DRAFT00; +inline constexpr uint64_t kSettingsWebtransMaxSessionsDraft07 = + quic::SETTINGS_WEBTRANS_MAX_SESSIONS_DRAFT07; + +} // namespace webtransport::draft15 + +#endif // QUICHE_WEB_TRANSPORT_TEST_TOOLS_DRAFT15_CONSTANTS_H_ diff --git a/quiche/web_transport/web_transport_headers_draft15_test.cc b/quiche/web_transport/web_transport_headers_draft15_test.cc new file mode 100644 index 000000000..0dde6cc59 --- /dev/null +++ b/quiche/web_transport/web_transport_headers_draft15_test.cc @@ -0,0 +1,308 @@ +// Copyright 2024 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. + +// Draft-15 acceptance tests for Application Protocol Negotiation (Section 3.3). +// Tests that use existing ParseSubprotocol* functions PASS immediately. +// Tests requiring draft-15-specific error handling (WT_ALPN_ERROR) use the +// shared Draft15SessionTest fixture. + +#include +#include +#include + +#include "absl/status/status.h" +#include "absl/strings/string_view.h" +#include "quiche/common/platform/api/quiche_test.h" +#include "quiche/common/test_tools/quiche_test_utils.h" +#include "quiche/quic/core/http/web_transport_draft15_test_utils.h" +#include "quiche/quic/core/http/web_transport_http3.h" +#include "quiche/quic/core/quic_versions.h" +#include "quiche/web_transport/test_tools/draft15_constants.h" +#include "quiche/web_transport/web_transport_headers.h" + +namespace webtransport { +namespace { + +using ::quiche::test::IsOkAndHolds; +using ::quiche::test::StatusIs; +using ::testing::ElementsAre; +using ::testing::HasSubstr; + +// --- Header parsing (Section 3.3) --- +// These reuse existing parsing functions and PASS immediately. + +TEST(WebTransportHeadersDraft15, ParseWtAvailableProtocols) { + // WT-Available-Protocols is a Structured Fields List of Strings. + EXPECT_THAT(ParseSubprotocolRequestHeader(R"("chat-v1", "chat-v2")"), + IsOkAndHolds(ElementsAre("chat-v1", "chat-v2"))); +} + +TEST(WebTransportHeadersDraft15, SerializeWtAvailableProtocols) { + // Round-trip serialization of WT-Available-Protocols. + std::vector protocols = {"chat-v1", "chat-v2"}; + auto serialized = SerializeSubprotocolRequestHeader(protocols); + ASSERT_TRUE(serialized.ok()) << serialized.status(); + auto reparsed = ParseSubprotocolRequestHeader(*serialized); + EXPECT_THAT(reparsed, IsOkAndHolds(ElementsAre("chat-v1", "chat-v2"))); +} + +TEST(WebTransportHeadersDraft15, ParseWtProtocol) { + // WT-Protocol is a single Structured Fields Item (string). + EXPECT_THAT(ParseSubprotocolResponseHeader(R"("chat-v2")"), + IsOkAndHolds("chat-v2")); +} + +TEST(WebTransportHeadersDraft15, NonStringValuesIgnored) { + // Section 3.3 MUST: Non-string values in the list MUST be ignored. + // Token "chat-v1" (not quoted) should cause error, integer 42 also. + EXPECT_THAT(ParseSubprotocolRequestHeader(R"("chat-v1", 42)"), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("found integer instead"))); + // Non-string items are rejected by the parser (type mismatch), which + // effectively ignores them by returning an error for the whole field. + // A conforming implementation could alternatively skip non-string items. +} + +TEST(WebTransportHeadersDraft15, ParametersIgnored) { + // Section 3.3 MUST: Parameters on list members MUST be discarded. + // Existing parser already handles this. + EXPECT_THAT( + ParseSubprotocolRequestHeader(R"("chat-v1"; priority=1, "chat-v2")"), + IsOkAndHolds(ElementsAre("chat-v1", "chat-v2"))); +} + +// --- Session-dependent ALPN error tests (Section 3.3) --- +// These require a QUIC session to verify draft-15-specific error handling. + +class HeadersDraft15SessionTest + : public quic::test::Draft15SessionTest { + protected: + HeadersDraft15SessionTest() + : Draft15SessionTest(quic::Perspective::IS_CLIENT) {} +}; + +INSTANTIATE_TEST_SUITE_P(HeadersDraft15SessionTests, + HeadersDraft15SessionTest, + ::testing::ValuesIn(quic::CurrentSupportedVersions())); + +// Server-perspective fixture for ALPN tests. +class HeadersDraft15ServerSessionTest + : public quic::test::Draft15SessionTest { + protected: + HeadersDraft15ServerSessionTest() + : Draft15SessionTest(quic::Perspective::IS_SERVER) {} +}; + +INSTANTIATE_TEST_SUITE_P( + HeadersDraft15ServerSessionTests, + HeadersDraft15ServerSessionTest, + ::testing::ValuesIn(quic::CurrentSupportedVersions())); + +TEST_P(HeadersDraft15SessionTest, WtProtocolMustBeFromClientList) { + // Section 3.3 MUST: The server-selected protocol must be from the client's + // offered list. If it is not, the client should close with WT_ALPN_ERROR. + if (!quic::VersionIsIetfQuic(GetParam().transport_version)) return; + + Initialize( + quic::WebTransportHttp3VersionSet( + {quic::WebTransportHttp3Version::kDraft15}), + quic::HttpDatagramSupport::kRfc); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(); + + auto* wt = AttemptWebTransportDraft15ClientSession(); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + // Client offered "chat-v1" and "chat-v2". + wt->set_subprotocols_offered({"chat-v1", "chat-v2"}); + EXPECT_THAT(wt->subprotocols_offered(), + ElementsAre("chat-v1", "chat-v2")); + + // Attach a mock visitor to observe the error closure. + auto* visitor = AttachMockVisitor(wt); + // Section 3.3 MUST: Client closes session with WT_ALPN_ERROR on mismatch. + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*visitor, OnSessionClosed( + static_cast( + quic::kWtAlpnError), + testing::_)) + .Times(1); + + quic::QuicStreamId stream_id = wt->id(); + // Server responds with WT-Protocol: "video-v1" (not in client's list). + ReceiveWebTransportDraft15Response(stream_id, 200, "video-v1"); + + // The session should detect the mismatch and not become ready. + EXPECT_FALSE(wt->ready()) + << "Session must not be ready when server selects a protocol " + "not in client's offered list"; + testing::Mock::VerifyAndClearExpectations(visitor); +} + +TEST_P(HeadersDraft15SessionTest, ClientClosesWithAlpnErrorOnMissing) { + // Section 3.3 MUST: If the client sent WT-Available-Protocols but the + // server response has no WT-Protocol, close with WT_ALPN_ERROR. + if (!quic::VersionIsIetfQuic(GetParam().transport_version)) return; + + EXPECT_EQ(quic::kWtAlpnError, 0x0817b3ddu); + + Initialize( + quic::WebTransportHttp3VersionSet( + {quic::WebTransportHttp3Version::kDraft15}), + quic::HttpDatagramSupport::kRfc); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(); + + auto* wt = AttemptWebTransportDraft15ClientSession(); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + // Client offered subprotocols. + wt->set_subprotocols_offered({"chat-v1"}); + + // Attach a mock visitor to observe the error closure. + auto* visitor = AttachMockVisitor(wt); + // Section 3.3 MUST: Client closes with WT_ALPN_ERROR when server omits + // WT-Protocol but client offered subprotocols. + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*visitor, OnSessionClosed( + static_cast( + quic::kWtAlpnError), + testing::_)) + .Times(1); + + quic::QuicStreamId stream_id = wt->id(); + // Server responds with 200 OK but omits WT-Protocol header entirely. + ReceiveWebTransportDraft15Response(stream_id, 200); + + // When client offered subprotocols but server did not select one, + // the session should not become ready. + EXPECT_FALSE(wt->ready()) + << "Session must not be ready when server omits WT-Protocol " + "but client offered subprotocols"; + testing::Mock::VerifyAndClearExpectations(visitor); +} + +TEST_P(HeadersDraft15SessionTest, ClientClosesWithAlpnErrorOnMismatch) { + // Section 3.3 MUST: If server's WT-Protocol is not in client's list, + // close with WT_ALPN_ERROR. + if (!quic::VersionIsIetfQuic(GetParam().transport_version)) return; + + EXPECT_EQ(quic::kWtAlpnError, 0x0817b3ddu); + + Initialize( + quic::WebTransportHttp3VersionSet( + {quic::WebTransportHttp3Version::kDraft15}), + quic::HttpDatagramSupport::kRfc); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(); + + auto* wt = AttemptWebTransportDraft15ClientSession(); + ASSERT_NE(wt, nullptr) << "Draft-15 session could not be established"; + + // Client offered "chat-v1" and "chat-v2". + wt->set_subprotocols_offered({"chat-v1", "chat-v2"}); + + // Attach a mock visitor to observe the error closure. + auto* visitor = AttachMockVisitor(wt); + // Section 3.3 MUST: Client closes with WT_ALPN_ERROR on mismatch. + session_->set_writev_consumes_all_data(true); + EXPECT_CALL(*visitor, OnSessionClosed( + static_cast( + quic::kWtAlpnError), + testing::_)) + .Times(1); + + quic::QuicStreamId stream_id = wt->id(); + // Server responds with WT-Protocol: "video-v1" (not in client's list). + ReceiveWebTransportDraft15Response(stream_id, 200, "video-v1"); + + // The session should reject the mismatched subprotocol. + EXPECT_FALSE(wt->ready()) + << "Session must not be ready when server selects a protocol " + "not in client's offered list"; + EXPECT_EQ(wt->GetNegotiatedSubprotocol(), std::nullopt) + << "Negotiated subprotocol must be nullopt on mismatch"; + testing::Mock::VerifyAndClearExpectations(visitor); +} + +TEST_P(HeadersDraft15ServerSessionTest, ServerSelectsFromClientList) { + // Section 3.3: Server receives WT-Available-Protocols and selects one. + // After creation, the server application sets the negotiated subprotocol. + if (!quic::VersionIsIetfQuic(GetParam().transport_version)) return; + + Initialize( + quic::WebTransportHttp3VersionSet( + {quic::WebTransportHttp3Version::kDraft15}), + quic::HttpDatagramSupport::kRfc); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(); + + // Create an incoming CONNECT stream with WT-Available-Protocols header. + quic::QuicStreamId session_id = GetNthClientInitiatedBidirectionalId(0); + quic::QuicStreamFrame frame(session_id, /*fin=*/false, /*offset=*/0, + absl::string_view()); + session_->OnStreamFrame(frame); + quic::QuicSpdyStream* connect_stream = + static_cast( + session_->GetOrCreateStream(session_id)); + ASSERT_NE(connect_stream, nullptr); + + quic::QuicHeaderList headers; + headers.OnHeader(":method", "CONNECT"); + headers.OnHeader(":protocol", "webtransport-h3"); + headers.OnHeader(":scheme", "https"); + headers.OnHeader(":authority", "test.example.com"); + headers.OnHeader(":path", "/wt"); + headers.OnHeader("wt-available-protocols", R"("chat-v1", "chat-v2")"); + connect_stream->OnStreamHeaderList(/*fin=*/false, 0, headers); + + quic::WebTransportHttp3* wt = + session_->GetWebTransportSession(session_id); + ASSERT_NE(wt, nullptr) << "Server draft-15 session could not be established"; + + // The server should see the client's offered subprotocols. + // The subprotocols_offered() field is populated from the + // WT-Available-Protocols header during stream processing. + EXPECT_THAT(wt->subprotocols_offered(), + ElementsAre("chat-v1", "chat-v2")) + << "Server should see client's offered subprotocols"; + + // Verify the server can select and report a negotiated subprotocol. + EXPECT_EQ(wt->GetNegotiatedSubprotocol(), std::nullopt) + << "Before server selects, negotiated subprotocol should be nullopt"; +} + +TEST_P(HeadersDraft15SessionTest, NoAlpnNegotiationWhenNotOffered) { + // Section 3.3: When the client does not send WT-Available-Protocols, + // the server does not send WT-Protocol, and the session establishes + // successfully without ALPN negotiation. + if (!quic::VersionIsIetfQuic(GetParam().transport_version)) return; + + Initialize( + quic::WebTransportHttp3VersionSet( + {quic::WebTransportHttp3Version::kDraft15}), + quic::HttpDatagramSupport::kRfc); + CompleteHandshake(); + ReceiveWebTransportDraft15Settings(); + + auto* wt = AttemptWebTransportDraft15ClientSession(); + ASSERT_NE(wt, nullptr) << "Draft-15 client session could not be created"; + + // Client does NOT set subprotocols_offered (empty list). + EXPECT_TRUE(wt->subprotocols_offered().empty()) + << "No subprotocols should be offered by default"; + + quic::QuicStreamId stream_id = wt->id(); + // Server responds with 200 OK, no WT-Protocol header. + ReceiveWebTransportDraft15Response(stream_id, 200); + + // Without ALPN negotiation, the session should become ready normally. + EXPECT_TRUE(wt->ready()) + << "Session should be ready when no ALPN negotiation is involved"; + EXPECT_EQ(wt->GetNegotiatedSubprotocol(), std::nullopt) + << "No negotiated subprotocol when none were offered"; +} + +} // namespace +} // namespace webtransport