Skip to content

Commit dd1df30

Browse files
committed
Honour a declared Content-Length in send_stream, and reject a chunk size rather than salvaging it
Review of the change this branch already carries. The defect it reports is real and the fix is placed correctly --- `dispatch` is the single funnel for every body byte on both framing paths, and `captureBody` is decided after the headers are read, where the status is final. Measured against master, one program, one source file: master status=418 events=0 body.size()=0 this status=418 events=0 body.size()=135 What follows is what that fix could not do on its own. --- 1. A DECLARED LENGTH, WHICH IS WHY THE TEST HAD TO CLOSE THE CONNECTION ---- `send_stream` had no branch for `Content-Length`: a response that was not chunked was read until the connection closed, whatever its headers said. On this library's own defaults --- `keepAlive = true`, so the request carries `Connection: keep-alive` --- the server does not close, and the read loop ran until `readTimeoutMs` expired. Measured against httpbin's `/status/418`: keepAlive = false status=418 body=135 elapsed 1370 ms keepAlive = true status=418 body=135 elapsed 9379 ms (timeout 8000) The error body arrived either way, and on the defaults it arrived a full read timeout late --- sixty seconds, as the defaults stand. `send()` has had this branch throughout, which is the same asymmetry between the two entry points that this branch exists to remove. The live test set `keepAlive = false`, "so the server closes and the read loop ends". That comment was the defect, and the test was examining the one arrangement in which it does not appear. It now runs on the defaults and asserts the elapsed time. after: keepAlive = true status=418 body=135 elapsed 1192 ms --- 2. A CHUNK SIZE THAT DOES NOT PARSE IS NOT A TERMINAL CHUNK --------------- `parse_hex` returns what it accumulated when it meets a character it does not recognise, and zero for an empty line --- and `read_line` returns an empty line on a timeout or a closed connection. So a stream that was cut short read as a stream that ended cleanly and this loop reported success. #9 established `parse_chunk_size_line` for exactly this and it reached `download_to_file` alone; `send` and `send_stream` were left on the old one. --- 3. Content-Length WAS PARSED BY KEEPING THE DIGITS ------------------------ Measured, by compiling that parser on its own: "135" -> 135 "abc" -> 0 <- a refusal read as a real zero "12abc" -> 12 <- stops twelve bytes in "-1" -> 1 <- the sign is discarded "99999999999999999999" -> 7766279631452241919 <- wraps, in silence The last two are the ones no care at the call site could recover from, because what it receives is a plausible number. `parse_content_length` is exported and shaped like `parse_chunk_size_line`, for the reason #9 gave: it is the half of the body framing that can be examined without a server. Both readers use it. --- criteria ----------------------------------------------------------------- Six unit tests over the two pure parsers, and two live ones: the failed stream now runs on the DEFAULT configuration with the elapsed time asserted, and a chunked 2xx is asserted to leave `body` empty and to return promptly --- the success path is the one this change restructured around, so it is observed rather than assumed. 17 tests from 6 suites pass, plus 3 in test_resolver.
1 parent f99fe73 commit dd1df30

2 files changed

Lines changed: 191 additions & 9 deletions

File tree

src/http.cppm

Lines changed: 96 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,37 @@ static int parse_hex(std::string_view s) {
242242
return result;
243243
}
244244

245+
// `Content-Length`, rejected rather than salvaged.
246+
//
247+
// Both readers parsed this by walking the characters and keeping the digits, so
248+
// `12abc` was 12, `abc` was 0 — indistinguishable from a genuine `0` — and a
249+
// value past the width of the accumulator wrapped in silence. A length is the
250+
// number of bytes the reader will then trust, so a wrong one is not a cosmetic
251+
// error: too small leaves the next response's bytes in the stream, and too
252+
// large waits for bytes that are not coming.
253+
//
254+
// Shaped like `parse_chunk_size_line` above, and exported for the same reason:
255+
// it is the half of the body framing that can be examined without a server.
256+
export std::optional<std::int64_t>
257+
parse_content_length(std::string_view value) {
258+
// A field value may carry optional whitespace on either side (RFC 9110).
259+
while (!value.empty() && (value.front() == ' ' || value.front() == '\t'))
260+
value.remove_prefix(1);
261+
while (!value.empty() && (value.back() == ' ' || value.back() == '\t'))
262+
value.remove_suffix(1);
263+
if (value.empty()) return std::nullopt;
264+
265+
std::uint64_t parsed {};
266+
auto [end, error] = std::from_chars(
267+
value.data(), value.data() + value.size(), parsed, 10);
268+
if (error != std::errc{} || end != value.data() + value.size()
269+
|| parsed > static_cast<std::uint64_t>(
270+
std::numeric_limits<std::int64_t>::max())) {
271+
return std::nullopt;
272+
}
273+
return static_cast<std::int64_t>(parsed);
274+
}
275+
245276
export std::optional<std::int64_t>
246277
parse_chunk_size_line(std::string_view line) {
247278
if (line.empty()) return std::nullopt;
@@ -478,12 +509,11 @@ private:
478509
chunked = true;
479510
}
480511
if (iequals(key, "Content-Length")) {
481-
contentLength = 0;
482-
for (char c : valStr) {
483-
if (c >= '0' && c <= '9') {
484-
contentLength = contentLength * 10 + (c - '0');
485-
}
486-
}
512+
// Rejected rather than salvaged; parse_content_length says
513+
// why. A malformed value leaves this at -1, which is the
514+
// same state as an absent header and is a framing this
515+
// reader already handles.
516+
contentLength = parse_content_length(valStr).value_or(-1);
487517
}
488518
if (iequals(key, "Connection") && iequals(valStr, "close")) {
489519
connectionClose = true;
@@ -723,6 +753,7 @@ public:
723753
// Read headers
724754
bool chunked = false;
725755
bool connectionClose = false;
756+
std::int64_t contentLength = -1;
726757

727758
while (true) {
728759
std::string headerLine = read_line(*sock, config_.readTimeoutMs);
@@ -745,6 +776,12 @@ public:
745776
if (iequals(key, "Connection") && iequals(valStr, "close")) {
746777
connectionClose = true;
747778
}
779+
// send() reads this and send_stream() did not, which is the
780+
// same asymmetry this change exists to remove. See the body
781+
// loop below for what its absence cost.
782+
if (iequals(key, "Content-Length")) {
783+
contentLength = parse_content_length(valStr).value_or(-1);
784+
}
748785
}
749786
}
750787

@@ -782,7 +819,22 @@ public:
782819
sizeLine.pop_back();
783820
}
784821

785-
int chunkSize = parse_hex(sizeLine);
822+
// A CHUNK HEADER THAT DOES NOT PARSE IS NOT A TERMINAL CHUNK.
823+
//
824+
// `parse_hex` returned what it had accumulated when it met a
825+
// character it did not recognise, and zero for an empty line —
826+
// and `read_line` returns an empty line on a timeout or a
827+
// closed connection. So a stream that was cut short read as a
828+
// stream that ended cleanly, and this loop reported success.
829+
// #9 established `parse_chunk_size_line` for exactly this and
830+
// it reached only `download_to_file`.
831+
auto parsedChunkSize = parse_chunk_size_line(sizeLine);
832+
if (!parsedChunkSize) {
833+
response.statusText = "Invalid chunk size: " + sizeLine;
834+
connectionClose = true;
835+
break;
836+
}
837+
int chunkSize = static_cast<int>(*parsedChunkSize);
786838
if (chunkSize == 0) {
787839
// Terminal chunk — read trailing \r\n
788840
read_line(*sock, config_.readTimeoutMs);
@@ -801,8 +853,44 @@ public:
801853
break;
802854
}
803855
}
856+
} else if (contentLength >= 0) {
857+
// A DECLARED LENGTH IS READ AND THE READER THEN STOPS.
858+
//
859+
// This branch did not exist: a response that was not chunked was
860+
// read until the connection closed, whatever its headers said. On
861+
// the library's own defaults — `keepAlive = true`, so the request
862+
// carries `Connection: keep-alive` — the server does not close,
863+
// and the loop below ran until `readTimeoutMs` expired.
864+
//
865+
// Measured against httpbin's `/status/418`, which answers with a
866+
// Content-Length and keeps the connection:
867+
//
868+
// keepAlive = false status=418 body=135 elapsed 1370 ms
869+
// keepAlive = true status=418 body=135 elapsed 9379 ms (readTimeoutMs = 8000)
870+
//
871+
// The error body arrived either way, and on the defaults it arrived
872+
// a full read timeout late — sixty seconds, as the defaults stand.
873+
// `send()` has had this branch throughout, which is why the same
874+
// request through it returns at once.
875+
std::int64_t remaining = contentLength;
876+
char buf[4096];
877+
while (!stopped && remaining > 0) {
878+
if (!sock->wait_readable(config_.readTimeoutMs)) {
879+
break;
880+
}
881+
const auto want = static_cast<std::size_t>(
882+
remaining < static_cast<std::int64_t>(sizeof buf)
883+
? remaining : static_cast<std::int64_t>(sizeof buf));
884+
int ret = sock->read(buf, want);
885+
if (ret <= 0) break;
886+
remaining -= ret;
887+
if (!dispatch(std::string_view(buf, static_cast<std::size_t>(ret)))) {
888+
break;
889+
}
890+
}
804891
} else {
805-
// Not chunked — read until connection closes
892+
// Neither chunked nor a declared length: the end of the body is the
893+
// end of the connection, so the connection must not be reused.
806894
connectionClose = true;
807895
char buf[4096];
808896
while (!stopped) {

tests/test_download.cpp

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,52 @@ TEST(ChunkedProtocol, AcceptsValidSizeAndTerminalChunk) {
2020
EXPECT_EQ(*https::parse_chunk_size_line("0"), 0);
2121
}
2222

23+
// `Content-Length` decides how many bytes a reader will trust, so a wrong
24+
// answer is not cosmetic: too small leaves the next response's bytes in the
25+
// stream and too large waits for bytes that are not coming.
26+
//
27+
// Both readers used to keep the digits and discard everything else. Measured,
28+
// by compiling that parser on its own:
29+
//
30+
// "135" -> 135
31+
// "0" -> 0
32+
// "abc" -> 0 <- a refusal read as a real zero
33+
// "12abc" -> 12 <- stops twelve bytes in
34+
// "" -> 0
35+
// "-1" -> 1 <- the sign is discarded
36+
// "99999999999999999999" -> 7766279631452241919 <- wraps, in silence
37+
//
38+
// The last two are the ones no amount of care at the call site could recover
39+
// from, because the value it receives is a plausible number.
40+
TEST(ContentLength, AcceptsAWellFormedValue) {
41+
ASSERT_TRUE(https::parse_content_length("135").has_value());
42+
EXPECT_EQ(*https::parse_content_length("135"), 135);
43+
// A field value may carry optional whitespace on either side.
44+
ASSERT_TRUE(https::parse_content_length(" 135\t").has_value());
45+
EXPECT_EQ(*https::parse_content_length(" 135\t"), 135);
46+
}
47+
48+
// The one a salvaging parser cannot express. `abc` used to yield 0, which is
49+
// indistinguishable from a server that genuinely declared an empty body — and
50+
// the two call for opposite behaviour.
51+
TEST(ContentLength, AZeroIsDistinguishableFromARefusal) {
52+
ASSERT_TRUE(https::parse_content_length("0").has_value());
53+
EXPECT_EQ(*https::parse_content_length("0"), 0);
54+
EXPECT_FALSE(https::parse_content_length("abc").has_value());
55+
}
56+
57+
TEST(ContentLength, RejectsEmptyTrailingGarbageAndOverflow) {
58+
EXPECT_FALSE(https::parse_content_length("").has_value());
59+
EXPECT_FALSE(https::parse_content_length(" ").has_value());
60+
// `12abc` used to be 12: the reader would then stop twelve bytes in and
61+
// leave the rest of the body to be read as the next response.
62+
EXPECT_FALSE(https::parse_content_length("12abc").has_value());
63+
EXPECT_FALSE(https::parse_content_length("-1").has_value());
64+
EXPECT_FALSE(https::parse_content_length("+1").has_value());
65+
// Past the width of the accumulator, which used to wrap in silence.
66+
EXPECT_FALSE(https::parse_content_length("99999999999999999999").has_value());
67+
}
68+
2369
TEST(StreamErrorBody, KeepsEverythingWhileUnderLimit) {
2470
std::string buffer;
2571
EXPECT_TRUE(https::append_within_limit(buffer, "abc", 8));
@@ -68,27 +114,75 @@ class StreamErrorBodyLiveTest : public ::testing::Test {
68114
void SetUp() override { https::Socket::platform_init(); }
69115
};
70116

117+
// ON THE LIBRARY'S OWN DEFAULTS, AND THE READ TIMEOUT IS PART OF THE
118+
// OBSERVATION RATHER THAN A SAFETY NET.
119+
//
120+
// The first form of this test set `keepAlive = false`, "so the server closes
121+
// and the read loop ends". That comment was the defect: `send_stream` had no
122+
// branch for a declared `Content-Length`, so a response that was not chunked
123+
// was read until the connection closed — and on the defaults the server does
124+
// not close. The body arrived, one full `readTimeoutMs` late.
125+
//
126+
// keepAlive = false status=418 body=135 elapsed 1370 ms
127+
// keepAlive = true status=418 body=135 elapsed 9379 ms (timeout 8000)
128+
//
129+
// So the configuration under test is the default one, and the elapsed time is
130+
// asserted. A test that closed the connection to make the loop end would be
131+
// examining the one arrangement in which the defect does not appear.
71132
TEST_F(StreamErrorBodyLiveTest, FailedStreamKeepsTheErrorBody) {
72133
https::HttpClientConfig cfg;
73134
cfg.connectTimeoutMs = 15000;
74135
cfg.readTimeoutMs = 30000;
75-
cfg.keepAlive = false; // so the server closes and the read loop ends
136+
// keepAlive is left at its default, which is true.
76137
https::HttpClient client(cfg);
77138

78139
https::HttpRequest req;
79140
req.method = https::Method::GET;
80141
req.url = "https://httpbin.org/status/418";
81142

82143
int events = 0;
144+
const auto started = std::chrono::steady_clock::now();
83145
auto res = client.send_stream(req, [&](const https::SseEvent&) {
84146
++events;
85147
return true;
86148
});
149+
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
150+
std::chrono::steady_clock::now() - started).count();
87151

88152
EXPECT_EQ(res.statusCode, 418);
89153
EXPECT_EQ(events, 0) << "an error document is not an event stream";
90154
EXPECT_FALSE(res.body.empty()) << "error body was dropped";
91155
EXPECT_NE(res.body.find("teapot"), std::string::npos);
156+
// Generous against a slow runner and still an order of magnitude below the
157+
// read timeout, which is what the defect consumed.
158+
EXPECT_LT(elapsed, 15000)
159+
<< "the body arrived after " << elapsed
160+
<< " ms; a declared Content-Length was not honoured and the reader "
161+
"waited for a close that keep-alive was never going to bring";
162+
}
163+
164+
// The success path, on the framing an event stream actually uses. A 2xx is not
165+
// captured, so `body` stays empty and the bytes reach the parser — and it must
166+
// still return promptly, since the chunked branch is the one this change did
167+
// not restructure.
168+
TEST_F(StreamErrorBodyLiveTest, AChunkedSuccessIsUnchangedAndPrompt) {
169+
https::HttpClientConfig cfg;
170+
cfg.connectTimeoutMs = 15000;
171+
cfg.readTimeoutMs = 30000;
172+
https::HttpClient client(cfg);
173+
174+
https::HttpRequest req;
175+
req.method = https::Method::GET;
176+
req.url = "https://httpbin.org/stream/3";
177+
178+
const auto started = std::chrono::steady_clock::now();
179+
auto res = client.send_stream(req, [](const https::SseEvent&) { return true; });
180+
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
181+
std::chrono::steady_clock::now() - started).count();
182+
183+
EXPECT_EQ(res.statusCode, 200);
184+
EXPECT_TRUE(res.body.empty()) << "a 2xx body is not captured; it is the caller's stream";
185+
EXPECT_LT(elapsed, 15000) << "the chunked reader did not terminate promptly";
92186
}
93187

94188
// Test download_to_file against a real HTTPS endpoint.

0 commit comments

Comments
 (0)