Skip to main content

base_http1/
input.rs

1// Released under the MIT License.
2// Copyright, 2026, by Samuel Williams.
3
4use crate::{Error, Options, wire};
5use base_endpoint::stream::ReadableStream;
6use base_http::{
7    Fields, Method, Request, RequestMetadata, Version,
8    body::{BodyFrame, ReadableBody},
9};
10use base_scheduler::Cancellation;
11use std::{
12    io,
13    sync::{
14        Arc,
15        atomic::{AtomicBool, Ordering},
16    },
17};
18
19#[derive(Default)]
20struct ExchangeState {
21    upgrade: Option<Box<[u8]>>,
22    trailer_options: Vec<Vec<u8>>,
23    complete: AtomicBool,
24    tunnel: AtomicBool,
25    abandoned: AtomicBool,
26}
27
28#[derive(Clone, Copy)]
29struct ExchangeFacts {
30    version: Version,
31    head: bool,
32    connect: bool,
33    persistent: bool,
34    expect_continue: bool,
35}
36
37/// Captured framing facts and, when input can still change, shared state. A successful switch changes incoming body reads to raw data without releasing transport.
38#[derive(Clone)]
39pub struct Exchange {
40    facts: ExchangeFacts,
41    state: Option<Arc<ExchangeState>>,
42}
43
44pub(crate) fn request_persistent(head: &RequestMetadata) -> bool {
45    head.persistent != Some(false)
46        && !wire::has_token(&head.fields, "connection", b"close")
47        && (head.version.unwrap_or(Version::Http11) == Version::Http11
48            || head.persistent == Some(true)
49            || wire::has_token(&head.fields, "connection", b"keep-alive"))
50}
51
52impl Exchange {
53    pub(crate) fn new(head: &RequestMetadata, trailer_options: Vec<Vec<u8>>) -> Self {
54        // Retain only protocol decisions. Ordinary fields and target strings belong solely to the application request; mutations cannot change already established framing. Trailer exclusions retain only the bytes needed for later protocol processing. The client selects upgrades from its own request, so its exchange retains no upgrade offers.
55        Self {
56            facts: ExchangeFacts {
57                version: head.version.unwrap_or(Version::Http11),
58                head: head.method == Method::HEAD,
59                connect: head.method == Method::CONNECT,
60                persistent: request_persistent(head),
61                expect_continue: wire::has_token(&head.fields, "expect", b"100-continue"),
62            },
63            // Client response completion depends on incoming response framing,
64            // independently of the matching outgoing request's body length.
65            state: Some(Arc::new(ExchangeState {
66                trailer_options,
67                ..Default::default()
68            })),
69        }
70    }
71    fn from_request(head: &RequestMetadata, framing: wire::RequestFraming) -> Self {
72        let facts = ExchangeFacts {
73            version: head.version.unwrap_or(Version::Http11),
74            head: head.method == Method::HEAD,
75            connect: head.method == Method::CONNECT,
76            persistent: framing.persistent,
77            expect_continue: framing.expect_continue,
78        };
79        // Completed ordinary server input cannot become active or abandoned.
80        // CONNECT and offered upgrades can become raw even when initially empty.
81        let mutable = facts.connect
82            || framing.upgrade.is_some()
83            || framing.chunked
84            || framing.length.is_some_and(|n| n != 0);
85        Self {
86            facts,
87            state: mutable.then(|| {
88                Arc::new(ExchangeState {
89                    upgrade: framing.upgrade,
90                    trailer_options: framing.trailer_options,
91                    ..Default::default()
92                })
93            }),
94        }
95    }
96
97    fn filter_trailers(&self, fields: &mut Fields) {
98        let state = self
99            .state
100            .as_ref()
101            .expect("chunked input retains shared state");
102        fields.retain(|field| {
103            !state
104                .trailer_options
105                .iter()
106                .any(|option| field.name().as_bytes().eq_ignore_ascii_case(option))
107        });
108    }
109
110    pub fn version(&self) -> Version {
111        self.facts.version
112    }
113    pub fn is_head(&self) -> bool {
114        self.facts.head
115    }
116    pub fn is_connect(&self) -> bool {
117        self.facts.connect
118    }
119    pub fn expects_continue(&self) -> bool {
120        self.facts.expect_continue
121    }
122    pub(crate) fn has_upgrade(&self) -> bool {
123        self.state
124            .as_ref()
125            .is_some_and(|state| state.upgrade.is_some())
126    }
127    pub(crate) fn accepts_upgrade(&self, protocol: &[u8]) -> bool {
128        self.state
129            .as_ref()
130            .and_then(|state| state.upgrade.as_ref())
131            .is_some_and(|offers| {
132                offers
133                    .split(|byte| *byte == b',')
134                    .any(|offer| wire::trim(offer).eq_ignore_ascii_case(protocol))
135            })
136    }
137    pub fn is_complete(&self) -> bool {
138        self.state
139            .as_ref()
140            .is_none_or(|state| state.complete.load(Ordering::Acquire))
141    }
142    pub fn is_tunnel(&self) -> bool {
143        self.state
144            .as_ref()
145            .is_some_and(|state| state.tunnel.load(Ordering::Acquire))
146    }
147    /// Whether the application discarded input before reaching its boundary.
148    pub fn is_abandoned(&self) -> bool {
149        self.state
150            .as_ref()
151            .is_some_and(|state| state.abandoned.load(Ordering::Acquire))
152    }
153    pub fn is_persistent(&self) -> bool {
154        self.facts.persistent
155    }
156    pub(crate) fn switch(&self) {
157        self.state
158            .as_ref()
159            .expect("switches retain shared state")
160            .tunnel
161            .store(true, Ordering::Release);
162    }
163    fn set_complete(&self, complete: bool) {
164        if let Some(state) = &self.state {
165            state.complete.store(complete, Ordering::Release);
166        }
167    }
168    fn abandon(&self) {
169        // Input without shared state is complete from the start, so it is never abandoned.
170        self.state
171            .as_ref()
172            .expect("incomplete input retains shared state")
173            .abandoned
174            .store(true, Ordering::Release);
175    }
176}
177
178/// Sequential request admission. Input read-ahead survives body completion and
179/// protocol switches. Failed reads and dropped head reads prohibit further
180/// admission; a dropped or cancelled body read loses no input.
181pub struct RequestReader<R> {
182    reader: R,
183    buffer: Vec<u8>,
184    start: usize,
185    end: usize,
186    pub(crate) options: Options,
187    pub(crate) poisoned: bool,
188    pub(crate) previous: Option<Exchange>,
189}
190
191impl<R: ReadableStream> RequestReader<R> {
192    pub fn new(reader: R, options: Options) -> Result<Self, Error> {
193        options.validate()?;
194        Ok(Self {
195            reader,
196            buffer: vec![0; options.read_size],
197            start: 0,
198            end: 0,
199            options,
200            poisoned: false,
201            previous: None,
202        })
203    }
204
205    /// Whether a started head read failed or was dropped, a body read failed other than by cancellation, or the input was closed. The input position is then unknown and the input cannot be reused. A dropped or cancelled body read, or a body left unread, does not poison input, so after a request is admitted this reports a failed read, usually of a malformed or truncated request.
206    pub fn is_poisoned(&self) -> bool {
207        self.poisoned
208    }
209
210    pub fn is_reusable(&self) -> bool {
211        !self.poisoned
212            && self
213                .previous
214                .as_ref()
215                .is_none_or(|x| x.is_complete() && !x.is_tunnel() && !x.is_abandoned())
216    }
217
218    fn available(&self) -> &[u8] {
219        &self.buffer[self.start..self.end]
220    }
221
222    async fn fill(&mut self, cancellation: &Cancellation) -> Result<bool, Error> {
223        let spare = self.spare();
224        let n = self
225            .reader
226            .read_into(&mut self.buffer[spare], cancellation)
227            .await?;
228        self.end += n;
229        Ok(n != 0)
230    }
231
232    // Compact unread input to the front and return the initialized spare
233    // storage for one read. Only a successful read count advances `end`.
234    fn spare(&mut self) -> std::ops::Range<usize> {
235        if self.start != 0 {
236            self.buffer.copy_within(self.start..self.end, 0);
237            self.end -= self.start;
238            self.start = 0;
239        }
240        // Neither the buffer nor its initial `read_size` allocation can exceed `isize::MAX`, so this cannot overflow.
241        let limit = self.end + self.options.read_size;
242        if self.buffer.len() < limit {
243            // Keep initialized spare capacity across reads. A pending/cancelled
244            // read never publishes it: only a successful count advances end.
245            self.buffer.resize(limit, 0);
246        }
247        self.end..limit
248    }
249
250    /// Whether idle input can still begin a message. Read-ahead, or an
251    /// immediate read that yields data, EOF or an error, means the peer sent
252    /// or closed outside an exchange, and prohibits admission. The immediate
253    /// read reaches the transport, so it observes a close already queued
254    /// there; `WouldBlock` loses no input, so input stays usable. Input
255    /// that has not yet completed an exchange is not read: probing a fresh
256    /// connection could retire every replacement for a peer that writes or
257    /// closes on accept, without bound.
258    pub(crate) fn probe_idle(&mut self) -> bool {
259        if !self.is_reusable() {
260            return false;
261        }
262        if self.previous.is_none() {
263            return true;
264        }
265        if self.available().is_empty() {
266            let spare = self.spare();
267            match self.reader.try_read_into(&mut self.buffer[spare]) {
268                Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => return true,
269                Ok(n) => self.end += n,
270                Err(_) => {}
271            }
272        }
273        self.poisoned = true;
274        false
275    }
276
277    fn take(&mut self, n: usize) -> Vec<u8> {
278        let data = self.available()[..n].to_vec();
279        self.start += n;
280        data
281    }
282
283    /// Parse a response head or trailer section with `parse`, which returns
284    /// the section's length once it is complete. As for requests, the first
285    /// read is parsed directly, and an incomplete section is parsed again only
286    /// once its end arrives. `Ok(None)` means the input ended cleanly first.
287    pub(crate) async fn read_section<T>(
288        &mut self,
289        cancellation: &Cancellation,
290        limit: &'static str,
291        parse: impl Fn(&[u8]) -> Result<Option<(T, usize)>, Error>,
292    ) -> Result<Option<T>, Error> {
293        let max_head_bytes = self.options.max_head_bytes;
294        let mut scanned = None;
295        loop {
296            let available = self.available();
297            let window = &available[..available.len().min(max_head_bytes)];
298            // A trailer section can be only the empty line.
299            let terminated = window.starts_with(b"\r\n")
300                || scanned.is_none_or(|scanned: usize| {
301                    window[scanned..].windows(4).any(|w| w == b"\r\n\r\n")
302                });
303            if terminated && !window.is_empty() {
304                if let Some((section, n)) = parse(window)? {
305                    self.start += n;
306                    return Ok(Some(section));
307                }
308            }
309            if available.len() >= max_head_bytes {
310                return Err(Error::Limit(limit));
311            }
312            if !available.is_empty() {
313                scanned = Some(available.len().saturating_sub(3));
314            }
315            if !self.fill(cancellation).await? {
316                if self.available().is_empty() {
317                    return Ok(None);
318                }
319                return Err(Error::Truncated);
320            }
321        }
322    }
323
324    /// Clean EOF between requests returns `None`; partial heads are errors.
325    pub async fn read_request(
326        &mut self,
327        cancellation: &Cancellation,
328    ) -> Result<Option<(Request<IncomingBody<'_, R>>, Exchange)>, Error> {
329        cancellation.check()?;
330        if !self.is_reusable() {
331            return Err(Error::Invalid("request input is not reusable"));
332        }
333        // Parse the first input available, which is usually a complete head.
334        // A head that is still incomplete is parsed again only once its
335        // terminator arrives, so fragmented input costs linear work. The loop stays in this function so the parsed metadata
336        // is not moved through another future's output.
337        self.poisoned = true;
338        let max_head_bytes = self.options.max_head_bytes;
339        let mut scanned = None;
340        let (head, facts) = loop {
341            let available = self.available();
342            let window = &available[..available.len().min(max_head_bytes)];
343            let terminated = scanned.is_none_or(|scanned: usize| {
344                window[scanned..].windows(4).any(|w| w == b"\r\n\r\n")
345            });
346            if terminated && !window.is_empty() {
347                if let Some((head, facts, n)) = wire::request(window, self.options.max_fields)? {
348                    self.start += n;
349                    break (head, facts);
350                }
351            }
352            if available.len() >= max_head_bytes {
353                return Err(Error::Limit("message head"));
354            }
355            if !available.is_empty() {
356                scanned = Some(available.len().saturating_sub(3));
357            }
358            if !self.fill(cancellation).await? {
359                if self.available().is_empty() {
360                    self.poisoned = false;
361                    return Ok(None);
362                }
363                return Err(Error::Truncated);
364            }
365        };
366        let length = facts.length;
367        let chunked = facts.chunked;
368        if chunked && head.version != Some(Version::Http11) {
369            return Err(Error::Invalid("HTTP/1.0 transfer-encoding"));
370        }
371        let framing = if chunked {
372            Framing::CHUNKED
373        } else {
374            Framing::Fixed(length.unwrap_or(0))
375        };
376        let exchange = Exchange::from_request(&head, facts);
377        self.previous = Some(exchange.clone());
378        self.poisoned = false;
379        let body = IncomingBody::new(
380            self,
381            exchange.clone(),
382            framing,
383            if chunked {
384                None
385            } else {
386                Some(length.unwrap_or(0))
387            },
388            false,
389            false,
390        );
391        Ok(Some((Request::from_parts(head, body), exchange)))
392    }
393
394    /// Establish backend retirement even after cancellation or parse failure.
395    pub async fn close(&mut self) -> io::Result<()> {
396        self.poisoned = true;
397        self.reader.close().await
398    }
399
400    async fn line(&mut self, limit: usize, cancellation: &Cancellation) -> Result<Vec<u8>, Error> {
401        loop {
402            if let Some(end) = self.available().windows(2).position(|w| w == b"\r\n") {
403                if end + 2 > limit {
404                    return Err(Error::Limit("line length"));
405                }
406                return Ok(self.take(end + 2));
407            }
408            if self.available().len() >= limit {
409                return Err(Error::Limit("line length"));
410            }
411            if !self.fill(cancellation).await? {
412                return Err(Error::Truncated);
413            }
414        }
415    }
416
417    async fn trailers(&mut self, cancellation: &Cancellation) -> Result<Fields, Error> {
418        let max_fields = self.options.max_fields;
419        let fields = self
420            .read_section(cancellation, "trailer section", |bytes| {
421                wire::trailers(bytes, max_fields)
422            })
423            .await?
424            .ok_or(Error::Truncated)?;
425        crate::output::validate_trailer_fields(fields.pairs())?;
426        Ok(fields)
427    }
428}
429
430pub(crate) enum Framing {
431    Fixed(u64),
432    /// Chunked input position: the unread data of the current chunk, whether
433    /// its CRLF delimiter remains unread, and whether the last-chunk line has
434    /// been read so that only the trailer section remains.
435    Chunked {
436        remaining: u64,
437        delimiter: bool,
438        trailers: bool,
439    },
440}
441
442impl Framing {
443    pub(crate) const CHUNKED: Self = Self::Chunked {
444        remaining: 0,
445        delimiter: false,
446        trailers: false,
447    };
448}
449
450/// A pull body borrowing the connection's input lane. Returning it as the
451/// response body provides streaming echo and managed duplex tunnels without
452/// boxing, queueing, spawning, or transferring socket ownership.
453///
454/// Reads are cancellation safe: dropping a pending read, or cancelling its
455/// wait, loses no input, and a later read resumes at the same position. This
456/// relies on `R` transferring bytes only within a `read_into` poll that returns
457/// them, as scheduler socket readers and the TLS stream do.
458pub struct IncomingBody<'a, R> {
459    input: &'a mut RequestReader<R>,
460    exchange: Exchange,
461    framing: Framing,
462    length: Option<u64>,
463    complete: bool,
464    closed: bool,
465    raw: bool,
466    forbid_content: bool,
467}
468
469impl<'a, R: ReadableStream> IncomingBody<'a, R> {
470    pub(crate) fn new(
471        input: &'a mut RequestReader<R>,
472        exchange: Exchange,
473        framing: Framing,
474        length: Option<u64>,
475        raw: bool,
476        forbid_content: bool,
477    ) -> Self {
478        let complete = !raw && matches!(framing, Framing::Fixed(0));
479        exchange.set_complete(complete);
480        Self {
481            input,
482            exchange,
483            framing,
484            length,
485            complete,
486            closed: false,
487            raw,
488            forbid_content,
489        }
490    }
491    async fn read_inner(
492        &mut self,
493        cancellation: &Cancellation,
494    ) -> Result<BodyFrame<Vec<u8>>, Error> {
495        if self.exchange.is_tunnel() && !self.raw {
496            self.raw = true;
497            self.complete = false;
498        }
499        if self.complete || self.closed {
500            return Ok(BodyFrame::End);
501        }
502        loop {
503            if self.raw {
504                if self.input.available().is_empty() && !self.input.fill(cancellation).await? {
505                    self.complete = true;
506                    return Ok(BodyFrame::End);
507                }
508                return Ok(BodyFrame::Data {
509                    data: self.input.take(self.input.available().len()),
510                    end: false,
511                });
512            }
513            match &mut self.framing {
514                Framing::Fixed(remaining) => {
515                    if self.input.available().is_empty() && !self.input.fill(cancellation).await? {
516                        return Err(Error::Truncated);
517                    }
518                    let n = (*remaining).min(self.input.available().len() as u64) as usize;
519                    *remaining -= n as u64;
520                    let data = self.input.take(n);
521                    if *remaining == 0 {
522                        self.complete = true;
523                    }
524                    return Ok(BodyFrame::Data {
525                        data,
526                        end: self.complete,
527                    });
528                }
529                Framing::Chunked {
530                    remaining,
531                    delimiter,
532                    trailers,
533                } => {
534                    if *remaining != 0 {
535                        if self.input.available().is_empty()
536                            && !self.input.fill(cancellation).await?
537                        {
538                            return Err(Error::Truncated);
539                        }
540                        let n = (*remaining).min(self.input.available().len() as u64) as usize;
541                        *remaining -= n as u64;
542                        return Ok(BodyFrame::Data {
543                            data: self.input.take(n),
544                            end: false,
545                        });
546                    }
547                    // Each wait below consumes no input, and consumption is
548                    // recorded before the next wait, so a dropped read resumes
549                    // at the same position.
550                    if !*trailers {
551                        if *delimiter {
552                            while self.input.available().len() < 2 {
553                                if !self.input.fill(cancellation).await? {
554                                    return Err(Error::Truncated);
555                                }
556                            }
557                            if !self.input.available().starts_with(b"\r\n") {
558                                return Err(Error::Invalid("missing chunk delimiter"));
559                            }
560                            self.input.start += 2;
561                            *delimiter = false;
562                        }
563                        let line = self
564                            .input
565                            .line(self.input.options.max_chunk_line_bytes, cancellation)
566                            .await?;
567                        let size = chunk_size(&line)?;
568                        if size != 0 {
569                            *remaining = size;
570                            *delimiter = true;
571                            continue;
572                        }
573                        *trailers = true;
574                    }
575                    let mut fields = self.input.trailers(cancellation).await?;
576                    self.exchange.filter_trailers(&mut fields);
577                    self.complete = true;
578                    return Ok(BodyFrame::Headers { fields, end: true });
579                }
580            }
581        }
582    }
583}
584
585impl<R: ReadableStream> ReadableBody for IncomingBody<'_, R> {
586    type Data = Vec<u8>;
587    type Error = Error;
588    fn may_have_trailers(&self) -> bool {
589        !self.raw && !self.exchange.is_tunnel() && matches!(self.framing, Framing::Chunked { .. })
590    }
591    fn is_ready(&self) -> bool {
592        !self.input.available().is_empty() || self.complete || self.closed
593    }
594    fn is_empty(&self) -> bool {
595        (self.complete && !self.exchange.is_tunnel()) || self.closed
596    }
597    fn length(&self) -> Option<u64> {
598        if self.exchange.is_tunnel() {
599            None
600        } else {
601            self.length
602        }
603    }
604    fn close(&mut self, _: Option<Error>) {
605        if !self.complete {
606            self.exchange.abandon();
607        }
608        self.closed = true;
609    }
610    async fn read(&mut self, cancellation: &Cancellation) -> Result<BodyFrame<Vec<u8>>, Error> {
611        cancellation.check()?;
612        if self.input.poisoned {
613            return Err(Error::Invalid("previous body read failed"));
614        }
615        // `read_inner` records each consumption before its next wait, so a
616        // dropped read or a cancelled wait loses nothing and a later read
617        // resumes. Any other failure leaves the input position unknown.
618        let result = self.read_inner(cancellation).await.and_then(|chunk| {
619            if self.forbid_content
620                && matches!(&chunk, BodyFrame::Data { data, .. } if !data.is_empty())
621            {
622                Err(Error::Invalid("content on 205"))
623            } else {
624                Ok(chunk)
625            }
626        });
627        match &result {
628            Ok(_) => self.exchange.set_complete(self.complete),
629            Err(Error::Cancelled) => {}
630            Err(_) => self.input.poisoned = true,
631        }
632        result
633    }
634}
635
636impl<R> Drop for IncomingBody<'_, R> {
637    fn drop(&mut self) {
638        if !self.complete {
639            self.exchange.abandon();
640        }
641    }
642}
643
644// Validate extension grammar rather than trusting ignored bytes after ';'.
645// A line ends at its first CRLF, and this grammar rejects any other CR or LF.
646fn chunk_size(line: &[u8]) -> Result<u64, Error> {
647    let bytes = &line[..line.len() - 2];
648    let end = bytes
649        .iter()
650        .position(|b| !b.is_ascii_hexdigit())
651        .unwrap_or(bytes.len());
652    if end == 0 {
653        return Err(Error::Invalid("invalid chunk size"));
654    }
655    let size = u64::from_str_radix(std::str::from_utf8(&bytes[..end]).unwrap(), 16)
656        .map_err(|_| Error::Invalid("chunk size overflow"))?;
657    let mut rest = &bytes[end..];
658    while !rest.is_empty() {
659        rest = wire::trim(rest);
660        if rest.first() != Some(&b';') {
661            return Err(Error::Invalid("invalid chunk extension"));
662        }
663        rest = &rest[1..];
664        rest = wire::trim(rest);
665        let end = rest
666            .iter()
667            .position(|b| !wire::token(&[*b]))
668            .unwrap_or(rest.len());
669        if end == 0 {
670            return Err(Error::Invalid("missing chunk extension name"));
671        }
672        rest = wire::trim(&rest[end..]);
673        if rest.first() == Some(&b'=') {
674            rest = wire::trim(&rest[1..]);
675            if rest.first() == Some(&b'"') {
676                rest = &rest[1..];
677                loop {
678                    let b = *rest
679                        .first()
680                        .ok_or(Error::Invalid("unterminated chunk extension"))?;
681                    rest = &rest[1..];
682                    if b == b'"' {
683                        break;
684                    }
685                    if b == b'\\' {
686                        let b = *rest
687                            .first()
688                            .ok_or(Error::Invalid("invalid quoted escape"))?;
689                        rest = &rest[1..];
690                        if b < 32 && b != b'\t' || b == 127 {
691                            return Err(Error::Invalid("invalid quoted escape"));
692                        }
693                    } else if b < 32 && b != b'\t' || b == 127 {
694                        return Err(Error::Invalid("invalid chunk extension"));
695                    }
696                }
697            } else {
698                let end = rest
699                    .iter()
700                    .position(|b| !wire::token(&[*b]))
701                    .unwrap_or(rest.len());
702                if end == 0 {
703                    return Err(Error::Invalid("missing chunk extension value"));
704                }
705                rest = &rest[end..];
706            }
707        }
708    }
709    Ok(size)
710}
711
712#[cfg(test)]
713mod tests {
714    use super::chunk_size;
715
716    #[test]
717    fn chunk_lines_reject_stray_line_controls() {
718        assert_eq!(chunk_size(b"1a;name=\"v\\\"\";flag\r\n").unwrap(), 26);
719        for line in [
720            &b"1\r;x\r\n"[..],
721            b"1\n\r\n",
722            b"1;x\r\r\n",
723            b"1;x=\r\r\n",
724            b"1;x=\"\r\"\r\n",
725            b"1;x=\"\\\n\"\r\n",
726            b"1;x\0\r\n",
727        ] {
728            assert!(chunk_size(line).is_err(), "{line:?}");
729        }
730    }
731
732    #[test]
733    fn chunk_lines_accept_token_extension_values_and_reject_malformed_ones() {
734        assert_eq!(chunk_size(b"ff ; name = value ; flag\r\n").unwrap(), 255);
735        for (line, reason) in [
736            (&b"11111111111111111\r\n"[..], "chunk size overflow"),
737            (b"1;x=\"open\r\n", "unterminated chunk extension"),
738            (b"1;x=\"\\\r\n", "invalid quoted escape"),
739        ] {
740            assert!(
741                matches!(chunk_size(line), Err(crate::Error::Invalid(r)) if r == reason),
742                "{line:?}"
743            );
744        }
745    }
746}