Skip to content

fix(http): reject incomplete chunked transfers - #9

Merged
Sunrisepeak merged 2 commits into
masterfrom
fix/chunked-eof-metadata
Jul 11, 2026
Merged

fix(http): reject incomplete chunked transfers#9
Sunrisepeak merged 2 commits into
masterfrom
fix/chunked-eof-metadata

Conversation

@Sunrisepeak

Copy link
Copy Markdown
Member

Summary

  • distinguish a complete CRLF line from EOF/timeout in chunked downloads
  • reject empty, malformed, and overflowing chunk-size lines
  • require chunk delimiters and the terminal trailer terminator
  • expose expected byte count, final URL, ETag, and Last-Modified in download results
  • bump tinyhttps to 0.2.9 and refresh the mcpp bootstrap/lock configuration

Test plan

  • TDD RED: missing parser and metadata contract
  • mcpp test — 2 test binaries passed; 12 tests passed
  • existing real HTTPS download/redirect/progress tests passed
  • git diff --check

Refs openxlings/xlings#356

@Sunrisepeak
Sunrisepeak marked this pull request as ready for review July 11, 2026 18:16
@Sunrisepeak
Sunrisepeak merged commit 965d805 into master Jul 11, 2026
1 check passed
@Sunrisepeak
Sunrisepeak deleted the fix/chunked-eof-metadata branch July 11, 2026 18:16
Sunrisepeak added a commit that referenced this pull request Aug 29, 2026
…framing defects the review found (#14)

* fix(http): keep the error body of a failed streaming request

send() fills HttpResponse::body on every path including failures; send_stream()
was the one entry point that dropped it. A non-2xx answer to a streaming request
is an error document, not an event stream: SseParser finds no event boundary in
it, emits nothing, and the bytes stay in its private buffer. Callers were left
with a status line and no reason.

Capture the body when the status is not 2xx. Events are still parsed and
dispatched exactly as before, and nothing is copied on a 2xx stream, so the
success path is byte-identical.

The copy is bounded by stream_error_body_limit (1 MiB) so a server answering 5xx
with an endless body cannot grow the buffer without limit. Truncation lives in an
exported append_within_limit, in the same spirit as parse_chunk_size_line, with
three unit tests for under, across and past the limit; a live test against
httpbin's /status/418 covers the wiring.

* 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.

---------

Co-authored-by: Cloud_Yun <yunfeng66645@gmail.com>
注册 for free to join this conversation on GitHub. Already have an account? 登录 to comment

标签

None yet

项目

None yet

Development

Successfully merging this pull request may close these issues.

1 participant