Skip to main content

mail_builder/
mime.rs

1/*
2 * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
3 *
4 * SPDX-License-Identifier: Apache-2.0 OR MIT
5 */
6
7use crate::{
8    encoders::{base64::base64_encode_wrapped, encode::write_encoded_body},
9    headers::{
10        Header, HeaderType, content_type::ContentType, message_id::MessageId, raw::Raw, text::Text,
11    },
12    writer::Writer,
13};
14use std::{
15    borrow::Cow,
16    cell::Cell,
17    sync::atomic::{AtomicU64, Ordering},
18    time::{SystemTime, UNIX_EPOCH},
19};
20
21/// MIME part of an e-mail.
22#[derive(Clone, Debug)]
23pub struct MimePart<'x> {
24    pub headers: Vec<(Cow<'x, str>, HeaderType<'x>)>,
25    pub contents: BodyPart<'x>,
26}
27
28#[derive(Clone, Debug)]
29pub enum BodyPart<'x> {
30    Text(Cow<'x, str>),
31    Binary(Cow<'x, [u8]>),
32    Multipart(Vec<MimePart<'x>>),
33}
34
35impl<'x> From<&'x str> for BodyPart<'x> {
36    fn from(value: &'x str) -> Self {
37        BodyPart::Text(value.into())
38    }
39}
40
41impl<'x> From<&'x [u8]> for BodyPart<'x> {
42    fn from(value: &'x [u8]) -> Self {
43        BodyPart::Binary(value.into())
44    }
45}
46
47impl From<String> for BodyPart<'_> {
48    fn from(value: String) -> Self {
49        BodyPart::Text(value.into())
50    }
51}
52
53impl<'x> From<&'x String> for BodyPart<'x> {
54    fn from(value: &'x String) -> Self {
55        BodyPart::Text(value.as_str().into())
56    }
57}
58
59impl<'x> From<Cow<'x, str>> for BodyPart<'x> {
60    fn from(value: Cow<'x, str>) -> Self {
61        BodyPart::Text(value)
62    }
63}
64
65impl From<Vec<u8>> for BodyPart<'_> {
66    fn from(value: Vec<u8>) -> Self {
67        BodyPart::Binary(value.into())
68    }
69}
70
71impl<'x> From<Vec<MimePart<'x>>> for BodyPart<'x> {
72    fn from(value: Vec<MimePart<'x>>) -> Self {
73        BodyPart::Multipart(value)
74    }
75}
76
77impl<'x> From<&'x str> for ContentType<'x> {
78    fn from(value: &'x str) -> Self {
79        ContentType::new(value)
80    }
81}
82
83impl From<String> for ContentType<'_> {
84    fn from(value: String) -> Self {
85        ContentType::new(value)
86    }
87}
88
89impl<'x> From<&'x String> for ContentType<'x> {
90    fn from(value: &'x String) -> Self {
91        ContentType::new(value.as_str())
92    }
93}
94
95const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef";
96const GOLDEN_RATIO: u64 = 0x9E37_79B9_7F4A_7C15;
97const BOUNDARY_HEX_LEN: usize = 16;
98
99thread_local!(static BOUNDARY_STATE: Cell<(u64, u64)> = const { Cell::new((0, 0)) });
100static THREAD_SEQUENCE: AtomicU64 = AtomicU64::new(0);
101static PROCESS_ENTROPY: AtomicU64 = AtomicU64::new(0);
102
103#[inline(always)]
104fn splitmix64(seed: u64) -> u64 {
105    let mut z = seed;
106    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
107    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
108    z ^ (z >> 31)
109}
110
111#[inline(always)]
112fn push_hex(buffer: &mut [u8; BOUNDARY_HEX_LEN], end: usize, mut value: u64) -> usize {
113    let mut at = end;
114    while let Some(next) = at.checked_sub(1) {
115        at = next;
116        if let Some(slot) = buffer.get_mut(at) {
117            *slot = HEX_DIGITS[(value & 15) as usize];
118        }
119        value >>= 4;
120        if value == 0 {
121            break;
122        }
123    }
124    at
125}
126
127#[inline]
128fn unix_nanos() -> u64 {
129    SystemTime::now()
130        .duration_since(UNIX_EPOCH)
131        .map_or(0, |elapsed| elapsed.as_nanos() as u64)
132}
133
134fn process_entropy(address: u64, nanos: u64) -> u64 {
135    let known = PROCESS_ENTROPY.load(Ordering::Relaxed);
136    if known != 0 {
137        return known;
138    }
139    let candidate = splitmix64(address ^ nanos.rotate_left(17)) | 1;
140    match PROCESS_ENTROPY.compare_exchange(0, candidate, Ordering::Relaxed, Ordering::Relaxed) {
141        Ok(_) => candidate,
142        Err(existing) => existing,
143    }
144}
145
146#[inline]
147fn boundary_fields() -> (u64, u64, u64) {
148    let nanos = unix_nanos();
149    BOUNDARY_STATE.with(|state| {
150        let (mut thread_seed, counter) = state.get();
151        if thread_seed == 0 {
152            let address = std::ptr::from_ref(state) as usize as u64;
153            let sequence = THREAD_SEQUENCE.fetch_add(1, Ordering::Relaxed);
154            thread_seed = splitmix64(sequence) ^ process_entropy(address, nanos);
155            if thread_seed == 0 {
156                thread_seed = GOLDEN_RATIO;
157            }
158        }
159        let counter = counter.wrapping_add(1);
160        state.set((thread_seed, counter));
161        (
162            nanos,
163            splitmix64(thread_seed ^ counter.wrapping_mul(GOLDEN_RATIO)),
164            thread_seed,
165        )
166    })
167}
168
169/// Writes a pseudo-unique MIME boundary without allocating.
170///
171/// The three hexadecimal fields are only guaranteed distinct as a triple,
172/// so `separator` should be a non-empty string without hexadecimal digits.
173pub fn write_boundary(output: &mut impl Writer, separator: &str) {
174    let (nanos, unique, thread_seed) = boundary_fields();
175
176    let mut buffer = [0u8; BOUNDARY_HEX_LEN];
177    let at = push_hex(&mut buffer, BOUNDARY_HEX_LEN, nanos);
178    output.write(buffer.get(at..).unwrap_or_default());
179    output.write(separator.as_bytes());
180    let at = push_hex(&mut buffer, BOUNDARY_HEX_LEN, unique);
181    output.write(buffer.get(at..).unwrap_or_default());
182    output.write(separator.as_bytes());
183    let at = push_hex(&mut buffer, BOUNDARY_HEX_LEN, thread_seed);
184    output.write(buffer.get(at..).unwrap_or_default());
185}
186
187pub fn make_boundary(separator: &str) -> String {
188    let mut boundary = Vec::with_capacity(BOUNDARY_HEX_LEN * 3 + separator.len() * 2);
189    write_boundary(&mut boundary, separator);
190    String::from_utf8(boundary).unwrap_or_default()
191}
192
193impl<'x> MimePart<'x> {
194    /// Create a new MIME part.
195    pub fn new(
196        content_type: impl Into<ContentType<'x>>,
197        contents: impl Into<BodyPart<'x>>,
198    ) -> Self {
199        let mut content_type = content_type.into();
200        let contents = contents.into();
201
202        if matches!(contents, BodyPart::Text(_)) && content_type.attributes.is_empty() {
203            content_type.attributes.reserve_exact(1);
204            content_type
205                .attributes
206                .push((Cow::from("charset"), Cow::from("utf-8")));
207        }
208
209        let mut headers = Vec::with_capacity(2);
210        headers.push((Cow::from("Content-Type"), content_type.into()));
211
212        Self { contents, headers }
213    }
214
215    /// Create a new raw MIME part that includes both headers and body.
216    pub fn raw(contents: impl Into<BodyPart<'x>>) -> Self {
217        Self {
218            contents: contents.into(),
219            headers: vec![],
220        }
221    }
222
223    /// Set the attachment filename of a MIME part.
224    pub fn attachment(mut self, filename: impl Into<Cow<'x, str>>) -> Self {
225        self.headers.push((
226            "Content-Disposition".into(),
227            ContentType::new("attachment")
228                .attribute("filename", filename)
229                .into(),
230        ));
231        self
232    }
233
234    /// Set the MIME part as inline.
235    pub fn inline(mut self) -> Self {
236        self.headers.push((
237            "Content-Disposition".into(),
238            ContentType::new("inline").into(),
239        ));
240        self
241    }
242
243    /// Set the Content-Language header of a MIME part.
244    pub fn language(mut self, value: impl Into<Cow<'x, str>>) -> Self {
245        self.headers
246            .push(("Content-Language".into(), Text::new(value).into()));
247        self
248    }
249
250    /// Set the Content-ID header of a MIME part.
251    pub fn cid(mut self, value: impl Into<Cow<'x, str>>) -> Self {
252        self.headers
253            .push(("Content-ID".into(), MessageId::new(value).into()));
254        self
255    }
256
257    /// Set the Content-Location header of a MIME part.
258    pub fn location(mut self, value: impl Into<Cow<'x, str>>) -> Self {
259        self.headers
260            .push(("Content-Location".into(), Raw::new(value).into()));
261        self
262    }
263
264    /// Disable automatic Content-Transfer-Encoding detection and treat this as a raw MIME part
265    pub fn transfer_encoding(mut self, value: impl Into<Cow<'x, str>>) -> Self {
266        self.headers
267            .push(("Content-Transfer-Encoding".into(), Raw::new(value).into()));
268        self
269    }
270
271    /// Set custom headers of a MIME part.
272    pub fn header(
273        mut self,
274        header: impl Into<Cow<'x, str>>,
275        value: impl Into<HeaderType<'x>>,
276    ) -> Self {
277        self.headers.push((header.into(), value.into()));
278        self
279    }
280
281    /// Returns the part's size
282    pub fn size(&self) -> usize {
283        match &self.contents {
284            BodyPart::Text(b) => b.len(),
285            BodyPart::Binary(b) => b.len(),
286            BodyPart::Multipart(bl) => bl.iter().map(|b| b.size()).sum(),
287        }
288    }
289
290    pub(crate) fn estimated_len(&self) -> usize {
291        self.estimated_len_at_depth(0)
292    }
293
294    fn estimated_len_at_depth(&self, depth: usize) -> usize {
295        let headers = estimated_headers_len(&self.headers);
296        match &self.contents {
297            BodyPart::Text(text) => headers
298                .saturating_add(base64_len(text.len()))
299                .saturating_add(LEAF_OVERHEAD),
300            BodyPart::Binary(binary) => headers
301                .saturating_add(base64_len(binary.len()))
302                .saturating_add(LEAF_OVERHEAD),
303            BodyPart::Multipart(parts) if depth < MAX_ESTIMATE_DEPTH => {
304                parts
305                    .iter()
306                    .fold(headers.saturating_add(MULTIPART_OVERHEAD), |total, part| {
307                        total
308                            .saturating_add(part.estimated_len_at_depth(depth + 1))
309                            .saturating_add(BOUNDARY_OVERHEAD)
310                    })
311            }
312            BodyPart::Multipart(parts) => headers
313                .saturating_add(MULTIPART_OVERHEAD)
314                .saturating_add(parts.len().saturating_mul(BOUNDARY_OVERHEAD)),
315        }
316    }
317
318    /// Add a body part to a multipart/* MIME part.
319    pub fn add_part(&mut self, part: MimePart<'x>) {
320        if let BodyPart::Multipart(ref mut parts) = self.contents {
321            parts.push(part);
322        }
323    }
324
325    /// Write the MIME part to a writer.
326    pub fn write_part(self, output: &mut impl Writer) {
327        let children = match self.contents {
328            BodyPart::Text(text) => {
329                return write_leaf_part(&self.headers, text.as_bytes(), true, output);
330            }
331            BodyPart::Binary(binary) => {
332                return write_leaf_part(&self.headers, binary.as_ref(), false, output);
333            }
334            BodyPart::Multipart(children) => children,
335        };
336
337        let mut boundary = write_multipart_headers(self.headers, output);
338        let mut parts = children.into_iter();
339        let mut stack: Vec<(std::vec::IntoIter<MimePart<'x>>, Cow<'x, str>)> = Vec::new();
340
341        loop {
342            while let Some(part) = parts.next() {
343                output.write(b"\r\n--");
344                output.write(boundary.as_bytes());
345                output.write(b"\r\n");
346
347                match part.contents {
348                    BodyPart::Text(text) => {
349                        write_leaf_part(&part.headers, text.as_bytes(), true, output);
350                    }
351                    BodyPart::Binary(binary) => {
352                        write_leaf_part(&part.headers, binary.as_ref(), false, output);
353                    }
354                    BodyPart::Multipart(children) => {
355                        stack.push((parts, boundary));
356                        boundary = write_multipart_headers(part.headers, output);
357                        parts = children.into_iter();
358                    }
359                }
360            }
361
362            output.write(b"\r\n--");
363            output.write(boundary.as_bytes());
364            output.write(b"--\r\n");
365
366            match stack.pop() {
367                Some((previous_parts, previous_boundary)) => {
368                    parts = previous_parts;
369                    boundary = previous_boundary;
370                }
371                None => return,
372            }
373        }
374    }
375}
376
377const MAX_ESTIMATE_DEPTH: usize = 32;
378const LEAF_OVERHEAD: usize = 96;
379const MULTIPART_OVERHEAD: usize = 128;
380const BOUNDARY_OVERHEAD: usize = 64;
381
382#[inline(always)]
383pub(crate) fn base64_len(len: usize) -> usize {
384    let encoded = len.div_ceil(3).saturating_mul(4);
385    encoded.saturating_add(encoded.div_ceil(76).saturating_mul(2))
386}
387
388pub(crate) fn estimated_headers_len(headers: &[(Cow<'_, str>, HeaderType<'_>)]) -> usize {
389    headers
390        .iter()
391        .map(|(name, _)| name.len() + HEADER_VALUE_ESTIMATE)
392        .sum()
393}
394
395const HEADER_VALUE_ESTIMATE: usize = 64;
396
397fn write_leaf_part(
398    headers: &[(Cow<'_, str>, HeaderType<'_>)],
399    body: &[u8],
400    is_text_body: bool,
401    output: &mut impl Writer,
402) {
403    let mut is_text = is_text_body;
404    let mut is_attachment = false;
405    let mut is_raw = headers.is_empty();
406
407    for (header_name, header_value) in headers {
408        write_header_name(header_name, output);
409
410        if !is_text && header_name == "Content-Type" {
411            is_text = header_value
412                .as_content_type()
413                .is_some_and(|value| value.is_text());
414        } else if !is_attachment && header_name == "Content-Disposition" {
415            is_attachment = header_value
416                .as_content_type()
417                .is_some_and(|value| value.is_attachment());
418        } else if !is_raw && header_name == "Content-Transfer-Encoding" {
419            is_raw = true;
420        }
421
422        header_value.write_header(output, header_name.len() + 2);
423    }
424
425    if !is_raw {
426        if is_text {
427            write_encoded_body(body, output, !is_attachment);
428        } else {
429            output.write(b"Content-Transfer-Encoding: base64\r\n\r\n");
430            base64_encode_wrapped(body, output);
431        }
432    } else {
433        if !headers.is_empty() {
434            output.write(b"\r\n");
435        }
436        output.write(body);
437    }
438}
439
440fn write_multipart_headers<'x>(
441    headers: Vec<(Cow<'x, str>, HeaderType<'x>)>,
442    output: &mut impl Writer,
443) -> Cow<'x, str> {
444    let mut boundary: Option<Cow<'x, str>> = None;
445
446    for (header_name, header_value) in headers {
447        write_header_name(&header_name, output);
448
449        if boundary.is_none() && header_name.eq_ignore_ascii_case("Content-Type") {
450            boundary = Some(match header_value {
451                HeaderType::ContentType(mut content_type) => {
452                    let position = match content_type
453                        .attributes
454                        .iter()
455                        .position(|(attribute, _)| attribute.eq_ignore_ascii_case("boundary"))
456                    {
457                        Some(position) => position,
458                        None => {
459                            let position = content_type.attributes.len();
460                            content_type
461                                .attributes
462                                .push(("boundary".into(), make_boundary("_").into()));
463                            position
464                        }
465                    };
466                    content_type.write_header(output, 14);
467                    content_type.attributes.swap_remove(position).1
468                }
469                HeaderType::Raw(raw) => raw_boundary(raw, output),
470                HeaderType::Text(text) => raw_boundary(Raw::new(text.text), output),
471                other => {
472                    other.write_header(output, header_name.len() + 2);
473                    continue;
474                }
475            });
476        } else {
477            header_value.write_header(output, header_name.len() + 2);
478        }
479    }
480
481    let boundary = boundary.unwrap_or_else(|| {
482        output.write(b"Content-Type: ");
483        let boundary = make_boundary("_");
484        ContentType::new("multipart/mixed")
485            .attribute("boundary", &boundary)
486            .write_header(output, 14);
487        boundary.into()
488    });
489
490    output.write(b"\r\n");
491    boundary
492}
493
494fn raw_boundary<'x>(raw: Raw<'x>, output: &mut impl Writer) -> Cow<'x, str> {
495    match raw.raw.find("boundary=\"") {
496        Some(position) => {
497            raw.write_header(output, 14);
498            match raw.raw {
499                Cow::Borrowed(value) => value
500                    .get(position..)
501                    .and_then(|tail| tail.split('"').nth(1))
502                    .map_or_else(|| make_boundary("_").into(), Cow::Borrowed),
503                Cow::Owned(value) => value
504                    .get(position..)
505                    .and_then(|tail| tail.split('"').nth(1))
506                    .map_or_else(|| make_boundary("_"), str::to_string)
507                    .into(),
508            }
509        }
510        None => {
511            let boundary = make_boundary("_");
512            output.write(raw.raw.as_bytes());
513            output.write(b"; boundary=\"");
514            output.write(boundary.as_bytes());
515            output.write(b"\"\r\n");
516            boundary.into()
517        }
518    }
519}
520
521#[inline(always)]
522pub(crate) fn write_header_name(name: &str, output: &mut impl Writer) {
523    output.write(name.as_bytes());
524    output.write(b": ");
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530
531    #[test]
532    fn unexpected_multipart_content_type_values_do_not_panic() {
533        let mut output = Vec::new();
534        MimePart::raw(vec![MimePart::new("text/plain", "hello")])
535            .header("Content-Type", Text::new("multipart/mixed"))
536            .write_part(&mut output);
537        let output = String::from_utf8(output).unwrap();
538        assert!(
539            output.starts_with("Content-Type: multipart/mixed; boundary=\""),
540            "{output:?}"
541        );
542        assert!(output.ends_with("--\r\n"), "{output:?}");
543
544        let mut output = Vec::new();
545        MimePart::raw(vec![MimePart::new("text/plain", "hello")])
546            .header("Content-Type", MessageId::new("id@example.org"))
547            .write_part(&mut output);
548        let output = String::from_utf8(output).unwrap();
549        assert!(
550            output.contains("Content-Type: multipart/mixed;") && output.contains("boundary=\""),
551            "{output:?}"
552        );
553    }
554
555    #[test]
556    fn raw_content_type_with_boundary_is_written_and_reused() {
557        let mut output = Vec::new();
558        MimePart::raw(vec![MimePart::new("text/plain", "hello")])
559            .header(
560                "Content-Type",
561                Raw::new("multipart/mixed; boundary=\"abc\""),
562            )
563            .write_part(&mut output);
564        let output = String::from_utf8(output).unwrap();
565        assert!(
566            output.starts_with(
567                "Content-Type: multipart/mixed; boundary=\"abc\"\r\n\r\n\r\n--abc\r\n"
568            ),
569            "{output:?}"
570        );
571        assert!(output.ends_with("\r\n--abc--\r\n"), "{output:?}");
572    }
573    use std::collections::HashSet;
574
575    fn is_boundary_safe(value: &str) -> bool {
576        value.bytes().all(|byte| {
577            byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-' | b'+' | b'=' | b':')
578        })
579    }
580
581    #[test]
582    fn hex_digits_match_the_formatter() {
583        for value in [
584            0u64,
585            1,
586            9,
587            10,
588            15,
589            16,
590            255,
591            256,
592            0xFFFF_FFFF,
593            0x1_0000_0000,
594            u64::MAX,
595            u64::MAX - 1,
596            0x0123_4567_89AB_CDEF,
597        ] {
598            let mut buffer = [0u8; BOUNDARY_HEX_LEN];
599            let at = push_hex(&mut buffer, BOUNDARY_HEX_LEN, value);
600            let digits = std::str::from_utf8(buffer.get(at..).unwrap_or_default()).unwrap();
601            assert_eq!(digits, format!("{value:x}"), "value {value}");
602        }
603    }
604
605    #[test]
606    fn boundary_keeps_its_shape() {
607        let boundary = make_boundary("_");
608        let fields = boundary.split('_').collect::<Vec<_>>();
609        assert_eq!(fields.len(), 3, "{boundary}");
610        assert!(
611            fields.iter().all(
612                |field| !field.is_empty() && field.bytes().all(|byte| byte.is_ascii_hexdigit())
613            ),
614            "{boundary}"
615        );
616        assert!(boundary.len() <= 70, "{boundary}");
617        assert!(is_boundary_safe(&boundary), "{boundary}");
618        let dotted = make_boundary(".");
619        assert_eq!(dotted.split('.').count(), 3, "{dotted}");
620        assert!(is_boundary_safe(&dotted), "{dotted}");
621        let long = make_boundary("_separator_");
622        assert_eq!(long.split("_separator_").count(), 3, "{long}");
623    }
624
625    #[test]
626    fn boundaries_are_unique_within_a_thread() {
627        let count = 1_000_000;
628        let mut seen = HashSet::with_capacity(count);
629        for _ in 0..count {
630            let boundary = make_boundary("_");
631            assert!(is_boundary_safe(&boundary), "{boundary}");
632            assert!(seen.insert(boundary), "duplicate boundary");
633        }
634        assert_eq!(seen.len(), count);
635    }
636
637    #[test]
638    fn boundaries_are_unique_across_threads() {
639        let per_thread = 50_000;
640        let threads = (0..8)
641            .map(|_| {
642                std::thread::spawn(move || {
643                    (0..per_thread)
644                        .map(|_| make_boundary("_"))
645                        .collect::<Vec<_>>()
646                })
647            })
648            .collect::<Vec<_>>();
649        let mut seen = HashSet::with_capacity(per_thread * 8);
650        for thread in threads {
651            for boundary in thread.join().expect("thread panicked") {
652                assert!(is_boundary_safe(&boundary), "{boundary}");
653                assert!(seen.insert(boundary), "duplicate boundary");
654            }
655        }
656        assert_eq!(seen.len(), per_thread * 8);
657    }
658
659    #[test]
660    fn message_id_boundaries_are_unique_across_threads() {
661        let per_thread = 20_000;
662        let threads = (0..8)
663            .map(|_| {
664                std::thread::spawn(move || {
665                    (0..per_thread)
666                        .map(|_| make_boundary("."))
667                        .collect::<Vec<_>>()
668                })
669            })
670            .collect::<Vec<_>>();
671        let mut seen = HashSet::with_capacity(per_thread * 8);
672        for thread in threads {
673            for boundary in thread.join().expect("thread panicked") {
674                assert!(seen.insert(boundary), "duplicate boundary");
675            }
676        }
677        assert_eq!(seen.len(), per_thread * 8);
678    }
679
680    #[test]
681    fn estimates_cover_the_output() {
682        let ascii = "lorem ipsum dolor sit amet ".repeat(40_000);
683        let latin = "réunion d'équipe: résumé ".repeat(40_000);
684        let cjk = "안녕하세요 세계 ".repeat(40_000);
685        let binary: Vec<u8> = (0..1_000_000u32).map(|value| value as u8).collect();
686        let parts = vec![
687            MimePart::new("text/plain", ascii.as_str()),
688            MimePart::new("text/plain", latin.as_str()),
689            MimePart::new("text/plain", cjk.as_str()),
690            MimePart::new("image/png", binary.as_slice()),
691            MimePart::new("text/plain", cjk.as_str()).attachment("report.txt"),
692            MimePart::new("image/png", binary.as_slice()).attachment("photo.png"),
693            MimePart::new(
694                "multipart/alternative",
695                vec![
696                    MimePart::new("text/plain", "short"),
697                    MimePart::new("text/html", "<p>short</p>"),
698                ],
699            ),
700        ];
701        for part in parts {
702            let estimate = part.estimated_len();
703            let mut output = Vec::new();
704            part.write_part(&mut output);
705            assert!(
706                estimate >= output.len(),
707                "estimate {estimate} below output {}",
708                output.len()
709            );
710            assert!(
711                estimate <= output.len() * 2,
712                "estimate {estimate} more than twice the output {}",
713                output.len()
714            );
715        }
716    }
717}