Skip to main content

toolkit_contract/runtime/
multipart.rs

1//! `multipart/mixed` reader used by streaming clients.
2//!
3//! Translates a byte stream into a stream of typed items, one JSON item per
4//! body part. This is streaming *response* framing only — `multipart/form-data`
5//! requests, mixed per-part content types and `Content-Disposition` are all out
6//! of scope, and every part is assumed to carry a JSON document decodable as
7//! `T`.
8//!
9//! ### Framing is length-driven, not parse-driven
10//!
11//! A part's extent is decided by its MIME headers, never by where its JSON
12//! payload happens to become syntactically complete. Two modes, in this
13//! precedence:
14//!
15//! 1. **`Content-Length: N` present** — consume exactly `N` body bytes, decode,
16//!    then expect the delimiter. Exact, one part per part, no lookahead, so a
17//!    part is yielded the instant its own bytes have arrived — the reader never
18//!    runs a part behind a live stream.
19//! 2. **Absent** — strict RFC 2046 delimiter scan. Correct, but a part is only
20//!    emitted once the *next* delimiter (or the close delimiter) arrives, so a
21//!    live producer that writes part `N+1`'s delimiter only when item `N+1`
22//!    exists leaves the reader one part behind.
23//!
24//! `Content-Length` on a body part is unusual but well-formed: RFC 2046 §5.1
25//! makes the *delimiter* the authoritative boundary mechanism, so a strict
26//! parser that ignores the header still reads the stream correctly. The header
27//! is a fast path, not a protocol fork.
28//!
29//! ### A missing close delimiter is a truncation
30//!
31//! A complete multipart body ends with the `--<boundary>--` close delimiter
32//! (RFC 2046). A graceful end of the byte stream that did **not** see it is a
33//! truncation — a proxy idle-timeout, an LB half-close, or a rolling deploy
34//! that closed the connection mid-body — and the streaming client surfaces it
35//! as a [`TransportError::Framing`] end-of-stream error, exactly as the SSE
36//! path errors on a close without a terminal `event: done`.
37//!
38//! This is reported by the transport rather than left to the consumer because
39//! on the public path the consumer *cannot* detect it: `send_streaming` /
40//! `open_streaming` return a boxed stream that erases the concrete
41//! [`MultipartStream`], so [`MultipartStream::saw_close_delimiter`] is
42//! unreachable there, and a generic item type carries no terminal marker of its
43//! own. (`saw_close_delimiter` remains available to a caller holding the
44//! concrete stream, as diagnostic detail about the framing.) A partially
45//! buffered part at end of stream is discarded rather than surfaced, mirroring
46//! how the SSE parser discards an unterminated trailing event.
47//!
48//! An *aborted* body — the peer's byte stream erroring, which is what a
49//! truncated chunked encoding looks like — is a different thing and is
50//! surfaced as [`TransportError::Network`]. That is reserved for a genuine
51//! transport fault (a server item that would not serialize at all).
52//!
53//! A post-open **domain** failure is not an abort: the framer sends it as a
54//! typed **error part** — one `application/problem+json` part whose body is an
55//! RFC 9457 [`Problem`] — followed by the close delimiter. This reader decodes
56//! such a part into [`TransportError::Problem`] (which the generated client
57//! recovers as a typed `CanonicalError`), so a mid-stream domain error arrives
58//! as a *typed* `Err` item, not a truncation. An error part is **terminal**: the
59//! reader stops after it and treats it as a clean end, so a non-conforming peer
60//! cannot smuggle further data items past a reported error.
61//!
62//! Accumulated buffers are bounded by [`MAX_ACCUMULATED_BYTES`] for the same
63//! reason the SSE parser bounds its own: otherwise a peer that streams an
64//! unterminated construct grows the buffer without limit for the lifetime of a
65//! self-healing, indefinitely-reconnecting client.
66
67use std::collections::VecDeque;
68use std::pin::Pin;
69use std::task::{Context, Poll};
70
71use bytes::{Buf, Bytes, BytesMut};
72use futures_core::Stream;
73use serde::de::DeserializeOwned;
74
75use toolkit_canonical_errors::Problem;
76
77use crate::ir::binding::StreamFraming;
78use crate::runtime::sse::StreamActivity;
79use crate::runtime::transport_error::TransportError;
80
81/// Maximum bytes the reader accumulates for a single not-yet-complete
82/// construct — the preamble, a part's header block, a length-less part body, or
83/// a delimiter — before treating the peer as protocol-violating and
84/// terminating the stream with [`TransportError::Framing`]. Same value and same
85/// rationale as the SSE parser's own guard.
86pub const MAX_ACCUMULATED_BYTES: usize = 16 * 1024 * 1024;
87
88const CRLF: &[u8] = b"\r\n";
89const CLOSE_MARKER: &[u8] = b"--";
90const CONTENT_LENGTH: &[u8] = b"content-length";
91const CONTENT_TYPE: &[u8] = b"content-type";
92/// Media type marking a part as a typed **error part**: its body is an RFC 9457
93/// [`Problem`], not a `T`. The server framer emits exactly this token (see
94/// `toolkit::http::multipart`); matched case-insensitively and ignoring any
95/// `;`-parameters.
96const PROBLEM_MEDIA_TYPE: &[u8] = b"application/problem+json";
97
98/// Adapter that lifts a `Display`-only error into an
99/// `Error + Send + Sync + 'static` so it can be boxed into
100/// [`TransportError::Network`] without losing the original message.
101///
102/// Twin of the same adapter in [`crate::runtime::sse`]; duplicated rather than
103/// shared because the SSE parser is deliberately not touched by this work.
104#[derive(Debug)]
105struct DisplayError(String);
106impl std::fmt::Display for DisplayError {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        f.write_str(&self.0)
109    }
110}
111impl std::error::Error for DisplayError {}
112
113/// A framing error message that keeps its underlying cause reachable via
114/// [`Error::source`], so a caller can see the `Utf8Error`/`ParseIntError`
115/// behind a parse failure rather than a flattened string (#4740). `Display` is
116/// the human message; `source()` is the real error.
117#[derive(Debug)]
118struct SourcedError {
119    message: String,
120    source: Box<dyn std::error::Error + Send + Sync + 'static>,
121}
122impl std::fmt::Display for SourcedError {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        f.write_str(&self.message)
125    }
126}
127impl std::error::Error for SourcedError {
128    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
129        Some(&*self.source)
130    }
131}
132
133/// Extract the `boundary` parameter from a `Content-Type` header value.
134///
135/// The media type is matched case-insensitively and the boundary may be quoted
136/// (`boundary="abc"`) or bare (`boundary=abc`). Parameters are split on `;`,
137/// which is safe because RFC 2046 excludes `;` from the legal boundary
138/// characters, so no legal boundary can contain one even when quoted.
139///
140/// # Errors
141/// [`TransportError::Framing`] when the media type is not `multipart/mixed`,
142/// when no `boundary` parameter is present, or when the parameter is empty or
143/// an unterminated quoted string.
144pub fn boundary_from_content_type(value: &str) -> Result<String, TransportError> {
145    let mut params = value.split(';');
146    let media_type = params.next().unwrap_or_default().trim();
147    if !media_type.eq_ignore_ascii_case(StreamFraming::MultipartMixed.media_type()) {
148        return Err(framing_error(format!(
149            "expected media type `{}`, got `{media_type}`",
150            StreamFraming::MultipartMixed.media_type()
151        )));
152    }
153
154    for param in params {
155        let Some((name, raw)) = param.split_once('=') else {
156            continue;
157        };
158        if !name.trim().eq_ignore_ascii_case("boundary") {
159            continue;
160        }
161        let raw = raw.trim();
162        let boundary = if let Some(inner) = raw.strip_prefix('"') {
163            inner.strip_suffix('"').ok_or_else(|| {
164                framing_error(format!(
165                    "`boundary` parameter is not a closed quoted string: `{raw}`"
166                ))
167            })?
168        } else {
169            raw
170        };
171        if boundary.is_empty() {
172            return Err(framing_error("`boundary` parameter is empty"));
173        }
174        return Ok(boundary.to_owned());
175    }
176
177    Err(framing_error(format!(
178        "`{}` response carries no `boundary` parameter: `{value}`",
179        StreamFraming::MultipartMixed.media_type()
180    )))
181}
182
183fn framing_error(message: impl Into<String>) -> TransportError {
184    TransportError::framing(StreamFraming::MultipartMixed, DisplayError(message.into()))
185}
186
187/// A framing error whose `source()` is `source` and whose `Display` is
188/// `message`, so the underlying parse error stays on the chain (#4740).
189fn framing_error_sourced(
190    message: impl Into<String>,
191    source: impl std::error::Error + Send + Sync + 'static,
192) -> TransportError {
193    TransportError::framing(
194        StreamFraming::MultipartMixed,
195        SourcedError {
196            message: message.into(),
197            source: Box::new(source),
198        },
199    )
200}
201
202/// Render an untrusted header value for an error message: truncate to a short
203/// fixed length and escape control characters, so a hostile peer cannot inject
204/// bare CR/LF or up to `MAX_ACCUMULATED_BYTES` of arbitrary bytes into a string
205/// that is then propagated and logged (#4740).
206fn display_value(text: &str) -> String {
207    const MAX_CHARS: usize = 64;
208    let mut out = String::new();
209    let mut truncated = false;
210    for (i, ch) in text.chars().enumerate() {
211        if i >= MAX_CHARS {
212            truncated = true;
213            break;
214        }
215        if ch.is_control() {
216            out.extend(ch.escape_default());
217        } else {
218            out.push(ch);
219        }
220    }
221    if truncated {
222        out.push_str("...");
223    }
224    out
225}
226
227/// Parse a `multipart/mixed` byte stream into a stream of typed items.
228///
229/// `body` is typically the byte-stream view of
230/// `toolkit_http::HttpResponse::into_body()` (adapted via
231/// [`crate::runtime::http::body_to_byte_stream`]); `boundary` is the value
232/// returned by [`boundary_from_content_type`] — the bare token, with no
233/// leading dashes. Errors from the inner stream are surfaced as
234/// [`TransportError::Network`].
235pub fn parse_multipart_stream<T, S, E>(body: S, boundary: &str) -> MultipartStream<T, S>
236where
237    S: Stream<Item = Result<Bytes, E>> + Unpin + 'static,
238    E: std::fmt::Display,
239    T: DeserializeOwned + 'static,
240{
241    let mut dash_boundary = Vec::with_capacity(CLOSE_MARKER.len() + boundary.len());
242    dash_boundary.extend_from_slice(CLOSE_MARKER);
243    dash_boundary.extend_from_slice(boundary.as_bytes());
244
245    let mut crlf_dash_boundary = Vec::with_capacity(CRLF.len() + dash_boundary.len());
246    crlf_dash_boundary.extend_from_slice(CRLF);
247    crlf_dash_boundary.extend_from_slice(&dash_boundary);
248
249    MultipartStream {
250        inner: body,
251        buf: BytesMut::with_capacity(4 * 1024),
252        scan_from: 0,
253        state: State::Preamble,
254        pending: VecDeque::new(),
255        dash_boundary,
256        crlf_dash_boundary,
257        deferred_decode_error: None,
258        done: false,
259        saw_close: false,
260        activity: StreamActivity::new(),
261        _marker: std::marker::PhantomData,
262    }
263}
264
265/// What a part's `Content-Type` says its body is, and therefore how the reader
266/// decodes it: an ordinary data item (`T`) or a typed error part (a [`Problem`],
267/// `application/problem+json`). Selected in [`MultipartStream::step_headers`] and
268/// carried through the body states.
269#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
270enum PartKind {
271    /// A normal `application/json` data part; the body decodes as `T`.
272    #[default]
273    Data,
274    /// An `application/problem+json` error part; the body decodes as a
275    /// [`Problem`] and is surfaced as a *terminal* typed `Err` item.
276    Error,
277}
278
279/// Where the reader is in the `multipart/mixed` grammar.
280///
281/// The plan's single `Delimiter` state is realised as the pair
282/// `Delimiter` (expects the `CRLF--<boundary>` token that closes a part) and
283/// `BoundaryTail` (having consumed a boundary token, decides between `CRLF`
284/// for another part and `--` for the close delimiter). Splitting them lets
285/// `Preamble` reuse the tail logic, since the very first delimiter has no
286/// leading `CRLF`.
287#[derive(Debug, Clone, Copy)]
288enum State {
289    /// Discard bytes until the first `--<boundary>`.
290    Preamble,
291    /// A boundary token has just been consumed: `CRLF` starts another part,
292    /// `--` is the close delimiter.
293    BoundaryTail,
294    /// Accumulating a part's header block, up to its terminating empty line.
295    Headers {
296        /// `Content-Length` seen so far for this part, if any.
297        content_length: Option<usize>,
298        /// Header-block bytes consumed so far, for the accumulation guard —
299        /// header lines leave `buf` as they are parsed, so `buf.len()` alone
300        /// would not bound a peer streaming an endless run of tiny headers.
301        consumed: usize,
302        /// What this part's `Content-Type` has said so far.
303        kind: PartKind,
304    },
305    /// Reading a part body. `Some(n)` is the exact `Content-Length` byte count;
306    /// `None` means scan for the next delimiter.
307    Body {
308        /// Exact remaining body length, when the part declared one.
309        remaining: Option<usize>,
310        /// Carried from [`State::Headers`]: how to decode this body.
311        kind: PartKind,
312    },
313    /// Expecting the `CRLF--<boundary>` token that closes the part just read.
314    Delimiter,
315    /// Terminal.
316    Closed,
317}
318
319/// Outcome of one step of the state machine.
320#[derive(Debug, Clone, Copy)]
321enum Step {
322    /// Progress was made — step again.
323    Progress,
324    /// Not enough buffered bytes to make progress — poll the wire.
325    NeedMore,
326    /// Terminal state reached; stop stepping.
327    Stop,
328}
329
330/// Stream yielded by [`parse_multipart_stream`].
331pub struct MultipartStream<T, S> {
332    inner: S,
333    buf: BytesMut,
334    /// Prefix length of `buf`, in bytes, already confirmed to contain no match
335    /// for whatever the current state is scanning for. Lets [`find_from`]
336    /// resume instead of rescanning the whole buffer on every poll — without
337    /// this, a single long construct delivered over many small chunks costs
338    /// `O(total_length²)` instead of `O(total_length)`. Reset to 0 whenever
339    /// bytes are consumed or the state changes what it scans for.
340    scan_from: usize,
341    state: State,
342    pending: VecDeque<Result<T, TransportError>>,
343    /// `--<boundary>`, precomputed.
344    dash_boundary: Vec<u8>,
345    /// `CRLF--<boundary>`, precomputed.
346    crlf_dash_boundary: Vec<u8>,
347    /// A sized part's body that would not decode, held until the bytes that
348    /// follow reveal *why*. A well-framed part with a malformed payload is a
349    /// [`TransportError::Serialization`] fault; a part whose declared length
350    /// disagrees with its delimiter is a [`TransportError::Framing`] fault —
351    /// and from the body bytes alone the two are indistinguishable, because a
352    /// wrong length hands the decoder the wrong bytes. Deciding at the
353    /// delimiter keeps the *successful* decode on the fast path, which is what
354    /// the latency property depends on: a part that decodes is yielded from
355    /// its own bytes, and only a part that does not wait for its delimiter.
356    ///
357    /// Only ever set while in [`State::Delimiter`].
358    deferred_decode_error: Option<serde_json::Error>,
359    done: bool,
360    saw_close: bool,
361    activity: StreamActivity,
362    _marker: std::marker::PhantomData<fn() -> T>,
363}
364
365impl<T, S> MultipartStream<T, S> {
366    /// Returns a clone of the shared wire-activity counter, bumped on every
367    /// wire chunk — including chunks that dispatch no item, such as a part
368    /// header block arriving on its own. The streaming client snapshots it
369    /// around an idle-timeout wait so the idle deadline stays *idle* rather
370    /// than merely *quiet*. Mirrors
371    /// [`crate::runtime::sse::SseStream::activity_handle`].
372    #[must_use]
373    pub fn activity_handle(&self) -> StreamActivity {
374        self.activity.clone()
375    }
376
377    /// Whether the stream reached a graceful end — the peer sent the closing
378    /// `--<boundary>--` delimiter, **or** a terminal error part ended it (an
379    /// error part is a complete terminator, so it counts as a clean end).
380    ///
381    /// Analogous to [`crate::runtime::sse::SseStream::saw_done_event`]: the
382    /// streaming client treats a graceful end *without* either as a truncation
383    /// and surfaces a [`TransportError::Framing`] error (see the module docs).
384    /// This accessor exposes the same framing fact to a caller holding the
385    /// concrete stream as diagnostic detail. Only meaningful once the stream has
386    /// yielded `None`.
387    #[must_use]
388    pub fn saw_close_delimiter(&self) -> bool {
389        self.saw_close
390    }
391}
392
393// `inner` is bounded by `Unpin` at construction; the rest of the fields are
394// trivially `Unpin`. Implement `Unpin` unconditionally so callers can poll
395// `Pin<&mut MultipartStream<...>>` without pinning the type itself.
396impl<T, S: Unpin> Unpin for MultipartStream<T, S> {}
397
398impl<T, S> MultipartStream<T, S>
399where
400    T: DeserializeOwned + 'static,
401{
402    /// Run the state machine over the currently buffered bytes, stopping as
403    /// soon as an item is ready.
404    ///
405    /// Stopping at the first `pending` item bounds the work done in one
406    /// `poll_next`: one wire chunk carrying many parts no longer runs every
407    /// `serde_json::from_slice` before yielding, and no longer pushes every
408    /// decoded item into the unbounded `pending` queue at once. The remaining
409    /// buffered bytes are drained on the next poll (`poll_next` drains before it
410    /// polls the wire), so no chunk is needed to make progress.
411    fn drain_buffer(&mut self) {
412        while self.pending.is_empty() && matches!(self.step(), Step::Progress) {}
413        if matches!(self.state, State::Closed) {
414            self.done = true;
415        }
416    }
417
418    fn step(&mut self) -> Step {
419        match self.state {
420            State::Preamble => self.step_preamble(),
421            State::BoundaryTail => self.step_boundary_tail(),
422            State::Headers {
423                content_length,
424                consumed,
425                kind,
426            } => self.step_headers(content_length, consumed, kind),
427            State::Body { remaining, kind } => match remaining {
428                Some(n) => self.step_sized_body(n, kind),
429                None => self.step_scanned_body(kind),
430            },
431            State::Delimiter => self.step_delimiter(),
432            State::Closed => Step::Stop,
433        }
434    }
435
436    fn step_preamble(&mut self) -> Step {
437        let mut scan = self.scan_from;
438        let hit = find_from(&self.buf, &self.dash_boundary, &mut scan);
439        self.scan_from = scan;
440        match hit {
441            Some(at) => {
442                self.consume(at + self.dash_boundary.len());
443                self.state = State::BoundaryTail;
444                Step::Progress
445            }
446            None => self.need_more("multipart preamble"),
447        }
448    }
449
450    fn step_boundary_tail(&mut self) -> Step {
451        if self.buf.len() >= CLOSE_MARKER.len() {
452            if self.buf.starts_with(CLOSE_MARKER) {
453                self.consume(CLOSE_MARKER.len());
454                self.saw_close = true;
455                self.state = State::Closed;
456                return Step::Stop;
457            }
458        } else if CLOSE_MARKER.starts_with(&self.buf) {
459            // Could still turn into the close delimiter once more bytes land.
460            return self.need_more("multipart boundary delimiter");
461        }
462
463        let mut scan = self.scan_from;
464        let hit = find_from(&self.buf, CRLF, &mut scan);
465        self.scan_from = scan;
466        match hit {
467            Some(at) => {
468                // RFC 2046 permits transport padding (linear whitespace)
469                // between the boundary and its CRLF; anything else means the
470                // boundary token was a prefix of some longer token.
471                if self.buf[..at].iter().any(|b| !matches!(b, b' ' | b'\t')) {
472                    return self.fail("boundary delimiter is followed by neither `CRLF` nor `--`");
473                }
474                self.consume(at + CRLF.len());
475                self.state = State::Headers {
476                    content_length: None,
477                    consumed: 0,
478                    kind: PartKind::Data,
479                };
480                Step::Progress
481            }
482            None => self.need_more("multipart boundary delimiter"),
483        }
484    }
485
486    fn step_headers(
487        &mut self,
488        content_length: Option<usize>,
489        consumed: usize,
490        kind: PartKind,
491    ) -> Step {
492        let mut scan = self.scan_from;
493        let hit = find_from(&self.buf, CRLF, &mut scan);
494        self.scan_from = scan;
495        let Some(at) = hit else {
496            return self.need_more("multipart part headers");
497        };
498
499        let line = self.buf.split_to(at + CRLF.len());
500        self.scan_from = 0;
501        let consumed = consumed + line.len();
502
503        // An empty line terminates the header block. A part with no headers at
504        // all is legal MIME and lands here on the first iteration.
505        if at == 0 {
506            self.state = State::Body {
507                remaining: content_length,
508                kind,
509            };
510            return Step::Progress;
511        }
512
513        if consumed > MAX_ACCUMULATED_BYTES {
514            return self.fail(format!(
515                "multipart part headers exceed maximum accumulated size ({MAX_ACCUMULATED_BYTES} bytes); aborting stream"
516            ));
517        }
518
519        // `Content-Length` sizes the body; `Content-Type` is read only to spot a
520        // typed error part (`application/problem+json`). Every other part header
521        // is deliberately ignored.
522        let content_length = match header_value(&line[..at], CONTENT_LENGTH) {
523            None => content_length,
524            Some(raw) => match parse_content_length(raw) {
525                Ok(n) => Some(n),
526                Err(err) => return self.fail_with(err),
527            },
528        };
529        let kind = if is_problem_content_type(header_value(&line[..at], CONTENT_TYPE)) {
530            PartKind::Error
531        } else {
532            kind
533        };
534
535        self.state = State::Headers {
536            content_length,
537            consumed,
538            kind,
539        };
540        Step::Progress
541    }
542
543    fn step_sized_body(&mut self, len: usize, kind: PartKind) -> Step {
544        if self.buf.len() < len {
545            return self.need_more("multipart part body");
546        }
547        let body = self.buf.split_to(len);
548        self.scan_from = 0;
549        self.state = State::Delimiter;
550        // The delimiter has not been proven yet, so a decode failure here may
551        // be the length's fault rather than the payload's — defer it.
552        match Self::decode_body(&body, kind) {
553            Ok(item) => {
554                self.pending.push_back(item);
555                self.finish_if_error(kind);
556            }
557            Err(e) => self.deferred_decode_error = Some(e),
558        }
559        Step::Progress
560    }
561
562    fn step_scanned_body(&mut self, kind: PartKind) -> Step {
563        let mut scan = self.scan_from;
564        let hit = find_from(&self.buf, &self.crlf_dash_boundary, &mut scan);
565        self.scan_from = scan;
566        match hit {
567            Some(at) => {
568                let body = self.buf.split_to(at);
569                self.scan_from = 0;
570                // `buf` now starts with the delimiter the scan found, so the
571                // body's extent is already authoritative and a decode failure
572                // can only be the payload's fault.
573                self.state = State::Delimiter;
574                self.decode_now(&body, kind)
575            }
576            None => self.need_more("multipart part body"),
577        }
578    }
579
580    /// An error part is **terminal** (#4740 F5): once a typed error item is
581    /// queued, close the stream so a non-conforming peer's parts after it are
582    /// never surfaced as data. The framer emits the close delimiter after an
583    /// error part anyway, but the reader must not rely on the peer to stop.
584    ///
585    /// The error part is itself a graceful, complete terminator, so it counts as
586    /// a clean end (`saw_close`) — otherwise the streaming client would treat the
587    /// unread close delimiter as a truncation and append a spurious framing error
588    /// after the typed one.
589    fn finish_if_error(&mut self, kind: PartKind) {
590        if kind == PartKind::Error {
591            self.state = State::Closed;
592            self.saw_close = true;
593        }
594    }
595
596    fn step_delimiter(&mut self) -> Step {
597        // RFC 2046 makes the CRLF part of the delimiter, so it is normally
598        // present; tolerate a producer that omits it before the close
599        // delimiter, since the boundary token alone is unambiguous.
600        let skip = if self.buf.starts_with(CRLF) {
601            CRLF.len()
602        } else if self.buf.len() < CRLF.len() && CRLF.starts_with(&self.buf) {
603            return self.need_more("multipart part delimiter");
604        } else {
605            0
606        };
607
608        let outcome = {
609            let avail = &self.buf[skip..];
610            if avail.len() < self.dash_boundary.len() {
611                if self.dash_boundary.starts_with(avail) {
612                    DelimiterCheck::NeedMore
613                } else {
614                    DelimiterCheck::Overrun
615                }
616            } else if avail.starts_with(&self.dash_boundary) {
617                DelimiterCheck::Matched
618            } else {
619                DelimiterCheck::Overrun
620            }
621        };
622
623        match outcome {
624            DelimiterCheck::NeedMore => self.need_more("multipart part delimiter"),
625            // The bytes right after the part body are not a delimiter, so the
626            // part's declared length did not describe the part: either it ran
627            // short (leaving payload bytes here) or it ran long (having eaten
628            // into the delimiter). Either way the framing is unusable.
629            DelimiterCheck::Overrun => {
630                // The length is the root cause and subsumes any deferred
631                // decode failure it caused, so drop that and report the
632                // framing fault.
633                self.deferred_decode_error = None;
634                self.fail(
635                    "part `Content-Length` does not agree with the multipart delimiter that follows it",
636                )
637            }
638            DelimiterCheck::Matched => {
639                // The part was framed correctly after all, so a held decode
640                // failure really was the payload's fault.
641                if let Some(e) = self.deferred_decode_error.take() {
642                    return self.fail_serialization(e);
643                }
644                self.consume(skip + self.dash_boundary.len());
645                self.state = State::BoundaryTail;
646                Step::Progress
647            }
648        }
649    }
650
651    /// Decode a part body whose extent is already proven by its delimiter.
652    ///
653    /// A malformed part terminates the stream: unlike a named SSE event, a
654    /// part is unambiguously the typed data channel, so a body that will not
655    /// decode is real data loss and must not be silently dropped.
656    fn decode_now(&mut self, body: &[u8], kind: PartKind) -> Step {
657        match Self::decode_body(body, kind) {
658            Ok(item) => {
659                self.pending.push_back(item);
660                self.finish_if_error(kind);
661                Step::Progress
662            }
663            Err(e) => self.fail_serialization(e),
664        }
665    }
666
667    /// Decode one part body into a pending item. A [`PartKind::Data`] part
668    /// decodes as `T` and yields `Ok(item)`; a [`PartKind::Error`] part decodes
669    /// as an RFC 9457 [`Problem`] and yields `Err(TransportError::Problem { .. })`.
670    ///
671    /// The `Err` variant is a *decoded item*, not a stream fault; the caller
672    /// pushes it and then closes the stream (`finish_if_error`), because an error
673    /// part is terminal. Only a body that will not decode at all is a
674    /// `serde_json::Error` here — a genuine serialization fault the caller
675    /// surfaces via `fail_serialization` (directly, or deferred to the delimiter
676    /// for a sized body).
677    fn decode_body(
678        body: &[u8],
679        kind: PartKind,
680    ) -> Result<Result<T, TransportError>, serde_json::Error> {
681        match kind {
682            PartKind::Error => {
683                let problem = serde_json::from_slice::<Problem>(body)?;
684                Ok(Err(TransportError::problem(problem)))
685            }
686            PartKind::Data => Ok(Ok(serde_json::from_slice::<T>(body)?)),
687        }
688    }
689
690    fn fail_serialization(&mut self, e: serde_json::Error) -> Step {
691        self.state = State::Closed;
692        self.pending
693            .push_back(Err(TransportError::serialization(e)));
694        Step::Stop
695    }
696
697    fn consume(&mut self, n: usize) {
698        self.buf.advance(n);
699        self.scan_from = 0;
700    }
701
702    /// Not enough bytes to complete the current construct. Trips the
703    /// accumulation guard first, so a peer that streams unbounded bytes
704    /// without ever completing one terminates the stream instead of growing
705    /// `buf` without limit.
706    fn need_more(&mut self, what: &str) -> Step {
707        if self.buf.len() > MAX_ACCUMULATED_BYTES {
708            return self.fail(format!(
709                "{what} exceeds maximum accumulated size ({MAX_ACCUMULATED_BYTES} bytes); aborting stream"
710            ));
711        }
712        Step::NeedMore
713    }
714
715    fn fail(&mut self, message: impl Into<String>) -> Step {
716        self.fail_with(framing_error(message))
717    }
718
719    /// Like [`fail`](Self::fail) but for an already-built error, so a caller
720    /// that constructed a sourced framing error keeps its `source()` chain.
721    fn fail_with(&mut self, err: TransportError) -> Step {
722        self.state = State::Closed;
723        self.pending.push_back(Err(err));
724        Step::Stop
725    }
726}
727
728/// Result of matching the buffered bytes against a part delimiter.
729#[derive(Debug, Clone, Copy)]
730enum DelimiterCheck {
731    Matched,
732    NeedMore,
733    Overrun,
734}
735
736impl<T, S, E> Stream for MultipartStream<T, S>
737where
738    T: DeserializeOwned + 'static,
739    S: Stream<Item = Result<Bytes, E>> + Unpin + 'static,
740    E: std::fmt::Display,
741{
742    type Item = Result<T, TransportError>;
743
744    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
745        let this = self.get_mut();
746
747        loop {
748            if let Some(item) = this.pending.pop_front() {
749                return Poll::Ready(Some(item));
750            }
751            if this.done {
752                return Poll::Ready(None);
753            }
754
755            // Drain what is already buffered before touching the wire: a
756            // previous poll may have left decoded-but-unyielded parts in the
757            // buffer (drain_buffer stops at the first item). Only poll the wire
758            // when the buffer can produce nothing more on its own.
759            this.drain_buffer();
760            if !this.pending.is_empty() || this.done {
761                continue;
762            }
763
764            match Pin::new(&mut this.inner).poll_next(cx) {
765                Poll::Pending => return Poll::Pending,
766                Poll::Ready(None) => {
767                    // A graceful end of the byte stream is a clean end of this
768                    // stream, with or without the closing `--<boundary>--`.
769                    // Whatever partial construct is buffered is discarded
770                    // rather than surfaced as a truncation error — see the
771                    // module docs on why completion detection is the
772                    // consumer's, and why an *aborted* body (the `Err` arm
773                    // below) is loud instead.
774                    this.done = true;
775                    this.state = State::Closed;
776                    this.buf.clear();
777                    // A held decode failure is the one thing a graceful EOF
778                    // does not excuse: the part's `Content-Length` said its
779                    // bytes were all there, and they were, and they did not
780                    // decode. That is data loss rather than truncation, so it
781                    // is surfaced even though the stream is ending cleanly.
782                    if let Some(e) = this.deferred_decode_error.take() {
783                        this.pending
784                            .push_back(Err(TransportError::serialization(e)));
785                    }
786                }
787                Poll::Ready(Some(Err(e))) => {
788                    this.done = true;
789                    this.state = State::Closed;
790                    // `E: Display` only — wrap in a small Display->Error
791                    // adapter so the source chain stays intact through
792                    // `TransportError::network`.
793                    return Poll::Ready(Some(Err(TransportError::network(DisplayError(
794                        e.to_string(),
795                    )))));
796                }
797                Poll::Ready(Some(Ok(chunk))) => {
798                    // Any wire chunk counts as activity — even one carrying
799                    // only a part header block — so the idle timeout in the
800                    // streaming driver can tell "quiet but alive" from "truly
801                    // idle". The new bytes are drained at the top of the next
802                    // loop iteration, before the wire is polled again.
803                    this.activity.bump();
804                    this.buf.extend_from_slice(&chunk);
805                }
806            }
807        }
808    }
809}
810
811/// Find `needle` in `haystack`, resuming from `*scan_from` — the length of a
812/// prefix already confirmed to start no match.
813///
814/// On a miss, `*scan_from` advances to `haystack.len()`, so the next call
815/// rescans only the unavoidable `needle.len() - 1` bytes of overlap plus
816/// whatever was appended since. On a hit it is left at the match offset; the
817/// caller consumes bytes and resets it.
818fn find_from(haystack: &[u8], needle: &[u8], scan_from: &mut usize) -> Option<usize> {
819    let start = scan_from.saturating_sub(needle.len().saturating_sub(1));
820    if needle.is_empty() || haystack.len() < needle.len() {
821        *scan_from = haystack.len();
822        return None;
823    }
824    for at in start..=(haystack.len() - needle.len()) {
825        if haystack[at..].starts_with(needle) {
826            *scan_from = at;
827            return Some(at);
828        }
829    }
830    *scan_from = haystack.len();
831    None
832}
833
834/// Value of the header `name` (given lowercase) on `line`, or `None` when the
835/// line is a different header. `line` excludes its terminating `CRLF`.
836fn header_value<'l>(line: &'l [u8], name: &[u8]) -> Option<&'l [u8]> {
837    let at = line.iter().position(|b| *b == b':')?;
838    let (candidate, rest) = line.split_at(at);
839    if candidate.len() != name.len()
840        || !candidate
841            .iter()
842            .zip(name)
843            .all(|(a, b)| a.to_ascii_lowercase() == *b)
844    {
845        return None;
846    }
847    Some(rest[1..].trim_ascii())
848}
849
850/// Whether a part's `Content-Type` header value marks it a typed error part,
851/// i.e. its media type is `application/problem+json`. `None` (no `Content-Type`
852/// on this line) is not a match. Parameters after `;` are ignored and the media
853/// type is compared case-insensitively, per RFC 7231.
854fn is_problem_content_type(value: Option<&[u8]>) -> bool {
855    let Some(value) = value else { return false };
856    let media_type = match value.iter().position(|b| *b == b';') {
857        Some(at) => &value[..at],
858        None => value,
859    }
860    .trim_ascii();
861    media_type.eq_ignore_ascii_case(PROBLEM_MEDIA_TYPE)
862}
863
864/// Parse a part's `Content-Length`, rejecting a value the accumulation guard
865/// would not let us buffer anyway. `Err` is the framing error, carrying the
866/// underlying `Utf8Error`/`ParseIntError` as its `source()` and echoing the
867/// offending value only after truncating and escaping it.
868fn parse_content_length(raw: &[u8]) -> Result<usize, TransportError> {
869    let text = std::str::from_utf8(raw)
870        .map_err(|e| framing_error_sourced("part `Content-Length` is not valid UTF-8", e))?;
871    let len: usize = text.parse().map_err(|e| {
872        framing_error_sourced(
873            format!(
874                "part `Content-Length` is not a byte count: `{}`",
875                display_value(text)
876            ),
877            e,
878        )
879    })?;
880    if len > MAX_ACCUMULATED_BYTES {
881        return Err(framing_error(format!(
882            "part `Content-Length` of {len} exceeds maximum accumulated size ({MAX_ACCUMULATED_BYTES} bytes)"
883        )));
884    }
885    Ok(len)
886}
887
888#[cfg(test)]
889#[cfg_attr(coverage_nightly, coverage(off))]
890#[allow(clippy::unwrap_used)]
891#[path = "multipart_tests.rs"]
892mod tests;