Skip to main content

noyalib/
tokio_async.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! Native async YAML parsing for [`tokio`](https://tokio.rs)
5//! runtimes.
6//!
7//! Bridges noyalib's strict-parser entry points
8//! ([`crate::from_str`], [`crate::from_slice`]) onto
9//! `tokio::io::AsyncRead` sources without forcing the caller
10//! through `tokio::task::spawn_blocking`. Two surface shapes
11//! are provided so callers can pick the right ergonomics for
12//! their workload:
13//!
14//! * `from_async_reader` — drain a single document out of any
15//!   `tokio::io::AsyncRead` into the caller's `T`.
16//! * `from_async_reader_multi` — drain every `---`-separated
17//!   document and return `Vec<T>`.
18//! * [`AsyncYamlStream`](crate::tokio_async::AsyncYamlStream): a
19//!   backpressured `Stream` of parsed documents, constructed with
20//!   [`async_yaml_stream`](crate::tokio_async::async_yaml_stream) or
21//!   [`async_yaml_stream_with_config`](crate::tokio_async::async_yaml_stream_with_config).
22//! * [`YamlDecoder`](crate::tokio_async::YamlDecoder): the lower-level
23//!   `tokio_util::codec::Decoder`
24//!   used by the stream surface and available for custom framed
25//!   pipelines.
26//!
27//! # Backpressure
28//!
29//! The `from_async_reader` entry points buffer the full payload
30//! into a `Vec<u8>` before parsing because the underlying parser
31//! is synchronous. The codec surface is the streaming choice:
32//! it emits one document per `decode` call as soon as a complete
33//! `---` boundary is in the buffer.
34//!
35//! Gated behind the `tokio` Cargo feature (which transitively
36//! enables `tokio-util` and `bytes` for the codec API).
37//!
38//! # Example
39//!
40//! ```no_run
41//! # #[cfg(feature = "tokio")] {
42//! # async fn demo() -> Result<(), Box<dyn std::error::Error>> {
43//! use tokio::io::BufReader;
44//! let bytes: &[u8] = b"name: noyalib\nversion: 0.0.6\n";
45//! let mut reader = BufReader::new(bytes);
46//! #[derive(serde::Deserialize)]
47//! struct Pkg { name: String, version: String }
48//! let pkg: Pkg = noyalib::tokio_async::from_async_reader(&mut reader).await?;
49//! assert_eq!(pkg.name, "noyalib");
50//! # Ok(()) }
51//! # }
52//! ```
53
54use bytes::BytesMut;
55use core::marker::PhantomData;
56use tokio::io::{AsyncRead, AsyncReadExt as _};
57use tokio_util::codec::{Decoder, FramedRead};
58
59use crate::de::{ParserConfig, from_slice_with_config};
60use crate::error::{Error, Result};
61
62/// Drain the supplied reader to end-of-stream, then parse the
63/// buffered bytes as a single YAML document into `T`.
64///
65/// Uses the default [`ParserConfig`]; pair with
66/// [`from_async_reader_with_config`] to pass custom limits.
67///
68/// # Errors
69///
70/// Returns the underlying [`Error`] from either the I/O drain
71/// (including reads that exceed [`ParserConfig::max_document_length`])
72/// or the parse step.
73pub async fn from_async_reader<R, T>(reader: &mut R) -> Result<T>
74where
75    R: AsyncRead + Unpin,
76    // `'static` is inherited from `from_slice_with_config` and
77    // `from_str_with_config` — async I/O cannot generally hand
78    // out a borrowed `&[u8]` that outlives the await point, so
79    // dropping the bound here would only paper over the issue.
80    T: serde_core::de::DeserializeOwned + 'static,
81{
82    from_async_reader_with_config(reader, &ParserConfig::default()).await
83}
84
85/// [`from_async_reader`] with a caller-supplied [`ParserConfig`].
86///
87/// The reader probes at most one byte beyond
88/// [`ParserConfig::max_document_length`] via
89/// [`tokio::io::AsyncReadExt::take`] so a slow-drip adversary
90/// cannot drive the in-memory buffer beyond the configured limit.
91///
92/// # Errors
93///
94/// Returns the underlying [`Error`] from either the I/O drain or
95/// the parse step. An input larger than `max_document_length`
96/// returns [`Error::Io`] with [`std::io::ErrorKind::InvalidData`];
97/// a truncated prefix is never parsed as a complete document.
98pub async fn from_async_reader_with_config<R, T>(reader: &mut R, config: &ParserConfig) -> Result<T>
99where
100    R: AsyncRead + Unpin,
101    // `'static` is inherited from `from_slice_with_config` and
102    // `from_str_with_config` — async I/O cannot generally hand
103    // out a borrowed `&[u8]` that outlives the await point, so
104    // dropping the bound here would only paper over the issue.
105    T: serde_core::de::DeserializeOwned + 'static,
106{
107    let buf = drain_bounded(reader, config.max_document_length, "max_document_length").await?;
108    let buf = strip_bom_owned(buf);
109    from_slice_with_config(&buf, config)
110}
111
112/// Drain the reader and parse every `---`-separated document
113/// into `Vec<T>` using the default [`ParserConfig`].
114///
115/// Pair with [`from_async_reader_multi_with_config`] when the
116/// caller needs custom limits (it is the version most production
117/// services should pick).
118///
119/// # Errors
120///
121/// Returns the underlying [`Error`] from either the I/O drain or
122/// the parse step.
123pub async fn from_async_reader_multi<R, T>(reader: &mut R) -> Result<Vec<T>>
124where
125    R: AsyncRead + Unpin,
126    // `'static` is inherited from `from_slice_with_config` and
127    // `from_str_with_config` — async I/O cannot generally hand
128    // out a borrowed `&[u8]` that outlives the await point, so
129    // dropping the bound here would only paper over the issue.
130    T: serde_core::de::DeserializeOwned + 'static,
131{
132    from_async_reader_multi_with_config(reader, &ParserConfig::default()).await
133}
134
135/// [`from_async_reader_multi`] with a caller-supplied
136/// [`ParserConfig`]. The reader is bounded by
137/// [`ParserConfig::max_stream_bytes`]. Every split document is then
138/// bounded independently by [`ParserConfig::max_document_length`].
139///
140/// Uses [`crate::from_slice_with_config`] on the buffered bytes
141/// when only one document is present; otherwise routes through
142/// the standard multi-document loader so all per-document
143/// semantics (duplicate-key policy, anchor budgets, …) match
144/// [`crate::load_all_with_config`].
145///
146/// # Errors
147///
148/// Returns the underlying [`Error`] from either the I/O drain
149/// (including bounded-read overflow and UTF-8 invalidity) or the
150/// parse step.
151pub async fn from_async_reader_multi_with_config<R, T>(
152    reader: &mut R,
153    config: &ParserConfig,
154) -> Result<Vec<T>>
155where
156    R: AsyncRead + Unpin,
157    // `'static` is inherited from `from_slice_with_config` and
158    // `from_str_with_config` — async I/O cannot generally hand
159    // out a borrowed `&[u8]` that outlives the await point, so
160    // dropping the bound here would only paper over the issue.
161    T: serde_core::de::DeserializeOwned + 'static,
162{
163    let buf = drain_bounded(reader, config.max_stream_bytes, "max_stream_bytes").await?;
164    let buf = strip_bom_owned(buf);
165    // Route UTF-8 invalidity through Error::Io (InvalidData)
166    // rather than `Error::custom`, which is serde-flavoured and
167    // would mislead callers matching on error kind (M9).
168    let text = core::str::from_utf8(&buf)
169        .map_err(|e| Error::from(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
170    // Split on `---` with the marker cap honoured (security
171    // finding C2), then deserialise each document under the
172    // caller's config so every per-document limit fires.
173    let docs = crate::doc_boundary::split_documents_checked(text, config.max_documents)?;
174    // One tally for the stream: the stream-wide budgets are charged
175    // across documents rather than reset for each.
176    let config = &crate::parser::meter::StreamTally::share(config);
177    let mut results = Vec::with_capacity(docs.len());
178    for doc in docs {
179        results.push(crate::from_str_with_config::<T>(doc, config)?);
180    }
181    Ok(results)
182}
183
184/// Read at most `max_bytes` from `reader` into a fresh `Vec<u8>`.
185/// One additional byte is probed so an over-limit input is rejected
186/// rather than silently truncated to a possibly valid YAML prefix.
187/// Consistent with the synchronous parser, `max_bytes == 0` permits
188/// only an empty input.
189async fn drain_bounded<R>(reader: &mut R, max_bytes: usize, limit_name: &str) -> Result<Vec<u8>>
190where
191    R: AsyncRead + Unpin,
192{
193    let probe_bytes = max_bytes.saturating_add(1);
194    let initial_capacity = max_bytes.min(16 * 1024);
195    let mut buf = Vec::with_capacity(initial_capacity);
196    let take = u64::try_from(probe_bytes).unwrap_or(u64::MAX);
197    let mut limited = reader.take(take);
198    let _ = limited.read_to_end(&mut buf).await.map_err(Error::from)?;
199    if buf.len() > max_bytes {
200        return Err(Error::from(std::io::Error::new(
201            std::io::ErrorKind::InvalidData,
202            format!(
203                "noyalib async reader: input {} > {limit_name} {}",
204                buf.len(),
205                max_bytes
206            ),
207        )));
208    }
209    Ok(buf)
210}
211
212/// Strip a leading UTF-8 BOM in-place. The owned `Vec` form
213/// keeps the buffer's allocation; we just elide the three BOM
214/// bytes at the front.
215fn strip_bom_owned(mut buf: Vec<u8>) -> Vec<u8> {
216    if crate::doc_boundary::strip_bom(&buf) == 3 {
217        let _ = buf.drain(..3);
218    }
219    buf
220}
221
222/// [`tokio_util::codec::Decoder`] that emits one parsed `T` per
223/// `---`-delimited YAML document in the byte stream.
224///
225/// Drop this into a
226/// [`tokio_util::codec::FramedRead`] / [`tokio_util::codec::FramedWrite`]
227/// pipeline to plug streaming YAML parsing into a tower service
228/// chain. Each `decode` call returns:
229///
230/// * `Ok(Some(T))` — a complete document was found and parsed.
231/// * `Ok(None)` — no complete document yet; ask for more bytes.
232/// * `Err(e)` — the next document failed to parse.
233///
234/// The decoder treats `---` at column 0 followed by whitespace
235/// or end-of-line as the document terminator, matching the YAML
236/// 1.2.2 §9.1.2 directive-end grammar.
237#[derive(Debug, Clone)]
238pub struct YamlDecoder<T> {
239    config: ParserConfig,
240    /// Hard cap on the `BytesMut` buffer size between `decode`
241    /// calls. Constructors derive it from
242    /// `ParserConfig::max_document_length`; callers may tighten it
243    /// with [`Self::max_frame_size`]. A larger value has no effect:
244    /// `max_document_length` always caps a frame as well.
245    max_frame_size: Option<usize>,
246    /// Offset in the buffered frame up to which the boundary scan has
247    /// already looked and found nothing, so the next `decode` resumes
248    /// there instead of rescanning from the start.
249    scanned: usize,
250    _marker: PhantomData<fn() -> T>,
251}
252
253/// A backpressured asynchronous stream of YAML documents.
254///
255/// The reader is consumed through Tokio's [`AsyncRead`] contract. Each
256/// stream item is parsed only when the consumer polls for it, and source
257/// order is preserved. The decoder buffers at most one incomplete document
258/// up to its configured frame limit, although an individual read may also
259/// contain later complete documents that remain buffered until subsequent
260/// polls.
261///
262/// Construct this type with [`async_yaml_stream`] or
263/// [`async_yaml_stream_with_config`]. Consumers can use any compatible
264/// `StreamExt` implementation to await items.
265pub type AsyncYamlStream<R, T> = FramedRead<R, YamlDecoder<T>>;
266
267/// Wrap an asynchronous reader as a backpressured YAML document stream.
268///
269/// The default [`ParserConfig`] applies to each document independently.
270/// Use [`async_yaml_stream_with_config`] for custom limits and policies.
271#[must_use]
272pub fn async_yaml_stream<R, T>(reader: R) -> AsyncYamlStream<R, T> {
273    FramedRead::new(reader, YamlDecoder::new())
274}
275
276/// Wrap an asynchronous reader as a backpressured YAML document stream with
277/// caller-supplied parser limits and policies.
278#[must_use]
279pub fn async_yaml_stream_with_config<R, T>(
280    reader: R,
281    config: ParserConfig,
282) -> AsyncYamlStream<R, T> {
283    FramedRead::new(reader, YamlDecoder::with_config(config))
284}
285
286impl<T> Default for YamlDecoder<T> {
287    fn default() -> Self {
288        Self::new()
289    }
290}
291
292impl<T> YamlDecoder<T> {
293    /// Create a decoder with default [`ParserConfig`] limits. The
294    /// frame-size cap defaults to `max_document_length`.
295    #[must_use]
296    pub fn new() -> Self {
297        Self::with_config(ParserConfig::default())
298    }
299
300    /// Create a decoder with a caller-supplied [`ParserConfig`].
301    ///
302    /// The stream-wide budgets (`max_documents`, `max_events`,
303    /// `max_nodes`, `max_total_scalar_bytes`, `max_merge_keys`) are
304    /// charged across every document the decoder yields, not per frame.
305    #[must_use]
306    pub fn with_config(config: ParserConfig) -> Self {
307        let config = crate::parser::meter::StreamTally::share(&config);
308        let max_frame_size = Some(config.max_document_length);
309        Self {
310            config,
311            max_frame_size,
312            scanned: 0,
313            _marker: PhantomData,
314        }
315    }
316
317    /// Set a hard cap on the inter-frame buffer size. When the
318    /// `BytesMut` passed to `decode` exceeds `max`, the next
319    /// `decode` call returns an `Error::Io` with `InvalidData`
320    /// rather than letting the buffer grow without bound.
321    ///
322    /// The configured `max_document_length` caps a frame too, so a
323    /// value above it has no effect: a frame the parser would refuse is
324    /// never buffered in full.
325    #[must_use]
326    pub fn max_frame_size(mut self, max: usize) -> Self {
327        self.max_frame_size = Some(max);
328        self
329    }
330
331    /// Refuse a frame longer than `max_frame_size` or
332    /// `max_document_length`, whichever is smaller.
333    fn check_frame_len(&self, frame_len: usize) -> core::result::Result<(), Error> {
334        let doc_max = self.config.max_document_length;
335        let (max, name) = match self.max_frame_size {
336            Some(frame_max) if frame_max <= doc_max => (frame_max, "max_frame_size"),
337            _ => (doc_max, "max_document_length"),
338        };
339        if frame_len > max {
340            return Err(Error::from(std::io::Error::new(
341                std::io::ErrorKind::InvalidData,
342                format!("noyalib YamlDecoder: frame {frame_len} > {name} {max}"),
343            )));
344        }
345        Ok(())
346    }
347
348    /// The next document boundary in `bytes`, resuming the scan where
349    /// the previous call on the same frame stopped. A marker before EOF
350    /// needs three `---` bytes, the byte before it, and one lookahead
351    /// byte after it, so every position before `len - 3` has had its
352    /// final answer once scanned.
353    ///
354    /// This relies on `decode` seeing one growing buffer, as
355    /// `FramedRead` guarantees; a buffer shorter than the scanned
356    /// offset is treated as a new frame.
357    fn next_boundary(&mut self, bytes: &[u8], at_eof: bool) -> Option<usize> {
358        if self.scanned > bytes.len() {
359            self.scanned = 0;
360        }
361        let found = crate::doc_boundary::next_marker_after_streaming(bytes, self.scanned, at_eof);
362        if found.is_none() {
363            let tail = if at_eof { 2 } else { 3 };
364            self.scanned = self.scanned.max(bytes.len().saturating_sub(tail));
365        }
366        found
367    }
368
369    fn decode_step(
370        &mut self,
371        src: &mut BytesMut,
372        at_eof: bool,
373    ) -> core::result::Result<Option<T>, Error>
374    where
375        T: serde_core::de::DeserializeOwned + 'static,
376    {
377        // C6 — iterate rather than recurse so an all-whitespace
378        //      preamble (or repeated `---` markers preceding the
379        //      first real document) cannot blow the stack.
380        loop {
381            let boundary = self.next_boundary(src.as_ref(), at_eof);
382
383            // Apply the cap to the next logical document, not the entire
384            // read buffer. A single AsyncRead poll may legally return several
385            // complete, individually bounded documents. Reject only when the
386            // first document or incomplete frame exceeds the limit.
387            self.check_frame_len(boundary.unwrap_or(src.len()))?;
388
389            let Some(end) = boundary else {
390                return Ok(None);
391            };
392
393            // Split off everything up to (but not including) the
394            // next `---` marker; the marker stays in `src` to be
395            // picked up by the following call. The buffer now starts a
396            // new frame, so the scan starts over.
397            let doc = src.split_to(end);
398            self.scanned = 0;
399            if doc.iter().all(u8::is_ascii_whitespace) {
400                // Skip an all-whitespace preamble silently and
401                // retry from the new buffer head; no recursion.
402                continue;
403            }
404            let parsed = from_slice_with_config::<T>(&doc, &self.config)?;
405            return Ok(Some(parsed));
406        }
407    }
408}
409
410impl<T> Decoder for YamlDecoder<T>
411where
412    // `'static` is inherited from `from_slice_with_config` and
413    // `from_str_with_config` — async I/O cannot generally hand
414    // out a borrowed `&[u8]` that outlives the await point, so
415    // dropping the bound here would only paper over the issue.
416    T: serde_core::de::DeserializeOwned + 'static,
417{
418    type Item = T;
419    type Error = Error;
420
421    fn decode(&mut self, src: &mut BytesMut) -> core::result::Result<Option<T>, Error> {
422        self.decode_step(src, false)
423    }
424
425    fn decode_eof(&mut self, src: &mut BytesMut) -> core::result::Result<Option<T>, Error> {
426        if src.is_empty() {
427            return Ok(None);
428        }
429        // Last document — try a normal decode with `at_eof = true` first
430        // (it may have a trailing `---` from a previous frame), otherwise
431        // parse the remainder.
432        if let Some(v) = self.decode_step(src, true)? {
433            return Ok(Some(v));
434        }
435        if src.iter().all(u8::is_ascii_whitespace) {
436            src.clear();
437            self.scanned = 0;
438            return Ok(None);
439        }
440        let doc = src.split();
441        self.scanned = 0;
442        let parsed = from_slice_with_config::<T>(&doc, &self.config)?;
443        Ok(Some(parsed))
444    }
445}
446
447/// Find the byte offset of the next column-0 `---` document
448/// boundary, accepting both `\n` and `\r\n` line terminators
449/// (security/correctness finding C4 — the previous copy missed
450/// CRLF inputs, so Windows-saved files would never frame).
451///
452/// Returns `None` if no boundary is present in the buffer.
453/// The returned offset is the **first byte of the marker** so
454/// callers may use [`bytes::BytesMut::split_to`] to consume the
455/// preceding document while leaving the marker available for the
456/// next frame.
457#[cfg(test)]
458fn find_doc_boundary(bytes: &[u8]) -> Option<usize> {
459    crate::doc_boundary::next_marker_after(bytes, 0)
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use bytes::BytesMut;
466
467    use tokio::io::BufReader;
468
469    #[derive(Debug, serde::Deserialize, PartialEq)]
470    struct Pkg {
471        name: String,
472        version: String,
473    }
474
475    #[tokio::test]
476    async fn reader_parses_single_document() {
477        let mut r = BufReader::new(&b"name: noyalib\nversion: 0.0.6\n"[..]);
478        let p: Pkg = from_async_reader(&mut r).await.unwrap();
479        assert_eq!(
480            p,
481            Pkg {
482                name: "noyalib".into(),
483                version: "0.0.6".into(),
484            }
485        );
486    }
487
488    #[tokio::test]
489    async fn reader_multi_parses_each_document() {
490        let yaml = b"---\nname: a\nversion: '1'\n---\nname: b\nversion: '2'\n";
491        let mut r = BufReader::new(&yaml[..]);
492        let docs: Vec<Pkg> = from_async_reader_multi(&mut r).await.unwrap();
493        assert_eq!(docs.len(), 2);
494        assert_eq!(docs[0].name, "a");
495        assert_eq!(docs[1].name, "b");
496    }
497
498    #[test]
499    fn decoder_emits_first_complete_document() {
500        let mut decoder: YamlDecoder<Pkg> = YamlDecoder::new();
501        let mut buf = BytesMut::from(&b"name: a\nversion: '1'\n---\nname: b\nversion: '2'\n"[..]);
502        let first = decoder.decode(&mut buf).unwrap().unwrap();
503        assert_eq!(first.name, "a");
504        // The second document is still in the buffer, prefixed
505        // with the `---` marker; decode_eof handles it.
506        let second = decoder.decode_eof(&mut buf).unwrap().unwrap();
507        assert_eq!(second.name, "b");
508    }
509
510    #[test]
511    fn decoder_returns_none_on_incomplete_buffer() {
512        let mut decoder: YamlDecoder<Pkg> = YamlDecoder::new();
513        // No `---` boundary visible — pending.
514        let mut buf = BytesMut::from(&b"name: a\n"[..]);
515        assert!(decoder.decode(&mut buf).unwrap().is_none());
516    }
517
518    #[tokio::test]
519    async fn reader_with_config_respects_overrides() {
520        let mut r = BufReader::new(&b"name: x\nversion: '1'\n"[..]);
521        let cfg = ParserConfig::default();
522        let p: Pkg = from_async_reader_with_config(&mut r, &cfg).await.unwrap();
523        assert_eq!(p.name, "x");
524    }
525
526    #[test]
527    fn decoder_with_config_constructor() {
528        let cfg = ParserConfig::default();
529        let _d: YamlDecoder<Pkg> = YamlDecoder::with_config(cfg);
530        let _d2: YamlDecoder<Pkg> = YamlDecoder::default();
531        let _printed = format!("{:?}", YamlDecoder::<Pkg>::new());
532    }
533
534    #[test]
535    fn decoder_eof_on_empty_buffer_returns_none() {
536        let mut decoder: YamlDecoder<Pkg> = YamlDecoder::new();
537        let mut buf = BytesMut::new();
538        assert!(decoder.decode_eof(&mut buf).unwrap().is_none());
539    }
540
541    #[test]
542    fn decoder_skips_whitespace_only_preamble() {
543        let mut decoder: YamlDecoder<Pkg> = YamlDecoder::new();
544        // Whitespace-only chunk before a `---` boundary — the
545        // decoder recurses and emits the document after it.
546        let mut buf = BytesMut::from(&b"\n\n---\nname: q\nversion: '2'\n"[..]);
547        let p = decoder.decode_eof(&mut buf).unwrap().unwrap();
548        assert_eq!(p.name, "q");
549    }
550
551    #[test]
552    fn decoder_eof_drains_trailing_whitespace() {
553        let mut decoder: YamlDecoder<Pkg> = YamlDecoder::new();
554        // First emit the only doc; then EOF should clean up
555        // any trailing whitespace without erroring.
556        let mut buf = BytesMut::from(&b"name: r\nversion: '3'\n\n\n"[..]);
557        let p = decoder.decode_eof(&mut buf).unwrap().unwrap();
558        assert_eq!(p.name, "r");
559    }
560
561    #[tokio::test]
562    async fn reader_multi_handles_invalid_utf8() {
563        // 0xFF is invalid UTF-8 — exercises the from_utf8 branch
564        // in from_async_reader_multi.
565        let mut r = BufReader::new(&[0xFFu8, 0xFE, 0xFD][..]);
566        let res: Result<Vec<Pkg>> = from_async_reader_multi(&mut r).await;
567        assert!(res.is_err());
568    }
569
570    #[test]
571    fn find_doc_boundary_handles_short_input() {
572        assert!(find_doc_boundary(b"").is_none());
573        assert!(find_doc_boundary(b"abc").is_none());
574    }
575
576    #[tokio::test]
577    async fn reader_strips_leading_bom() {
578        // C5 — BOM-prefixed payload must parse identically to
579        //      the LF-on-Linux equivalent.
580        let mut r = BufReader::new(&b"\xEF\xBB\xBFname: x\nversion: '1'\n"[..]);
581        let p: Pkg = from_async_reader(&mut r).await.unwrap();
582        assert_eq!(p.name, "x");
583    }
584
585    #[tokio::test]
586    async fn reader_multi_accepts_crlf() {
587        // C4 — Windows-saved YAML round-trips the multi path.
588        let yaml = b"---\r\nname: a\r\nversion: '1'\r\n---\r\nname: b\r\nversion: '2'\r\n";
589        let mut r = BufReader::new(&yaml[..]);
590        let docs: Vec<Pkg> = from_async_reader_multi(&mut r).await.unwrap();
591        assert_eq!(docs.len(), 2);
592        assert_eq!(docs[0].name, "a");
593        assert_eq!(docs[1].name, "b");
594    }
595
596    #[tokio::test]
597    async fn reader_caps_at_max_document_length() {
598        // C3 — a valid prefix must never be accepted after the
599        //      configured limit silently truncates its trailing data.
600        let valid_prefix = "name: x\nversion: '1'\n";
601        let yaml = format!("{valid_prefix}ignored: true\n");
602        let cfg = ParserConfig {
603            max_document_length: valid_prefix.len(),
604            ..ParserConfig::default()
605        };
606        let mut r = BufReader::new(yaml.as_bytes());
607        let err = from_async_reader_with_config::<_, Pkg>(&mut r, &cfg)
608            .await
609            .expect_err("over-limit input must be rejected, not truncated");
610        assert!(err.to_string().contains("max_document_length"));
611    }
612
613    #[tokio::test]
614    async fn reader_accepts_input_exactly_at_limit() {
615        let yaml = "name: x\nversion: '1'\n";
616        let cfg = ParserConfig {
617            max_document_length: yaml.len(),
618            ..ParserConfig::default()
619        };
620        let mut r = BufReader::new(yaml.as_bytes());
621        let pkg = from_async_reader_with_config::<_, Pkg>(&mut r, &cfg)
622            .await
623            .unwrap();
624        assert_eq!(pkg.name, "x");
625    }
626
627    #[tokio::test]
628    async fn reader_zero_limit_rejects_nonempty_input() {
629        let cfg = ParserConfig {
630            max_document_length: 0,
631            ..ParserConfig::default()
632        };
633        let mut r = BufReader::new(&b"null\n"[..]);
634        let err = from_async_reader_with_config::<_, crate::Value>(&mut r, &cfg)
635            .await
636            .expect_err("a zero limit must reject nonempty input");
637        assert!(err.to_string().contains("max_document_length 0"));
638    }
639
640    #[test]
641    fn decoder_rejects_oversize_frame() {
642        // M7 — frame-size cap prevents adversarial buffer growth.
643        let mut decoder: YamlDecoder<Pkg> = YamlDecoder::new().max_frame_size(16);
644        let mut buf = BytesMut::from(&b"name: long-name-no-marker-yet-need-more-bytes"[..]);
645        let err = decoder.decode(&mut buf).err().unwrap();
646        assert!(err.to_string().contains("max_frame_size"));
647    }
648
649    #[test]
650    fn decoder_accepts_multiple_buffered_documents_beyond_frame_cap() {
651        let cfg = ParserConfig {
652            max_document_length: 28,
653            ..ParserConfig::default()
654        };
655        let mut decoder: YamlDecoder<Pkg> = YamlDecoder::with_config(cfg);
656        let mut buf = BytesMut::from(&b"name: a\nversion: '1'\n---\nname: b\nversion: '2'\n"[..]);
657        assert!(buf.len() > 28);
658
659        let first = decoder.decode(&mut buf).unwrap().unwrap();
660        assert_eq!(first.name, "a");
661        let second = decoder.decode_eof(&mut buf).unwrap().unwrap();
662        assert_eq!(second.name, "b");
663    }
664
665    #[test]
666    fn stream_constructors_preserve_decoder_configuration() {
667        let default_stream = async_yaml_stream::<_, Pkg>(&b""[..]);
668        assert_eq!(
669            default_stream.decoder().max_frame_size,
670            Some(ParserConfig::default().max_document_length)
671        );
672
673        let cfg = ParserConfig {
674            max_document_length: 17,
675            ..ParserConfig::default()
676        };
677        let configured_stream = async_yaml_stream_with_config::<_, Pkg>(&b""[..], cfg);
678        assert_eq!(configured_stream.decoder().max_frame_size, Some(17));
679    }
680
681    #[test]
682    fn decoder_derives_frame_cap_from_parser_config() {
683        let cfg = ParserConfig {
684            max_document_length: 16,
685            ..ParserConfig::default()
686        };
687        let mut decoder: YamlDecoder<Pkg> = YamlDecoder::with_config(cfg);
688        let mut buf = BytesMut::from(&b"name: long-name-without-boundary"[..]);
689        let err = decoder.decode(&mut buf).unwrap_err();
690        assert!(err.to_string().contains("max_frame_size 16"));
691    }
692
693    #[test]
694    fn decoder_eof_preserves_parser_config() {
695        let cfg = ParserConfig::strict();
696        let mut decoder: YamlDecoder<crate::Value> = YamlDecoder::with_config(cfg);
697        let mut buf = BytesMut::from(&b"a: 1\na: 2\n"[..]);
698        let err = decoder
699            .decode_eof(&mut buf)
700            .expect_err("strict duplicate-key policy must apply at EOF");
701        assert!(matches!(
702            err.kind(),
703            crate::error::ErrorKind::DuplicateKey | crate::error::ErrorKind::KeyCollision
704        ));
705    }
706
707    #[test]
708    fn decoder_frame_cap_never_exceeds_max_document_length() {
709        // Raising `max_frame_size` past `max_document_length` cannot
710        // let a frame buffer beyond what the parser would accept.
711        let cfg = ParserConfig {
712            max_document_length: 16,
713            ..ParserConfig::default()
714        };
715        let mut decoder: YamlDecoder<Pkg> = YamlDecoder::with_config(cfg).max_frame_size(1 << 20);
716        let mut buf = BytesMut::from(&b"name: long-name-without-boundary-at-all"[..]);
717        let err = decoder.decode(&mut buf).unwrap_err();
718        assert!(err.to_string().contains("max_document_length 16"), "{err}");
719    }
720
721    #[test]
722    fn decoder_finds_boundaries_split_across_reads() {
723        let input = b"name: a\nversion: '1'\n---\nname: b\nversion: '2'\n";
724        for split in 0..=input.len() {
725            let mut decoder: YamlDecoder<Pkg> = YamlDecoder::new();
726            let mut buf = BytesMut::from(&input[..split]);
727            let mut out = Vec::new();
728            if let Some(doc) = decoder.decode(&mut buf).unwrap() {
729                out.push(doc);
730            }
731            buf.extend_from_slice(&input[split..]);
732            while let Some(doc) = decoder.decode(&mut buf).unwrap() {
733                out.push(doc);
734            }
735            if let Some(doc) = decoder.decode_eof(&mut buf).unwrap() {
736                out.push(doc);
737            }
738            let names: Vec<_> = out.iter().map(|p| p.name.as_str()).collect();
739            assert_eq!(names, ["a", "b"], "split at {split}");
740        }
741    }
742
743    #[test]
744    fn decoder_scan_resumes_instead_of_rescanning() {
745        // 8 MiB arriving in 8 KiB reads with no boundary until the
746        // end. Rescanning the whole buffer on every read is quadratic.
747        let cfg = ParserConfig {
748            max_document_length: 16 << 20,
749            ..ParserConfig::default()
750        };
751        let mut decoder: YamlDecoder<crate::Value> = YamlDecoder::with_config(cfg);
752        let mut chunk = Vec::new();
753        while chunk.len() < 8 << 10 {
754            chunk.extend_from_slice(b"# padding line\n");
755        }
756        let mut buf = BytesMut::new();
757        let start = std::time::Instant::now();
758        for _ in 0..1024 {
759            buf.extend_from_slice(&chunk);
760            assert!(decoder.decode(&mut buf).unwrap().is_none());
761        }
762        buf.extend_from_slice(b"k: v\n---\n");
763        let doc = decoder.decode(&mut buf).unwrap().unwrap();
764        let elapsed = start.elapsed();
765        assert_eq!(doc["k"].as_str(), Some("v"));
766        assert!(
767            elapsed < std::time::Duration::from_secs(10),
768            "8 MiB in 8 KiB reads took {elapsed:?}"
769        );
770    }
771
772    #[test]
773    fn decoder_accepts_crlf_boundary() {
774        // C4 — `\r\n---\r\n` framing must be recognised.
775        let mut decoder: YamlDecoder<Pkg> = YamlDecoder::new();
776        let mut buf =
777            BytesMut::from(&b"name: a\r\nversion: '1'\r\n---\r\nname: b\r\nversion: '2'\r\n"[..]);
778        let first = decoder.decode(&mut buf).unwrap().unwrap();
779        assert_eq!(first.name, "a");
780    }
781
782    #[tokio::test]
783    async fn reader_multi_with_config_routes_through() {
784        // M6 — multi-with-config entry point.
785        let yaml = b"---\nname: a\nversion: '1'\n---\nname: b\nversion: '2'\n";
786        let mut r = BufReader::new(&yaml[..]);
787        let cfg = ParserConfig::default();
788        let docs: Vec<Pkg> = from_async_reader_multi_with_config(&mut r, &cfg)
789            .await
790            .unwrap();
791        assert_eq!(docs.len(), 2);
792    }
793
794    #[tokio::test]
795    async fn multi_reader_separates_stream_and_document_byte_limits() {
796        let yaml = b"---\nname: a\nversion: '1'\n---\nname: b\nversion: '2'\n";
797        let cfg = ParserConfig::default()
798            .max_document_length(32)
799            .max_stream_bytes(yaml.len());
800        let mut reader = BufReader::new(&yaml[..]);
801
802        let docs: Vec<Pkg> = from_async_reader_multi_with_config(&mut reader, &cfg)
803            .await
804            .unwrap();
805        assert_eq!(docs.len(), 2);
806    }
807
808    #[tokio::test]
809    async fn multi_reader_rejects_stream_byte_overflow() {
810        let yaml = b"---\nname: a\nversion: '1'\n---\nname: b\nversion: '2'\n";
811        let cfg = ParserConfig::default().max_stream_bytes(yaml.len() - 1);
812        let mut reader = BufReader::new(&yaml[..]);
813
814        let error = from_async_reader_multi_with_config::<_, Pkg>(&mut reader, &cfg)
815            .await
816            .unwrap_err();
817        assert!(error.to_string().contains("max_stream_bytes"));
818    }
819
820    #[test]
821    fn find_doc_boundary_skips_leading_marker() {
822        // A leading `---` at byte 0 is not a boundary — it's the
823        // start of the first document.
824        assert!(find_doc_boundary(b"---\na: 1\n").is_none());
825        // The second `---` is the boundary.
826        let bs = b"---\na: 1\n---\nb: 2\n";
827        let at = find_doc_boundary(bs).unwrap();
828        // The boundary points at the `---` after the `\n` (byte 9).
829        assert_eq!(at, 9);
830    }
831}