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}