From d2d72e3a57915d6ba211e431698c8ae7ecb76122 Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 05:55:43 +0700 Subject: [PATCH 1/8] feat: add chunked decoder interface --- src/http/chunked_decoder.hpp | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/http/chunked_decoder.hpp diff --git a/src/http/chunked_decoder.hpp b/src/http/chunked_decoder.hpp new file mode 100644 index 0000000..384de49 --- /dev/null +++ b/src/http/chunked_decoder.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include +#include + +#include + +namespace cpp_request::detail::http { + +enum class ChunkDecodeProgress { + NeedMore, + Complete +}; + +class ChunkedDecoder final { +public: + [[nodiscard]] Result process( + std::string& input, + std::string& output); + +private: + enum class Stage { + SizeLine, + Data, + DataCrlf, + Trailers, + Complete + }; + + Stage stage_{Stage::SizeLine}; + std::size_t chunk_remaining_{0}; +}; + +} // namespace cpp_request::detail::http From 705b413cb96b8ceea044e4dab8fda49a062bb7c5 Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 05:56:15 +0700 Subject: [PATCH 2/8] feat: implement incremental chunked decoder --- src/http/chunked_decoder.cpp | 243 +++++++++++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 src/http/chunked_decoder.cpp diff --git a/src/http/chunked_decoder.cpp b/src/http/chunked_decoder.cpp new file mode 100644 index 0000000..84ec173 --- /dev/null +++ b/src/http/chunked_decoder.cpp @@ -0,0 +1,243 @@ +#include "http/chunked_decoder.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +namespace cpp_request::detail::http { +namespace { + +[[nodiscard]] constexpr char ascii_lower(char ch) noexcept { + return ch >= 'A' && ch <= 'Z' + ? static_cast(ch + ('a' - 'A')) + : ch; +} + +[[nodiscard]] bool ascii_iequals( + std::string_view lhs, + std::string_view rhs) noexcept { + if (lhs.size() != rhs.size()) { + return false; + } + + for (std::size_t index = 0; index < lhs.size(); ++index) { + if (ascii_lower(lhs[index]) != ascii_lower(rhs[index])) { + return false; + } + } + return true; +} + +[[nodiscard]] constexpr bool is_tchar(unsigned char ch) noexcept { + if ((ch >= '0' && ch <= '9') + || (ch >= 'A' && ch <= 'Z') + || (ch >= 'a' && ch <= 'z')) { + return true; + } + + switch (ch) { + case '!': + case '#': + case '$': + case '%': + case '&': + case '\'': + case '*': + case '+': + case '-': + case '.': + case '^': + case '_': + case '`': + case '|': + case '~': + return true; + default: + return false; + } +} + +[[nodiscard]] bool valid_field_name(std::string_view name) noexcept { + if (name.empty()) { + return false; + } + + for (const unsigned char ch : name) { + if (!is_tchar(ch)) { + return false; + } + } + return true; +} + +[[nodiscard]] bool valid_field_value(std::string_view value) noexcept { + for (const unsigned char ch : value) { + if (ch == '\t') { + continue; + } + if (ch < 0x20 || ch == 0x7f) { + return false; + } + } + return true; +} + +[[nodiscard]] bool valid_chunk_extension(std::string_view extension) noexcept { + for (const unsigned char ch : extension) { + if (ch == '\t') { + continue; + } + if (ch < 0x20 || ch == 0x7f) { + return false; + } + } + return true; +} + +[[nodiscard]] Result parse_chunk_size(std::string_view line) noexcept { + const std::size_t semicolon = line.find(';'); + const std::string_view size_text = line.substr(0, semicolon); + if (size_text.empty()) { + return Error{ErrorCode::InvalidChunkSize}; + } + + if (semicolon != std::string_view::npos + && !valid_chunk_extension(line.substr(semicolon + 1))) { + return Error{ErrorCode::InvalidChunkFraming}; + } + + std::size_t value = 0; + const char* const first = size_text.data(); + const char* const last = size_text.data() + size_text.size(); + const auto parsed = std::from_chars(first, last, value, 16); + if (parsed.ec != std::errc{} || parsed.ptr != last) { + return Error{ErrorCode::InvalidChunkSize}; + } + return value; +} + +[[nodiscard]] bool valid_trailer_line(std::string_view line) noexcept { + if (line.empty() || line.front() == ' ' || line.front() == '\t') { + return false; + } + + const std::size_t colon = line.find(':'); + if (colon == std::string_view::npos || colon == 0) { + return false; + } + + const std::string_view name = line.substr(0, colon); + const std::string_view value = line.substr(colon + 1); + if (!valid_field_name(name) || !valid_field_value(value)) { + return false; + } + + return !ascii_iequals(name, "Content-Length") + && !ascii_iequals(name, "Transfer-Encoding"); +} + +void consume_prefix(std::string& input, std::size_t count) { + if (count != 0) { + input.erase(0, count); + } +} + +} // namespace + +Result ChunkedDecoder::process( + std::string& input, + std::string& output) { + std::size_t cursor = 0; + + while (true) { + switch (stage_) { + case Stage::SizeLine: { + const std::size_t line_end = input.find("\r\n", cursor); + if (line_end == std::string::npos) { + consume_prefix(input, cursor); + return ChunkDecodeProgress::NeedMore; + } + + const std::string_view line{ + input.data() + cursor, + line_end - cursor}; + auto size = parse_chunk_size(line); + if (!size) { + return size.error(); + } + + cursor = line_end + 2; + chunk_remaining_ = size.value(); + stage_ = chunk_remaining_ == 0 + ? Stage::Trailers + : Stage::Data; + continue; + } + + case Stage::Data: { + const std::size_t available = input.size() - cursor; + const std::size_t amount = std::min(chunk_remaining_, available); + if (amount != 0) { + output.append(input.data() + cursor, amount); + cursor += amount; + chunk_remaining_ -= amount; + } + + if (chunk_remaining_ != 0) { + consume_prefix(input, cursor); + return ChunkDecodeProgress::NeedMore; + } + + stage_ = Stage::DataCrlf; + continue; + } + + case Stage::DataCrlf: + if (input.size() - cursor < 2) { + consume_prefix(input, cursor); + return ChunkDecodeProgress::NeedMore; + } + if (input[cursor] != '\r' || input[cursor + 1] != '\n') { + return Error{ErrorCode::InvalidChunkFraming}; + } + cursor += 2; + stage_ = Stage::SizeLine; + continue; + + case Stage::Trailers: { + const std::size_t line_end = input.find("\r\n", cursor); + if (line_end == std::string::npos) { + consume_prefix(input, cursor); + return ChunkDecodeProgress::NeedMore; + } + + const std::string_view line{ + input.data() + cursor, + line_end - cursor}; + cursor = line_end + 2; + + if (line.empty()) { + stage_ = Stage::Complete; + consume_prefix(input, cursor); + return ChunkDecodeProgress::Complete; + } + + if (!valid_trailer_line(line)) { + return Error{ErrorCode::InvalidHeader}; + } + continue; + } + + case Stage::Complete: + consume_prefix(input, cursor); + return ChunkDecodeProgress::Complete; + } + } +} + +} // namespace cpp_request::detail::http From ee1c5b82fdebdffd13753a1c646660d02b641613 Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 05:56:26 +0700 Subject: [PATCH 3/8] feat: integrate chunked decoder state --- src/http/response_parser.hpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/http/response_parser.hpp b/src/http/response_parser.hpp index 33fe1cc..e0a15b3 100644 --- a/src/http/response_parser.hpp +++ b/src/http/response_parser.hpp @@ -1,5 +1,7 @@ #pragma once +#include "http/chunked_decoder.hpp" + #include #include #include @@ -12,8 +14,7 @@ namespace cpp_request::detail::http { enum class ResponseParseProgress { NeedMore, - Complete, - NeedsChunkedDecoder + Complete }; class ResponseParser final { @@ -45,6 +46,7 @@ class ResponseParser final { Stage stage_{Stage::Head}; Response response_; std::string buffer_; + ChunkedDecoder chunked_decoder_; std::size_t content_length_remaining_{0}; bool connection_close_requested_{false}; bool close_delimited_{false}; From 8bb17b60901d582d295b5184113766345ec4b7d6 Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 05:57:04 +0700 Subject: [PATCH 4/8] feat: decode chunked response bodies --- src/http/response_parser.cpp | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/http/response_parser.cpp b/src/http/response_parser.cpp index 4e5f17b..dd7a437 100644 --- a/src/http/response_parser.cpp +++ b/src/http/response_parser.cpp @@ -267,8 +267,7 @@ Result ResponseParser::process_buffer() { return parsed.error(); } - const std::size_t consumed = head_end + 4; - buffer_.erase(0, consumed); + buffer_.erase(0, head_end + 4); ParsedHead head = std::move(parsed).value(); if (is_interim_status(head.status_code)) { @@ -305,7 +304,7 @@ Result ResponseParser::process_buffer() { std::size_t token_begin = 0; while (token_begin <= field.value.size()) { const std::size_t comma = field.value.find(',', token_begin); - const std::size_t token_end = comma == std::string::npos + const std::size_t token_end = comma == std::string_view::npos ? field.value.size() : comma; const std::string_view token = trim_ows( @@ -319,7 +318,7 @@ Result ResponseParser::process_buffer() { if (!ascii_iequals(token, "chunked")) { transfer_encoding_is_chunked = false; } - if (comma == std::string::npos) { + if (comma == std::string_view::npos) { break; } token_begin = comma + 1; @@ -347,7 +346,7 @@ Result ResponseParser::process_buffer() { return Error{ErrorCode::MalformedResponse}; } stage_ = Stage::ChunkedBody; - return ResponseParseProgress::NeedsChunkedDecoder; + continue; } if (content_length_count == 1) { @@ -390,8 +389,17 @@ Result ResponseParser::process_buffer() { } return ResponseParseProgress::NeedMore; - case Stage::ChunkedBody: - return ResponseParseProgress::NeedsChunkedDecoder; + case Stage::ChunkedBody: { + auto decoded = chunked_decoder_.process(buffer_, response_.body_); + if (!decoded) { + return decoded.error(); + } + if (decoded.value() == ChunkDecodeProgress::NeedMore) { + return ResponseParseProgress::NeedMore; + } + stage_ = Stage::Complete; + return ResponseParseProgress::Complete; + } case Stage::Complete: return ResponseParseProgress::Complete; From 90ecf7a412721cc014c3d58e9ded23c986c1323e Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 05:57:18 +0700 Subject: [PATCH 5/8] build: compile chunked decoder --- src/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index fdec0d8..aa28d3e 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -3,6 +3,7 @@ add_library(internal ${CMAKE_CURRENT_SOURCE_DIR}/url.cpp ${CMAKE_CURRENT_SOURCE_DIR}/headers.cpp ${CMAKE_CURRENT_SOURCE_DIR}/http/request_serializer.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/http/chunked_decoder.cpp ${CMAKE_CURRENT_SOURCE_DIR}/http/response_parser.cpp ${CMAKE_CURRENT_SOURCE_DIR}/platform/native_socket.cpp ${CMAKE_CURRENT_SOURCE_DIR}/platform/network_runtime.cpp From 7ba3462e1471fb0f08c1e629afc46090b1756767 Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 05:57:36 +0700 Subject: [PATCH 6/8] test: cover chunked decoder --- tests/chunked_decoder_test.cpp | 133 +++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 tests/chunked_decoder_test.cpp diff --git a/tests/chunked_decoder_test.cpp b/tests/chunked_decoder_test.cpp new file mode 100644 index 0000000..7b9c90d --- /dev/null +++ b/tests/chunked_decoder_test.cpp @@ -0,0 +1,133 @@ +#include + +#include "http/chunked_decoder.hpp" + +#include + +#include +#include + +namespace { + +using cpp_request::ErrorCode; +using cpp_request::detail::http::ChunkDecodeProgress; +using cpp_request::detail::http::ChunkedDecoder; + +TEST(ChunkedDecoderTest, DecodesMultipleChunks) { + ChunkedDecoder decoder; + std::string input = + "4\r\nWiki\r\n" + "5\r\npedia\r\n" + "0\r\n\r\n"; + std::string output; + + auto result = decoder.process(input, output); + + ASSERT_TRUE(result); + EXPECT_EQ(result.value(), ChunkDecodeProgress::Complete); + EXPECT_EQ(output, "Wikipedia"); + EXPECT_TRUE(input.empty()); +} + +TEST(ChunkedDecoderTest, HandlesOneByteAtATime) { + ChunkedDecoder decoder; + std::string input; + std::string output; + const std::string wire = "3\r\nabc\r\n2\r\nde\r\n0\r\n\r\n"; + + ChunkDecodeProgress progress = ChunkDecodeProgress::NeedMore; + for (const char ch : wire) { + input.push_back(ch); + auto result = decoder.process(input, output); + ASSERT_TRUE(result); + progress = result.value(); + } + + EXPECT_EQ(progress, ChunkDecodeProgress::Complete); + EXPECT_EQ(output, "abcde"); + EXPECT_TRUE(input.empty()); +} + +TEST(ChunkedDecoderTest, ConsumesExtensionsAndTrailersAndPreservesFollowingBytes) { + ChunkedDecoder decoder; + std::string input = + "4;foo=bar\r\nWiki\r\n" + "0;end=yes\r\n" + "X-Checksum: abc123\r\n" + "X-Trace: done\r\n" + "\r\n" + "NEXT"; + std::string output; + + auto result = decoder.process(input, output); + + ASSERT_TRUE(result); + EXPECT_EQ(result.value(), ChunkDecodeProgress::Complete); + EXPECT_EQ(output, "Wiki"); + EXPECT_EQ(input, "NEXT"); +} + +TEST(ChunkedDecoderTest, RejectsInvalidChunkSize) { + ChunkedDecoder decoder; + std::string input = "xyz\r\n"; + std::string output; + + auto result = decoder.process(input, output); + + ASSERT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::InvalidChunkSize); +} + +TEST(ChunkedDecoderTest, RejectsMissingChunkDataCrlf) { + ChunkedDecoder decoder; + std::string input = "1\r\naXY"; + std::string output; + + auto result = decoder.process(input, output); + + ASSERT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::InvalidChunkFraming); +} + +TEST(ChunkedDecoderTest, RejectsMalformedTrailer) { + ChunkedDecoder decoder; + std::string input = + "0\r\n" + "Bad Trailer: value\r\n" + "\r\n"; + std::string output; + + auto result = decoder.process(input, output); + + ASSERT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::InvalidHeader); +} + +TEST(ChunkedDecoderTest, RejectsFramingFieldsInTrailers) { + ChunkedDecoder decoder; + std::string input = + "0\r\n" + "Content-Length: 4\r\n" + "\r\n"; + std::string output; + + auto result = decoder.process(input, output); + + ASSERT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::InvalidHeader); +} + +TEST(ChunkedDecoderTest, IncompleteChunkNeedsMoreData) { + ChunkedDecoder decoder; + std::string input = "5\r\nabc"; + std::string output; + + auto result = decoder.process(input, output); + + ASSERT_TRUE(result); + EXPECT_EQ(result.value(), ChunkDecodeProgress::NeedMore); + EXPECT_EQ(output, "abc"); + EXPECT_TRUE(input.empty()); +} + +} // namespace From d47229150d9ce6a66a486473256ad8a09d71217e Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 05:57:59 +0700 Subject: [PATCH 7/8] test: integrate chunked response decoding --- tests/response_parser_test.cpp | 64 +++++++++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/tests/response_parser_test.cpp b/tests/response_parser_test.cpp index 5ea10c9..342400c 100644 --- a/tests/response_parser_test.cpp +++ b/tests/response_parser_test.cpp @@ -114,19 +114,57 @@ TEST(ResponseParserTest, ConnectionClosePreventsReuseEvenWithContentLength) { EXPECT_FALSE(parser.connection_reusable()); } -TEST(ResponseParserTest, ChunkedResponseHandsOffWithoutConsumingBodyBytes) { +TEST(ResponseParserTest, DecodesChunkedResponseAndPreservesFollowingBytes) { + ResponseParser parser{Method::Get}; + + const std::string wire = + "HTTP/1.1 200 OK\r\n" + "Transfer-Encoding: chunked\r\n" + "\r\n" + "4\r\nWiki\r\n" + "5;part=two\r\npedia\r\n" + "0\r\n" + "X-Trailer: done\r\n" + "\r\n" + "HTTP/1.1 204 No Content\r\n\r\n"; + + for (const char ch : wire) { + if (parser.complete()) { + break; + } + auto result = parser.feed(std::string_view{&ch, 1}); + ASSERT_TRUE(result); + } + + ASSERT_TRUE(parser.complete()); + EXPECT_EQ(parser.response().body(), "Wikipedia"); + EXPECT_TRUE(parser.connection_reusable()); + + const std::size_t response_end = wire.find("HTTP/1.1 204 No Content"); + ASSERT_NE(response_end, std::string::npos); + if (parser.pending_bytes().empty()) { + auto tail = parser.feed(std::string_view{wire}.substr(response_end)); + ASSERT_TRUE(tail); + } +} + +TEST(ResponseParserTest, ChunkedResponsePreservesAlreadyBufferedFollowingResponse) { ResponseParser parser{Method::Get}; auto result = parser.feed( "HTTP/1.1 200 OK\r\n" "Transfer-Encoding: chunked\r\n" "\r\n" - "4\r\nWiki\r\n0\r\n\r\n"); + "4\r\nWiki\r\n" + "0\r\n\r\n" + "HTTP/1.1 204 No Content\r\n\r\n"); ASSERT_TRUE(result); - EXPECT_EQ(result.value(), ResponseParseProgress::NeedsChunkedDecoder); - EXPECT_FALSE(parser.complete()); - EXPECT_EQ(parser.pending_bytes(), "4\r\nWiki\r\n0\r\n\r\n"); + EXPECT_EQ(result.value(), ResponseParseProgress::Complete); + EXPECT_EQ(parser.response().body(), "Wiki"); + EXPECT_EQ( + parser.pending_bytes(), + "HTTP/1.1 204 No Content\r\n\r\n"); } TEST(ResponseParserTest, RejectsTransferEncodingAndContentLengthTogether) { @@ -238,4 +276,20 @@ TEST(ResponseParserTest, PrematureEofForContentLengthIsError) { EXPECT_EQ(eof.error().code, ErrorCode::UnexpectedEof); } +TEST(ResponseParserTest, PrematureEofForChunkedBodyIsError) { + ResponseParser parser{Method::Get}; + + auto result = parser.feed( + "HTTP/1.1 200 OK\r\n" + "Transfer-Encoding: chunked\r\n" + "\r\n" + "5\r\nabc"); + ASSERT_TRUE(result); + EXPECT_EQ(result.value(), ResponseParseProgress::NeedMore); + + auto eof = parser.finish_eof(); + ASSERT_FALSE(eof); + EXPECT_EQ(eof.error().code, ErrorCode::UnexpectedEof); +} + } // namespace From 364345a15f7b97c73a3a41058e3b5e6f37739531 Mon Sep 17 00:00:00 2001 From: Rafi Indra Pramudhito Zuhayr Date: Tue, 15 Sep 2026 05:58:08 +0700 Subject: [PATCH 8/8] test: wire chunked decoder coverage --- tests/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a541067..0e15c25 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -6,6 +6,7 @@ add_executable(cpp_request_tests ${CMAKE_CURRENT_SOURCE_DIR}/headers_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/request_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/request_serializer_test.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/chunked_decoder_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/response_parser_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/response_upgrade_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/native_socket_test.cpp