Skip to main content

mime_tree/
yenc.rs

1//! Inline yEnc scanner for MIME body parts.
2//!
3//! # What is inline yEnc?
4//!
5//! yEnc binary posts on Usenet rarely carry a `Content-Transfer-Encoding`
6//! header. Instead, the article body simply contains `=ybegin`/`=yend` framing
7//! directly in the message body — often with no MIME structure at all. The
8//! outer message is treated as `text/plain` (by default when no `Content-Type`
9//! is present), and the encoded binary is embedded in it.
10//!
11//! # This module vs. `parse()` / `decode_body_value()`
12//!
13//! [`parse()`][crate::parse] and [`decode_body_value()`][crate::decode_body_value]
14//! do not decode yEnc content — there is no standard `Content-Transfer-Encoding`
15//! value for yEnc. Those functions will return the raw body text including the
16//! `=ybegin` lines verbatim.
17//!
18//! [`scan_inline_yencode()`] is the opt-in scanner for this case. It operates
19//! on the raw bytes of a part's body and locates every `=ybegin`…`=yend` block,
20//! decoding each via the [`yencoding`] crate.
21//!
22//! # When to call this
23//!
24//! A reasonable heuristic: call `scan_inline_yencode()` on any `text/plain`
25//! leaf part whose body bytes contain the ASCII sequence `b"=ybegin "`. This
26//! avoids scanning every part while still catching all practical cases.
27//!
28//! ```rust
29//! use mime_tree::{parse, scan_inline_yencode};
30//!
31//! // A message with no MIME structure — just a yEnc block in the body.
32//! // Oracle: bytes [0,1,2] encode as ['*','+',',']; CRC32 = 0x0854897f
33//! let raw: &[u8] = b"From: poster@example.com\r\n\
34//!                    Subject: [1/1] hi.bin\r\n\
35//!                    \r\n\
36//!                    Some prose before the attachment.\r\n\
37//!                    =ybegin line=128 size=3 name=hi.bin\r\n\
38//!                    *+,\r\n\
39//!                    =yend size=3 crc32=0854897f\r\n\
40//!                    Some prose after.\r\n";
41//!
42//! let msg = parse(raw).unwrap();
43//! let part = msg.part_index.find_by_id("1").unwrap();
44//!
45//! let blocks = scan_inline_yencode(raw, part);
46//! assert_eq!(blocks.len(), 1);
47//! assert_eq!(blocks[0].filename, "hi.bin");
48//! assert_eq!(blocks[0].data, &[0u8, 1, 2]);
49//! assert!(blocks[0].crc32_verified);
50//! assert!(!blocks[0].is_encoding_problem);
51//! ```
52
53use crate::part::ParsedPart;
54
55/// A single yEnc-encoded block found inside a part body.
56///
57/// All byte offsets are **absolute** — they are in the same coordinate space
58/// as `ParsedPart::body_range` and the `raw` buffer passed to
59/// [`scan_inline_yencode()`].
60#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
61#[non_exhaustive]
62pub struct InlineYEncBlock {
63    /// Byte offset of the `=ybegin` line within `raw`.
64    ///
65    /// Slicing `raw[begin_offset .. begin_offset + begin_length]` yields the
66    /// complete yEnc article from the `=ybegin` line through the `=yend` line
67    /// (inclusive of its line ending).
68    pub begin_offset: u32,
69
70    /// Byte length of the entire block: from the start of `=ybegin` through
71    /// the end of `=yend` (inclusive of its newline).
72    ///
73    /// **When [`is_encoding_problem`] is `true`**, this field holds the length
74    /// of the `=ybegin` line only (up to and including its newline), not the
75    /// full block through `=yend`.  The `=yend` line could not be located
76    /// because decoding failed before it was reached.  Do not rely on
77    /// `begin_offset + begin_length` spanning a complete block when
78    /// `is_encoding_problem` is set.
79    pub begin_length: u32,
80
81    /// Filename from the `name=` field of `=ybegin`.
82    ///
83    /// Not sanitised against path traversal. Callers writing this to disk must
84    /// validate against `..` and absolute paths.
85    ///
86    /// Empty when [`is_encoding_problem`] is `true` and the block header
87    /// could not be parsed.
88    pub filename: String,
89
90    /// Total declared file size in bytes, from `=ybegin size=`. For multi-part
91    /// articles this is the size of the complete file, not just this part.
92    ///
93    /// When [`is_encoding_problem`] is `true`, this field is `0` and does not
94    /// reflect a declared size (the header could not be parsed).
95    pub file_size: u64,
96
97    /// 1-based part number from `=ybegin part=`. `None` for single-part articles.
98    pub part: Option<u32>,
99
100    /// Total number of parts in the series from `=ybegin total=`.
101    /// `None` for single-part articles.
102    pub total_parts: Option<u32>,
103
104    /// 1-based byte offset of the first byte of this part within the full file,
105    /// from `=ypart begin=`. `None` for single-part articles.
106    pub part_begin: Option<u64>,
107
108    /// 1-based byte offset of the last byte of this part within the full file,
109    /// from `=ypart end=`. `None` for single-part articles.
110    pub part_end: Option<u64>,
111
112    /// Decoded binary payload.
113    pub data: Vec<u8>,
114
115    /// `true` if the CRC32 in `=yend` was present and matched the decoded
116    /// bytes. `false` if no CRC field was present in the article (some older
117    /// encoders omit it).
118    pub crc32_verified: bool,
119
120    /// `true` if any decoding error was encountered (missing `=ybegin`,
121    /// invalid header field, missing `=yend`, CRC mismatch, or any other
122    /// error returned by [`yencoding::decode`]).
123    ///
124    /// When this is `true`, `data` may be empty or partial.  The specific
125    /// yEnc error variant is not exposed — callers only see this boolean
126    /// flag.  The underlying [`yencoding::YencError`] is consumed internally
127    /// to populate the sentinel fields; inspect `data.is_empty()`,
128    /// `crc32_verified`, and `begin_length` to distinguish failure modes.
129    pub is_encoding_problem: bool,
130}
131
132/// Scan a MIME part's body for inline yEnc-encoded blocks.
133///
134/// Slices `raw` using `part.body_range` to obtain the body bytes, then finds
135/// every `=ybegin`…`=yend` block within the body, decoding each one via
136/// [`yencoding::decode`]. Returns one [`InlineYEncBlock`] per block found.
137///
138/// # Parameters
139///
140/// * `raw`  — the full raw message bytes (same buffer passed to [`parse()`][crate::parse]).
141/// * `part` — a [`ParsedPart`][crate::ParsedPart] from the parsed tree.
142///   Only `part.body_range` is used.
143///
144/// # Return value
145///
146/// An empty `Vec` when:
147/// - the body contains no `=ybegin` blocks, or
148/// - `part.body_range` is out of bounds for `raw`.
149///
150/// Otherwise one entry per block, in order of appearance.
151///
152/// # Multiple blocks
153///
154/// A single body part may contain more than one yEnc article (though this is
155/// unusual in practice). All blocks are decoded and returned.
156///
157/// # Notes
158///
159/// * Byte offsets in the returned blocks are absolute — relative to the start
160///   of `raw`, matching the coordinate space of `part.body_range`.
161/// * No panic on any input.
162#[must_use = "the scanned yEnc blocks must be used"]
163pub fn scan_inline_yencode(raw: &[u8], part: &ParsedPart) -> Vec<InlineYEncBlock> {
164    let (offset_u32, length_u32) = part.body_range;
165    let offset = offset_u32 as usize;
166    let length = length_u32 as usize;
167
168    // Defensive: body_range out of bounds → empty result, no panic.
169    let end = match offset.checked_add(length) {
170        Some(e) if e <= raw.len() => e,
171        _ => return Vec::new(),
172    };
173    let body = &raw[offset..end];
174
175    let mut results = Vec::new();
176    let mut pos = 0usize;
177
178    while pos < body.len() {
179        // Find the next =ybegin line starting at or after pos.
180        let ybegin_rel = match find_ybegin(body, pos) {
181            Some(r) => r,
182            None => break, // no more blocks
183        };
184
185        // Attempt to decode from the =ybegin line onward. yencoding::decode()
186        // scans forward for =ybegin itself, so passing the slice starting at
187        // ybegin_rel is correct (it will find it immediately).
188        let slice = &body[ybegin_rel..];
189        let (block, yend_rel_in_slice, is_error) = decode_one_block(slice);
190
191        // Absolute offset in `raw` of this block's =ybegin line.
192        let abs_begin = offset_u32.saturating_add(u32::try_from(ybegin_rel).unwrap_or(u32::MAX));
193
194        // Byte length of the block: from =ybegin to end of =yend line.
195        let block_len = u32::try_from(yend_rel_in_slice).unwrap_or(u32::MAX);
196
197        results.push(InlineYEncBlock {
198            begin_offset: abs_begin,
199            begin_length: block_len,
200            filename: block.metadata.filename,
201            file_size: block.metadata.size,
202            part: block.part,
203            total_parts: block.metadata.total_parts,
204            part_begin: block.part_begin,
205            part_end: block.part_end,
206            data: block.data,
207            crc32_verified: block.crc32_verified,
208            is_encoding_problem: is_error,
209        });
210
211        // Advance past the consumed block. If we couldn't find =yend, advance
212        // past the =ybegin line only so we don't re-process it.
213        // .max(1) guarantees forward progress even when yend_rel_in_slice is 0
214        // (e.g. a zero-length =ybegin line at end of body), preventing an
215        // infinite loop.
216        pos = ybegin_rel + yend_rel_in_slice.max(1);
217    }
218
219    results
220}
221
222// ---------------------------------------------------------------------------
223// Helpers
224// ---------------------------------------------------------------------------
225
226/// Find the relative offset of the next `=ybegin ` line at or after `start`
227/// within `body`. Returns `None` if no such line exists.
228///
229/// Matches only at true line boundaries (offset 0 or immediately after `\n`)
230/// to avoid false positives from encoded data that happens to contain
231/// the ASCII bytes `=ybegin`.
232///
233/// # Precondition
234///
235/// `start` must be `0` or immediately following a `\n` byte in `body`
236/// (i.e. a line-boundary offset). Passing a mid-line offset will not
237/// produce a panic, but the search will begin at a non-line-boundary
238/// position and may miss a `=ybegin` line that starts before the next
239/// `\n`, or — in pathological encoded data — match `=ybegin` bytes that
240/// do not appear at a true line start.
241fn find_ybegin(body: &[u8], start: usize) -> Option<usize> {
242    debug_assert!(
243        start == 0 || body.get(start - 1) == Some(&b'\n'),
244        "find_ybegin: start must be a line-boundary offset"
245    );
246    let needle = b"=ybegin ";
247    let mut pos = start;
248
249    while pos < body.len() {
250        // Check at a line boundary.
251        if body[pos..].starts_with(needle) {
252            return Some(pos);
253        }
254        // Advance to the next line.
255        match body[pos..].iter().position(|&b| b == b'\n') {
256            Some(rel) => pos += rel + 1,
257            None => break,
258        }
259    }
260    None
261}
262
263/// Decode one yEnc block starting at the beginning of `slice`.
264///
265/// Returns `(DecodedPart, bytes_consumed, is_error)` where:
266/// - `bytes_consumed` is how many bytes of `slice` this block spans
267/// - `is_error` is `true` when `yencoding::decode` returned `Err`
268fn decode_one_block(slice: &[u8]) -> (yencoding::DecodedPart, usize, bool) {
269    match yencoding::decode(slice) {
270        Ok(part) => {
271            // Find where =yend line ends within slice so the caller knows
272            // how many bytes to skip.
273            //
274            // If yencoding::decode() succeeded, =yend was definitely in the
275            // slice and find_yend_end() must find it too. If it somehow returns
276            // None that is a logic error: fall back to advancing past =ybegin
277            // only (rather than consuming the whole remaining body) and mark
278            // the block as an encoding problem so the caller is not silently
279            // misled.
280            match find_yend_end(slice) {
281                Some(consumed) => (part, consumed, false),
282                None => {
283                    // yencoding::decode() succeeded, so =yend was definitely
284                    // present in the slice — find_yend_end() returning None
285                    // here is a logic error in this module.  The decoded bytes
286                    // are valid, but we cannot report a correct begin_length
287                    // (consumed = only the =ybegin line, not the full block),
288                    // so the slice invariant would be violated.  Mark as
289                    // is_encoding_problem=true to signal that the offset
290                    // metadata is unreliable.
291                    debug_assert!(
292                        false,
293                        "find_yend_end returned None after successful decode — logic error"
294                    );
295                    let consumed = find_line_end(slice, 0);
296                    (part, consumed, true)
297                }
298            }
299        }
300        Err(e) => {
301            // Build a sentinel DecodedPart for the error case.
302            let sentinel = make_error_sentinel(e);
303            // Advance past =ybegin line only to ensure forward progress.
304            let consumed = find_line_end(slice, 0);
305            (sentinel, consumed, true)
306        }
307    }
308}
309
310/// Find the byte offset just past the `=yend` line in `slice`.
311/// Returns `None` if no `=yend` line is found (truncated article).
312///
313/// Matches `=yend` only when followed by a space, `\r`, `\n`, or end-of-slice
314/// — the same boundary requirement that `yencoding::decode` uses internally
315/// via `strip_keyword(line, b"=yend ")`.  This guard is a safety margin for
316/// non-compliant encoders: compliant yEnc encoders cannot produce a data line
317/// starting with `=y` because `=` (0x3D) is always escaped, so no well-formed
318/// data line can begin with a literal `=` character.
319fn find_yend_end(slice: &[u8]) -> Option<usize> {
320    let needle = b"=yend";
321    let mut pos = 0;
322    while pos < slice.len() {
323        let rest = &slice[pos..];
324        if rest.starts_with(needle) {
325            // Require the keyword to be followed by a delimiter so we don't
326            // match =yend inside an encoded data line.
327            let after = rest.get(needle.len()).copied();
328            match after {
329                None | Some(b' ') | Some(b'\r') | Some(b'\n') => {
330                    return Some(find_line_end(slice, pos));
331                }
332                _ => {} // false match — continue scanning
333            }
334        }
335        match rest.iter().position(|&b| b == b'\n') {
336            Some(rel) => pos += rel + 1,
337            None => break,
338        }
339    }
340    None
341}
342
343/// Return the byte offset just past the end of the line starting at `pos`
344/// within `slice`. If there is no `\n`, returns `slice.len()`.
345fn find_line_end(slice: &[u8], pos: usize) -> usize {
346    match slice[pos..].iter().position(|&b| b == b'\n') {
347        Some(rel) => pos + rel + 1,
348        None => slice.len(),
349    }
350}
351
352/// Build a zero-data `DecodedPart` to use when decode returns an error.
353fn make_error_sentinel(_err: yencoding::YencError) -> yencoding::DecodedPart {
354    yencoding::DecodedPart::new(
355        Vec::new(),
356        yencoding::YencMetadata::new(String::new(), 0, 128, None),
357        None,
358        None,
359        None,
360        false,
361        None,
362    )
363}
364
365// ---------------------------------------------------------------------------
366// Tests
367// ---------------------------------------------------------------------------
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use crate::part::{ParsedPart, TransferEncoding};
373
374    fn make_part(prefix: &[u8], body_bytes: &[u8]) -> (Vec<u8>, ParsedPart) {
375        let mut raw = prefix.to_vec();
376        let body_offset = raw.len();
377        raw.extend_from_slice(body_bytes);
378        let part = ParsedPart {
379            part_id: "1".to_owned(),
380            content_type: "text/plain".to_owned(),
381            charset: Some("utf-8".to_owned()),
382            transfer_encoding: TransferEncoding::Identity,
383            disposition: None,
384            filename: None,
385            cid: None,
386            header_range: (0u32, body_offset as u32),
387            body_range: (body_offset as u32, body_bytes.len() as u32),
388            children: vec![],
389            is_encoding_problem: false,
390        };
391        (raw, part)
392    }
393
394    // Oracle: bytes [0,1,2] → ['*','+',','] (add 42, no escapes).
395    // CRC32 of [0,1,2]: python3 -c "import binascii; print(hex(binascii.crc32(bytes([0,1,2]))&0xffffffff))"
396    // → 0x0854897f
397    const BLOCK_012: &[u8] =
398        b"=ybegin line=128 size=3 name=hi.bin\r\n*+,\r\n=yend size=3 crc32=0854897f\r\n";
399
400    // Oracle: bytes [3,4,5] → ['-','.','/'] (add 42).
401    // CRC32: python3 -c "print(hex(binascii.crc32(bytes([3,4,5]))&0xffffffff))"
402    // → 0xe90156c0
403    const BLOCK_345: &[u8] =
404        b"=ybegin line=128 size=3 name=other.bin\r\n-./\r\n=yend size=3 crc32=e90156c0\r\n";
405
406    #[test]
407    fn single_block_no_preamble() {
408        let (raw, part) = make_part(b"", BLOCK_012);
409        let blocks = scan_inline_yencode(&raw, &part);
410        assert_eq!(blocks.len(), 1);
411        assert_eq!(blocks[0].data, &[0u8, 1, 2]);
412        assert_eq!(blocks[0].filename, "hi.bin");
413        assert_eq!(blocks[0].file_size, 3);
414        assert!(blocks[0].crc32_verified);
415        assert!(!blocks[0].is_encoding_problem);
416        assert_eq!(blocks[0].begin_offset, 0);
417        assert_eq!(blocks[0].begin_length, BLOCK_012.len() as u32);
418    }
419
420    #[test]
421    fn single_block_with_preamble() {
422        let preamble = b"Some prose.\r\nMore prose.\r\n";
423        let (raw, part) = make_part(b"", &[preamble, BLOCK_012].concat());
424        let blocks = scan_inline_yencode(&raw, &part);
425        assert_eq!(blocks.len(), 1);
426        assert_eq!(blocks[0].data, &[0u8, 1, 2]);
427        assert_eq!(blocks[0].begin_offset, preamble.len() as u32);
428        assert_eq!(blocks[0].begin_length, BLOCK_012.len() as u32);
429        // Verify slice invariant: raw[begin_offset..begin_offset+begin_length] == BLOCK_012
430        let start = blocks[0].begin_offset as usize;
431        let end = start + blocks[0].begin_length as usize;
432        assert_eq!(&raw[start..end], BLOCK_012);
433    }
434
435    #[test]
436    fn two_sequential_blocks() {
437        let separator = b"Some text between blocks.\r\n";
438        let body = [BLOCK_012, separator, BLOCK_345].concat();
439        let (raw, part) = make_part(b"", &body);
440
441        let blocks = scan_inline_yencode(&raw, &part);
442        assert_eq!(blocks.len(), 2, "expected 2 blocks");
443
444        assert_eq!(blocks[0].data, &[0u8, 1, 2]);
445        assert_eq!(blocks[0].filename, "hi.bin");
446        assert_eq!(blocks[0].begin_offset, 0);
447
448        assert_eq!(blocks[1].data, &[3u8, 4, 5]);
449        assert_eq!(blocks[1].filename, "other.bin");
450        assert_eq!(
451            blocks[1].begin_offset,
452            (BLOCK_012.len() + separator.len()) as u32
453        );
454
455        // Non-overlapping
456        assert!(blocks[0].begin_offset + blocks[0].begin_length <= blocks[1].begin_offset);
457    }
458
459    #[test]
460    fn block_with_absolute_prefix_offset() {
461        let prefix = b"MIME headers here\r\n\r\n";
462        let (raw, part) = make_part(prefix, BLOCK_012);
463        let blocks = scan_inline_yencode(&raw, &part);
464        assert_eq!(blocks.len(), 1);
465        // Absolute offset = prefix.len() (body starts there, block at body start)
466        assert_eq!(blocks[0].begin_offset, prefix.len() as u32);
467        // Verify slice invariant
468        let start = blocks[0].begin_offset as usize;
469        let end = start + blocks[0].begin_length as usize;
470        assert_eq!(&raw[start..end], BLOCK_012);
471    }
472
473    #[test]
474    fn no_blocks_returns_empty() {
475        let (raw, part) = make_part(b"", b"Just plain text.\r\nNo yEnc here.\r\n");
476        assert!(scan_inline_yencode(&raw, &part).is_empty());
477    }
478
479    #[test]
480    fn empty_body_returns_empty() {
481        let (raw, part) = make_part(b"", b"");
482        assert!(scan_inline_yencode(&raw, &part).is_empty());
483    }
484
485    #[test]
486    fn out_of_bounds_body_range_returns_empty() {
487        let raw = b"short";
488        let part = ParsedPart {
489            part_id: "1".to_owned(),
490            content_type: "text/plain".to_owned(),
491            charset: None,
492            transfer_encoding: TransferEncoding::Identity,
493            disposition: None,
494            filename: None,
495            cid: None,
496            header_range: (0, 0),
497            body_range: (3, 100), // end = 103 > 5
498            children: vec![],
499            is_encoding_problem: false,
500        };
501        assert!(scan_inline_yencode(raw, &part).is_empty());
502    }
503
504    #[test]
505    fn overflow_safe_body_range() {
506        let raw = b"data";
507        let part = ParsedPart {
508            part_id: "1".to_owned(),
509            content_type: "text/plain".to_owned(),
510            charset: None,
511            transfer_encoding: TransferEncoding::Identity,
512            disposition: None,
513            filename: None,
514            cid: None,
515            header_range: (0, 0),
516            body_range: (u32::MAX, 1),
517            children: vec![],
518            is_encoding_problem: false,
519        };
520        assert!(scan_inline_yencode(raw, &part).is_empty());
521    }
522
523    #[test]
524    fn crc_mismatch_sets_is_encoding_problem() {
525        // Correct encoding but wrong CRC in =yend.
526        let bad = b"=ybegin line=128 size=3 name=f.bin\r\n*+,\r\n=yend size=3 crc32=00000000\r\n";
527        let (raw, part) = make_part(b"", bad);
528        let blocks = scan_inline_yencode(&raw, &part);
529        assert_eq!(blocks.len(), 1);
530        assert!(
531            blocks[0].is_encoding_problem,
532            "CRC mismatch should set is_encoding_problem"
533        );
534        assert!(
535            blocks[0].data.is_empty(),
536            "data should be empty on CRC error"
537        );
538    }
539
540    #[test]
541    fn truncated_block_sets_is_encoding_problem() {
542        // =yend line absent.
543        let trunc = b"=ybegin line=128 size=3 name=f.bin\r\n*+,\r\n";
544        let (raw, part) = make_part(b"", trunc);
545        let blocks = scan_inline_yencode(&raw, &part);
546        assert_eq!(blocks.len(), 1);
547        assert!(blocks[0].is_encoding_problem);
548    }
549
550    #[test]
551    fn ybegin_mid_line_not_matched() {
552        // "not =ybegin" — keyword not at line start, must be ignored.
553        let body = b"this is not =ybegin a real block\r\n=ybegin line=128 size=3 name=f.bin\r\n*+,\r\n=yend size=3 crc32=0854897f\r\n";
554        let (raw, part) = make_part(b"", body);
555        let blocks = scan_inline_yencode(&raw, &part);
556        // Only the real block at the line boundary should be found.
557        assert_eq!(blocks.len(), 1);
558        assert_eq!(blocks[0].data, &[0u8, 1, 2]);
559    }
560
561    #[test]
562    fn multipart_article_fields_populated() {
563        // Oracle: multi-part article with =ypart.
564        // Encode bytes [0,1,2] as part 1 of 2, begin=1 end=3.
565        use yencoding::{encode_part, EncodePartOptions, DEFAULT_LINE_LENGTH};
566        let data = [0u8, 1, 2];
567        // Oracle: python3 -c "import binascii; print(hex(binascii.crc32(bytes([0,1,2,3,4,5]))&0xffffffff))"
568        // → 0x30ebcf4a
569        let whole_crc: u32 = 0x30eb_cf4a;
570        let opts = EncodePartOptions {
571            filename: "split.bin",
572            total_size: 6,
573            total_parts: 2,
574            part: 1,
575            begin: 1,
576            end: 3,
577            whole_file_crc32: whole_crc,
578            line_length: DEFAULT_LINE_LENGTH,
579        };
580        let encoded = encode_part(&data, &opts);
581        let (raw, part) = make_part(b"", &encoded);
582
583        let blocks = scan_inline_yencode(&raw, &part);
584        assert_eq!(blocks.len(), 1);
585        assert_eq!(blocks[0].part, Some(1));
586        assert_eq!(blocks[0].total_parts, Some(2));
587        assert_eq!(blocks[0].part_begin, Some(1));
588        assert_eq!(blocks[0].part_end, Some(3));
589        assert_eq!(blocks[0].file_size, 6);
590        assert!(blocks[0].crc32_verified);
591        // Oracle: bytes [0,1,2] are the decoded payload of this part.
592        assert_eq!(
593            blocks[0].data,
594            &[0u8, 1, 2],
595            "decoded bytes must match oracle"
596        );
597        // Slice invariant: raw[begin_offset..begin_offset+begin_length] == encoded
598        let start = blocks[0].begin_offset as usize;
599        let end = start + blocks[0].begin_length as usize;
600        assert_eq!(
601            &raw[start..end],
602            encoded.as_slice(),
603            "slice invariant must hold for multi-part block"
604        );
605    }
606
607    // Integration test: full parse() → scan_inline_yencode() pipeline
608    #[test]
609    fn full_parse_pipeline() {
610        use crate::parse;
611
612        // A bare message with no MIME headers — just a yEnc block in the body.
613        let raw: Vec<u8> = [
614            b"From: poster@example.com\r\n" as &[u8],
615            b"Subject: [1/1] hi.bin\r\n",
616            b"\r\n",
617            b"Some prose.\r\n",
618            BLOCK_012,
619            b"More prose.\r\n",
620        ]
621        .concat();
622
623        let msg = parse(&raw).expect("parse failed");
624        // Should be a single text/plain part.
625        let part = msg.part_index.find_by_id("1").unwrap();
626        assert_eq!(part.content_type, "text/plain");
627
628        let blocks = scan_inline_yencode(&raw, part);
629        assert_eq!(blocks.len(), 1);
630        assert_eq!(blocks[0].data, &[0u8, 1, 2]);
631        assert_eq!(blocks[0].filename, "hi.bin");
632        assert!(blocks[0].crc32_verified);
633    }
634}